diff --git a/desktop/wrapper/src/intercept_frontend_message.rs b/desktop/wrapper/src/intercept_frontend_message.rs index f32ea1ccc3..4e141046cc 100644 --- a/desktop/wrapper/src/intercept_frontend_message.rs +++ b/desktop/wrapper/src/intercept_frontend_message.rs @@ -8,8 +8,7 @@ use super::messages::{DesktopFrontendMessage, FileFilter, OpenFileDialogContext, pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageDispatcher, message: FrontendMessage) -> Option { match message { FrontendMessage::Await { future } => { - let message = futures::executor::block_on(async move { future.await }); - return intercept_frontend_message(dispatcher, message); + dispatcher.queue_editor_message(futures::executor::block_on(async move { future.await })); } FrontendMessage::RenderOverlays { context } => { dispatcher.respond(DesktopFrontendMessage::UpdateOverlays(context.take_scene())); diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index 52ec511d1d..eecc563f48 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -1,7 +1,7 @@ use super::IconName; use super::utility_types::{MouseCursorIcon, PersistedState}; use crate::messages::app_window::app_window_message_handler::AppWindowPlatform; -use crate::messages::frontend::utility_types::{DocumentInfo, EyedropperPreviewImage, FrontendMessageFuture}; +use crate::messages::frontend::utility_types::{DocumentInfo, EyedropperPreviewImage, MessageFuture}; use crate::messages::input_mapper::utility_types::misc::ActionShortcut; use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::portfolio::document::node_graph::utility_types::{ @@ -31,7 +31,7 @@ pub enum FrontendMessage { #[serde(skip, default)] #[derivative(Debug = "ignore", PartialEq = "ignore")] #[cfg_attr(feature = "wasm", tsify(type = "unknown"))] - future: FrontendMessageFuture, + future: MessageFuture, }, // Display prefix: make the frontend show something, like a dialog @@ -123,6 +123,10 @@ pub enum FrontendMessage { font: Font, url: String, }, + TriggerFontRegister { + name: String, + data: serde_bytes::ByteBuf, + }, TriggerPersistenceReadState, TriggerPersistenceReadDocument { #[serde(rename = "documentId")] diff --git a/editor/src/messages/frontend/mod.rs b/editor/src/messages/frontend/mod.rs index 03ffecbc2b..28ba98c7b7 100644 --- a/editor/src/messages/frontend/mod.rs +++ b/editor/src/messages/frontend/mod.rs @@ -4,7 +4,7 @@ pub mod utility_types; #[doc(inline)] pub use frontend_message::{FrontendMessage, FrontendMessageDiscriminant}; -pub use utility_types::FrontendMessageFuture; +pub use utility_types::MessageFuture; // TODO: Make this an enum with the actual icon names, somehow derived from or tied to the frontend icon set. // TODO: Then remove `#[widget_builder(string)]` from all icon fields. diff --git a/editor/src/messages/frontend/utility_types.rs b/editor/src/messages/frontend/utility_types.rs index 32a4ccf40b..76539623f3 100644 --- a/editor/src/messages/frontend/utility_types.rs +++ b/editor/src/messages/frontend/utility_types.rs @@ -87,23 +87,23 @@ pub struct EyedropperPreviewImage { } #[derive(Clone, Default)] -pub struct FrontendMessageFuture { - inner: Arc>>, +pub struct MessageFuture { + inner: Arc>>, } -impl FrontendMessageFuture { - pub fn new(future: impl Future + Send + 'static) -> Self { +impl MessageFuture { + pub fn new(future: impl Future + Send + 'static) -> Self { Self { inner: Arc::new(Mutex::new(Some(Box::pin(future)))), } } } -type InnerFrontendMessageFuture = Pin + Send + 'static>>; +type InnerMessageFuture = Pin + Send + 'static>>; -impl IntoFuture for FrontendMessageFuture { - type Output = FrontendMessage; - type IntoFuture = InnerFrontendMessageFuture; +impl IntoFuture for MessageFuture { + type Output = Message; + type IntoFuture = InnerMessageFuture; fn into_future(self) -> Self::IntoFuture { self.inner diff --git a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs index a19aac95bf..06eff3ebc5 100644 --- a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs @@ -147,6 +147,7 @@ impl std::hash::Hash for MenuListEntry { self.label.hash(state); self.icon.hash(state); self.disabled.hash(state); + self.font.hash(state); } } diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 4a6cc18d2a..5934e9f1a2 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -931,7 +931,7 @@ impl MessageHandler> for DocumentMes let name = format!("{}.{}", self.name.clone(), FILE_EXTENSION); responses.add(FrontendMessage::Await { - future: FrontendMessageFuture::new(async move { + future: MessageFuture::new(async move { let loads = resource_hashes .into_iter() .map(|hash| { @@ -950,6 +950,7 @@ impl MessageHandler> for DocumentMes folder, content: content.into_bytes().into(), } + .into() }), }); } @@ -2663,17 +2664,7 @@ impl DocumentMessageHandler { /// Loads all of the fonts in the document. pub fn load_layer_resources(&self, responses: &mut VecDeque) { - let mut fonts_to_load = HashSet::new(); - - for (_, node, _) in self.document_network().recursive_nodes() { - for input in &node.inputs { - if let Some(TaggedValue::Font(font)) = input.as_value() { - fonts_to_load.insert(font.clone()); - } - } - } - - for font in fonts_to_load { + for font in super::utility_types::text_resource_resolution::fonts_missing_text_resources(self.document_network()) { responses.add(PortfolioMessage::LoadFontData { font }); } } 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 fcfe128492..90a8936ed5 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -251,9 +251,10 @@ impl<'a> ModifyInputsContext<'a> { let text = resolve_proto_node_type(graphene_std::text::text::IDENTIFIER) .expect("Text node does not exist") .node_template_input_override([ - Some(NodeInput::scope("editor-api")), + None, Some(NodeInput::value(TaggedValue::String(text), false)), Some(NodeInput::value(TaggedValue::Font(font), false)), + Some(NodeInput::value(TaggedValue::None, false)), Some(NodeInput::value(TaggedValue::F64(typesetting.font_size), false)), Some(NodeInput::value(TaggedValue::F64(typesetting.line_height_ratio), false)), Some(NodeInput::value(TaggedValue::F64(typesetting.character_spacing), false)), 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 a58576c408..465325d392 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -850,7 +850,12 @@ pub fn font_inputs(parameter_widgets_info: ParameterWidgetsInfo) -> (Vec (Vec::INDEX, + value: TaggedValue::None, + } + .into(), + PortfolioMessage::LoadFontData { font: load_font }.into(), ]), } } @@ -925,11 +938,18 @@ pub fn font_inputs(parameter_widgets_info: ParameterWidgetsInfo) -> (Vec::INDEX, + value: TaggedValue::None, + } + .into(), + PortfolioMessage::LoadFontData { font: load_font }.into(), ]), } } diff --git a/editor/src/messages/portfolio/document/overlays/source-sans-pro-regular.ttf b/editor/src/messages/portfolio/document/overlays/source-sans-pro-regular.ttf deleted file mode 100644 index ffe27865aa..0000000000 Binary files a/editor/src/messages/portfolio/document/overlays/source-sans-pro-regular.ttf and /dev/null differ diff --git a/editor/src/messages/portfolio/document/overlays/utility_functions.rs b/editor/src/messages/portfolio/document/overlays/utility_functions.rs index cdc6968ee5..e6b5f58f2e 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_functions.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_functions.rs @@ -6,7 +6,7 @@ use crate::messages::tool::common_functionality::shape_editor::{SelectedLayerSta use crate::messages::tool::tool_messages::tool_prelude::DocumentMessageHandler; use glam::{DAffine2, DVec2}; use graphene_std::subpath::{Bezier, BezierHandles}; -use graphene_std::text::{Font, FontCache, TextAlign, TextContext, TypesettingConfig}; +use graphene_std::text::{Blob, FALLBACK_FONT_BYTES, Font, TextAlign, TextContext, TypesettingConfig, load_font}; use graphene_std::vector::misc::ManipulatorPointId; use graphene_std::vector::{PointId, SegmentId, Vector}; use std::collections::HashMap; @@ -221,15 +221,9 @@ pub fn path_endpoint_overlays(document: &DocumentMessageHandler, shape_editor: & } } -// Global lazy initialized font cache and text context -pub static GLOBAL_FONT_CACHE: LazyLock = LazyLock::new(|| { - let mut font_cache = FontCache::default(); - // Initialize with the hardcoded font used by overlay text - const FONT_DATA: &[u8] = include_bytes!("source-sans-pro-regular.ttf"); - let font = Font::new("Source Sans Pro".to_string(), "Regular".to_string()); - font_cache.insert(font, FONT_DATA.to_vec()); - font_cache -}); +// Global lazy initialized font data and text context used for overlay text measurement. +// Reuses the embedded fallback font bytes from the text node so we don't ship a second copy of the file. +pub static OVERLAY_FONT_BLOB: LazyLock> = LazyLock::new(|| load_font(FALLBACK_FONT_BYTES)); pub static GLOBAL_TEXT_CONTEXT: LazyLock> = LazyLock::new(|| Mutex::new(TextContext::default())); @@ -249,7 +243,7 @@ pub fn text_width(text: &str, font_size: f64) -> f64 { // TODO: And maybe use the WOFF2 version (if it's supported) for its smaller, compressed file size. let font = Font::new("Source Sans Pro".to_string(), "Regular".to_string()); let mut text_context = GLOBAL_TEXT_CONTEXT.lock().expect("Failed to lock global text context"); - let bounds = text_context.bounding_box(text, &font, &GLOBAL_FONT_CACHE, typesetting, false); + let bounds = text_context.bounding_box(text, &font, &OVERLAY_FONT_BLOB, typesetting, false); bounds.x } diff --git a/editor/src/messages/portfolio/document/overlays/utility_types_native.rs b/editor/src/messages/portfolio/document/overlays/utility_types_native.rs index acda35d859..80145d1f72 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_native.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_native.rs @@ -3,7 +3,7 @@ use crate::consts::{ COLOR_OVERLAY_YELLOW_DULL, COMPASS_ROSE_ARROW_SIZE, COMPASS_ROSE_HOVER_RING_DIAMETER, COMPASS_ROSE_MAIN_RING_DIAMETER, COMPASS_ROSE_RING_INNER_DIAMETER, DOWEL_PIN_RADIUS, GRADIENT_MIDPOINT_DIAMOND_RADIUS, MANIPULATOR_GROUP_MARKER_SIZE, PIVOT_CROSSHAIR_LENGTH, PIVOT_CROSSHAIR_THICKNESS, PIVOT_DIAMETER, RESIZE_HANDLE_SIZE, SKEW_TRIANGLE_OFFSET, SKEW_TRIANGLE_SIZE, }; -use crate::messages::portfolio::document::overlays::utility_functions::{GLOBAL_FONT_CACHE, GLOBAL_TEXT_CONTEXT, hex_to_rgba_u8}; +use crate::messages::portfolio::document::overlays::utility_functions::{GLOBAL_TEXT_CONTEXT, OVERLAY_FONT_BLOB, hex_to_rgba_u8}; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::prelude::Message; use crate::messages::prelude::ViewportMessageHandler; @@ -1122,14 +1122,14 @@ impl OverlayContextInternal { // Get text dimensions directly from layout let mut text_context = GLOBAL_TEXT_CONTEXT.lock().expect("Failed to lock global text context"); - let text_size = text_context.bounding_box(text, &font, &GLOBAL_FONT_CACHE, typesetting, false); + let text_size = text_context.bounding_box(text, &font, &OVERLAY_FONT_BLOB, typesetting, false); let text_width = text_size.x; let text_height = text_size.y; // Create a rect from the size (assuming text starts at origin) let text_bounds = kurbo::Rect::new(0., 0., text_width, text_height); // Convert text to vector paths for rendering - let text_list = text_context.to_path(text, &font, &GLOBAL_FONT_CACHE, typesetting, false); + let text_list = text_context.to_path(text, &font, &OVERLAY_FONT_BLOB, typesetting, false); // Calculate position based on pivot let mut position = DVec2::ZERO; diff --git a/editor/src/messages/portfolio/document/utility_types/mod.rs b/editor/src/messages/portfolio/document/utility_types/mod.rs index 93d3e8b552..645776ee58 100644 --- a/editor/src/messages/portfolio/document/utility_types/mod.rs +++ b/editor/src/messages/portfolio/document/utility_types/mod.rs @@ -5,5 +5,6 @@ pub mod error; pub mod misc; pub mod network_interface; pub mod nodes; +pub mod text_resource_resolution; pub mod transformation; pub mod wires; diff --git a/editor/src/messages/portfolio/document/utility_types/text_resource_resolution.rs b/editor/src/messages/portfolio/document/utility_types/text_resource_resolution.rs new file mode 100644 index 0000000000..6dc2723232 --- /dev/null +++ b/editor/src/messages/portfolio/document/utility_types/text_resource_resolution.rs @@ -0,0 +1,103 @@ +use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, NodeNetworkInterface}; +use crate::messages::portfolio::utility_types::FontCache; +use graph_craft::application_io::ResourceHash; +use graph_craft::document::value::TaggedValue; +use graph_craft::document::{DocumentNodeImplementation, NodeId, NodeInput, NodeNetwork}; +use graphene_std::NodeInputDecleration; +use graphene_std::text::Font; + +const TEXT_NODE_FONT_RESOURCE_INPUT_INDEX: usize = 3; + +pub fn patch_text_nodes_with_loaded_font(network_interface: &mut NodeNetworkInterface, target_font: &Font, hash: ResourceHash) -> bool { + let updates = collect_text_node_updates(network_interface.document_network(), &[], &mut |font, current| { + if font != target_font { + return false; + } + matches!(current, TaggedValue::None) + }); + + let patched_any = !updates.is_empty(); + for (network_path, node_id, _font) in updates { + network_interface.set_input( + &InputConnector::node(node_id, TEXT_NODE_FONT_RESOURCE_INPUT_INDEX), + NodeInput::value(TaggedValue::Resource(hash), false), + &network_path, + ); + } + patched_any +} + +pub fn fonts_missing_text_resources(network: &NodeNetwork) -> Vec { + let updates = collect_text_node_updates(network, &[], &mut |_font, current| matches!(current, TaggedValue::None)); + + let mut fonts = Vec::new(); + for (_network_path, _node_id, font) in updates { + if !fonts.contains(&font) { + fonts.push(font); + } + } + fonts +} + +pub fn refresh_text_node_font_resources(network_interface: &mut NodeNetworkInterface, font_cache: &FontCache) -> Vec { + let updates = collect_text_node_updates(network_interface.document_network(), &[], &mut |_font, _current| true); + + let mut needs_loading = Vec::new(); + for (network_path, node_id, font) in updates { + let current_input = network_interface + .input_from_connector(&InputConnector::node(node_id, TEXT_NODE_FONT_RESOURCE_INPUT_INDEX), &network_path) + .cloned(); + + let current_value = current_input.as_ref().and_then(|input| input.as_value()); + + if font_cache.contains(&font) { + if matches!(current_value, Some(TaggedValue::None)) && !needs_loading.contains(&font) { + needs_loading.push(font); + } + } else { + if !matches!(current_value, Some(TaggedValue::None)) { + network_interface.set_input( + &InputConnector::node(node_id, TEXT_NODE_FONT_RESOURCE_INPUT_INDEX), + NodeInput::value(TaggedValue::None, false), + &network_path, + ); + } + if !needs_loading.contains(&font) { + needs_loading.push(font); + } + } + } + + needs_loading +} + +fn collect_text_node_updates(network: &NodeNetwork, parent_path: &[NodeId], predicate: &mut dyn FnMut(&Font, &TaggedValue) -> bool) -> Vec<(Vec, NodeId, Font)> { + let mut out = Vec::new(); + for (node_id, node) in network.nodes.iter() { + if let DocumentNodeImplementation::Network(nested) = &node.implementation { + let mut path = parent_path.to_vec(); + path.push(*node_id); + out.extend(collect_text_node_updates(nested, &path, predicate)); + continue; + } + + let is_text_node = matches!(&node.implementation, DocumentNodeImplementation::ProtoNode(identifier) if *identifier == graphene_std::text::text::IDENTIFIER); + if !is_text_node { + continue; + } + + let Some(NodeInput::Value { tagged_value, .. }) = node.inputs.get(graphene_std::text::text::FontInput::INDEX) else { + continue; + }; + let TaggedValue::Font(font) = (**tagged_value).clone() else { continue }; + + let Some(NodeInput::Value { tagged_value: resource_value, .. }) = node.inputs.get(TEXT_NODE_FONT_RESOURCE_INPUT_INDEX) else { + continue; + }; + + if predicate(&font, resource_value) { + out.push((parent_path.to_vec(), *node_id, font)); + } + } + out +} diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index 72fd196138..83ef25cc5c 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -1501,6 +1501,22 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], for i in 10..=12 { document.network_interface.set_input(&InputConnector::node(*node_id, i), old_inputs[i - 2].clone(), network_path); } + + inputs_count = 13; + } + + // Convert text nodes to use resource for loading font data. + if reference == DefinitionIdentifier::ProtoNode(graphene_std::text::text::IDENTIFIER) && inputs_count == 13 && matches!(node.inputs.first(), Some(NodeInput::Scope(_))) { + let mut template: NodeTemplate = resolve_document_node_type(&reference)?.default_node_template(); + document.network_interface.replace_implementation(node_id, network_path, &mut template); + let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut template)?; + + document.network_interface.set_input(&InputConnector::node(*node_id, 1), old_inputs[1].clone(), network_path); + document.network_interface.set_input(&InputConnector::node(*node_id, 2), old_inputs[2].clone(), network_path); + #[allow(clippy::needless_range_loop)] + for i in 3..=12 { + document.network_interface.set_input(&InputConnector::node(*node_id, i + 1), old_inputs[i].clone(), network_path); + } } // Upgrade Sine, Cosine, and Tangent nodes to include a boolean input for whether the output should be in radians, which was previously the only option but is now not the default diff --git a/editor/src/messages/portfolio/portfolio_message.rs b/editor/src/messages/portfolio/portfolio_message.rs index 629c6332d5..f3ed254f9a 100644 --- a/editor/src/messages/portfolio/portfolio_message.rs +++ b/editor/src/messages/portfolio/portfolio_message.rs @@ -5,8 +5,10 @@ use crate::messages::frontend::utility_types::{ExportBounds, FileType, Persisted use crate::messages::portfolio::document::utility_types::clipboards::Clipboard; use crate::messages::portfolio::utility_types::FontCatalog; use crate::messages::prelude::*; +use graph_craft::application_io::ResourceHash; use graphene_std::Color; use graphene_std::raster::Image; +use graphene_std::resource::Resource; use graphene_std::text::Font; use std::path::PathBuf; @@ -62,6 +64,12 @@ pub enum PortfolioMessage { font_style: String, data: Vec, }, + FontResouceLoaded { + font: Font, + hash: ResourceHash, + #[serde(skip, default)] + resource: Resource, + }, LoadDocumentResources { document_id: DocumentId, }, diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index 219c05d06c..664ba286d2 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -391,57 +391,53 @@ impl MessageHandler> for Portfolio if let Some(style) = self.cached_data.font_catalog.find_font_style_in_catalog(&font) { let font = Font::new(font.font_family, style.to_named_style()); - if !self.cached_data.font_cache.loaded_font(&font) { + if let Some(hash) = self.cached_data.font_cache.get_hash(&font) { + let mut patched_any = false; + for document in self.documents.values_mut() { + patched_any |= + crate::messages::portfolio::document::utility_types::text_resource_resolution::patch_text_nodes_with_loaded_font(&mut document.network_interface, &font, hash); + } + if patched_any { + responses.add(NodeGraphMessage::RunDocumentGraph); + } + } else { responses.add(FrontendMessage::TriggerFontDataLoad { font, url: style.url }); } } } PortfolioMessage::FontLoaded { font_family, font_style, data } => { let font = Font::new(font_family, font_style); - self.cached_data.font_cache.insert(font, data); - self.executor.update_font_cache(self.cached_data.font_cache.clone()); - - for document_id in self.document_ids.iter() { - let node_to_inspect = self.node_to_inspect(); - - let Some(document) = self.documents.get_mut(document_id) else { - if self.unloaded_documents.contains_key(document_id) { - continue; - } - log::error!("Tried to render non-existent document"); - continue; - }; - - let document_to_viewport = document - .navigation_handler - .calculate_offset_transform(viewport.center_in_viewport_space().into(), &document.document_ptz); - let pointer_position = document_to_viewport.inverse().transform_point2(ipp.mouse.position); + let data = Arc::<[u8]>::from(data.as_ref()); + let hash = graph_craft::application_io::ResourceHash::from(data.as_ref()); - let scale = viewport.scale(); - // Use exact physical dimensions from browser (via ResizeObserver's devicePixelContentBoxSize) - let physical_resolution = viewport.size().to_physical().into_dvec2().round().as_uvec2(); + responses.add(ResourceMessage::Store { data }); - // TODO: Remove this when we do the SVG rendering with a separate library on desktop, thus avoiding a need for the hole punch. - // TODO: See #3796. There is a second instance of this todo comment and code block (be sure to remove both). - #[cfg(not(target_family = "wasm"))] - responses.add_front(FrontendMessage::UpdateViewportHolePunch { - active: document.render_mode != graphene_std::vector::style::RenderMode::SvgPreview, - }); - - if let Ok(message) = self - .executor - .submit_node_graph_evaluation(document, *document_id, physical_resolution, scale, timing_information, node_to_inspect, true, pointer_position) - { - responses.add_front(message); - } + for document in self.documents.values_mut() { + crate::messages::portfolio::document::utility_types::text_resource_resolution::patch_text_nodes_with_loaded_font(&mut document.network_interface, &font, hash); } if self.active_document_mut().is_some() { responses.add(NodeGraphMessage::RunDocumentGraph); } - if current_tool == &ToolType::Text { - responses.add(TextToolMessage::RefreshEditingFontData); + let resources = resources.resources(); + responses.add(FrontendMessage::Await { + future: MessageFuture::new(async move { + let Some(resource) = resources.load(hash).await else { + return Message::NoOp; + }; + PortfolioMessage::FontResouceLoaded { font, hash, resource }.into() + }), + }); + } + PortfolioMessage::FontResouceLoaded { font, resource, hash } => { + responses.add(FrontendMessage::TriggerFontRegister { + name: format!("font-resource-{hash}"), + data: serde_bytes::ByteBuf::from(resource.as_ref()), + }); + self.cached_data.font_cache.insert(font.clone(), hash, resource); + if self.active_document_mut().is_some() { + responses.add(NodeGraphMessage::RunDocumentGraph); } } PortfolioMessage::EditorPreferences => self.executor.update_editor_preferences(preferences.editor_preferences()), @@ -458,6 +454,7 @@ impl MessageHandler> for Portfolio for document in self.documents.values() { used_resources.extend(document.used_resources(true)); } + used_resources.extend(self.cached_data.font_cache.used_resources()); responses.add(ResourceMessage::GarbageCollect { used: Vec::from_iter(used_resources).into_boxed_slice(), }); diff --git a/editor/src/messages/portfolio/utility_types.rs b/editor/src/messages/portfolio/utility_types.rs index ebb39c5c96..1a0d8cf34f 100644 --- a/editor/src/messages/portfolio/utility_types.rs +++ b/editor/src/messages/portfolio/utility_types.rs @@ -1,6 +1,9 @@ +use graph_craft::application_io::ResourceHash; use graphene_std::Color; use graphene_std::raster::Image; -use graphene_std::text::{Font, FontCache}; +use graphene_std::resource::Resource; +use graphene_std::text::{Font, FontSource}; +use vello::peniko::Blob; /// Proportional share (0-1) for the document panel's side when splitting adjacent to non-document panels. const DOCUMENT_PANEL_SHARE: f64 = 0.8; @@ -13,6 +16,49 @@ pub struct CachedData { pub font_catalog: FontCatalog, } +#[derive(Clone, Default)] +pub struct FontCache { + pub font_hashes: std::collections::HashMap, +} + +#[derive(Clone)] +struct FontCacheEntry { + hash: ResourceHash, + data: Resource, +} + +impl std::fmt::Debug for FontCache { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FontCache").field("fonts", &self.font_hashes.keys().collect::>()).finish() + } +} + +impl FontCache { + pub fn get_data(&self, font: &Font) -> Option { + self.font_hashes.get(font).map(|entry| entry.data.clone()) + } + + pub fn get_blob_or_fallback(&self, font: &Font) -> Blob { + self.get_data(font).map(|resource| Blob::new((&resource).into())).unwrap_or_else(|| ().font_blob()) + } + + pub fn get_hash(&self, font: &Font) -> Option { + self.font_hashes.get(font).map(|entry| entry.hash) + } + + pub fn contains(&self, font: &Font) -> bool { + self.font_hashes.contains_key(font) + } + + pub fn insert(&mut self, font: Font, hash: ResourceHash, data: Resource) { + self.font_hashes.insert(font, FontCacheEntry { hash, data }); + } + + pub fn used_resources(&self) -> std::collections::HashSet { + self.font_hashes.values().map(|entry| entry.hash).collect() + } +} + // TODO: Should this be a BTreeMap instead? #[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)] pub struct FontCatalog(pub Vec); @@ -80,14 +126,6 @@ impl FontCatalogStyle { let italic = named_style.contains("Italic ("); FontCatalogStyle { weight, italic, url: url.into() } } - - /// Get the URL for the stylesheet for loading a font preview for this style of the given family name, subsetted to only the letters in the family name. - pub fn preview_url(&self, family: impl Into) -> String { - let name = family.into().replace(' ', "+"); - let italic = if self.italic { "ital," } else { "" }; - let weight = self.weight; - format!("https://fonts.googleapis.com/css2?display=swap&family={name}:{italic}wght@{weight}&text={name}") - } } #[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))] diff --git a/editor/src/messages/prelude.rs b/editor/src/messages/prelude.rs index 36ac6314e8..6ace5ab619 100644 --- a/editor/src/messages/prelude.rs +++ b/editor/src/messages/prelude.rs @@ -15,7 +15,7 @@ pub use crate::messages::dialog::export_dialog::{ExportDialogMessage, ExportDial pub use crate::messages::dialog::new_document_dialog::{NewDocumentDialogMessage, NewDocumentDialogMessageDiscriminant, NewDocumentDialogMessageHandler}; pub use crate::messages::dialog::preferences_dialog::{PreferencesDialogMessage, PreferencesDialogMessageContext, PreferencesDialogMessageDiscriminant, PreferencesDialogMessageHandler}; pub use crate::messages::dialog::{DialogMessage, DialogMessageContext, DialogMessageDiscriminant, DialogMessageHandler}; -pub use crate::messages::frontend::{FrontendMessage, FrontendMessageDiscriminant, FrontendMessageFuture}; +pub use crate::messages::frontend::{FrontendMessage, FrontendMessageDiscriminant, MessageFuture}; pub use crate::messages::input_mapper::key_mapping::{KeyMappingMessage, KeyMappingMessageContext, KeyMappingMessageDiscriminant, KeyMappingMessageHandler}; pub use crate::messages::input_mapper::{InputMapperMessage, InputMapperMessageContext, InputMapperMessageDiscriminant, InputMapperMessageHandler}; pub use crate::messages::input_preprocessor::{InputPreprocessorMessage, InputPreprocessorMessageContext, InputPreprocessorMessageDiscriminant, InputPreprocessorMessageHandler}; diff --git a/editor/src/messages/tool/common_functionality/utility_functions.rs b/editor/src/messages/tool/common_functionality/utility_functions.rs index 567672498c..2f3956a39d 100644 --- a/editor/src/messages/tool/common_functionality/utility_functions.rs +++ b/editor/src/messages/tool/common_functionality/utility_functions.rs @@ -5,6 +5,7 @@ use crate::messages::portfolio::document::node_graph::document_node_definitions: use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::{NodeNetworkInterface, OutputConnector}; use crate::messages::portfolio::document::utility_types::transformation::Selected; +use crate::messages::portfolio::utility_types::FontCache; use crate::messages::prelude::*; use crate::messages::tool::common_functionality::graph_modification_utils::{NodeGraphLayer, get_text}; use crate::messages::tool::common_functionality::transformation_cage::SelectedEdges; @@ -16,7 +17,6 @@ use graph_craft::document::value::TaggedValue; use graphene_std::list::List; use graphene_std::renderer::Quad; use graphene_std::subpath::{Bezier, BezierHandles}; -use graphene_std::text::FontCache; use graphene_std::vector::algorithms::bezpath_algorithms::pathseg_compute_lookup_table; use graphene_std::vector::misc::{HandleId, ManipulatorPointId, dvec2_to_point}; use graphene_std::vector::{HandleExt, PointId, SegmentId, Vector, VectorModification, VectorModificationType}; @@ -73,7 +73,8 @@ pub fn text_bounding_box(layer: LayerNodeIdentifier, document: &DocumentMessageH let Some((text, font, typesetting, _)) = get_text(layer, &document.network_interface) else { return Quad::from_box([DVec2::ZERO, DVec2::ZERO]); }; - let far = graphene_std::text::bounding_box(text, font, font_cache, typesetting, false); + let blob = font_cache.get_blob_or_fallback(font); + let far = graphene_std::text::bounding_box(text, font, &blob, typesetting, false); Quad::from_box([DVec2::ZERO, far]) } diff --git a/editor/src/messages/tool/tool_messages/text_tool.rs b/editor/src/messages/tool/tool_messages/text_tool.rs index 7c94b8d82c..acd8e5bb05 100644 --- a/editor/src/messages/tool/tool_messages/text_tool.rs +++ b/editor/src/messages/tool/tool_messages/text_tool.rs @@ -6,7 +6,7 @@ use crate::messages::portfolio::document::graph_operation::utility_types::Transf use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::InputConnector; -use crate::messages::portfolio::utility_types::{CachedData, FontCatalog, FontCatalogStyle}; +use crate::messages::portfolio::utility_types::{CachedData, FontCache, FontCatalog, FontCatalogStyle}; use crate::messages::tool::common_functionality::auto_panning::AutoPanning; use crate::messages::tool::common_functionality::color_selector::{ ToolColorOptions, apply_fill_only_color_pick, apply_fill_only_enabled, refresh_slot_working_color, selection_changed_since_last_sync, solid, sync_fill_only, @@ -22,7 +22,7 @@ use graph_craft::document::{NodeId, NodeInput}; use graphene_std::choice_type::ChoiceTypeStatic; use graphene_std::color::SRGBA8; use graphene_std::renderer::Quad; -use graphene_std::text::{Font, FontCache, TextAlign, TypesettingConfig, lines_clipping}; +use graphene_std::text::{Font, TextAlign, TypesettingConfig, lines_clipping}; use graphene_std::vector::style::{Fill, FillChoice, FillChoiceUI}; use graphene_std::{Color, NodeInputDecleration}; @@ -104,7 +104,7 @@ impl ToolMetadata for TextTool { } } -fn create_text_widgets(tool: &TextTool, font_catalog: &FontCatalog, document: &DocumentMessageHandler) -> Vec { +fn create_text_widgets(tool: &TextTool, font_catalog: &FontCatalog, font_cache: &FontCache, document: &DocumentMessageHandler) -> Vec { // If a single text layer is selected, the control bar's font/style menus drive that layer's text node directly, going through the // same code path as the Properties panel (LoadFontData + SetInputValue, with closest_style and font_style_to_restore bookkeeping). // Otherwise the menus only update the control bar option for the next created text. @@ -126,8 +126,20 @@ fn create_text_widgets(tool: &TextTool, font_catalog: &FontCatalog, document: &D } }; let preview_font = move |new_font: Font| -> Message { + let mut messages: Vec = vec![apply_font(new_font.clone())]; + if let Some(node_id) = text_node_id { + messages.push( + NodeGraphMessage::SetInputValue { + node_id, + input_index: graphene_std::text::text::FontResourceInput::<()>::INDEX, + value: TaggedValue::None, + } + .into(), + ); + } + messages.push(PortfolioMessage::LoadFontData { font: new_font }.into()); Message::Batched { - messages: Box::new([PortfolioMessage::LoadFontData { font: new_font.clone() }.into(), apply_font(new_font)]), + messages: messages.into_boxed_slice(), } }; let commit_font = move |new_font: Font| -> Message { @@ -158,7 +170,12 @@ fn create_text_widgets(tool: &TextTool, font_catalog: &FontCatalog, document: &D MenuListEntry::new(family.name.clone()) .label(family.name.clone()) - .font(family.closest_style(400, false).preview_url(&family.name)) + .font({ + let style = family.closest_style(400, false); + let font = Font::new(family.name.clone(), style.to_named_style()); + let hash = font_cache.get_hash(&font).map(|h| h.to_string()).unwrap_or("unknown".into()); + format!("font-resource-{}", hash) + }) .on_update(move |_| preview_font(new_font.clone())) .on_commit(move |_| commit_font(commit_only_font.clone())) }) @@ -261,14 +278,14 @@ impl ToolRefreshOptions for TextTool { } impl TextTool { - fn send_layout(&self, responses: &mut VecDeque, layout_target: LayoutTarget, font_catalog: &FontCatalog, document: &DocumentMessageHandler) { + fn send_layout(&self, responses: &mut VecDeque, layout_target: LayoutTarget, font_catalog: &FontCatalog, font_cache: &FontCache, document: &DocumentMessageHandler) { responses.add(LayoutMessage::SendLayout { - layout: self.layout(font_catalog, document), + layout: self.layout(font_catalog, font_cache, document), layout_target, }); } - fn layout(&self, font_catalog: &FontCatalog, document: &DocumentMessageHandler) -> Layout { + fn layout(&self, font_catalog: &FontCatalog, font_cache: &FontCache, document: &DocumentMessageHandler) -> Layout { let mut widgets = vec![ ColorInput::new(FillChoiceUI::from(self.options.fill.fill_choice.as_ref().unwrap_or(&FillChoice::None))) .mixed(self.options.fill.fill_choice.is_none()) @@ -283,7 +300,7 @@ impl TextTool { Separator::new(SeparatorStyle::Unrelated).widget_instance(), ]; - widgets.extend(create_text_widgets(self, font_catalog, document)); + widgets.extend(create_text_widgets(self, font_catalog, font_cache, document)); Layout(vec![LayoutGroup::row(widgets)]) } @@ -326,7 +343,13 @@ impl<'a> MessageHandler> for Text // Text tool has no fill checkbox; keep enabled so new text never starts with `None` self.options.fill.enabled = Some(true); - self.send_layout(responses, LayoutTarget::ToolOptions, &context.cached_data.font_catalog, context.document); + self.send_layout( + responses, + LayoutTarget::ToolOptions, + &context.cached_data.font_catalog, + &context.cached_data.font_cache, + context.document, + ); return; } _ => { @@ -385,7 +408,13 @@ impl<'a> MessageHandler> for Text } } - self.send_layout(responses, LayoutTarget::ToolOptions, &context.cached_data.font_catalog, context.document); + self.send_layout( + responses, + LayoutTarget::ToolOptions, + &context.cached_data.font_catalog, + &context.cached_data.font_cache, + context.document, + ); } fn actions(&self) -> ActionList { @@ -499,7 +528,7 @@ impl TextToolData { line_height_ratio: editing_text.typesetting.line_height_ratio, font_size: editing_text.typesetting.font_size, color: editing_text.color.map_or("#000000".to_string(), |color| SRGBA8::from(color).to_css_hex()), - font_data: font_cache.get(&editing_text.font).map(|(data, _)| data.clone()).unwrap_or_default().into(), + font_data: font_cache.get_blob_or_fallback(&editing_text.font).data().to_vec().into(), transform: editing_text.transform.to_cols_array(), max_width: editing_text.typesetting.max_width, max_height: editing_text.typesetting.max_height, @@ -550,7 +579,10 @@ impl TextToolData { responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![self.layer.to_node()] }); // Make the rendered text invisible while editing responses.add(NodeGraphMessage::SetInput { - input_connector: InputConnector::node(graph_modification_utils::get_text_id(self.layer, &document.network_interface).unwrap(), 1), + input_connector: InputConnector::node( + graph_modification_utils::get_text_id(self.layer, &document.network_interface).unwrap(), + graphene_std::text::text::TextInput::INDEX, + ), input: NodeInput::value(TaggedValue::String("".to_string()), false), }); responses.add(NodeGraphMessage::RunDocumentGraph); @@ -564,7 +596,6 @@ impl TextToolData { self.layer = LayerNodeIdentifier::new_unchecked(NodeId::new()); - responses.add(PortfolioMessage::LoadFontData { font: editing_text.font.clone() }); responses.add(GraphOperationMessage::NewTextLayer { id: self.layer.to_node(), text: String::new(), @@ -573,6 +604,7 @@ impl TextToolData { parent: document.new_layer_parent(true), insert_index: 0, }); + responses.add(PortfolioMessage::LoadFontData { font: editing_text.font.clone() }); responses.add(GraphOperationMessage::FillSet { layer: self.layer, fill: if let Some(color) = editing_text.color { Fill::Solid(color) } else { Fill::None }, @@ -667,7 +699,8 @@ impl Fsm for TextToolFsmState { let transform = document.metadata().transform_to_viewport(tool_data.layer).to_cols_array(); responses.add(FrontendMessage::DisplayEditableTextboxTransform { transform }); if let Some(editing_text) = tool_data.editing_text.as_mut() { - let far = graphene_std::text::bounding_box(&tool_data.new_text, &editing_text.font, font_cache, editing_text.typesetting, false); + let blob = font_cache.get_blob_or_fallback(&editing_text.font); + let far = graphene_std::text::bounding_box(&tool_data.new_text, &editing_text.font, &blob, editing_text.typesetting, false); 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; @@ -711,8 +744,10 @@ impl Fsm for TextToolFsmState { // Draw red overlay if text is clipped let transformed_quad = layer_transform * bounds; if let Some((text, font, typesetting, _)) = graph_modification_utils::get_text(layer.unwrap(), &document.network_interface) - && lines_clipping(text.as_str(), font, font_cache, typesetting) - { + && { + let blob = font_cache.get_blob_or_fallback(font); + lines_clipping(text.as_str(), font, &blob, typesetting) + } { overlay_context.line(transformed_quad.0[2], transformed_quad.0[3], Some(COLOR_OVERLAY_RED), Some(3.)); } @@ -1014,7 +1049,7 @@ impl Fsm for TextToolFsmState { (TextToolFsmState::Editing, TextToolMessage::RefreshEditingFontData) => { let font = Font::new(tool_options.font.font_family.clone(), tool_options.font.font_style.clone()); responses.add(FrontendMessage::DisplayEditableTextboxUpdateFontData { - font_data: font_cache.get(&font).map(|(data, _)| data.clone()).unwrap_or_default().into(), + font_data: font_cache.get_blob_or_fallback(&font).data().to_vec().into(), }); TextToolFsmState::Editing @@ -1026,7 +1061,10 @@ impl Fsm for TextToolFsmState { tool_data.set_editing(false, font_cache, responses); responses.add(NodeGraphMessage::SetInput { - input_connector: InputConnector::node(graph_modification_utils::get_text_id(tool_data.layer, &document.network_interface).unwrap(), 1), + input_connector: InputConnector::node( + graph_modification_utils::get_text_id(tool_data.layer, &document.network_interface).unwrap(), + graphene_std::text::text::TextInput::INDEX, + ), input: NodeInput::value(TaggedValue::String(tool_data.new_text.clone()), false), }); responses.add(NodeGraphMessage::RunDocumentGraph); diff --git a/editor/src/node_graph_executor.rs b/editor/src/node_graph_executor.rs index 30bcf64f1f..8b6b3d1b51 100644 --- a/editor/src/node_graph_executor.rs +++ b/editor/src/node_graph_executor.rs @@ -9,7 +9,6 @@ use graphene_std::application_io::{NodeGraphUpdateMessage, RenderConfig, TimingI use graphene_std::color::SRGBA8; use graphene_std::raster::{CPU, Raster}; use graphene_std::renderer::RenderMetadata; -use graphene_std::text::FontCache; use graphene_std::transform::Footprint; use graphene_std::vector::Vector; use interpreted_executor::dynamic_executor::ResolvedDocumentNodeTypesDelta; @@ -95,10 +94,6 @@ impl NodeGraphExecutor { execution_id } - pub fn update_font_cache(&self, font_cache: FontCache) { - self.runtime_io.send(GraphRuntimeRequest::FontCacheUpdate(font_cache)).expect("Failed to send font cache update"); - } - pub fn update_editor_preferences(&self, editor_preferences: EditorPreferences) { self.runtime_io .send(GraphRuntimeRequest::EditorPreferencesUpdate(editor_preferences)) diff --git a/editor/src/node_graph_executor/runtime.rs b/editor/src/node_graph_executor/runtime.rs index 660fa6d2a4..023f0ea1b7 100644 --- a/editor/src/node_graph_executor/runtime.rs +++ b/editor/src/node_graph_executor/runtime.rs @@ -16,7 +16,6 @@ use graphene_std::ops::Convert; use graphene_std::platform_application_io::canvas_utils::{Canvas, CanvasSurface, CanvasSurfaceHandle}; use graphene_std::raster_types::Raster; use graphene_std::renderer::{Render, RenderParams, RenderSvgSegmentList, SvgRender, SvgSegment}; -use graphene_std::text::FontCache; use graphene_std::transform::RenderQuality; use graphene_std::vector::Vector; use graphene_std::vector::style::RenderMode; @@ -69,7 +68,6 @@ pub struct NodeRuntime { pub enum GraphRuntimeRequest { GraphUpdate(GraphUpdate), ExecutionRequest(ExecutionRequest), - FontCacheUpdate(FontCache), EditorPreferencesUpdate(EditorPreferences), } @@ -130,7 +128,6 @@ impl NodeRuntime { update_thumbnails: true, editor_api: PlatformEditorApi { - font_cache: FontCache::default(), editor_preferences: Box::new(EditorPreferences::default()), node_graph_message_sender: Box::new(InternalNodeGraphUpdateSender(sender)), @@ -158,7 +155,6 @@ impl NodeRuntime { } pub async fn run(&mut self) -> Option { - let mut font = None; let mut preferences = None; let mut graph = None; let mut eyedropper = None; @@ -182,7 +178,6 @@ impl NodeRuntime { break; } } - GraphRuntimeRequest::FontCacheUpdate(_) => font = Some(request), GraphRuntimeRequest::EditorPreferencesUpdate(_) => preferences = Some(request), } } @@ -195,27 +190,13 @@ impl NodeRuntime { eyedropper.render_config.pointer = execution.render_config.pointer; } - let requests = [font, preferences, graph, eyedropper, execution].into_iter().flatten(); + let requests = [preferences, graph, eyedropper, execution].into_iter().flatten(); for request in requests { match request { - GraphRuntimeRequest::FontCacheUpdate(font_cache) => { - self.editor_api = PlatformEditorApi { - font_cache, - application_io: self.editor_api.application_io.clone(), - node_graph_message_sender: Box::new(self.sender.clone()), - editor_preferences: Box::new(self.editor_preferences.clone()), - } - .into(); - if let Some(graph) = self.old_graph.clone() { - // We ignore this result as compilation errors should have been reported in an earlier iteration - let _ = self.update_network(graph).await; - } - } GraphRuntimeRequest::EditorPreferencesUpdate(preferences) => { self.editor_preferences = preferences.clone(); self.editor_api = PlatformEditorApi { - font_cache: self.editor_api.font_cache.clone(), application_io: self.editor_api.application_io.clone(), node_graph_message_sender: Box::new(self.sender.clone()), editor_preferences: Box::new(preferences), @@ -576,7 +557,6 @@ pub(crate) fn replace_application_io(application_io: PlatformApplicationIo) { impl NodeRuntime { pub(crate) fn replace_application_io(&mut self, application_io: PlatformApplicationIo) { self.editor_api = PlatformEditorApi { - font_cache: self.editor_api.font_cache.clone(), application_io: Some(application_io.into()), node_graph_message_sender: Box::new(self.sender.clone()), editor_preferences: Box::new(self.editor_preferences.clone()), diff --git a/frontend/src/components/floating-menus/MenuList.svelte b/frontend/src/components/floating-menus/MenuList.svelte index 1a8e0279a6..9acf0a249b 100644 --- a/frontend/src/components/floating-menus/MenuList.svelte +++ b/frontend/src/components/floating-menus/MenuList.svelte @@ -49,9 +49,7 @@ let destroyed = false; let maxMenuWidth = 0; let resizeObserver: ResizeObserver | undefined = undefined; - // eslint-disable-next-line svelte/prefer-svelte-reactivity -- `loadedFonts` reactivity is driven by `loadedFontsGeneration`, not the Set itself - let loadedFonts = new Set(); - let loadedFontsGeneration = 0; + let suppressScrollIntoView = false; // `watchOpen` is called only when `open` is changed from outside this component $: watchOpen(open); @@ -168,8 +166,12 @@ }); } - function watchEntriesHash(_: bigint) { + async function watchEntriesHash(_: bigint) { + suppressScrollIntoView = true; reactiveEntries = entries; + await tick(); + await tick(); + suppressScrollIntoView = false; } function watchRemeasureWidth(_: MenuListEntry[][], __: boolean) { @@ -421,6 +423,9 @@ // dispatch("activeEntry", newHighlight); // } + // Skip auto-scroll during entries refresh (e.g. lazy-loaded font hash updates) so the user's scroll position is preserved + if (suppressScrollIntoView) return; + // Scroll into view let container = scroller?.div?.(); if (!container || !highlighted) return; @@ -497,26 +502,12 @@
{/if} - {#if entry.font} - { - document.fonts.load(`16px "${entry.value}"`).then(() => { - loadedFonts.add(entry.value); - loadedFontsGeneration += 1; // Modify the dirty trigger - }); - }} - /> - {/if} - diff --git a/frontend/src/managers/fonts.ts b/frontend/src/managers/fonts.ts index 043f7ae18e..8c0ff98160 100644 --- a/frontend/src/managers/fonts.ts +++ b/frontend/src/managers/fonts.ts @@ -1,3 +1,4 @@ +import { tick } from "svelte"; import type { SubscriptionsRouter } from "/src/subscriptions-router"; import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper"; @@ -58,6 +59,16 @@ export function createFontsManager(subscriptions: SubscriptionsRouter, editor: E console.error("Failed to load font:", error); } }); + + subscriptions.subscribeFrontendMessage("TriggerFontRegister", async (data) => { + await tick(); + + if (data.data.length > 0 && data.data.buffer instanceof ArrayBuffer) { + const fontView = new Uint8Array(data.data.buffer, data.data.byteOffset, data.data.byteLength); + const face = new FontFace(data.name, fontView); + window.document.fonts.add(face); + } + }); } export function destroyFontsManager() { @@ -67,6 +78,7 @@ export function destroyFontsManager() { abortController?.abort(); subscriptions.unsubscribeFrontendMessage("TriggerFontCatalogLoad"); subscriptions.unsubscribeFrontendMessage("TriggerFontDataLoad"); + subscriptions.unsubscribeFrontendMessage("TriggerFontRegister"); } // Self-accepting HMR: tear down the old instance and re-create with the new module's code diff --git a/frontend/wrapper/src/editor_wrapper.rs b/frontend/wrapper/src/editor_wrapper.rs index 7982ece916..e8c6e3ec90 100644 --- a/frontend/wrapper/src/editor_wrapper.rs +++ b/frontend/wrapper/src/editor_wrapper.rs @@ -146,7 +146,7 @@ impl EditorWrapper { if let FrontendMessage::Await { future } = message { let wrapper = self.clone(); wasm_bindgen_futures::spawn_local(async move { - wrapper.send_frontend_message_to_js(future.await); + wrapper.dispatch(future.await); }); return; } diff --git a/node-graph/graphene-cli/src/main.rs b/node-graph/graphene-cli/src/main.rs index bf89c743fd..3f44e81f28 100644 --- a/node-graph/graphene-cli/src/main.rs +++ b/node-graph/graphene-cli/src/main.rs @@ -10,7 +10,6 @@ use graph_craft::graphene_compiler::Compiler; use graph_craft::proto::ProtoNetwork; use graph_craft::util::load_network; use graphene_std::application_io::{ApplicationIo, NodeGraphUpdateMessage, NodeGraphUpdateSender}; -use graphene_std::text::FontCache; use interpreted_executor::dynamic_executor::DynamicExecutor; use interpreted_executor::util::wrap_network_in_scope; use std::error::Error; @@ -133,7 +132,6 @@ async fn main() -> Result<(), Box> { max_render_region_size: EditorPreferences::default().max_render_region_size, }; let editor_api = Arc::new(PlatformEditorApi { - font_cache: FontCache::default(), application_io: Some(application_io_for_api), node_graph_message_sender: Box::new(UpdateLogger {}), editor_preferences: Box::new(preferences), diff --git a/node-graph/libraries/application-io/src/lib.rs b/node-graph/libraries/application-io/src/lib.rs index 6da4bc1b79..8967f7645f 100644 --- a/node-graph/libraries/application-io/src/lib.rs +++ b/node-graph/libraries/application-io/src/lib.rs @@ -6,7 +6,6 @@ use std::hash::{Hash, Hasher}; use std::ptr::addr_of; use std::sync::Arc; use std::time::Duration; -use text_nodes::FontCache; use vector_types::vector::style::RenderMode; pub mod resource; @@ -128,8 +127,6 @@ impl GetEditorPreferences for DummyPreferences { } pub struct EditorApi { - /// Font data (for rendering text) made available to the graph through the `PlatformEditorApi`. - pub font_cache: FontCache, /// Gives access to APIs like resources. pub application_io: Option>, pub node_graph_message_sender: Box, @@ -142,7 +139,6 @@ impl Eq for EditorApi {} impl Default for EditorApi { fn default() -> Self { Self { - font_cache: FontCache::default(), application_io: None, node_graph_message_sender: Box::new(Logger), editor_preferences: Box::new(DummyPreferences), @@ -152,7 +148,6 @@ impl Default for EditorApi { impl Hash for EditorApi { fn hash(&self, state: &mut H) { - self.font_cache.hash(state); self.application_io.as_ref().map_or(0, |io| io as *const _ as usize).hash(state); (self.node_graph_message_sender.as_ref() as *const dyn NodeGraphUpdateSender).hash(state); (self.editor_preferences.as_ref() as *const dyn GetEditorPreferences).hash(state); @@ -167,8 +162,7 @@ impl core_types::graphene_hash::CacheHash for EditorApi { impl PartialEq for EditorApi { fn eq(&self, other: &Self) -> bool { - self.font_cache == other.font_cache - && self.application_io.as_ref().map_or(0, |io| addr_of!(io) as usize) == other.application_io.as_ref().map_or(0, |io| addr_of!(io) as usize) + self.application_io.as_ref().map_or(0, |io| addr_of!(io) as usize) == other.application_io.as_ref().map_or(0, |io| addr_of!(io) as usize) && std::ptr::eq(self.node_graph_message_sender.as_ref() as *const _, other.node_graph_message_sender.as_ref() as *const _) && std::ptr::eq(self.editor_preferences.as_ref() as *const _, other.editor_preferences.as_ref() as *const _) } @@ -176,7 +170,7 @@ impl PartialEq for EditorApi { impl Debug for EditorApi { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("EditorApi").field("font_cache", &self.font_cache).finish() + f.debug_struct("EditorApi").finish() } } diff --git a/node-graph/libraries/core-types/src/resource.rs b/node-graph/libraries/core-types/src/resource.rs index 9121f55456..58b990470b 100644 --- a/node-graph/libraries/core-types/src/resource.rs +++ b/node-graph/libraries/core-types/src/resource.rs @@ -15,6 +15,12 @@ impl Resource { } } +impl Default for Resource { + fn default() -> Self { + Self { inner: Arc::new(Vec::new()) } + } +} + impl From<&Resource> for Arc + Send + Sync> { fn from(val: &Resource) -> Self { val.inner.clone() diff --git a/node-graph/nodes/gstd/src/text.rs b/node-graph/nodes/gstd/src/text.rs index 89c557e7ca..61069de873 100644 --- a/node-graph/nodes/gstd/src/text.rs +++ b/node-graph/nodes/gstd/src/text.rs @@ -1,16 +1,16 @@ +use std::sync::LazyLock; + use core_types::Ctx; use core_types::list::List; -use graph_craft::application_io::PlatformEditorApi; +use graph_craft::application_io::Resource; use graphic_types::Vector; pub use text_nodes::*; /// Draws a text string as vector geometry with a choice of font and styling. #[node_macro::node(category("Text"))] -fn text<'i: 'n>( +fn text( _: impl Ctx, - /// The Graphite editor's source for global font resources. - #[scope("editor-api")] - editor_resources: &'i PlatformEditorApi, + _primary: (), /// The text content to be drawn. #[widget(ParsedWidgetOverride::Custom = "text_area")] #[default("Lorem ipsum")] @@ -18,6 +18,10 @@ fn text<'i: 'n>( /// The typeface used to draw the text. #[widget(ParsedWidgetOverride::Custom = "text_font")] font: Font, + /// Hidden input that carries the loaded font bytes as a resource. `()` means use the embedded fallback font; the editor patches this to a `Resource` once the requested font has been loaded. + #[widget(ParsedWidgetOverride::Hidden)] + #[implementations((), Resource)] + font_resource: F, /// The font size used to draw the text. #[unit(" px")] #[default(24.)] @@ -73,5 +77,24 @@ fn text<'i: 'n>( align, }; - to_path(&text, &font, &editor_resources.font_cache, typesetting, separate_glyphs) + let blob = font_resource.font_blob(); + to_path(&text, &font, &blob, typesetting, separate_glyphs) +} + +pub trait FontSource: Send + Sync { + fn font_blob(&self) -> Blob; +} + +static FALLBACK_FONT_BLOB: LazyLock> = LazyLock::new(|| Blob::new(std::sync::Arc::new(FALLBACK_FONT_BYTES))); + +impl FontSource for () { + fn font_blob(&self) -> Blob { + FALLBACK_FONT_BLOB.clone() + } +} + +impl FontSource for Resource { + fn font_blob(&self) -> Blob { + Blob::new(self.into()) + } } diff --git a/node-graph/nodes/text/assets/source-sans-pro-regular.ttf b/node-graph/nodes/text/assets/source-sans-pro-regular.ttf new file mode 100644 index 0000000000..a8eae164ea Binary files /dev/null and b/node-graph/nodes/text/assets/source-sans-pro-regular.ttf differ diff --git a/node-graph/nodes/text/src/font.rs b/node-graph/nodes/text/src/font.rs new file mode 100644 index 0000000000..64b56f0496 --- /dev/null +++ b/node-graph/nodes/text/src/font.rs @@ -0,0 +1,77 @@ +use core_types::graphene_hash::CacheHash; +use dyn_any::DynAny; + +/// Fallback font bytes (Source Sans Pro Regular) embedded so text can render without a frontend font fetch. +pub const FALLBACK_FONT_BYTES: &[u8] = include_bytes!("../assets/source-sans-pro-regular.ttf"); + +/// A font type (storing font family and font style and an optional preview URL) +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(Debug, Clone, Eq, DynAny)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Font { + #[cfg_attr(feature = "serde", serde(rename = "fontFamily"))] + pub font_family: String, + #[cfg_attr(feature = "serde", serde(rename = "fontStyle", deserialize_with = "migrate_font_style"))] + pub font_style: String, + #[cfg_attr(feature = "serde", serde(skip))] + pub font_style_to_restore: Option, +} + +impl std::hash::Hash for Font { + fn hash(&self, state: &mut H) { + self.font_family.hash(state); + self.font_style.hash(state); + } +} + +impl CacheHash for Font { + fn cache_hash(&self, state: &mut H) { + self.font_family.cache_hash(state); + self.font_style.cache_hash(state); + } +} + +impl PartialEq for Font { + fn eq(&self, other: &Self) -> bool { + self.font_family == other.font_family && self.font_style == other.font_style + } +} + +impl Font { + pub fn new(font_family: String, font_style: String) -> Self { + Self { + font_family, + font_style, + font_style_to_restore: None, + } + } + + pub fn named_weight(weight: u32) -> &'static str { + // From https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight#common_weight_name_mapping + match weight { + 100 => "Thin", + 200 => "Extra Light", + 300 => "Light", + 400 => "Regular", + 500 => "Medium", + 600 => "Semi Bold", + 700 => "Bold", + 800 => "Extra Bold", + 900 => "Black", + 950 => "Extra Black", + _ => "Regular", + } + } +} + +impl Default for Font { + fn default() -> Self { + Self::new(core_types::consts::DEFAULT_FONT_FAMILY.into(), core_types::consts::DEFAULT_FONT_STYLE.into()) + } +} + +// TODO: Eventually remove this migration document upgrade code +fn migrate_font_style<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result { + use serde::Deserialize; + String::deserialize(deserializer).map(|name| if name == "Normal (400)" { "Regular (400)".to_string() } else { name }) +} diff --git a/node-graph/nodes/text/src/font_cache.rs b/node-graph/nodes/text/src/font_cache.rs deleted file mode 100644 index 258452ebc4..0000000000 --- a/node-graph/nodes/text/src/font_cache.rs +++ /dev/null @@ -1,142 +0,0 @@ -use core_types::graphene_hash::CacheHash; -use dyn_any::DynAny; -use parley::fontique::Blob; -use std::collections::HashMap; -use std::sync::Arc; - -/// A font type (storing font family and font style and an optional preview URL) -#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] -#[derive(Debug, Clone, Eq, DynAny)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct Font { - #[cfg_attr(feature = "serde", serde(rename = "fontFamily"))] - pub font_family: String, - #[cfg_attr(feature = "serde", serde(rename = "fontStyle", deserialize_with = "migrate_font_style"))] - pub font_style: String, - #[cfg_attr(feature = "serde", serde(skip))] - pub font_style_to_restore: Option, -} - -impl std::hash::Hash for Font { - fn hash(&self, state: &mut H) { - self.font_family.hash(state); - self.font_style.hash(state); - // Don't consider `font_style_to_restore` in the HashMaps - } -} - -impl CacheHash for Font { - fn cache_hash(&self, state: &mut H) { - self.font_family.cache_hash(state); - self.font_style.cache_hash(state); - // Don't consider `font_style_to_restore` in the HashMaps - } -} - -impl PartialEq for Font { - fn eq(&self, other: &Self) -> bool { - // Don't consider `font_style_to_restore` in the HashMaps - self.font_family == other.font_family && self.font_style == other.font_style - } -} - -impl Font { - pub fn new(font_family: String, font_style: String) -> Self { - Self { - font_family, - font_style, - font_style_to_restore: None, - } - } - - pub fn named_weight(weight: u32) -> &'static str { - // From https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight#common_weight_name_mapping - match weight { - 100 => "Thin", - 200 => "Extra Light", - 300 => "Light", - 400 => "Regular", - 500 => "Medium", - 600 => "Semi Bold", - 700 => "Bold", - 800 => "Extra Bold", - 900 => "Black", - 950 => "Extra Black", - _ => "Regular", - } - } -} -impl Default for Font { - fn default() -> Self { - Self::new(core_types::consts::DEFAULT_FONT_FAMILY.into(), core_types::consts::DEFAULT_FONT_STYLE.into()) - } -} - -/// A cache of all loaded font data and preview urls along with the default font (send from `init_app` in `editor_api.rs`) -#[derive(Clone, Default, DynAny)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct FontCache { - /// Actual font file data used for rendering a font - font_file_data: HashMap>, -} - -impl std::fmt::Debug for FontCache { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("FontCache").field("font_file_data", &self.font_file_data.keys().collect::>()).finish() - } -} - -impl std::hash::Hash for FontCache { - fn hash(&self, state: &mut H) { - self.font_file_data.len().hash(state); - self.font_file_data.keys().for_each(|font| font.hash(state)); - } -} - -impl PartialEq for FontCache { - fn eq(&self, other: &Self) -> bool { - if self.font_file_data.len() != other.font_file_data.len() { - return false; - } - self.font_file_data.keys().all(|font| other.font_file_data.contains_key(font)) - } -} - -impl FontCache { - /// Returns the font family name if the font is cached, otherwise returns the fallback font family name if that is cached - pub fn resolve_font<'a>(&'a self, font: &'a Font) -> Option<&'a Font> { - if self.font_file_data.contains_key(font) { - Some(font) - } else { - self.font_file_data - .keys() - .find(|font| font.font_family == core_types::consts::DEFAULT_FONT_FAMILY && font.font_style == core_types::consts::DEFAULT_FONT_STYLE) - } - } - - /// Try to get the bytes for a font - pub fn get<'a>(&'a self, font: &'a Font) -> Option<(&'a Vec, &'a Font)> { - self.resolve_font(font).and_then(|font| self.font_file_data.get(font).map(|data| (data, font))) - } - - /// Get font data as a Blob for use with parley/skrifa - pub fn get_blob<'a>(&'a self, font: &'a Font) -> Option<(Blob, &'a Font)> { - self.get(font).map(|(data, font)| (Blob::new(Arc::new(data.clone())), font)) - } - - /// Check if the font is already loaded - pub fn loaded_font(&self, font: &Font) -> bool { - self.font_file_data.contains_key(font) - } - - /// Insert a new font into the cache - pub fn insert(&mut self, font: Font, data: Vec) { - self.font_file_data.insert(font.clone(), data); - } -} - -// TODO: Eventually remove this migration document upgrade code -fn migrate_font_style<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result { - use serde::Deserialize; - String::deserialize(deserializer).map(|name| if name == "Normal (400)" { "Regular (400)".to_string() } else { name }) -} diff --git a/node-graph/nodes/text/src/lib.rs b/node-graph/nodes/text/src/lib.rs index 99f2968b1d..ed91407d06 100644 --- a/node-graph/nodes/text/src/lib.rs +++ b/node-graph/nodes/text/src/lib.rs @@ -1,4 +1,4 @@ -mod font_cache; +mod font; pub mod json; mod path_builder; pub mod regex; @@ -16,7 +16,8 @@ use unicode_segmentation::UnicodeSegmentation; // Re-export for convenience pub use core_types as gcore; -pub use font_cache::*; +pub use font::*; +pub use parley::fontique::Blob; pub use text_context::TextContext; pub use to_path::*; pub use vector_types; diff --git a/node-graph/nodes/text/src/text_context.rs b/node-graph/nodes/text/src/text_context.rs index ec9bf95a17..063a848069 100644 --- a/node-graph/nodes/text/src/text_context.rs +++ b/node-graph/nodes/text/src/text_context.rs @@ -1,4 +1,4 @@ -use super::{Font, FontCache, TypesettingConfig}; +use super::{Font, TypesettingConfig}; use core::cell::RefCell; use core_types::list::List; use glam::DVec2; @@ -19,8 +19,8 @@ thread_local! { pub struct TextContext { font_context: FontContext, layout_context: LayoutContext<()>, - /// Cached font metadata for performance optimization - font_info_cache: HashMap, + /// Cached font metadata for performance optimization. + font_info_cache: HashMap<(Font, usize), (FamilyId, FontInfo)>, } impl TextContext { @@ -32,40 +32,31 @@ impl TextContext { THREAD_TEXT.with_borrow_mut(f) } - /// Resolve a font and return its data as a Blob if available - fn resolve_font_data<'a>(&self, font: &'a Font, font_cache: &'a FontCache) -> Option<(Blob, &'a Font)> { - font_cache.get_blob(font) - } - - /// Get or cache font information for a given font + /// Get or cache font information for a given font and its loaded bytes. fn get_font_info(&mut self, font: &Font, font_data: &Blob) -> Option<(String, FontInfo)> { - // Check if we already have the font info cached - if let Some((family_id, font_info)) = self.font_info_cache.get(font) + let cache_key = (font.clone(), font_data.as_ref().as_ptr() as usize); + + if let Some((family_id, font_info)) = self.font_info_cache.get(&cache_key) && let Some(family_name) = self.font_context.collection.family_name(*family_id) { return Some((family_name.to_string(), font_info.clone())); } - // Register the font and cache the info let families = self.font_context.collection.register_fonts(font_data.clone(), None); families.first().and_then(|(family_id, fonts_info)| { fonts_info.first().and_then(|font_info| { self.font_context.collection.family_name(*family_id).map(|family_name| { - // Cache the font info for future use - self.font_info_cache.insert(font.clone(), (*family_id, font_info.clone())); + self.font_info_cache.insert(cache_key.clone(), (*family_id, font_info.clone())); (family_name.to_string(), font_info.clone()) }) }) }) } - /// Create a text layout using the specified font and typesetting configuration - fn layout_text(&mut self, text: &str, font: &Font, font_cache: &FontCache, typesetting: TypesettingConfig) -> Option> { - // Note that the actual_font may not be the desired font if that font is not yet loaded. - // It is important not to cache the default font under the name of another font. - let (font_data, actual_font) = self.resolve_font_data(font, font_cache)?; - let (font_family, font_info) = self.get_font_info(actual_font, &font_data)?; + /// Create a text layout using the specified font, font bytes, and typesetting configuration + fn layout_text(&mut self, text: &str, font: &Font, font_data: &Blob, typesetting: TypesettingConfig) -> Option> { + let (font_family, font_info) = self.get_font_info(font, font_data)?; const DISPLAY_SCALE: f32 = 1.; let mut builder = self.layout_context.ranged_builder(&mut self.font_context, text, DISPLAY_SCALE, false); @@ -89,8 +80,8 @@ impl TextContext { } /// Convert text to vector paths using the specified font and typesetting configuration - pub fn to_path(&mut self, text: &str, font: &Font, font_cache: &FontCache, typesetting: TypesettingConfig, per_glyph_items: bool) -> List { - let Some(layout) = self.layout_text(text, font, font_cache, typesetting) else { + pub fn to_path(&mut self, text: &str, font: &Font, font_data: &Blob, typesetting: TypesettingConfig, per_glyph_items: bool) -> List { + let Some(layout) = self.layout_text(text, font, font_data, typesetting) else { return List::new_from_element(Vector::default()); }; @@ -162,8 +153,8 @@ impl TextContext { } /// Calculate the bounding box of text using the specified font and typesetting configuration - pub fn bounding_box(&mut self, text: &str, font: &Font, font_cache: &FontCache, typesetting: TypesettingConfig, for_clipping_test: bool) -> DVec2 { - let Some(layout) = self.layout_text(text, font, font_cache, typesetting) else { + pub fn bounding_box(&mut self, text: &str, font: &Font, font_data: &Blob, typesetting: TypesettingConfig, for_clipping_test: bool) -> DVec2 { + let Some(layout) = self.layout_text(text, font, font_data, typesetting) else { return DVec2::ZERO; }; @@ -181,9 +172,9 @@ impl TextContext { } /// Check if text lines are being clipped due to height constraints - pub fn lines_clipping(&mut self, text: &str, font: &Font, font_cache: &FontCache, typesetting: TypesettingConfig) -> bool { + pub fn lines_clipping(&mut self, text: &str, font: &Font, font_data: &Blob, typesetting: TypesettingConfig) -> bool { let Some(max_height) = typesetting.max_height else { return false }; - let bounds = self.bounding_box(text, font, font_cache, typesetting, true); + let bounds = self.bounding_box(text, font, font_data, typesetting, true); max_height < bounds.y } } diff --git a/node-graph/nodes/text/src/to_path.rs b/node-graph/nodes/text/src/to_path.rs index d8ba556c17..9c6d070492 100644 --- a/node-graph/nodes/text/src/to_path.rs +++ b/node-graph/nodes/text/src/to_path.rs @@ -1,23 +1,23 @@ use super::text_context::TextContext; -use super::{Font, FontCache, TypesettingConfig}; +use super::{Font, TypesettingConfig}; use core_types::list::List; use glam::DVec2; use parley::fontique::Blob; use std::sync::Arc; use vector_types::Vector; -pub fn to_path(text: &str, font: &Font, font_cache: &FontCache, typesetting: TypesettingConfig, per_glyph_items: bool) -> List { - TextContext::with_thread_local(|ctx| ctx.to_path(text, font, font_cache, typesetting, per_glyph_items)) +pub fn to_path(text: &str, font: &Font, font_data: &Blob, typesetting: TypesettingConfig, per_glyph_items: bool) -> List { + TextContext::with_thread_local(|ctx| ctx.to_path(text, font, font_data, typesetting, per_glyph_items)) } -pub fn bounding_box(text: &str, font: &Font, font_cache: &FontCache, typesetting: TypesettingConfig, for_clipping_test: bool) -> DVec2 { - TextContext::with_thread_local(|ctx| ctx.bounding_box(text, font, font_cache, typesetting, for_clipping_test)) +pub fn bounding_box(text: &str, font: &Font, font_data: &Blob, typesetting: TypesettingConfig, for_clipping_test: bool) -> DVec2 { + TextContext::with_thread_local(|ctx| ctx.bounding_box(text, font, font_data, typesetting, for_clipping_test)) } pub fn load_font(data: &[u8]) -> Blob { Blob::new(Arc::new(data.to_vec())) } -pub fn lines_clipping(text: &str, font: &Font, font_cache: &FontCache, typesetting: TypesettingConfig) -> bool { - TextContext::with_thread_local(|ctx| ctx.lines_clipping(text, font, font_cache, typesetting)) +pub fn lines_clipping(text: &str, font: &Font, font_data: &Blob, typesetting: TypesettingConfig) -> bool { + TextContext::with_thread_local(|ctx| ctx.lines_clipping(text, font, font_data, typesetting)) }