diff --git a/Cargo.lock b/Cargo.lock index 7bc5c599c..f45b2d3dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 9986f8e36..e74525dbc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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", diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index 9de224f98..665d808eb 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -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, - #[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, diff --git a/editor/src/messages/input_mapper/default_mapping.rs b/editor/src/messages/input_mapper/default_mapping.rs index 4920f8d72..c412da375 100644 --- a/editor/src/messages/input_mapper/default_mapping.rs +++ b/editor/src/messages/input_mapper/default_mapping.rs @@ -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), diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs index 55685f9f8..3fb129388 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs @@ -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, }, diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs index 44430644d..f74ddb70b 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs @@ -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> 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)); } } } diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs index fcc748fcb..e3e4bc172 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -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(), ); diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_types.rs b/editor/src/messages/portfolio/document/node_graph/document_node_types.rs index 8dd3bfe1d..38f14b038 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_types.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_types.rs @@ -2482,9 +2482,9 @@ fn static_nodes() -> Vec { name: "Text Generator".to_string(), inputs: vec![ NodeInput::Network(concrete!(application_io::EditorApi)), - 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 { }), 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, diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index 206fc71a0..5e66e30c1 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -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, Option>) { - 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 { 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 { - 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 { diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index d6042afd7..b652917c8 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -264,8 +264,9 @@ impl MessageHandler> 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 => { diff --git a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs index a2a395adf..ea6e144c1 100644 --- a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs +++ b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs @@ -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 { diff --git a/editor/src/messages/tool/tool_messages/text_tool.rs b/editor/src/messages/tool/tool_messages/text_tool.rs index 6f1c0c1c5..8349f338c 100644 --- a/editor/src/messages/tool/tool_messages/text_tool.rs +++ b/editor/src/messages/tool/tool_messages/text_tool.rs @@ -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 }, + 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, Option), } @@ -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 { - 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 { .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 { .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 { .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> for TextTool { fn process_message(&mut self, message: ToolMessage, responses: &mut VecDeque, tool_data: &mut ToolActionHandlerData<'a>) { let ToolMessage::Text(TextToolMessage::UpdateOptions(action)) = message else { @@ -143,23 +346,64 @@ impl<'a> MessageHandler> 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> 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, transform: DAffine2, + editor: graphene_core::text::cosmic_text::Editor, + composition: Option, } -#[derive(Clone, Debug, Default)] +#[derive(Debug, Default)] struct TextToolData { layer: LayerNodeIdentifier, editing_text: Option, @@ -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) { - 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) { + 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) { - 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) -> 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 { @@ -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])]), ]), diff --git a/frontend/assets/icon-16px-solid/bold.svg b/frontend/assets/icon-16px-solid/bold.svg new file mode 100644 index 000000000..49ef7d476 --- /dev/null +++ b/frontend/assets/icon-16px-solid/bold.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/icon-16px-solid/italic.svg b/frontend/assets/icon-16px-solid/italic.svg new file mode 100644 index 000000000..ea69b89b7 --- /dev/null +++ b/frontend/assets/icon-16px-solid/italic.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/components/panels/Document.svelte b/frontend/src/components/panels/Document.svelte index e51ba58b1..0bebb2064 100644 --- a/frontend/src/components/panels/Document.svelte +++ b/frontend/src/components/panels/Document.svelte @@ -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("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 @@
{#if showTextInput} -
+ {/if}
diff --git a/frontend/src/components/widgets/inputs/CheckboxInput.svelte b/frontend/src/components/widgets/inputs/CheckboxInput.svelte index 8bc6c5856..e290deaf5 100644 --- a/frontend/src/components/widgets/inputs/CheckboxInput.svelte +++ b/frontend/src/components/widgets/inputs/CheckboxInput.svelte @@ -1,6 +1,7 @@