Text spans

This commit is contained in:
0hypercube 2024-01-28 13:04:37 +00:00 committed by Keavon Chambers
parent 938a688fa0
commit c2d0787bad
33 changed files with 1295 additions and 486 deletions

91
Cargo.lock generated
View file

@ -1105,6 +1105,26 @@ dependencies = [
"libc",
]
[[package]]
name = "cosmic-text"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75acbfb314aeb4f5210d379af45ed1ec2c98c7f1790bf57b8a4c562ac0c51b71"
dependencies = [
"fontdb 0.15.0",
"libm",
"log",
"rangemap",
"rustc-hash",
"rustybuzz 0.11.0",
"self_cell",
"sys-locale 0.3.1",
"unicode-bidi",
"unicode-linebreak",
"unicode-script",
"unicode-segmentation",
]
[[package]]
name = "cpufeatures"
version = "0.2.12"
@ -1681,6 +1701,19 @@ dependencies = [
"roxmltree",
]
[[package]]
name = "fontdb"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "020e203f177c0fb250fb19455a252e838d2bbbce1f80f25ecc42402aafa8cd38"
dependencies = [
"log",
"memmap2 0.8.0",
"slotmap",
"tinyvec",
"ttf-parser 0.19.2",
]
[[package]]
name = "fontdb"
version = "0.16.2"
@ -1689,7 +1722,7 @@ checksum = "b0299020c3ef3f60f526a4f64ab4a3d4ce116b1acbf24cdd22da0068e5d81dc3"
dependencies = [
"fontconfig-parser",
"log",
"memmap2",
"memmap2 0.9.4",
"slotmap",
"tinyvec",
"ttf-parser 0.20.0",
@ -2331,6 +2364,7 @@ dependencies = [
"base64 0.21.7",
"bezier-rs",
"bytemuck",
"cosmic-text",
"dyn-any",
"glam",
"image",
@ -2342,11 +2376,11 @@ dependencies = [
"num-traits",
"rand 0.8.5",
"rand_chacha 0.3.1",
"rustybuzz 0.10.0",
"serde",
"specta",
"spirv-std",
"tokio",
"unicode-segmentation",
"usvg",
"wasm-bindgen",
"web-sys",
@ -3404,6 +3438,15 @@ version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149"
[[package]]
name = "memmap2"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43a5a03cefb0d953ec0be133036f14e109412fa594edc2f77227249db66cc3ed"
dependencies = [
"libc",
]
[[package]]
name = "memmap2"
version = "0.9.4"
@ -4610,6 +4653,12 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8a99fddc9f0ba0a85884b8d14e3592853e787d581ca1816c91349b10e4eeab"
[[package]]
name = "rangemap"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "795915a3930a5d6bafd9053d37602fea3e61be2e5d4d788983a8ba9654c1c6f2"
[[package]]
name = "raw-window-handle"
version = "0.5.2"
@ -4952,14 +5001,15 @@ checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4"
[[package]]
name = "rustybuzz"
version = "0.10.0"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71cd15fef9112a1f94ac64b58d1e4628192631ad6af4dc69997f995459c874e7"
checksum = "2ee8fe2a8461a0854a37101fe7a1b13998d0cfa987e43248e81d2a5f4570f6fa"
dependencies = [
"bitflags 1.3.2",
"bytemuck",
"libm",
"smallvec",
"ttf-parser 0.19.2",
"ttf-parser 0.20.0",
"unicode-bidi-mirroring",
"unicode-ccc",
"unicode-properties",
@ -5051,7 +5101,7 @@ checksum = "82b2eaf3a5b264a521b988b2e73042e742df700c4f962cde845d1541adb46550"
dependencies = [
"ab_glyph",
"log",
"memmap2",
"memmap2 0.9.4",
"smithay-client-toolkit",
"tiny-skia",
]
@ -5099,6 +5149,12 @@ dependencies = [
"thin-slice",
]
[[package]]
name = "self_cell"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58bf37232d3bb9a2c4e641ca2a11d83b5062066f88df7fed36c28772046d65ba"
[[package]]
name = "semver"
version = "1.0.22"
@ -5430,7 +5486,7 @@ dependencies = [
"cursor-icon",
"libc",
"log",
"memmap2",
"memmap2 0.9.4",
"rustix 0.38.31",
"thiserror",
"wayland-backend",
@ -5686,6 +5742,15 @@ dependencies = [
"windows-sys 0.45.0",
]
[[package]]
name = "sys-locale"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e801cf239ecd6ccd71f03d270d67dd53d13e90aab208bf4b8fe4ad957ea949b0"
dependencies = [
"libc",
]
[[package]]
name = "system-configuration"
version = "0.5.1"
@ -5856,7 +5921,7 @@ dependencies = [
"serialize-to-javascript",
"shared_child",
"state",
"sys-locale",
"sys-locale 0.2.4",
"tar",
"tauri-macros",
"tauri-runtime",
@ -6516,6 +6581,12 @@ version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
[[package]]
name = "unicode-linebreak"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
[[package]]
name = "unicode-normalization"
version = "0.1.23"
@ -6588,7 +6659,7 @@ dependencies = [
"base64 0.21.7",
"data-url",
"flate2",
"fontdb",
"fontdb 0.16.2",
"imagesize",
"kurbo 0.9.5",
"log",
@ -7689,7 +7760,7 @@ dependencies = [
"js-sys",
"libc",
"log",
"memmap2",
"memmap2 0.9.4",
"ndk 0.8.0",
"ndk-sys 0.5.0+25.2.9519653",
"objc2",

View file

@ -72,7 +72,7 @@ glam = { version = "0.25", default-features = false, features = ["serde"] }
node-macro = { path = "node-graph/node-macro" }
base64 = { version = "0.21" }
image = { version = "0.24", default-features = false, features = ["png"] }
rustybuzz = { version = "0.10.0" }
cosmic-text = { version = "0.10", default-features = false, features = ["std"] }
num-derive = { version = "0.4" }
num-traits = { version = "0.2.15", default-features = false, features = [
"i128",

View file

@ -6,7 +6,6 @@ use crate::messages::prelude::*;
use crate::messages::tool::utility_types::HintData;
use graph_craft::document::NodeId;
use graphene_core::raster::color::Color;
use graphene_core::text::Font;
#[impl_message(Message, Frontend)]
@ -24,15 +23,6 @@ pub enum FrontendMessage {
},
DisplayEditableTextbox {
text: String,
#[serde(rename = "lineWidth")]
line_width: Option<f64>,
#[serde(rename = "fontSize")]
font_size: f64,
color: Color,
url: String,
transform: [f64; 6],
},
DisplayEditableTextboxTransform {
transform: [f64; 6],
},
DisplayRemoveEditableTextbox,
@ -94,7 +84,6 @@ pub enum FrontendMessage {
TriggerSavePreferences {
preferences: PreferencesMessageHandler,
},
TriggerTextCommit,
TriggerTextCopy {
#[serde(rename = "copyText")]
copy_text: String,

View file

@ -134,6 +134,8 @@ pub fn default_mapping() -> Mapping {
entry!(KeyDown(Escape); action_dispatch=EyedropperToolMessage::Abort),
//
// TextToolMessage
entry!(KeyDown(Lmb); action_dispatch=TextToolMessage::Select),
entry!(PointerMove; action_dispatch=TextToolMessage::Drag),
entry!(KeyUp(Lmb); action_dispatch=TextToolMessage::Interact),
entry!(KeyDown(Escape); action_dispatch=TextToolMessage::Abort),
entry!(KeyDown(Enter); modifiers=[Accel], action_dispatch=TextToolMessage::CommitText),

View file

@ -8,7 +8,6 @@ use graph_craft::document::DocumentNode;
use graph_craft::document::NodeId;
use graphene_core::raster::BlendMode;
use graphene_core::raster::ImageFrame;
use graphene_core::text::Font;
use graphene_core::uuid::ManipulatorGroupId;
use graphene_core::vector::brush_stroke::BrushStroke;
use graphene_core::vector::style::{Fill, Stroke};
@ -89,9 +88,7 @@ pub enum GraphOperationMessage {
},
NewTextLayer {
id: NodeId,
text: String,
font: Font,
size: f64,
text: graphene_core::text::RichText,
parent: LayerNodeIdentifier,
insert_index: isize,
},

View file

@ -7,7 +7,6 @@ use crate::messages::prelude::*;
use bezier_rs::{ManipulatorGroup, Subpath};
use graph_craft::document::{generate_uuid, NodeId, NodeInput, NodeNetwork};
use graphene_core::renderer::Quad;
use graphene_core::text::Font;
use graphene_core::uuid::ManipulatorGroupId;
use graphene_core::vector::style::{Fill, Gradient, GradientType, LineCap, LineJoin, Stroke};
use graphene_core::Color;
@ -180,17 +179,10 @@ impl MessageHandler<GraphOperationMessage, GraphOperationMessageData<'_>> for Gr
}
load_network_structure(document_network, document_metadata, selected_nodes, collapsed);
}
GraphOperationMessage::NewTextLayer {
id,
text,
font,
size,
parent,
insert_index,
} => {
GraphOperationMessage::NewTextLayer { id, text, parent, insert_index } => {
let mut modify_inputs = ModifyInputsContext::new(document_network, document_metadata, node_graph, responses);
if let Some(layer) = modify_inputs.create_layer_with_insert_index(id, insert_index, parent) {
modify_inputs.insert_text(text, font, size, layer);
modify_inputs.insert_text(text, layer);
}
load_network_structure(document_network, document_metadata, selected_nodes, collapsed);
}
@ -318,10 +310,11 @@ fn import_usvg_node(modify_inputs: &mut ModifyInputsContext, node: &usvg::Node,
usvg::Node::Image(_image) => {
warn!("Skip image")
}
usvg::Node::Text(text) => {
let font = Font::new(crate::consts::DEFAULT_FONT_FAMILY.to_string(), crate::consts::DEFAULT_FONT_STYLE.to_string());
modify_inputs.insert_text(text.chunks.iter().map(|chunk| chunk.text.clone()).collect(), font, 24., layer);
modify_inputs.fill_set(Fill::Solid(Color::BLACK));
usvg::Node::Text(_text) => {
warn!("Skip text");
// let font = Font::new(crate::consts::DEFAULT_FONT_FAMILY.to_string(), crate::consts::DEFAULT_FONT_STYLE.to_string());
// modify_inputs.insert_text(text.chunks.iter().map(|chunk| chunk.text.clone()).collect(), layer);
// modify_inputs.fill_set(Fill::Solid(Color::BLACK));
}
}
}

View file

@ -7,7 +7,7 @@ use bezier_rs::Subpath;
use graph_craft::document::value::TaggedValue;
use graph_craft::document::{generate_uuid, DocumentNode, NodeId, NodeInput, NodeNetwork, NodeOutput};
use graphene_core::raster::{BlendMode, ImageFrame};
use graphene_core::text::Font;
use graphene_core::text::RichText;
use graphene_core::uuid::ManipulatorGroupId;
use graphene_core::vector::brush_stroke::BrushStroke;
use graphene_core::vector::style::{Fill, FillType, Stroke};
@ -226,13 +226,13 @@ impl<'a> ModifyInputsContext<'a> {
self.responses.add(NodeGraphMessage::RunDocumentGraph);
}
pub fn insert_text(&mut self, text: String, font: Font, size: f64, layer: NodeId) {
pub fn insert_text(&mut self, rich_text: RichText, layer: NodeId) {
let text = resolve_document_node_type("Text").expect("Text node does not exist").to_document_node(
[
NodeInput::Network(graph_craft::concrete!(graphene_std::wasm_application_io::WasmEditorApi)),
NodeInput::value(TaggedValue::String(text), false),
NodeInput::value(TaggedValue::Font(font), false),
NodeInput::value(TaggedValue::F64(size), false),
NodeInput::value(TaggedValue::RichText(rich_text), false),
NodeInput::value(TaggedValue::F64(f64::MAX), false),
NodeInput::value(TaggedValue::VectorData(graphene_core::vector::VectorData::empty()), false),
],
Default::default(),
);

View file

@ -2482,9 +2482,9 @@ fn static_nodes() -> Vec<DocumentNodeDefinition> {
name: "Text Generator".to_string(),
inputs: vec![
NodeInput::Network(concrete!(application_io::EditorApi<graphene_std::wasm_application_io::WasmApplicationIo>)),
NodeInput::Network(concrete!(String)),
NodeInput::Network(concrete!(graphene_core::text::Font)),
NodeInput::Network(concrete!(graphene_core::text::RichText)),
NodeInput::Network(concrete!(f64)),
NodeInput::Network(concrete!(graphene_core::vector::VectorData)),
],
implementation: DocumentNodeImplementation::ProtoNode(ProtoNodeIdentifier::new("graphene_core::text::TextGeneratorNode<_, _, _>")),
..Default::default()
@ -2505,9 +2505,16 @@ fn static_nodes() -> Vec<DocumentNodeDefinition> {
}),
inputs: vec![
DocumentInputType::none(),
DocumentInputType::value("Text", TaggedValue::String("Lorem ipsum".to_string()), false),
DocumentInputType::value("Font", TaggedValue::Font(Font::new(DEFAULT_FONT_FAMILY.into(), DEFAULT_FONT_STYLE.into())), false),
DocumentInputType::value("Size", TaggedValue::F64(24.), false),
DocumentInputType::value(
"Rich Text",
TaggedValue::RichText(graphene_core::text::RichText::new(
"Rich Text",
[graphene_core::text::TextSpan::new(Font::new(DEFAULT_FONT_FAMILY.into(), DEFAULT_FONT_STYLE.into()), 24.)],
)),
false,
),
DocumentInputType::value("Line Length", TaggedValue::F64(f64::MAX), false),
DocumentInputType::value("Path", TaggedValue::VectorData(graphene_core::vector::VectorData::empty()), false),
],
outputs: vec![DocumentOutputType::new("Vector", FrontendGraphDataType::Subpath)],
properties: node_properties::node_section_font,

View file

@ -12,7 +12,6 @@ use graphene_core::memo::IORecord;
use graphene_core::raster::{
BlendMode, CellularDistanceFunction, CellularReturnType, Color, DomainWarpType, FractalType, ImageFrame, LuminanceCalculation, NoiseType, RedGreenBlue, RelativeAbsolute, SelectiveColorChoice,
};
use graphene_core::text::Font;
use graphene_core::vector::style::{FillType, GradientType, LineCap, LineJoin};
use glam::{DVec2, IVec2, UVec2};
@ -109,7 +108,27 @@ fn text_area_widget(document_node: &DocumentNode, node_id: NodeId, index: usize,
.on_update(update_value(|x: &TextAreaInput| TaggedValue::String(x.value.clone()), node_id, index))
.on_commit(commit_value)
.widget_holder(),
])
]);
} else if let NodeInput::Value {
tagged_value: TaggedValue::RichText(x),
exposed: false,
} = &document_node.inputs[index]
{
let text = x.clone();
widgets.extend_from_slice(&[
Separator::new(SeparatorType::Unrelated).widget_holder(),
TextAreaInput::new(x.text.clone())
.on_update(update_value(
move |x: &TextAreaInput| {
let mut text = text.clone();
text.text = x.value.clone();
TaggedValue::RichText(text)
},
node_id,
index,
))
.widget_holder(),
]);
}
widgets
}
@ -284,40 +303,6 @@ fn vec_dvec2_input(document_node: &DocumentNode, node_id: NodeId, index: usize,
widgets
}
fn font_inputs(document_node: &DocumentNode, node_id: NodeId, index: usize, name: &str, blank_assist: bool) -> (Vec<WidgetHolder>, Option<Vec<WidgetHolder>>) {
let mut first_widgets = start_widgets(document_node, node_id, index, name, FrontendGraphDataType::General, blank_assist);
let mut second_widgets = None;
let from_font_input = |font: &FontInput| TaggedValue::Font(Font::new(font.font_family.clone(), font.font_style.clone()));
if let NodeInput::Value {
tagged_value: TaggedValue::Font(font),
exposed: false,
} = &document_node.inputs[index]
{
first_widgets.extend_from_slice(&[
Separator::new(SeparatorType::Unrelated).widget_holder(),
FontInput::new(font.font_family.clone(), font.font_style.clone())
.on_update(update_value(from_font_input, node_id, index))
.on_commit(commit_value)
.widget_holder(),
]);
let mut second_row = vec![TextLabel::new("").widget_holder()];
add_blank_assist(&mut second_row);
second_row.extend_from_slice(&[
Separator::new(SeparatorType::Unrelated).widget_holder(),
FontInput::new(font.font_family.clone(), font.font_style.clone())
.is_style_picker(true)
.on_update(update_value(from_font_input, node_id, index))
.on_commit(commit_value)
.widget_holder(),
]);
second_widgets = Some(second_row);
}
(first_widgets, second_widgets)
}
fn vector_widget(document_node: &DocumentNode, node_id: NodeId, index: usize, name: &str, blank_assist: bool) -> Vec<WidgetHolder> {
let mut widgets = start_widgets(document_node, node_id, index, name, FrontendGraphDataType::Vector, blank_assist);
@ -1594,16 +1579,11 @@ pub fn transform_properties(document_node: &DocumentNode, node_id: NodeId, _cont
}
pub fn node_section_font(document_node: &DocumentNode, node_id: NodeId, _context: &mut NodePropertiesContext) -> Vec<LayoutGroup> {
let text = text_area_widget(document_node, node_id, 1, "Text", true);
let (font, style) = font_inputs(document_node, node_id, 2, "Font", true);
let size = number_widget(document_node, node_id, 3, "Size", NumberInput::default().unit(" px").min(1.), true);
let text = text_area_widget(document_node, node_id, 1, "Rich Text", true);
let line_width = number_widget(document_node, node_id, 2, "Line Width", NumberInput::default().min(0.), true);
let path = start_widgets(document_node, node_id, 3, "Path", FrontendGraphDataType::Vector, false);
let mut result = vec![LayoutGroup::Row { widgets: text }, LayoutGroup::Row { widgets: font }];
if let Some(style) = style {
result.push(LayoutGroup::Row { widgets: style });
}
result.push(LayoutGroup::Row { widgets: size });
result
vec![LayoutGroup::Row { widgets: text }, LayoutGroup::Row { widgets: line_width }, LayoutGroup::Row { widgets: path }]
}
pub fn imaginate_properties(document_node: &DocumentNode, node_id: NodeId, context: &mut NodePropertiesContext) -> Vec<LayoutGroup> {

View file

@ -264,8 +264,9 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageData<'_>> for PortfolioMes
self.persistent_data.font_cache.insert(font, preview_url, data, is_default);
self.executor.update_font_cache(self.persistent_data.font_cache.clone());
if self.active_document_mut().is_some() {
if self.active_document_id().is_some() {
responses.add(NodeGraphMessage::RunDocumentGraph);
responses.add(TextToolMessage::RefreshFonts);
}
}
PortfolioMessage::ImaginateCheckServerStatus => {

View file

@ -5,9 +5,9 @@ use crate::messages::prelude::*;
use bezier_rs::{ManipulatorGroup, Subpath};
use graph_craft::document::{value::TaggedValue, DocumentNode, NodeId, NodeInput, NodeNetwork};
use graphene_core::raster::{BlendMode, ImageFrame};
use graphene_core::text::Font;
use graphene_core::uuid::ManipulatorGroupId;
use graphene_core::vector::style::{FillType, Gradient};
use graphene_core::vector::VectorData;
use graphene_core::Color;
use glam::DVec2;
@ -165,33 +165,31 @@ pub fn get_text_id(layer: LayerNodeIdentifier, document_network: &NodeNetwork) -
}
/// Gets properties from the Text node
pub fn get_text(layer: LayerNodeIdentifier, document_network: &NodeNetwork) -> Option<(&String, &Font, f64)> {
pub fn get_text(layer: LayerNodeIdentifier, document_network: &NodeNetwork) -> Option<(&graphene_core::text::RichText, f64, &VectorData)> {
let inputs = NodeGraphLayer::new(layer, document_network).find_node_inputs("Text")?;
let NodeInput::Value {
tagged_value: TaggedValue::String(text),
tagged_value: TaggedValue::RichText(text),
..
} = &inputs[1]
else {
return None;
};
let NodeInput::Value {
tagged_value: TaggedValue::Font(font),
tagged_value: TaggedValue::F64(line_width),
..
} = &inputs[2]
else {
return None;
};
let NodeInput::Value {
tagged_value: TaggedValue::F64(font_size),
tagged_value: TaggedValue::VectorData(path),
..
} = inputs[3]
} = &inputs[3]
else {
return None;
};
Some((text, font, font_size))
Some((text, *line_width, path))
}
pub fn get_stroke_width(layer: LayerNodeIdentifier, network: &NodeNetwork) -> Option<f64> {

View file

@ -1,19 +1,23 @@
#![allow(clippy::too_many_arguments)]
use std::sync::Arc;
use super::tool_prelude::*;
use crate::application::generate_uuid;
use crate::consts::{DEFAULT_FONT_FAMILY, DEFAULT_FONT_STYLE};
use crate::consts::{DEFAULT_FONT_FAMILY, DEFAULT_FONT_STYLE, SELECTION_TOLERANCE};
use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn;
use crate::messages::portfolio::document::overlays::utility_types::OverlayContext;
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::tool::common_functionality::color_selector::{ToolColorOptions, ToolColorType};
use crate::messages::tool::common_functionality::graph_modification_utils::{self, is_layer_fed_by_node_of_name};
use glam::Vec2;
use graph_craft::document::value::TaggedValue;
use graph_craft::document::NodeId;
use graphene_core::renderer::Quad;
use graphene_core::text::{load_face, Font, FontCache};
use graphene_core::text::cosmic_text::Edit;
use graphene_core::text::{Font, FontCache, RichText, TextSpan};
use graphene_core::vector::style::Fill;
use graphene_core::vector::VectorData;
use graphene_core::Color;
#[derive(Default)]
@ -27,6 +31,14 @@ pub struct TextOptions {
font_size: u32,
font_name: String,
font_style: String,
bold: bool,
bold_size: f32,
italic: bool,
italic_size: f32,
letter_spacing: f32,
word_spacing: f32,
line_spacing: f32,
kerning: Vec2,
fill: ToolColorOptions,
}
@ -36,11 +48,36 @@ impl Default for TextOptions {
font_size: 24,
font_name: DEFAULT_FONT_FAMILY.into(),
font_style: DEFAULT_FONT_STYLE.into(),
bold: false,
bold_size: 10.,
italic: false,
italic_size: 15.,
letter_spacing: 0.,
word_spacing: 0.,
line_spacing: 1.,
kerning: Vec2::ZERO,
fill: ToolColorOptions::new_primary(),
}
}
}
impl TextOptions {
pub fn to_span(&self) -> TextSpan {
TextSpan {
font: Arc::new(Font::new(self.font_name.clone(), self.font_style.clone())),
font_size: self.font_size as f32,
bold: self.bold.then_some(self.bold_size),
italic: self.italic.then_some(self.italic_size),
letter_spacing: self.letter_spacing,
word_spacing: self.word_spacing,
line_spacing: self.line_spacing,
color: self.fill.active_color().unwrap_or(Color::BLACK),
kerning: self.kerning,
offset: 0,
}
}
}
#[impl_message(Message, ToolMessage, Text)]
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)]
pub enum TextToolMessage {
@ -51,10 +88,13 @@ pub enum TextToolMessage {
// Tool-specific messages
CommitText,
Drag,
EditSelected,
Interact,
TextChange { new_text: String },
UpdateBounds { new_text: String },
RefreshFonts,
Select,
TextInput { input_type: String, data: Option<String> },
TextNavigate { key: String, shift: bool, ctrl: bool },
UpdateOptions(TextOptionsUpdate),
}
@ -64,6 +104,15 @@ pub enum TextOptionsUpdate {
FillColorType(ToolColorType),
Font { family: String, style: String },
FontSize(u32),
Bold(bool),
Italic(bool),
BoldSize(f32),
ItalicSize(f32),
LetterSpacing(f32),
WordSpacing(f32),
LineSpacing(f32),
KerningX(f32),
KerningY(f32),
WorkingColors(Option<Color>, Option<Color>),
}
@ -79,8 +128,31 @@ impl ToolMetadata for TextTool {
}
}
fn get_cursor_index(buffer: &graphene_core::text::cosmic_text::Buffer, cursor: graphene_core::text::cosmic_text::Cursor) -> usize {
let mut index = 0;
for line in buffer.lines.iter().take(cursor.line) {
index += line.text().len() + 1;
}
index + cursor.index
}
fn create_text_widgets(tool: &TextTool) -> Vec<WidgetHolder> {
let font = FontInput::new(&tool.options.font_name, &tool.options.font_style)
let span = tool.tool_data.editing_text.as_ref().and_then(|editing| {
let mut offset = 0;
let cursor = get_cursor_index(editing.editor.buffer(), editing.editor.cursor());
editing
.text
.spans
.iter()
.filter(|span| {
offset += span.offset;
offset <= cursor
})
.last()
});
let family = span.map_or(&tool.options.font_name, |span| &span.font.font_family);
let style = span.map_or(&tool.options.font_style, |span| &span.font.font_style);
let font = FontInput::new(family, style)
.is_style_picker(false)
.on_update(|font_input: &FontInput| {
TextToolMessage::UpdateOptions(TextOptionsUpdate::Font {
@ -90,7 +162,7 @@ fn create_text_widgets(tool: &TextTool) -> Vec<WidgetHolder> {
.into()
})
.widget_holder();
let style = FontInput::new(&tool.options.font_name, &tool.options.font_style)
let style = FontInput::new(family, style)
.is_style_picker(true)
.on_update(|font_input: &FontInput| {
TextToolMessage::UpdateOptions(TextOptionsUpdate::Font {
@ -100,7 +172,13 @@ fn create_text_widgets(tool: &TextTool) -> Vec<WidgetHolder> {
.into()
})
.widget_holder();
let size = NumberInput::new(Some(tool.options.font_size as f64))
let bold = CheckboxInput::new(span.map_or(tool.options.bold, |span| span.bold.is_some()))
.icon("Bold")
.on_update(|input: &CheckboxInput| TextToolMessage::UpdateOptions(TextOptionsUpdate::Bold(input.checked)).into());
let italic = CheckboxInput::new(span.map_or(tool.options.italic, |span| span.italic.is_some()))
.icon("Italic")
.on_update(|input: &CheckboxInput| TextToolMessage::UpdateOptions(TextOptionsUpdate::Italic(input.checked)).into());
let size = NumberInput::new(Some(span.map_or(tool.options.font_size as f64, |span| span.font_size as f64)))
.unit(" px")
.label("Size")
.int()
@ -108,15 +186,93 @@ fn create_text_widgets(tool: &TextTool) -> Vec<WidgetHolder> {
.max((1_u64 << std::f64::MANTISSA_DIGITS) as f64)
.on_update(|number_input: &NumberInput| TextToolMessage::UpdateOptions(TextOptionsUpdate::FontSize(number_input.value.unwrap() as u32)).into())
.widget_holder();
vec![
font,
Separator::new(SeparatorType::Related).widget_holder(),
style,
Separator::new(SeparatorType::Related).widget_holder(),
bold.widget_holder(),
italic.widget_holder(),
bold_italic_options(tool, span).widget_holder(),
Separator::new(SeparatorType::Related).widget_holder(),
size,
spacing_options(tool, span).widget_holder(),
]
}
fn bold_italic_options(tool: &TextTool, span: Option<&TextSpan>) -> PopoverButton {
PopoverButton::new("Bold and Italic", "Bold and italic customization settings").options_widget(vec![
LayoutGroup::Row {
widgets: vec![
TextLabel::new("Boldness").table_align(true).widget_holder(),
Separator::new(SeparatorType::Unrelated).widget_holder(),
NumberInput::new(Some(span.map(|span| span.bold).flatten().unwrap_or(tool.options.bold_size) as f64))
.min(0.)
.on_update(|number_input: &NumberInput| TextToolMessage::UpdateOptions(TextOptionsUpdate::BoldSize(number_input.value.unwrap() as f32)).into())
.widget_holder(),
],
},
LayoutGroup::Row {
widgets: vec![
TextLabel::new("Italic slant").table_align(true).widget_holder(),
Separator::new(SeparatorType::Unrelated).widget_holder(),
NumberInput::new(Some(span.map(|span| span.italic).flatten().unwrap_or(tool.options.italic_size) as f64))
.min(0.)
.on_update(|number_input: &NumberInput| TextToolMessage::UpdateOptions(TextOptionsUpdate::ItalicSize(number_input.value.unwrap() as f32)).into())
.widget_holder(),
],
},
])
}
fn spacing_options(tool: &TextTool, span: Option<&TextSpan>) -> PopoverButton {
PopoverButton::new("Text Spacing", "Text spacing customization settings").options_widget(vec![
LayoutGroup::Row {
widgets: vec![
TextLabel::new("Letter spacing").table_align(true).widget_holder(),
Separator::new(SeparatorType::Unrelated).widget_holder(),
NumberInput::new(Some(span.map_or(tool.options.letter_spacing, |span| span.letter_spacing) as f64))
.on_update(|number_input: &NumberInput| TextToolMessage::UpdateOptions(TextOptionsUpdate::LetterSpacing(number_input.value.unwrap() as f32)).into())
.widget_holder(),
],
},
LayoutGroup::Row {
widgets: vec![
TextLabel::new("Word spacing").table_align(true).widget_holder(),
Separator::new(SeparatorType::Unrelated).widget_holder(),
NumberInput::new(Some(span.map_or(tool.options.word_spacing, |span| span.word_spacing) as f64))
.on_update(|number_input: &NumberInput| TextToolMessage::UpdateOptions(TextOptionsUpdate::WordSpacing(number_input.value.unwrap() as f32)).into())
.widget_holder(),
],
},
LayoutGroup::Row {
widgets: vec![
TextLabel::new("Line spacing").table_align(true).widget_holder(),
Separator::new(SeparatorType::Unrelated).widget_holder(),
NumberInput::new(Some(span.map_or(tool.options.line_spacing, |span| span.line_spacing) as f64))
.on_update(|number_input: &NumberInput| TextToolMessage::UpdateOptions(TextOptionsUpdate::LineSpacing(number_input.value.unwrap() as f32)).into())
.widget_holder(),
],
},
LayoutGroup::Row {
widgets: vec![
TextLabel::new("Kerning").table_align(true).widget_holder(),
Separator::new(SeparatorType::Unrelated).widget_holder(),
NumberInput::new(Some(span.map_or(tool.options.kerning, |span| span.kerning).x as f64))
.on_update(|number_input: &NumberInput| TextToolMessage::UpdateOptions(TextOptionsUpdate::KerningX(number_input.value.unwrap() as f32)).into())
.label("X")
.widget_holder(),
Separator::new(SeparatorType::Related).widget_holder(),
NumberInput::new(Some(span.map_or(tool.options.kerning, |span| span.kerning).y as f64))
.on_update(|number_input: &NumberInput| TextToolMessage::UpdateOptions(TextOptionsUpdate::KerningY(number_input.value.unwrap() as f32)).into())
.label("Y")
.widget_holder(),
],
},
])
}
impl LayoutHolder for TextTool {
fn layout(&self) -> Layout {
let mut widgets = create_text_widgets(self);
@ -135,6 +291,53 @@ impl LayoutHolder for TextTool {
}
}
fn update_span(tool_data: &mut TextToolData, font_cache: &FontCache, modification: impl Fn(&mut TextSpan)) {
let Some(editing_text) = &mut tool_data.editing_text else { return };
let editor = &mut editing_text.editor;
let selection = editor.select_opt().filter(|&select| select != editor.cursor());
let (start_index, end_index) = if let Some(selection) = selection {
let cursor = get_cursor_index(editor.buffer(), editor.cursor());
let selection = get_cursor_index(editor.buffer(), selection);
let (start, end) = (cursor.min(selection), cursor.max(selection));
let mut text_index = editing_text.text.spans.first().map_or(0, |span| span.offset);
let mut span_index = 0;
while editing_text.text.spans.get(span_index + 1).map_or(false, |next| text_index + next.offset < start) {
span_index += 1;
text_index += editing_text.text.spans[span_index].offset;
}
if !editing_text.text.spans.get(span_index + 1).map_or(false, |next| next.offset == start - text_index) {
if let Some(next) = editing_text.text.spans.get_mut(span_index + 1) {
next.offset -= start - text_index;
}
editing_text.text.spans.insert(span_index + 1, editing_text.text.spans[span_index].clone().offset(start - text_index));
}
let start_span = span_index + 1;
while editing_text.text.spans.get(span_index + 1).map_or(false, |next| text_index + next.offset < end) {
span_index += 1;
text_index += editing_text.text.spans[span_index].offset;
}
if !editing_text.text.spans.get(span_index + 1).map_or(false, |next| next.offset == end - text_index) {
if let Some(next) = editing_text.text.spans.get_mut(span_index + 1) {
next.offset -= end - text_index;
}
editing_text.text.spans.insert(span_index + 1, editing_text.text.spans[span_index].clone().offset(end - text_index));
}
let end_span = span_index + 1;
(start_span, end_span)
} else {
(0, editing_text.text.spans.len())
};
for span in &mut editing_text.text.spans[start_index..end_index] {
modification(span);
}
if let Some(mut font_system) = font_cache.get_system() {
graphene_core::text::create_buffer(editor.buffer_mut(), &mut font_system, &editing_text.text, font_cache, editing_text.line_length);
}
}
impl<'a> MessageHandler<ToolMessage, &mut ToolActionHandlerData<'a>> for TextTool {
fn process_message(&mut self, message: ToolMessage, responses: &mut VecDeque<Message>, tool_data: &mut ToolActionHandlerData<'a>) {
let ToolMessage::Text(TextToolMessage::UpdateOptions(action)) = message else {
@ -143,23 +346,64 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionHandlerData<'a>> for TextToo
};
match action {
TextOptionsUpdate::Font { family, style } => {
self.options.font_name = family;
self.options.font_style = style;
self.send_layout(responses, LayoutTarget::ToolOptions);
self.options.font_name = family.clone();
self.options.font_style = style.clone();
let font = Arc::new(Font::new(family, style));
update_span(&mut self.tool_data, tool_data.font_cache, |span| span.font = font.clone());
}
TextOptionsUpdate::FontSize(font_size) => {
self.options.font_size = font_size;
update_span(&mut self.tool_data, tool_data.font_cache, |span| span.font_size = font_size as f32);
}
TextOptionsUpdate::FontSize(font_size) => self.options.font_size = font_size,
TextOptionsUpdate::FillColor(color) => {
self.options.fill.custom_color = color;
self.options.fill.color_type = ToolColorType::Custom;
update_span(&mut self.tool_data, tool_data.font_cache, |span| span.color = color.unwrap_or(Color::BLACK));
}
TextOptionsUpdate::FillColorType(color_type) => self.options.fill.color_type = color_type,
TextOptionsUpdate::WorkingColors(primary, secondary) => {
self.options.fill.primary_working_color = primary;
self.options.fill.secondary_working_color = secondary;
}
TextOptionsUpdate::Bold(value) => {
self.options.bold = value;
update_span(&mut self.tool_data, tool_data.font_cache, |span| span.bold = self.options.bold.then_some(self.options.bold_size));
}
TextOptionsUpdate::Italic(value) => {
self.options.italic = value;
update_span(&mut self.tool_data, tool_data.font_cache, |span| span.italic = self.options.italic.then_some(self.options.italic_size));
}
TextOptionsUpdate::BoldSize(value) => {
self.options.bold_size = value;
update_span(&mut self.tool_data, tool_data.font_cache, |span| span.bold = self.options.bold.then_some(self.options.bold_size));
}
TextOptionsUpdate::ItalicSize(value) => {
self.options.italic_size = value;
update_span(&mut self.tool_data, tool_data.font_cache, |span| span.italic = self.options.italic.then_some(self.options.italic_size));
}
TextOptionsUpdate::LetterSpacing(value) => {
self.options.letter_spacing = value;
update_span(&mut self.tool_data, tool_data.font_cache, |span| span.letter_spacing = value);
}
TextOptionsUpdate::WordSpacing(value) => {
self.options.word_spacing = value;
update_span(&mut self.tool_data, tool_data.font_cache, |span| span.word_spacing = value);
}
TextOptionsUpdate::LineSpacing(value) => {
self.options.line_spacing = value;
update_span(&mut self.tool_data, tool_data.font_cache, |span| span.line_spacing = value);
}
TextOptionsUpdate::KerningX(value) => {
self.options.kerning.x = value;
update_span(&mut self.tool_data, tool_data.font_cache, |span| span.kerning.x = value);
}
TextOptionsUpdate::KerningY(value) => {
self.options.kerning.y = value;
update_span(&mut self.tool_data, tool_data.font_cache, |span| span.kerning.y = value);
}
}
responses.add(OverlaysMessage::Draw);
self.send_layout(responses, LayoutTarget::ToolOptions);
}
@ -172,6 +416,11 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionHandlerData<'a>> for TextToo
Interact,
Abort,
CommitText,
Select
),
TextToolFsmState::Selecting | TextToolFsmState::Wrap => actions!(TextToolMessageDiscriminant;
Interact,
Drag,
),
}
}
@ -194,17 +443,21 @@ enum TextToolFsmState {
#[default]
Ready,
Editing,
Selecting,
Wrap,
}
#[derive(Clone, Debug)]
#[derive(Debug)]
pub struct EditingText {
text: String,
font: Font,
font_size: f64,
text: RichText,
line_length: f64,
path: VectorData,
color: Option<Color>,
transform: DAffine2,
editor: graphene_core::text::cosmic_text::Editor,
composition: Option<usize>,
}
#[derive(Clone, Debug, Default)]
#[derive(Debug, Default)]
struct TextToolData {
layer: LayerNodeIdentifier,
editing_text: Option<EditingText>,
@ -213,51 +466,67 @@ struct TextToolData {
impl TextToolData {
/// Set the editing state of the currently modifying layer
fn set_editing(&self, editable: bool, font_cache: &FontCache, document: &DocumentMessageHandler, responses: &mut VecDeque<Message>) {
if let Some(node_id) = graph_modification_utils::get_fill_id(self.layer, &document.network) {
fn set_editing(&mut self, editable: bool, document: &DocumentMessageHandler, responses: &mut VecDeque<Message>) {
let fill_node = graph_modification_utils::get_fill_id(self.layer, &document.network);
if let Some(node_id) = fill_node {
responses.add(NodeGraphMessage::SetVisibility { node_id, visible: !editable });
}
if let Some(editing_text) = self.editing_text.as_ref().filter(|_| editable) {
if let Some(editing_text) = self.editing_text.as_ref().filter(|_| !editable) {
responses.add(GraphOperationMessage::FillSet {
layer: self.layer,
fill: editing_text.color.map_or(Fill::None, |color| Fill::Solid(color)),
});
}
if let Some(editing_text) = self.editing_text.as_mut().filter(|_| editable) {
responses.add(FrontendMessage::DisplayEditableTextbox {
text: editing_text.text.clone(),
line_width: None,
font_size: editing_text.font_size,
color: editing_text.color.unwrap_or(Color::BLACK),
url: font_cache.get_preview_url(&editing_text.font).cloned().unwrap_or_default(),
text: editing_text.text.text.clone(),
transform: editing_text.transform.to_cols_array(),
});
use graphene_core::text::cosmic_text::{Affinity, Cursor};
if let Some(last) = editing_text.editor.buffer().lines.last() {
let last_index = last.text().len();
let line = editing_text.editor.buffer().lines.len() - 1;
editing_text.editor.set_select_opt(Some(Cursor::new_with_affinity(0, 0, Affinity::Before)));
editing_text.editor.set_cursor(Cursor::new_with_affinity(line, last_index, Affinity::After));
responses.add(OverlaysMessage::Draw);
}
responses.add(OverlaysMessage::Draw);
} else {
responses.add(FrontendMessage::DisplayRemoveEditableTextbox);
}
responses.add(ToolMessage::RefreshToolOptions);
}
fn load_layer_text_node(&mut self, document: &DocumentMessageHandler) -> Option<()> {
fn load_layer_text_node(&mut self, document: &DocumentMessageHandler, font_cache: &FontCache) -> Option<()> {
let transform = document.metadata().transform_to_viewport(self.layer);
let color = graph_modification_utils::get_fill_color(self.layer, &document.network).unwrap_or(Color::BLACK);
let (text, font, font_size) = graph_modification_utils::get_text(self.layer, &document.network)?;
let color = Some(graph_modification_utils::get_fill_color(self.layer, &document.network).unwrap_or(Color::BLACK));
let (text, line_length, path) = graph_modification_utils::get_text(self.layer, &document.network)?;
let editor = graphene_core::text::create_cosmic_editor(&text, font_cache, line_length)?;
self.editing_text = Some(EditingText {
text: text.clone(),
font: font.clone(),
font_size,
color: Some(color),
line_length,
path: path.clone(),
transform,
color,
editor,
composition: None,
});
self.new_text = text.clone();
Some(())
}
fn start_editing_layer(&mut self, layer: LayerNodeIdentifier, tool_state: TextToolFsmState, document: &DocumentMessageHandler, font_cache: &FontCache, responses: &mut VecDeque<Message>) {
if tool_state == TextToolFsmState::Editing {
self.set_editing(false, font_cache, document, responses);
if tool_state != TextToolFsmState::Ready {
self.set_editing(false, document, responses);
}
self.layer = layer;
self.load_layer_text_node(document);
self.load_layer_text_node(document, font_cache);
responses.add(DocumentMessage::StartTransaction);
self.set_editing(true, font_cache, document, responses);
self.set_editing(true, document, responses);
responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![self.layer.to_node()] });
}
@ -280,15 +549,13 @@ impl TextToolData {
responses.add(GraphOperationMessage::NewTextLayer {
id: self.layer.to_node(),
text: String::new(),
font: editing_text.font.clone(),
size: editing_text.font_size,
text: editing_text.text.clone(),
parent: document.new_layer_parent(),
insert_index: -1,
});
responses.add(GraphOperationMessage::FillSet {
responses.add(GraphOperationMessage::TransformSetPivot {
layer: self.layer,
fill: if editing_text.color.is_some() { Fill::Solid(editing_text.color.unwrap()) } else { Fill::None },
pivot: DVec2::ZERO,
});
responses.add(GraphOperationMessage::TransformSet {
layer: self.layer,
@ -297,37 +564,18 @@ impl TextToolData {
skip_rerender: true,
});
self.set_editing(true, font_cache, document, responses);
self.set_editing(true, document, responses);
responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![self.layer.to_node()] });
TextToolFsmState::Editing
} else {
// Removing old text as editable
self.set_editing(false, font_cache, document, responses);
self.set_editing(false, document, responses);
TextToolFsmState::Ready
}
}
fn get_bounds(&self, text: &str, font_cache: &FontCache) -> Option<[DVec2; 2]> {
let editing_text = self.editing_text.as_ref()?;
let buzz_face = font_cache.get(&editing_text.font).map(|data| load_face(data));
let subpaths = graphene_core::text::to_path(text, buzz_face, editing_text.font_size, None);
let bounds = subpaths.iter().filter_map(|subpath| subpath.bounding_box());
let combined_bounds = bounds.reduce(|a, b| [a[0].min(b[0]), a[1].max(b[1])]).unwrap_or_default();
Some(combined_bounds)
}
fn fix_text_bounds(&self, new_text: &str, _document: &DocumentMessageHandler, font_cache: &FontCache, responses: &mut VecDeque<Message>) -> Option<()> {
responses.add(GraphOperationMessage::UpdateBounds {
layer: self.layer,
old_bounds: self.get_bounds(&self.editing_text.as_ref()?.text, font_cache)?,
new_bounds: self.get_bounds(new_text, font_cache)?,
});
Some(())
}
}
fn can_edit_selected(document: &DocumentMessageHandler) -> Option<LayerNodeIdentifier> {
@ -362,47 +610,109 @@ impl Fsm for TextToolFsmState {
return self;
};
match (self, event) {
(TextToolFsmState::Editing, TextToolMessage::Overlays(mut overlay_context)) => {
responses.add(FrontendMessage::DisplayEditableTextboxTransform {
transform: document.metadata().transform_to_viewport(tool_data.layer).to_cols_array(),
});
if let Some(editing_text) = tool_data.editing_text.as_ref() {
let buzz_face = font_cache.get(&editing_text.font).map(|data| load_face(data));
let far = graphene_core::text::bounding_box(&tool_data.new_text, buzz_face, editing_text.font_size, None);
if far.x != 0. && far.y != 0. {
let quad = Quad::from_box([DVec2::ZERO, far]);
let transformed_quad = document.metadata().transform_to_viewport(tool_data.layer) * quad;
overlay_context.quad(transformed_quad);
}
}
(TextToolFsmState::Editing | TextToolFsmState::Selecting | TextToolFsmState::Wrap, TextToolMessage::Overlays(mut overlay_context)) => {
let transform = document.metadata().transform_to_viewport(tool_data.layer);
TextToolFsmState::Editing
}
(_, TextToolMessage::Overlays(mut overlay_context)) => {
for layer in document.selected_nodes.selected_layers(document.metadata()) {
let Some((text, font, font_size)) = graph_modification_utils::get_text(layer, &document.network) else {
continue;
};
let buzz_face = font_cache.get(font).map(|data| load_face(data));
let far = graphene_core::text::bounding_box(text, buzz_face, font_size, None);
let quad = Quad::from_box([DVec2::ZERO, far]);
let multiplied = document.metadata().transform_to_viewport(layer) * quad;
overlay_context.quad(multiplied);
let Some(editing_text) = tool_data.editing_text.as_ref() else { return self };
if editing_text.composition.is_none() {
responses.add(FrontendMessage::DisplayEditableTextbox {
text: editing_text.editor.copy_selection().unwrap_or_default(),
transform: (transform * DAffine2::from_translation(graphene_core::text::cursor_rectangle(&editing_text.editor, &editing_text.text).map_or(DVec2::ZERO, |cursor| cursor[0])))
.to_cols_array(),
});
}
let Some(mut font_system) = font_cache.get_system() else { return self };
let subpaths = graphene_core::text::buffer_to_path(editing_text.editor.buffer(), &mut font_system, &editing_text.text.spans, &editing_text.path);
overlay_context.outline(subpaths.iter(), transform);
let handle = graphene_core::text::find_line_wrap_handle(editing_text.editor.buffer(), &editing_text.text.spans);
overlay_context.manipulator_anchor(transform.transform_point2(handle), self == TextToolFsmState::Wrap, None);
let subpaths = graphene_core::text::selection_shape(&editing_text.editor, &editing_text.text);
overlay_context.outline(subpaths.iter(), transform);
let subpaths = graphene_core::text::cursor_shape(&editing_text.editor, &editing_text.text);
overlay_context.outline(subpaths.iter(), transform);
self
}
(state, TextToolMessage::Interact) => {
tool_data.editing_text = Some(EditingText {
text: String::new(),
transform: DAffine2::from_translation(input.mouse.position),
font_size: tool_options.font_size as f64,
font: Font::new(tool_options.font_name.clone(), tool_options.font_style.clone()),
(_, TextToolMessage::RefreshFonts) => {
if let Some(editing) = &mut tool_data.editing_text {
if let Some(mut font_system) = font_cache.get_system() {
graphene_core::text::create_buffer(editing.editor.buffer_mut(), &mut font_system, &editing.text, &font_cache, editing.line_length);
responses.add(OverlaysMessage::Draw);
}
}
self
}
(_, TextToolMessage::Select) => {
let Some(editing_text) = tool_data.editing_text.as_mut() else { return self };
let handle = graphene_core::text::find_line_wrap_handle(editing_text.editor.buffer(), &editing_text.text.spans);
let to_viewport = document.metadata().transform_to_viewport(tool_data.layer);
if to_viewport.transform_point2(handle).distance_squared(input.mouse.position) < SELECTION_TOLERANCE * SELECTION_TOLERANCE {
return TextToolFsmState::Wrap;
}
let pos = to_viewport.inverse().transform_point2(input.mouse.position);
let Some(cursor) = graphene_core::text::compute_cursor_position(editing_text.editor.buffer(), &editing_text.text, pos)
.filter(|_| graphene_core::text::has_hit_text_bounds(editing_text.editor.buffer(), &editing_text.text.spans, pos.as_vec2()))
else {
return self;
};
editing_text.editor.set_cursor(cursor);
responses.add(ToolMessage::RefreshToolOptions);
editing_text.editor.set_select_opt(None);
responses.add(OverlaysMessage::Draw);
return TextToolFsmState::Selecting;
}
(TextToolFsmState::Selecting, TextToolMessage::Drag) => {
let Some(editing_text) = tool_data.editing_text.as_mut() else { return self };
let pos = document.metadata().transform_to_viewport(tool_data.layer).inverse().transform_point2(input.mouse.position);
if editing_text.editor.select_opt().is_none() {
editing_text.editor.set_select_opt(Some(editing_text.editor.cursor()));
}
if let Some(cursor) = graphene_core::text::compute_cursor_position(editing_text.editor.buffer(), &editing_text.text, pos) {
editing_text.editor.set_cursor(cursor);
responses.add(ToolMessage::RefreshToolOptions);
}
responses.add(OverlaysMessage::Draw);
self
}
(TextToolFsmState::Wrap, TextToolMessage::Drag) => {
let Some(editing_text) = tool_data.editing_text.as_mut() else { return self };
let pos = document.metadata().transform_to_viewport(tool_data.layer).inverse().transform_point2(input.mouse.position);
editing_text.line_length = pos.x.max(0.);
if let Some(mut font_system) = font_cache.get_system() {
graphene_core::text::create_buffer(editing_text.editor.buffer_mut(), &mut font_system, &editing_text.text, font_cache, editing_text.line_length);
}
responses.add(OverlaysMessage::Draw);
self
}
(TextToolFsmState::Selecting | TextToolFsmState::Wrap, TextToolMessage::Interact) => TextToolFsmState::Editing,
(TextToolFsmState::Ready, TextToolMessage::Interact) => {
let font = Arc::new(Font::new(tool_options.font_name.clone(), tool_options.font_style.clone()));
let size = tool_options.font_size as f32;
let text = {
let normal = TextSpan::new(font.clone(), size);
let italic = TextSpan::new(font.clone(), size).offset(5).italic(Some(5.));
let bold = TextSpan::new(font.clone(), size).offset(7).bold(Some(2.));
let letter = TextSpan::new(font.clone(), size).offset(5).letter_spacing(5.);
let word = TextSpan::new(font, size).offset(15).word_spacing(10.);
RichText::new("text italic bold letter spacing word spacing", [normal, italic, bold, letter, word])
};
let line_length = f64::MAX;
let editor = graphene_core::text::create_cosmic_editor(&text, font_cache, line_length);
tool_data.editing_text = editor.map(|editor| EditingText {
text,
editor,
color: tool_options.fill.active_color(),
transform: DAffine2::from_translation(input.mouse.position),
path: VectorData::empty(),
line_length,
composition: None,
});
tool_data.new_text = String::new();
tool_data.interact(state, input.mouse.position, document, font_cache, responses)
tool_data.interact(self, input.mouse.position, document, font_cache, responses)
}
(state, TextToolMessage::EditSelected) => {
if let Some(layer) = can_edit_selected(document) {
@ -413,33 +723,146 @@ impl Fsm for TextToolFsmState {
state
}
(state, TextToolMessage::Abort) => {
if state == TextToolFsmState::Editing {
tool_data.set_editing(false, font_cache, document, responses);
if state != TextToolFsmState::Ready {
tool_data.set_editing(false, document, responses);
}
TextToolFsmState::Ready
}
(TextToolFsmState::Editing, TextToolMessage::CommitText) => {
responses.add(FrontendMessage::TriggerTextCommit);
TextToolFsmState::Editing
}
(TextToolFsmState::Editing, TextToolMessage::TextChange { new_text }) => {
tool_data.fix_text_bounds(&new_text, document, font_cache, responses);
(TextToolFsmState::Editing | TextToolFsmState::Selecting | TextToolFsmState::Wrap, TextToolMessage::CommitText) | (TextToolFsmState::Editing, TextToolMessage::Interact) => {
tool_data.set_editing(false, document, responses);
let Some(editing_text) = tool_data.editing_text.take() else {
responses.add(NodeGraphMessage::RunDocumentGraph);
responses.add(OverlaysMessage::Draw);
return TextToolFsmState::Ready;
};
responses.add(NodeGraphMessage::SetQualifiedInputValue {
node_path: vec![graph_modification_utils::get_text_id(tool_data.layer, &document.network).unwrap()],
input_index: 1,
value: TaggedValue::String(new_text),
value: TaggedValue::RichText(editing_text.text),
});
tool_data.set_editing(false, font_cache, document, responses);
responses.add(NodeGraphMessage::SetQualifiedInputValue {
node_path: vec![graph_modification_utils::get_text_id(tool_data.layer, &document.network).unwrap()],
input_index: 2,
value: TaggedValue::F64(editing_text.line_length),
});
responses.add(NodeGraphMessage::RunDocumentGraph);
responses.add(OverlaysMessage::Draw);
TextToolFsmState::Ready
}
(TextToolFsmState::Editing, TextToolMessage::UpdateBounds { new_text }) => {
tool_data.new_text = new_text;
(_, TextToolMessage::TextInput { input_type, data }) => {
let Some(editing_text) = &mut tool_data.editing_text else { return self };
let Some(mut font_system) = font_cache.get_system() else { return self };
use graphene_core::text::cosmic_text::{Action, AttrsList, BufferLine, Cursor, Shaping};
match input_type.as_str() {
"insertText" | "insertFromPaste" => {
if let Some(data) = data {
editing_text.editor.insert_string(&data, None)
}
}
"insertLineBreak" => editing_text.editor.action(&mut font_system, Action::Enter),
"deleteContentForward" => editing_text.editor.action(&mut font_system, Action::Delete),
"deleteContentBackward" => editing_text.editor.action(&mut font_system, Action::Backspace),
"insertCompositionText" => {
editing_text.editor.delete_selection();
let cursor = editing_text.editor.cursor();
let line = &mut editing_text.editor.buffer_mut().lines[cursor.line];
let after = line.split_off(cursor.index);
let after_len = after.text().len();
line.split_off(line.text().len() - editing_text.composition.unwrap_or(0));
editing_text.composition = data.as_ref().filter(|data| !data.is_empty()).map(|data| data.len());
if let Some(data) = data {
line.append(BufferLine::new(data, AttrsList::new(line.attrs_list().get_span(cursor.index.saturating_sub(1))), Shaping::Advanced));
}
line.append(after);
let index = editing_text.editor.buffer().lines[cursor.line].text().len() - after_len;
editing_text.editor.set_cursor(Cursor { index, ..cursor });
}
"compositionend" => editing_text.composition = None,
input_type => warn!("Unhandled input type {input_type}"),
}
editing_text.text.text.clear();
let mut used = vec![false; editing_text.text.spans.len()];
let mut last_total_offset = 0;
for (index, line) in editing_text.editor.buffer().lines.iter().enumerate() {
if index != 0 {
editing_text.text.text.push('\n');
}
let line_start = editing_text.text.text.len();
let spans = line.attrs_list().spans();
for (val, attrs) in spans {
if used[attrs.metadata] {
continue;
}
let offset = (line_start + val.start) - last_total_offset;
editing_text.text.spans[attrs.metadata].offset = offset;
last_total_offset += offset;
used[attrs.metadata] = true;
}
editing_text.text.text.push_str(line.text());
if !used[line.attrs_list().defaults().metadata] {
let len = editing_text.text.text.len();
let offset = len - last_total_offset;
editing_text.text.spans[line.attrs_list().defaults().metadata].offset = offset;
last_total_offset += offset;
used[line.attrs_list().defaults().metadata] = true;
}
}
used[0] = true;
let mut used = used.iter();
editing_text.text.spans.retain(|_| used.next() == Some(&true));
editing_text.text.spans[0].offset = 0;
graphene_core::text::create_buffer(editing_text.editor.buffer_mut(), &mut font_system, &editing_text.text, font_cache, editing_text.line_length);
responses.add(OverlaysMessage::Draw);
TextToolFsmState::Editing
self
}
(_, TextToolMessage::TextNavigate { key, shift, ctrl }) => {
let Some(editing_text) = &mut tool_data.editing_text else { return self };
let Some(mut font_system) = font_cache.get_system() else { return self };
use graphene_core::text::cosmic_text::Action;
let action = match key.as_str() {
"ArrowLeft" if ctrl => Action::LeftWord,
"ArrowLeft" => Action::Left,
"ArrowRight" if ctrl => Action::RightWord,
"ArrowRight" => Action::Right,
"ArrowUp" => Action::Up,
"ArrowDown" => Action::Down,
"Home" if ctrl => Action::BufferStart,
"Home" => Action::Home,
"End" if ctrl => Action::BufferEnd,
"End" => Action::End,
"a" if ctrl => {
use graphene_core::text::cosmic_text::{Affinity, Cursor};
if let Some(last) = editing_text.editor.buffer().lines.last() {
let last_index = last.text().len();
let line = editing_text.editor.buffer().lines.len() - 1;
editing_text.editor.set_select_opt(Some(Cursor::new_with_affinity(0, 0, Affinity::Before)));
editing_text.editor.set_cursor(Cursor::new_with_affinity(line, last_index, Affinity::After));
responses.add(OverlaysMessage::Draw);
}
return self;
}
_ => return self,
};
if shift {
editing_text
.editor
.set_select_opt(Some(editing_text.editor.select_opt().unwrap_or_else(|| editing_text.editor.cursor())));
} else {
editing_text.editor.set_select_opt(None);
}
editing_text.editor.action(&mut font_system, action);
responses.add(OverlaysMessage::Draw);
self
}
(_, TextToolMessage::WorkingColorChanged) => {
responses.add(TextToolMessage::UpdateOptions(TextOptionsUpdate::WorkingColors(
@ -458,7 +881,7 @@ impl Fsm for TextToolFsmState {
HintGroup(vec![HintInfo::mouse(MouseMotion::Lmb, "Place Text")]),
HintGroup(vec![HintInfo::mouse(MouseMotion::Lmb, "Edit Text")]),
]),
TextToolFsmState::Editing => HintData(vec![
TextToolFsmState::Editing | TextToolFsmState::Selecting | TextToolFsmState::Wrap => HintData(vec![
HintGroup(vec![HintInfo::keys([Key::Escape], "Discard Changes")]),
HintGroup(vec![HintInfo::keys([Key::Control, Key::Enter], "Commit Changes").add_mac_keys([Key::Command, Key::Enter])]),
]),

View file

@ -0,0 +1,3 @@
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path d="m 7.2857146,1.9999998 q 2.4033617,0 3.6302524,0.672269 1.226891,0.6722689 1.226891,2.3697484 0,1.0252098 -0.487422,1.7310921 -0.470588,0.7058824 -1.361344,0.8571429 v 0.084034 q 0.605042,0.1176454 1.092437,0.4201682 0.487369,0.302521 0.773109,0.8739497 0.28574,0.5714287 0.28574,1.5126049 0,1.630253 -1.176471,2.554622 Q 10.092438,14 8.0420174,14 H 3.5546217 V 1.9999998 Z m 0.2857399,4.7563027 q 1.1092438,0 1.529412,-0.3529398 0.4369747,-0.3529399 0.4369747,-1.0252102 0,-0.6890759 -0.5042019,-0.9915973 Q 8.5294378,4.0840342 7.4370005,4.0840342 H 6.0924637 V 6.7563025 Z M 6.0924637,8.7731096 V 11.89916 h 1.6638657 q 1.1428574,0 1.5966388,-0.436974 0.4538083,-0.453782 0.4538083,-1.193278 0,-0.6722687 -0.4705883,-1.0756302 Q 8.88238,8.7731096 7.6723225,8.7731096 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 852 B

View file

@ -0,0 +1,3 @@
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path d="m 4.6302435,14 0.168076,-0.823529 1.428574,-0.336136 2.05042,-9.6806707 -1.277314,-0.3193285 0.168076,-0.8403359 H 11.369757 L 11.201681,2.8403358 9.7563025,3.1596643 7.7058835,12.840335 9.0000005,13.176471 8.8319245,14 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 304 B

View file

@ -2,16 +2,13 @@
import { getContext, onMount, tick } from "svelte";
import type { DocumentState } from "@graphite/state-providers/document";
import { textInputCleanup } from "@graphite/utility-functions/keyboard-entry";
import { extractPixelData, rasterizeSVGCanvas } from "@graphite/utility-functions/rasterization";
import type { Editor } from "@graphite/wasm-communication/editor";
import {
type MouseCursorIcon,
type XY,
DisplayEditableTextbox,
DisplayEditableTextboxTransform,
DisplayRemoveEditableTextbox,
TriggerTextCommit,
TriggerViewportResize,
UpdateDocumentArtwork,
UpdateDocumentRulers,
@ -37,7 +34,7 @@
const document = getContext<DocumentState>("document");
// Interactive text editing
let textInput: undefined | HTMLDivElement = undefined;
let textInput: undefined | HTMLInputElement = undefined;
let showTextInput: boolean;
let textInputMatrix: number[];
@ -286,54 +283,39 @@
canvasCursor = cursorString;
}
// Text entry
export function triggerTextCommit() {
if (!textInput) return;
const textCleaned = textInputCleanup(textInput.innerText);
editor.instance.onChangeText(textCleaned);
}
export async function displayEditableTextbox(displayEditableTextbox: DisplayEditableTextbox) {
showTextInput = true;
if (!showTextInput) {
showTextInput = true;
await tick();
await tick();
if (!textInput) {
return;
}
textInput.style.transformOrigin = "0 0";
textInput.style.width = "20px";
textInput.style.height = "auto";
textInput.style.color = "transparent";
textInput.style.background = "transparent";
textInput.style.opacity = "0";
textInput.onkeydown = (e) => editor.instance.onTextNavigate(e.key, e.shiftKey, e.ctrlKey);
textInput.ondrag = (e) => e.preventDefault();
textInput.onbeforeinput = (e) => {
e.preventDefault();
editor.instance.onTextInput(e.inputType, e.data || undefined);
};
addEventListener("compositionend", (e) => editor.instance.onTextInput("compositionend", e.data || undefined));
}
if (!textInput) {
return;
}
if (displayEditableTextbox.text === "") textInput.textContent = "";
else textInput.textContent = `${displayEditableTextbox.text}\n`;
textInput.contentEditable = "true";
textInput.style.transformOrigin = "0 0";
textInput.style.width = displayEditableTextbox.lineWidth ? `${displayEditableTextbox.lineWidth}px` : "max-content";
textInput.style.height = "auto";
textInput.style.fontSize = `${displayEditableTextbox.fontSize}px`;
textInput.style.color = displayEditableTextbox.color.toHexOptionalAlpha() || "transparent";
textInput.oninput = () => {
if (!textInput) return;
editor.instance.updateBounds(textInputCleanup(textInput.innerText));
};
textInput.value = displayEditableTextbox.text;
textInputMatrix = displayEditableTextbox.transform;
const newFont = new FontFace("text-font", `url(${displayEditableTextbox.url})`);
window.document.fonts.add(newFont);
textInput.style.fontFamily = "text-font";
// Necessary to select contenteditable: https://stackoverflow.com/questions/6139107/programmatically-select-text-in-a-contenteditable-html-element/6150060#6150060
const range = window.document.createRange();
range.selectNodeContents(textInput);
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
selection.addRange(range);
}
textInput.focus();
textInput.click();
textInput.setSelectionRange(0, textInput.value.length);
// Sends the text input element used for interactively editing with the text tool in a custom event
window.dispatchEvent(new CustomEvent("modifyinputfield", { detail: textInput }));
@ -399,19 +381,11 @@
});
// Text entry
editor.subscriptions.subscribeJsMessage(TriggerTextCommit, async () => {
await tick();
triggerTextCommit();
});
editor.subscriptions.subscribeJsMessage(DisplayEditableTextbox, async (data) => {
await tick();
displayEditableTextbox(data);
});
editor.subscriptions.subscribeJsMessage(DisplayEditableTextboxTransform, async (data) => {
textInputMatrix = data.transform;
});
editor.subscriptions.subscribeJsMessage(DisplayRemoveEditableTextbox, async () => {
await tick();
@ -491,7 +465,7 @@
</svg>
<div class="text-input" style:width={canvasWidthCSS} style:height={canvasHeightCSS} style:pointer-events={showTextInput ? "auto" : ""}>
{#if showTextInput}
<div bind:this={textInput} style:transform="matrix({textInputMatrix})" />
<input bind:this={textInput} style:transform="matrix({textInputMatrix})" />
{/if}
</div>
<canvas class="overlays" width={canvasWidthRoundedToEven} height={canvasHeightRoundedToEven} style:width={canvasWidthCSS} style:height={canvasHeightCSS} data-overlays-canvas>

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import { ICONS } from "@graphite/utility-functions/icons";
import type { IconName } from "@graphite/utility-functions/icons";
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
@ -39,6 +40,7 @@
type="checkbox"
id={`checkbox-input-${id}`}
{checked}
style:height={`${ICONS[icon].size}px`}
on:change={(_) => dispatch("checked", inputElement?.checked || false)}
{disabled}
tabindex={disabled ? -1 : 0}
@ -67,7 +69,6 @@
// Unchecked
label {
display: flex;
height: 16px;
// Provides rounded corners for the :focus outline
border-radius: 2px;

View file

@ -4,7 +4,7 @@ import { type DialogState } from "@graphite/state-providers/dialog";
import { type DocumentState } from "@graphite/state-providers/document";
import { type FullscreenState } from "@graphite/state-providers/fullscreen";
import { type PortfolioState } from "@graphite/state-providers/portfolio";
import { makeKeyboardModifiersBitfield, textInputCleanup, getLocalizedScanCode } from "@graphite/utility-functions/keyboard-entry";
import { makeKeyboardModifiersBitfield, getLocalizedScanCode } from "@graphite/utility-functions/keyboard-entry";
import { platformIsMac } from "@graphite/utility-functions/platform";
import { extractPixelData } from "@graphite/utility-functions/rasterization";
import { stripIndents } from "@graphite/utility-functions/strip-indents";
@ -161,7 +161,6 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
const { target } = e;
const isTargetingCanvas = target instanceof Element && target.closest("[data-viewport]");
const inDialog = target instanceof Element && target.closest("[data-dialog] [data-floating-menu-content]");
const inTextInput = target === textToolInteractiveInputElement;
if (get(dialog).visible && !inDialog) {
dialog.dismissDialog();
@ -169,10 +168,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
e.stopPropagation();
}
if (!inTextInput) {
if (textToolInteractiveInputElement) editor.instance.onChangeText(textInputCleanup(textToolInteractiveInputElement.innerText));
else viewportPointerInteractionOngoing = isTargetingCanvas instanceof Element;
}
viewportPointerInteractionOngoing = isTargetingCanvas instanceof Element;
if (viewportPointerInteractionOngoing) {
const modifiers = makeKeyboardModifiersBitfield(e);

View file

@ -94,6 +94,7 @@ import AlignRight from "@graphite-frontend/assets/icon-16px-solid/align-right.sv
import AlignTop from "@graphite-frontend/assets/icon-16px-solid/align-top.svg";
import AlignVerticalCenter from "@graphite-frontend/assets/icon-16px-solid/align-vertical-center.svg";
import Artboard from "@graphite-frontend/assets/icon-16px-solid/artboard.svg";
import Bold from "@graphite-frontend/assets/icon-16px-solid/bold.svg";
import BooleanDifference from "@graphite-frontend/assets/icon-16px-solid/boolean-difference.svg";
import BooleanIntersect from "@graphite-frontend/assets/icon-16px-solid/boolean-intersect.svg";
import BooleanSubtractBack from "@graphite-frontend/assets/icon-16px-solid/boolean-subtract-back.svg";
@ -119,6 +120,7 @@ import GraphViewOpen from "@graphite-frontend/assets/icon-16px-solid/graph-view-
import GraphiteLogo from "@graphite-frontend/assets/icon-16px-solid/graphite-logo.svg";
import IconsGrid from "@graphite-frontend/assets/icon-16px-solid/icons-grid.svg";
import Image from "@graphite-frontend/assets/icon-16px-solid/image.svg";
import Italic from "@graphite-frontend/assets/icon-16px-solid/italic.svg";
import Layer from "@graphite-frontend/assets/icon-16px-solid/layer.svg";
import License from "@graphite-frontend/assets/icon-16px-solid/license.svg";
import NewLayer from "@graphite-frontend/assets/icon-16px-solid/new-layer.svg";
@ -167,6 +169,7 @@ const SOLID_16PX = {
AlignTop: { svg: AlignTop, size: 16 },
AlignVerticalCenter: { svg: AlignVerticalCenter, size: 16 },
Artboard: { svg: Artboard, size: 16 },
Bold: { svg: Bold, size: 16 },
BooleanDifference: { svg: BooleanDifference, size: 16 },
BooleanIntersect: { svg: BooleanIntersect, size: 16 },
BooleanSubtractBack: { svg: BooleanSubtractBack, size: 16 },
@ -192,6 +195,7 @@ const SOLID_16PX = {
GraphViewOpen: { svg: GraphViewOpen, size: 16 },
IconsGrid: { svg: IconsGrid, size: 16 },
Image: { svg: Image, size: 16 },
Italic: { svg: Italic, size: 16 },
Layer: { svg: Layer, size: 16 },
License: { svg: License, size: 16 },
NewLayer: { svg: NewLayer, size: 16 },

View file

@ -11,12 +11,6 @@ export function makeKeyboardModifiersBitfield(e: WheelEvent | PointerEvent | Mou
);
}
// Necessary because innerText puts an extra newline character at the end when the text is more than one line.
export function textInputCleanup(text: string): string {
if (text[text.length - 1] === "\n") return text.slice(0, -1);
return text;
}
// This function tries to find what scan code the user pressed, even if using a non-US keyboard.
// Directly using `KeyboardEvent.code` scan code only works on a US QWERTY layout, because alternate layouts like
// QWERTZ (German) or AZERTY (French) will end up reporting the wrong keys.

View file

@ -580,19 +580,6 @@ export class UpdateDocumentLayerStructureJs extends JsMessage {
export class DisplayEditableTextbox extends JsMessage {
readonly text!: string;
readonly lineWidth!: undefined | number;
readonly fontSize!: number;
@Type(() => Color)
readonly color!: Color;
readonly url!: string;
readonly transform!: number[];
}
export class DisplayEditableTextboxTransform extends JsMessage {
readonly transform!: number[];
}
@ -656,8 +643,6 @@ export class TriggerVisitLink extends JsMessage {
url!: string;
}
export class TriggerTextCommit extends JsMessage {}
export class TriggerTextCopy extends JsMessage {
readonly copyText!: string;
}
@ -1285,7 +1270,6 @@ export const messageMakers: Record<string, MessageMaker> = {
DisplayDialogDismiss,
DisplayDialogPanic,
DisplayEditableTextbox,
DisplayEditableTextboxTransform,
DisplayRemoveEditableTextbox,
TriggerAboutGraphiteLocalizedCommitDate,
TriggerCopyToClipboardBlobUrl,
@ -1305,7 +1289,6 @@ export const messageMakers: Record<string, MessageMaker> = {
TriggerRefreshBoundsOfViewports,
TriggerRevokeBlobUrl,
TriggerSavePreferences,
TriggerTextCommit,
TriggerTextCopy,
TriggerViewportResize,
TriggerVisitLink,

View file

@ -517,15 +517,6 @@ impl JsEditorHandle {
self.dispatch(message);
}
/// A text box was committed
#[wasm_bindgen(js_name = onChangeText)]
pub fn on_change_text(&self, new_text: String) -> Result<(), JsValue> {
let message = TextToolMessage::TextChange { new_text };
self.dispatch(message);
Ok(())
}
/// A font has been downloaded
#[wasm_bindgen(js_name = onFontLoad)]
pub fn on_font_load(&self, font_family: String, font_style: String, preview_url: String, data: Vec<u8>, is_default: bool) -> Result<(), JsValue> {
@ -542,11 +533,18 @@ impl JsEditorHandle {
}
/// A text box was changed
#[wasm_bindgen(js_name = updateBounds)]
pub fn update_bounds(&self, new_text: String) -> Result<(), JsValue> {
let message = TextToolMessage::UpdateBounds { new_text };
#[wasm_bindgen(js_name = onTextInput)]
pub fn on_text_input(&self, input_type: String, data: Option<String>) -> Result<(), JsValue> {
let message = TextToolMessage::TextInput { input_type, data };
self.dispatch(message);
Ok(())
}
/// A text box was changed
#[wasm_bindgen(js_name = onTextNavigate)]
pub fn on_text_navigate(&self, key: String, shift: bool, ctrl: bool) -> Result<(), JsValue> {
let message = TextToolMessage::TextNavigate { key, shift, ctrl };
self.dispatch(message);
Ok(())
}

View file

@ -18,4 +18,4 @@ glam = { version = "0.25", features = ["serde"] }
dyn-any = { version = "0.3.0", path = "../dyn-any", optional = true }
serde = { workspace = true, optional = true }
log = { workspace = true, optional = true }
log = { workspace = true}

View file

@ -111,7 +111,6 @@ impl Debug for Bezier {
debug_struct_ref.field("end", &self.end).finish()
}
}
#[cfg(feature = "dyn-any")]
unsafe impl dyn_any::StaticType for Bezier {
type Static = Bezier;

View file

@ -272,9 +272,9 @@ impl Bezier {
_ => *self,
};
let should_flip_direction = (self.start - intersection).normalize().abs_diff_eq(normal_start, MAX_ABSOLUTE_DIFFERENCE);
let should_flip_direction = (self.start - intersection).normalize_or_zero().abs_diff_eq(normal_start, MAX_ABSOLUTE_DIFFERENCE);
intermediate.apply_transformation(|point| {
let mut direction_unit_vector = (intersection - point).normalize();
let mut direction_unit_vector = (intersection - point).normalize_or_zero();
if should_flip_direction {
direction_unit_vector *= -1.;
}

View file

@ -35,7 +35,7 @@ impl<ManipulatorGroupId: crate::Identifier> Subpath<ManipulatorGroupId> {
/// Both the input global `t` value and the output `t` value are in euclidean space, meaning there is a constant rate of change along the arc length.
pub fn global_euclidean_to_local_euclidean(&self, global_t: f64, lengths: &[f64], total_length: f64) -> (usize, f64) {
let mut accumulator = 0.;
for (index, length) in lengths.iter().enumerate() {
for (index, &length) in lengths.iter().enumerate() {
let length_ratio = length / total_length;
if (index == 0 || accumulator <= global_t) && global_t <= accumulator + length_ratio {
return (index, ((global_t - accumulator) / length_ratio).clamp(0., 1.));

View file

@ -322,8 +322,8 @@ impl<ManipulatorGroupId: crate::Identifier> Subpath<ManipulatorGroupId> {
let in_tangent = in_segment.tangent(TValue::Parametric(1.));
let out_tangent = out_segment.tangent(TValue::Parametric(0.));
let normalized_in_tangent = in_tangent.normalize();
let normalized_out_tangent = out_tangent.normalize();
let normalized_in_tangent = in_tangent.try_normalize()?;
let normalized_out_tangent = out_tangent.try_normalize()?;
// The tangents must not be parallel for the miter join
if !normalized_in_tangent.abs_diff_eq(normalized_out_tangent, MAX_ABSOLUTE_DIFFERENCE) && !normalized_in_tangent.abs_diff_eq(-normalized_out_tangent, MAX_ABSOLUTE_DIFFERENCE) {
@ -333,8 +333,8 @@ impl<ManipulatorGroupId: crate::Identifier> Subpath<ManipulatorGroupId> {
let intersection_to_end = out_segment.start() - intersection;
// Draw the miter join if the intersection occurs in the correct direction with respect to the path
if start_to_intersection.normalize().abs_diff_eq(in_tangent, MAX_ABSOLUTE_DIFFERENCE)
&& intersection_to_end.normalize().abs_diff_eq(out_tangent, MAX_ABSOLUTE_DIFFERENCE)
if start_to_intersection.try_normalize()?.abs_diff_eq(in_tangent, MAX_ABSOLUTE_DIFFERENCE)
&& intersection_to_end.try_normalize()?.abs_diff_eq(out_tangent, MAX_ABSOLUTE_DIFFERENCE)
&& miter_limit >= 1. / (start_to_intersection.angle_between(-intersection_to_end).abs() / 2.).sin()
{
return Some(ManipulatorGroup {

View file

@ -16,7 +16,8 @@ std = [
"glam/std",
"specta",
"num-traits/std",
"rustybuzz",
"cosmic-text",
"unicode-segmentation",
"image",
]
default = ["serde", "kurbo", "log", "std", "rand_chacha", "wasm"]
@ -52,7 +53,8 @@ image = { workspace = true, optional = true, default-features = false, features
"png",
] }
specta = { workspace = true, optional = true }
rustybuzz = { workspace = true, optional = true }
cosmic-text = { workspace = true, optional = true }
unicode-segmentation = { version = "1.10", optional = true }
num-derive = { workspace = true }
num-traits = { workspace = true, default-features = false, features = ["i128"] }
wasm-bindgen = { workspace = true, optional = true }

View file

@ -1,21 +1,116 @@
mod font_cache;
mod to_path;
use crate::application_io::EditorApi;
use core::future::Future;
use crate::vector::VectorData;
use crate::Color;
use crate::{application_io::EditorApi, transform::Footprint};
use alloc::sync::Arc;
use dyn_any::{DynAny, StaticType};
pub use font_cache::*;
use glam::Vec2;
use node_macro::node_fn;
pub use to_path::*;
use crate::Node;
pub struct TextGeneratorNode<Text, FontName, Size> {
text: Text,
font_name: FontName,
font_size: Size,
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct TextSpan {
pub offset: usize,
pub bold: Option<f32>,
pub italic: Option<f32>,
pub font: Arc<Font>,
pub font_size: f32,
pub letter_spacing: f32,
pub word_spacing: f32,
pub line_spacing: f32,
pub kerning: Vec2,
pub color: Color,
}
impl core::hash::Hash for TextSpan {
fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
self.offset.hash(state);
self.bold.map(|x| x.to_bits()).hash(state);
self.italic.map(|x| x.to_bits()).hash(state);
self.font.hash(state);
self.font_size.to_bits().hash(state);
self.letter_spacing.to_bits().hash(state);
self.word_spacing.to_bits().hash(state);
self.line_spacing.to_bits().hash(state);
self.kerning.x.to_bits().hash(state);
self.kerning.y.to_bits().hash(state);
self.color.hash(state);
}
}
impl TextSpan {
pub fn new(font: impl Into<Arc<Font>>, font_size: f32) -> Self {
Self {
offset: 0,
bold: None,
italic: None,
font: font.into(),
font_size,
letter_spacing: 0.,
word_spacing: 0.,
line_spacing: 1.,
kerning: Vec2::ZERO,
color: Color::BLACK,
}
}
pub fn offset(mut self, offset: usize) -> Self {
self.offset = offset;
self
}
pub fn italic(mut self, italic: Option<f32>) -> Self {
self.italic = italic;
self
}
pub fn bold(mut self, bold: Option<f32>) -> Self {
self.bold = bold;
self
}
pub fn letter_spacing(mut self, letter_spacing: f32) -> Self {
self.letter_spacing = letter_spacing;
self
}
pub fn word_spacing(mut self, word_spacing: f32) -> Self {
self.word_spacing = word_spacing;
self
}
}
#[derive(Clone, Debug, Default, Hash, PartialEq, DynAny)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct RichText {
pub text: String,
pub spans: Vec<TextSpan>,
}
impl RichText {
pub fn new(text: impl Into<String>, spans: impl Into<Vec<TextSpan>>) -> Self {
Self {
text: text.into(),
spans: spans.into(),
}
}
}
pub struct TextGeneratorNode<RichTextNode, LineLengthNode, PathNode> {
text: RichTextNode,
line_length: LineLengthNode,
path: PathNode,
}
#[node_fn(TextGeneratorNode)]
fn generate_text<'a: 'input, T>(editor: EditorApi<'a, T>, text: String, font_name: Font, font_size: f64) -> crate::vector::VectorData {
let buzz_face = editor.font_cache.get(&font_name).map(|data| load_face(data));
crate::vector::VectorData::from_subpaths(to_path(&text, buzz_face, font_size, None))
async fn generate_text<'a: 'input, T, FV: Future<Output = VectorData>>(
editor: EditorApi<'a, T>,
text: RichText,
line_length: f64,
path: impl Node<Footprint, Output = FV>,
) -> crate::vector::VectorData {
let path = self.path.eval(editor.render_config.viewport).await;
crate::vector::VectorData::from_subpaths(rich_text_to_path(&text, line_length, &path, editor.font_cache))
}

View file

@ -1,6 +1,8 @@
use dyn_any::{DynAny, StaticType};
use alloc::sync::Arc;
use std::collections::HashMap;
use std::sync::Mutex;
/// A font type (storing font family and font style and an optional preview URL)
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Hash, PartialEq, Eq, DynAny, specta::Type)]
@ -17,33 +19,27 @@ impl Font {
}
/// A cache of all loaded font data and preview urls along with the default font (send from `init_app` in `editor_api.rs`)
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)]
#[derive(Debug, Default, Clone)]
pub struct FontCache {
/// Actual font file data used for rendering a font with ttf_parser and rustybuzz
font_file_data: HashMap<Font, Vec<u8>>,
/// Actual font file data used for rendering a font
pub font_attrs: HashMap<Font, cosmic_text::AttrsOwned>,
/// Web font preview URLs used for showing fonts when live editing
preview_urls: HashMap<Font, String>,
/// The default font (used as a fallback)
default_font: Option<Font>,
pub default_font: Option<Font>,
system: Option<Arc<Mutex<cosmic_text::FontSystem>>>,
}
impl FontCache {
/// Returns the font family name if the font is cached, otherwise returns the default font family name if that is cached
pub fn resolve_font<'a>(&'a self, font: &'a Font) -> Option<&'a Font> {
if self.loaded_font(font) {
Some(font)
} else {
self.default_font.as_ref().filter(|font| self.loaded_font(font))
}
}
/// Try to get the bytes for a font
pub fn get<'a>(&'a self, font: &Font) -> Option<&'a Vec<u8>> {
self.resolve_font(font).and_then(|font| self.font_file_data.get(font))
#[must_use]
fn font_system() -> Arc<Mutex<cosmic_text::FontSystem>> {
// TODO: better locale
Arc::new(Mutex::new(cosmic_text::FontSystem::new_with_locale_and_db("en".to_string(), cosmic_text::fontdb::Database::new())))
}
/// Check if the font is already loaded
#[must_use]
pub fn loaded_font(&self, font: &Font) -> bool {
self.font_file_data.contains_key(font)
self.font_attrs.contains_key(font)
}
/// Insert a new font into the cache
@ -51,19 +47,41 @@ impl FontCache {
if is_default {
self.default_font = Some(font.clone());
}
self.font_file_data.insert(font.clone(), data);
let mut font_system = self.system.get_or_insert_with(Self::font_system).lock().expect("acquire font system");
let data = Arc::new(data);
let db = font_system.db_mut();
let id = db.load_font_source(cosmic_text::fontdb::Source::Binary(data.clone()))[0];
if let Some(face) = db.face(id) {
info!("Face {face:#?}");
let attrs = cosmic_text::AttrsOwned::new(
cosmic_text::Attrs::new()
.family(cosmic_text::Family::Name(&face.families[0].0))
.stretch(face.stretch)
.style(face.style)
.weight(face.weight),
);
self.font_attrs.insert(font.clone(), attrs);
}
self.preview_urls.insert(font, perview_url);
}
/// Checks if the font cache has a default font
#[must_use]
pub fn has_default(&self) -> bool {
self.default_font.is_some()
}
/// Gets the preview URL for showing in text field when live editing
#[must_use]
pub fn get_preview_url(&self, font: &Font) -> Option<&String> {
self.preview_urls.get(font)
}
#[must_use]
pub fn get_system(&self) -> Option<std::sync::MutexGuard<cosmic_text::FontSystem>> {
self.system.as_ref().map(|system| system.lock().expect("acquire font system"))
}
}
impl core::hash::Hash for FontCache {
@ -73,7 +91,13 @@ impl core::hash::Hash for FontCache {
font.hash(state);
url.hash(state)
});
self.font_file_data.len().hash(state);
self.font_file_data.keys().for_each(|font| font.hash(state));
self.font_attrs.len().hash(state);
self.font_attrs.keys().for_each(|font| font.hash(state));
}
}
impl core::cmp::PartialEq for FontCache {
fn eq(&self, other: &Self) -> bool {
self.font_attrs == other.font_attrs
}
}

View file

@ -1,47 +1,54 @@
use crate::uuid::ManipulatorGroupId;
use crate::text::to_path::cosmic_text::Edit;
use crate::{uuid::ManipulatorGroupId, vector::VectorData};
use bezier_rs::{ManipulatorGroup, Subpath};
use glam::{DAffine2, DVec2, Vec2};
pub extern crate cosmic_text;
use super::{FontCache, RichText, TextSpan};
use core::cmp;
use unicode_segmentation::UnicodeSegmentation;
use glam::DVec2;
use rustybuzz::ttf_parser::{GlyphId, OutlineBuilder};
use rustybuzz::{GlyphBuffer, UnicodeBuffer};
/// Builds subpaths out of a glyph outline
struct Builder {
current_subpath: Subpath<ManipulatorGroupId>,
other_subpaths: Vec<Subpath<ManipulatorGroupId>>,
pos: DVec2,
offset: DVec2,
transform: DAffine2,
ascender: f64,
scale: f64,
font_size: f64,
bold: Option<f64>,
italic: Option<f64>,
id: ManipulatorGroupId,
}
impl Builder {
fn point(&self, x: f32, y: f32) -> DVec2 {
self.pos + self.offset + DVec2::new(x as f64, self.ascender - y as f64) * self.scale
fn convert_point(&self, x: f32, y: f32) -> DVec2 {
self.transform
.transform_point2(DVec2::new(x as f64 + y as f64 * self.font_size * self.italic.unwrap_or(0.) / self.ascender, 0. - y as f64) * self.scale)
}
}
impl OutlineBuilder for Builder {
impl cosmic_text::rustybuzz::ttf_parser::OutlineBuilder for Builder {
fn move_to(&mut self, x: f32, y: f32) {
if !self.current_subpath.is_empty() {
self.other_subpaths.push(core::mem::replace(&mut self.current_subpath, Subpath::new(Vec::new(), false)));
}
self.current_subpath.push_manipulator_group(ManipulatorGroup::new_anchor_with_id(self.point(x, y), self.id.next_id()));
self.current_subpath
.push_manipulator_group(ManipulatorGroup::new_anchor_with_id(self.convert_point(x, y), self.id.next_id()));
}
fn line_to(&mut self, x: f32, y: f32) {
self.current_subpath.push_manipulator_group(ManipulatorGroup::new_anchor_with_id(self.point(x, y), self.id.next_id()));
self.current_subpath
.push_manipulator_group(ManipulatorGroup::new_anchor_with_id(self.convert_point(x, y), self.id.next_id()));
}
fn quad_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32) {
let [handle, anchor] = [self.point(x1, y1), self.point(x2, y2)];
let [handle, anchor] = [self.convert_point(x1, y1), self.convert_point(x2, y2)];
self.current_subpath.last_manipulator_group_mut().unwrap().out_handle = Some(handle);
self.current_subpath.push_manipulator_group(ManipulatorGroup::new_anchor_with_id(anchor, self.id.next_id()));
}
fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x3: f32, y3: f32) {
let [handle1, handle2, anchor] = [self.point(x1, y1), self.point(x2, y2), self.point(x3, y3)];
let [handle1, handle2, anchor] = [self.convert_point(x1, y1), self.convert_point(x2, y2), self.convert_point(x3, y3)];
self.current_subpath.last_manipulator_group_mut().unwrap().out_handle = Some(handle1);
self.current_subpath
.push_manipulator_group(ManipulatorGroup::new_with_id(anchor, Some(handle2), None, self.id.next_id()));
@ -49,129 +56,389 @@ impl OutlineBuilder for Builder {
fn close(&mut self) {
self.current_subpath.set_closed(true);
if let Some(bold) = self.bold {
self.current_subpath = self.current_subpath.offset(-bold * self.scale * self.font_size, bezier_rs::Join::Miter(None));
}
self.other_subpaths.push(core::mem::replace(&mut self.current_subpath, Subpath::new(Vec::new(), false)));
}
}
fn font_properties(buzz_face: &rustybuzz::Face, font_size: f64) -> (f64, f64, UnicodeBuffer) {
let scale = (buzz_face.units_per_em() as f64).recip() * font_size;
let line_height = font_size;
let buffer = UnicodeBuffer::new();
(scale, line_height, buffer)
#[must_use]
pub fn rich_text_to_path(text: &RichText, line_length: f64, path: &VectorData, font_cache: &FontCache) -> Vec<Subpath<ManipulatorGroupId>> {
let Some(mut font_system) = font_cache.get_system() else { return Vec::new() };
let mut buffer = construct_buffer(text, &mut font_system);
create_buffer(&mut buffer, &mut font_system, text, font_cache, line_length);
buffer_to_path(&buffer, &mut font_system, &text.spans, path)
}
fn push_str(buffer: &mut UnicodeBuffer, word: &str, trailing_space: bool) {
buffer.push_str(word);
if trailing_space {
buffer.push_str(" ");
}
}
fn wrap_word(line_width: Option<f64>, glyph_buffer: &GlyphBuffer, scale: f64, x_pos: f64) -> bool {
if let Some(line_width) = line_width {
let word_length: i32 = glyph_buffer.glyph_positions().iter().map(|pos| pos.x_advance).sum();
let scaled_word_length = word_length as f64 * scale;
if scaled_word_length + x_pos > line_width {
return true;
}
}
false
}
pub fn to_path(str: &str, buzz_face: Option<rustybuzz::Face>, font_size: f64, line_width: Option<f64>) -> Vec<Subpath<ManipulatorGroupId>> {
let buzz_face = match buzz_face {
Some(face) => face,
// Show blank layer if font has not loaded
None => return vec![],
};
let (scale, line_height, mut buffer) = font_properties(&buzz_face, font_size);
#[must_use]
pub fn buffer_to_path(buffer: &cosmic_text::Buffer, font_system: &mut cosmic_text::FontSystem, spans: &[TextSpan], path: &VectorData) -> Vec<Subpath<ManipulatorGroupId>> {
let mut builder = Builder {
current_subpath: Subpath::new(Vec::new(), false),
other_subpaths: Vec::new(),
pos: DVec2::ZERO,
offset: DVec2::ZERO,
ascender: (buzz_face.ascender() as f64 / buzz_face.height() as f64) * font_size / scale,
scale,
transform: DAffine2::IDENTITY,
ascender: 0.,
scale: 1.,
font_size: 1.,
bold: None,
italic: None,
id: ManipulatorGroupId::ZERO,
};
let subpath = path
.stroke_bezier_paths()
.map(|mut subpath| {
subpath.apply_transform(path.transform);
for line in str.split('\n') {
let length = line.split(' ').count();
for (index, word) in line.split(' ').enumerate() {
push_str(&mut buffer, word, index != length - 1);
let glyph_buffer = rustybuzz::shape(&buzz_face, &[], buffer);
(subpath.iter().map(|bezier| bezier.length(None)).collect::<Vec<f64>>(), subpath)
})
.find(|(_, subpath)| !subpath.is_empty());
if wrap_word(line_width, &glyph_buffer, scale, builder.pos.x) {
builder.pos = DVec2::new(0., builder.pos.y + line_height);
}
// Inspect the output runs
let mut offset;
for run in buffer.layout_runs() {
offset = DVec2::ZERO;
for glyph_position in run.glyphs.iter() {
let Some(font) = font_system.get_font(glyph_position.font_id) else { continue };
let buzz_face = font.rustybuzz();
builder.scale = glyph_position.font_size as f64 / buzz_face.units_per_em() as f64;
builder.font_size = glyph_position.font_size as f64;
builder.ascender = (buzz_face.ascender() as f64 / buzz_face.height() as f64) * glyph_position.font_size as f64 / builder.scale;
let span = &spans[glyph_position.metadata];
builder.bold = span.bold.map(|x| x as f64);
builder.italic = span.italic.map(|x| x as f64);
for (glyph_position, glyph_info) in glyph_buffer.glyph_positions().iter().zip(glyph_buffer.glyph_infos()) {
if let Some(line_width) = line_width {
if builder.pos.x + (glyph_position.x_advance as f64 * builder.scale) >= line_width {
builder.pos = DVec2::new(0., builder.pos.y + line_height);
}
let glyph_offset = DVec2::new(glyph_position.x_offset as f64, glyph_position.y_offset as f64) + span.kerning.as_dvec2() + offset;
if let Some((lengths, subpath)) = &subpath {
let total_length: f64 = lengths.iter().sum();
let eval_euclidean = |dist: f64| {
let (segment_index, segment_t_euclidean) = subpath.global_euclidean_to_local_euclidean(dist / total_length, lengths.as_slice(), total_length);
let segment = subpath.get_segment(segment_index).unwrap();
let segment_t_parametric = segment.euclidean_to_parametric_with_total_length(segment_t_euclidean, 0., lengths[segment_index]);
segment.evaluate(bezier_rs::TValue::Parametric(segment_t_parametric))
};
// Text on path based on https://svgwg.org/svg2-draft/text.html#TextpathLayoutRules
let left_x = glyph_position.x as f64 + glyph_offset.x;
let right_x = left_x + glyph_position.w as f64;
let centre_x = (left_x + right_x) / 2.;
if right_x >= total_length {
break;
}
builder.offset = DVec2::new(glyph_position.x_offset as f64, glyph_position.y_offset as f64) * builder.scale;
buzz_face.outline_glyph(GlyphId(glyph_info.glyph_id as u16), &mut builder);
if !builder.current_subpath.is_empty() {
builder.other_subpaths.push(core::mem::replace(&mut builder.current_subpath, Subpath::new(Vec::new(), false)));
}
builder.pos += DVec2::new(glyph_position.x_advance as f64, glyph_position.y_advance as f64) * builder.scale;
let left = eval_euclidean(left_x);
let right = eval_euclidean(right_x);
let centre = eval_euclidean(centre_x);
let angle = DVec2::X.angle_between(right - left);
let angle = if angle.is_finite() { angle } else { 0. };
builder.transform = DAffine2::from_translation(centre) * DAffine2::from_angle(angle) * DAffine2::from_translation(DVec2::X * (left_x - centre_x));
} else {
let pos = DVec2::new(glyph_position.x as f64, glyph_position.y as f64 + run.line_y as f64);
builder.transform = DAffine2::from_translation(pos + glyph_offset);
}
buzz_face.outline_glyph(cosmic_text::rustybuzz::ttf_parser::GlyphId(glyph_position.glyph_id), &mut builder);
buffer = glyph_buffer.clear();
if &run.text[glyph_position.start..glyph_position.end] == " " {
offset.x += span.word_spacing as f64;
}
offset.x += span.letter_spacing as f64;
}
builder.pos = DVec2::new(0., builder.pos.y + line_height);
}
builder.other_subpaths
}
pub fn bounding_box(str: &str, buzz_face: Option<rustybuzz::Face>, font_size: f64, line_width: Option<f64>) -> DVec2 {
let buzz_face = match buzz_face {
Some(face) => face,
// Show blank layer if font has not loaded
None => return DVec2::ZERO,
};
#[must_use]
pub fn has_hit_text_bounds(buffer: &cosmic_text::Buffer, spans: &[TextSpan], pos: Vec2) -> bool {
let mut offset;
let mut min = Vec2::MAX;
let mut max = Vec2::MIN;
let line_height = buffer.metrics().line_height;
for run in buffer.layout_runs() {
offset = Vec2::ZERO;
for glyph_position in run.glyphs.iter() {
let span = &spans[glyph_position.metadata];
let (scale, line_height, mut buffer) = font_properties(&buzz_face, font_size);
let glyph_offset = Vec2::new(glyph_position.x_offset, glyph_position.y_offset) + span.kerning + offset;
let glyph_pos = Vec2::new(glyph_position.x, glyph_position.y + run.line_y) + glyph_offset;
let mut pos = DVec2::ZERO;
let mut bounds = DVec2::ZERO;
let space = &run.text[glyph_position.start..glyph_position.end] == " ";
let spacing = span.letter_spacing + if space { span.word_spacing } else { 0. };
min = min.min(Vec2::new(glyph_pos.x, glyph_pos.y - line_height));
max = max.max(Vec2::new(glyph_pos.x + spacing + glyph_position.w, glyph_pos.y));
for line in str.split('\n') {
let length = line.split(' ').count();
for (index, word) in line.split(' ').enumerate() {
push_str(&mut buffer, word, index != length - 1);
let glyph_buffer = rustybuzz::shape(&buzz_face, &[], buffer);
if wrap_word(line_width, &glyph_buffer, scale, pos.x) {
pos = DVec2::new(0., pos.y + line_height);
}
for glyph_position in glyph_buffer.glyph_positions() {
if let Some(line_width) = line_width {
if pos.x + (glyph_position.x_advance as f64 * scale) >= line_width {
pos = DVec2::new(0., pos.y + line_height);
}
}
pos += DVec2::new(glyph_position.x_advance as f64, glyph_position.y_advance as f64) * scale;
}
bounds = bounds.max(pos + DVec2::new(0., line_height));
buffer = glyph_buffer.clear();
offset.x += spacing;
}
}
min.x <= pos.x && min.y <= pos.y && pos.x <= max.x && pos.y <= max.y
}
#[must_use]
pub fn find_line_wrap_handle(buffer: &cosmic_text::Buffer, spans: &[TextSpan]) -> DVec2 {
if buffer.size().0 != f64::MAX as f32 {
return DVec2::new(buffer.size().0 as f64, buffer.metrics().line_height as f64);
}
let mut offset = 0.;
let mut line = 0;
let mut max = 0_f32;
for run in buffer.layout_runs() {
if run.line_i != line {
offset = 0.;
}
line = run.line_i;
for glyph_position in run.glyphs.iter() {
let span = &spans[glyph_position.metadata];
max = max.max(glyph_position.x + glyph_position.w + offset + glyph_position.x_offset);
if &run.text[glyph_position.start..glyph_position.end] == " " {
offset += span.word_spacing;
}
offset += span.letter_spacing;
}
pos = DVec2::new(0., pos.y + line_height);
}
bounds
DVec2::new(max as f64, buffer.metrics().line_height as f64)
}
pub fn load_face(data: &[u8]) -> rustybuzz::Face {
rustybuzz::Face::from_slice(data, 0).expect("Loading font failed")
fn get_cursor_in_run(cursor: &cosmic_text::Cursor, run: &cosmic_text::LayoutRun) -> Option<(usize, f32)> {
if cursor.line != run.line_i {
return None;
}
for (glyph_i, glyph) in run.glyphs.iter().enumerate() {
if cursor.index == glyph.start {
return Some((glyph_i, 0.0));
} else if cursor.index > glyph.start && cursor.index < glyph.end {
// Guess x offset based on characters
let mut before = 0;
let mut total = 0;
let cluster = &run.text[glyph.start..glyph.end];
for (i, _) in cluster.grapheme_indices(true) {
if glyph.start + i < cursor.index {
before += 1;
}
total += 1;
}
let offset_x = glyph.w * (before as f32) / (total as f32);
return Some((glyph_i, offset_x));
}
}
match run.glyphs.last() {
Some(glyph) => {
if cursor.index == glyph.end {
return Some((run.glyphs.len(), 0.0));
}
}
None => {
return Some((0, 0.0));
}
}
None
}
pub fn selection_shape(editor: &cosmic_text::Editor, text: &RichText) -> Vec<Subpath<ManipulatorGroupId>> {
let Some(select) = editor.select_opt() else { return Vec::new() };
let (start, end) = match select.line.cmp(&editor.cursor().line) {
cmp::Ordering::Greater => (editor.cursor(), select),
cmp::Ordering::Less => (select, editor.cursor()),
cmp::Ordering::Equal => {
if select.index < editor.cursor().index {
(select, editor.cursor())
} else {
(editor.cursor(), select)
}
}
};
let mut result = Vec::new();
let line_height = editor.buffer().metrics().line_height as f64;
let mut offset_spacing;
for run in editor.buffer().layout_runs() {
offset_spacing = DVec2::ZERO;
let run: cosmic_text::LayoutRun = run;
if run.line_i >= start.line && run.line_i <= end.line {
let mut range_opt: Option<(f32, f32)> = None;
for glyph in run.glyphs {
let spans = &text.spans[glyph.metadata];
// Guess x offset based on characters
let cluster = &run.text[glyph.start..glyph.end];
let total = cluster.grapheme_indices(true).count();
let mut c_x = glyph.x;
let c_w = glyph.w / total as f32;
let spacing = if &run.text[glyph.start..glyph.end] == " " { spans.word_spacing as f64 } else { 0. } + spans.letter_spacing as f64;
for (i, c) in cluster.grapheme_indices(true) {
let c_start = glyph.start + i;
let c_end = glyph.start + i + c.len();
if (start.line != run.line_i || c_end > start.index) && (end.line != run.line_i || c_start < end.index) {
range_opt = match range_opt.take() {
Some((min, max)) => Some((min.min(c_x + offset_spacing.x as f32), max.max(c_x + c_w + offset_spacing.x as f32 + spacing as f32))),
None => Some((c_x + offset_spacing.x as f32, (c_x + c_w + offset_spacing.x as f32 + spacing as f32))),
};
} else if let Some((min, max)) = range_opt.take() {
result.push(Subpath::new_rect(
DVec2::new(min as f64, run.line_top as f64),
DVec2::new(max as f64, run.line_top as f64 + line_height),
));
}
c_x += c_w;
}
offset_spacing.x += spacing;
}
if let Some((mut min, mut max)) = range_opt.take() {
if end.line > run.line_i {
// Draw to end of line
if run.rtl {
min = 0.;
} else {
max = if editor.buffer().size().0 == f32::MAX { max } else { editor.buffer().size().0 };
}
}
result.push(Subpath::new_rect(
DVec2::new(min as f64, run.line_top as f64),
DVec2::new(max as f64, run.line_top as f64 + line_height),
));
}
}
}
result
}
pub fn cursor_rectangle(editor: &cosmic_text::Editor, text: &RichText) -> Option<[DVec2; 2]> {
let line_height = editor.buffer().metrics().line_height as f64;
for run in editor.buffer().layout_runs() {
let Some((cursor_glyph, cursor_glyph_offset)) = get_cursor_in_run(&editor.cursor(), &run) else {
continue;
};
let letter_spacing: f32 = run.glyphs.iter().take(cursor_glyph).map(|glyph| text.spans[glyph.metadata].letter_spacing).sum();
let spaces = run.glyphs.iter().take(cursor_glyph).filter(|glyph| &run.text[glyph.start..glyph.end] == " ");
let word_spacing: f32 = spaces.map(|glyph| text.spans[glyph.metadata].word_spacing).sum();
let x = match run.glyphs.get(cursor_glyph) {
Some(glyph) => {
// Start of detected glyph
if glyph.level.is_rtl() {
glyph.x + glyph.w - cursor_glyph_offset
} else {
glyph.x + cursor_glyph_offset
}
}
None => match run.glyphs.last() {
Some(glyph) => {
// End of last glyph
if glyph.level.is_rtl() {
glyph.x
} else {
glyph.x + glyph.w
}
} // Start of empty line
None => 0.,
},
};
return Some([
DVec2::new(x as f64 + letter_spacing as f64 + word_spacing as f64, run.line_top as f64),
DVec2::new(x as f64 + 1. + letter_spacing as f64 + word_spacing as f64, run.line_top as f64 + line_height),
]);
}
None
}
pub fn cursor_shape(editor: &cosmic_text::Editor, text: &RichText) -> Vec<Subpath<ManipulatorGroupId>> {
if let Some(cursor) = cursor_rectangle(editor, text) {
vec![Subpath::new_rect(cursor[0], cursor[1])]
} else {
Vec::new()
}
}
pub fn compute_cursor_position(buffer: &cosmic_text::Buffer, text: &RichText, mut pos: DVec2) -> Option<cosmic_text::Cursor> {
// Adjust for letter and word spacing
let cosmic_text::Metrics { font_size, line_height } = buffer.metrics();
let length = buffer.layout_runs().size_hint().0;
for (index, run) in buffer.layout_runs().enumerate() {
let first = index == 0;
let last = index + 1 == length;
let line_y = run.line_y;
if !((pos.y as f32 >= line_y - font_size || first) && ((pos.y as f32) < line_y - font_size + line_height || last)) {
continue;
}
for glyph in run.glyphs {
let span = &text.spans[glyph.metadata];
let space = &run.text[glyph.start..glyph.end] == " ";
let spacing = span.letter_spacing + if space { span.word_spacing } else { 0. };
if glyph.x <= pos.x as f32 && glyph.x + glyph.w + spacing >= pos.x as f32 {
pos.x = glyph.x as f64 + ((pos.x - glyph.x as f64) / (glyph.w + spacing) as f64).clamp(0., 1.) * glyph.w as f64;
} else if glyph.x + glyph.w + spacing <= pos.x as f32 {
pos.x -= spacing as f64;
}
}
}
// Compute on the underlying text with the updated position
buffer.hit(pos.x as f32, pos.y as f32)
}
pub fn create_buffer(buffer: &mut cosmic_text::Buffer, font_system: &mut cosmic_text::FontSystem, text: &RichText, font_cache: &FontCache, line_length: f64) {
// Set a size for the text buffer, in pixels
buffer.set_size(font_system, line_length as f32, f32::MAX);
buffer.set_metrics(font_system, create_metrics(text));
// Add our rich text spans
let mut start = 0;
let mut next_spans = text.spans.iter().skip(1);
let spans = text.spans.iter().enumerate().filter_map(|(metadata, span)| {
start += span.offset;
start = start.min(text.text.len());
let text = next_spans.next().map_or(&text.text[start..], |next| &text.text[start..(start + next.offset).min(text.text.len())]);
create_cosmic_attrs(font_cache, span, metadata).map(|attrs| (text, attrs))
});
buffer.set_rich_text(font_system, spans, cosmic_text::Shaping::Advanced);
// Trailing new line
if text.text.as_bytes().last().is_some_and(|&c| c == b'\n') {
let span_index = text.spans.len() - 1;
if let Some(attrs) = create_cosmic_attrs(font_cache, &text.spans[span_index], span_index) {
buffer.lines.push(cosmic_text::BufferLine::new("", cosmic_text::AttrsList::new(attrs), cosmic_text::Shaping::Advanced));
}
}
// Perform shaping as desired
buffer.shape_until_scroll(font_system);
}
#[must_use]
fn create_cosmic_attrs<'a>(font_cache: &'a FontCache, span: &TextSpan, metadata: usize) -> Option<cosmic_text::Attrs<'a>> {
font_cache
.font_attrs
.get(&*span.font)
.or_else(|| font_cache.default_font.as_ref().and_then(|font| font_cache.font_attrs.get(font)))
.map(|attrs| attrs.as_attrs().metadata(metadata))
}
#[must_use]
fn construct_buffer(text: &RichText, font_system: &mut cosmic_text::FontSystem) -> cosmic_text::Buffer {
let metrics = create_metrics(text);
// A Buffer provides shaping and layout for a UTF-8 string, create one per text widget
cosmic_text::Buffer::new(font_system, metrics)
}
#[must_use]
fn create_metrics(text: &RichText) -> cosmic_text::Metrics {
let max_font_size = text.spans.iter().map(|span| span.font_size).max_by(|a, b| a.partial_cmp(b).unwrap()).unwrap_or(24.);
let max_line = text.spans.iter().map(|span| span.font_size * span.line_spacing).max_by(|a, b| a.partial_cmp(b).unwrap()).unwrap_or(24.);
cosmic_text::Metrics::new(max_font_size, max_line)
}
#[must_use]
pub fn create_cosmic_editor(text: &RichText, font_cache: &FontCache, line_length: f64) -> Option<cosmic_text::Editor> {
let mut font_system = font_cache.get_system()?;
let mut buffer = construct_buffer(text, &mut font_system);
create_buffer(&mut buffer, &mut font_system, text, font_cache, line_length);
Some(cosmic_text::Editor::new(buffer))
}

View file

@ -3,6 +3,7 @@ use crate::proto::{ConstructionArgs, ProtoNetwork, ProtoNode, ProtoNodeInput};
use dyn_any::{DynAny, StaticType};
pub use graphene_core::uuid::generate_uuid;
use graphene_core::vector::VectorData;
use graphene_core::{GraphicGroup, ProtoNodeIdentifier, Type};
use glam::IVec2;
@ -1160,42 +1161,40 @@ impl NodeNetwork {
self.nodes.extend(extraction_nodes);
}
/// Due to the adaptive resolution system, nodes that take a `GraphicGroup` as input must call the upstream node with the `Footprint` parameter.
/// Due to the adaptive resolution system, nodes that take a `GraphicGroup` or `VectorData` as input must call the upstream node with the `Footprint` parameter.
///
/// However, in the case of the default input, we must insert a node that takes an input of `Footprint` and returns `GraphicGroup::Empty`, in order to satisfy the type system.
/// However, in the case of the default input, we must insert a node that takes an input of `Footprint` and returns `GraphicGroup::Empty` or `VectorData::empty()`, in order to satisfy the type system.
/// This is because the standard value node takes in `()`.
pub fn resolve_empty_stacks(&mut self) {
const EMPTY_STACK: &str = "Empty Stack";
let new_id = generate_uuid();
let mut used = false;
for target_tagged_value in [TaggedValue::GraphicGroup(GraphicGroup::EMPTY), TaggedValue::VectorData(VectorData::empty())] {
let new_id = generate_uuid();
let mut used = false;
// We filter out the newly inserted empty stack in case `resolve_empty_stacks` runs multiple times.
for node in self.nodes.values_mut().filter(|node| node.name != EMPTY_STACK) {
for input in &mut node.inputs {
if let NodeInput::Value {
tagged_value: TaggedValue::GraphicGroup(graphic_group),
..
} = input
{
if *graphic_group == GraphicGroup::EMPTY {
*input = NodeInput::node(NodeId(new_id), 0);
used = true;
// We filter out the newly inserted empty stack in case `resolve_empty_stacks` runs multiple times.
for node in self.nodes.values_mut().filter(|node| node.name != EMPTY_STACK) {
for input in &mut node.inputs {
if let NodeInput::Value { tagged_value, .. } = input {
if *tagged_value == target_tagged_value {
*input = NodeInput::node(NodeId(new_id), 0);
used = true;
}
}
}
}
}
// Only insert the node if necessary.
if used {
let new_node = DocumentNode {
name: EMPTY_STACK.to_string(),
implementation: DocumentNodeImplementation::proto("graphene_core::transform::CullNode<_>"),
manual_composition: Some(concrete!(graphene_core::transform::Footprint)),
inputs: vec![NodeInput::value(TaggedValue::GraphicGroup(graphene_core::GraphicGroup::EMPTY), false)],
..Default::default()
};
self.nodes.insert(NodeId(new_id), new_node);
// Only insert the node if necessary.
if used {
let new_node = DocumentNode {
name: EMPTY_STACK.to_string(),
implementation: DocumentNodeImplementation::proto("graphene_core::transform::CullNode<_>"),
manual_composition: Some(concrete!(graphene_core::transform::Footprint)),
inputs: vec![NodeInput::value(target_tagged_value, false)],
..Default::default()
};
self.nodes.insert(NodeId(new_id), new_node);
}
}
}

View file

@ -73,6 +73,7 @@ pub enum TaggedValue {
Footprint(graphene_core::transform::Footprint),
RenderOutput(RenderOutput),
Palette(Vec<Color>),
RichText(graphene_core::text::RichText),
}
#[allow(clippy::derived_hash_with_manual_eq)]
@ -150,6 +151,7 @@ impl Hash for TaggedValue {
Self::Footprint(x) => x.hash(state),
Self::RenderOutput(x) => x.hash(state),
Self::Palette(x) => x.hash(state),
Self::RichText(x) => x.hash(state),
}
}
}
@ -214,6 +216,7 @@ impl<'a> TaggedValue {
TaggedValue::Footprint(x) => Box::new(x),
TaggedValue::RenderOutput(x) => Box::new(x),
TaggedValue::Palette(x) => Box::new(x),
TaggedValue::RichText(x) => Box::new(x),
}
}
@ -228,6 +231,7 @@ impl<'a> TaggedValue {
TaggedValue::Bool(x) => x.to_string(),
TaggedValue::BlendMode(x) => "BlendMode::".to_string() + &x.to_string(),
TaggedValue::Color(x) => format!("Color {x:?}"),
TaggedValue::RichText(x) => format!("\"rich{}\"", x.text),
_ => panic!("Cannot convert to primitive string"),
}
}
@ -290,6 +294,7 @@ impl<'a> TaggedValue {
TaggedValue::Footprint(_) => concrete!(graphene_core::transform::Footprint),
TaggedValue::RenderOutput(_) => concrete!(RenderOutput),
TaggedValue::Palette(_) => concrete!(Vec<Color>),
TaggedValue::RichText(_) => concrete!(graphene_core::text::RichText),
}
}
@ -357,6 +362,7 @@ impl<'a> TaggedValue {
}
x if x == TypeId::of::<graphene_core::transform::Footprint>() => Ok(TaggedValue::Footprint(*downcast(input).unwrap())),
x if x == TypeId::of::<Vec<Color>>() => Ok(TaggedValue::Palette(*downcast(input).unwrap())),
x if x == TypeId::of::<graphene_core::text::RichText>() => Ok(TaggedValue::RichText(*downcast(input).unwrap())),
_ => Err(format!("Cannot convert {:?} to TaggedValue", DynAny::type_name(input.as_ref()))),
}
}

View file

@ -767,7 +767,7 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
input: Vec<graphene_core::vector::bezier_rs::Subpath<graphene_core::uuid::ManipulatorGroupId>>,
params: [Vec<graphene_core::uuid::ManipulatorGroupId>]
),
register_node!(graphene_core::text::TextGeneratorNode<_, _, _>, input: WasmEditorApi, params: [String, graphene_core::text::Font, f64]),
async_node!(graphene_core::text::TextGeneratorNode<_, _, _>, input: WasmEditorApi, output: VectorData, fn_params: [() => graphene_core::text::RichText, () => f64, Footprint => graphene_core::vector::VectorData]),
register_node!(graphene_std::brush::VectorPointsNode, input: VectorData, params: []),
register_node!(graphene_core::ExtractImageFrame, input: WasmEditorApi, params: []),
async_node!(graphene_core::ConstructLayerNode<_, _>, input: Footprint, output: GraphicGroup, fn_params: [Footprint => graphene_core::GraphicElement, Footprint => GraphicGroup]),