mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-24 08:18:12 +08:00
Migrate fonts to be stored as resources (#4165)
* Good start
* Impl
* allow responses.add(async { Message::NoOp });
* Fix
* Cache font blob in text node
* Refactor font handling to use Font struct direcly
* Fix fmt
* Embedd Font Resources by default
* Review
* Review
* Cache font info based on ResourceHash
This commit is contained in:
@@ -21,7 +21,7 @@ use crate::messages::portfolio::document::properties_panel::properties_panel_mes
|
||||
use crate::messages::portfolio::document::utility_types::document_metadata::{DocumentMetadata, LayerNodeIdentifier};
|
||||
use crate::messages::portfolio::document::utility_types::misc::{AlignAggregate, AlignAxis, FlipAxis, PTZ};
|
||||
use crate::messages::portfolio::document::utility_types::network_interface::{FlowType, InputConnector, NodeTemplate, OutputConnector};
|
||||
use crate::messages::portfolio::utility_types::{CachedData, PanelType};
|
||||
use crate::messages::portfolio::utility_types::PanelType;
|
||||
use crate::messages::prelude::*;
|
||||
use crate::messages::tool::common_functionality::graph_modification_utils::{self, get_blend_mode, get_fill, get_opacity};
|
||||
use crate::messages::tool::tool_messages::select_tool::SelectToolPointerKeys;
|
||||
@@ -52,7 +52,6 @@ use std::time::Duration;
|
||||
pub struct DocumentMessageContext<'a> {
|
||||
pub document_id: DocumentId,
|
||||
pub ipp: &'a InputPreprocessorMessageHandler,
|
||||
pub cached_data: &'a CachedData,
|
||||
pub executor: &'a mut NodeGraphExecutor,
|
||||
pub current_tool: &'a ToolType,
|
||||
pub preferences: &'a PreferencesMessageHandler,
|
||||
@@ -61,6 +60,7 @@ pub struct DocumentMessageContext<'a> {
|
||||
pub properties_panel_open: bool,
|
||||
pub viewport: &'a ViewportMessageHandler,
|
||||
pub resource_storage: &'a ResourceStorageMessageHandler,
|
||||
pub fonts: &'a FontsMessageHandler,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, ExtractField)]
|
||||
@@ -198,7 +198,6 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
|
||||
let DocumentMessageContext {
|
||||
document_id,
|
||||
ipp,
|
||||
cached_data,
|
||||
executor,
|
||||
viewport,
|
||||
current_tool,
|
||||
@@ -207,6 +206,7 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
|
||||
layers_panel_open,
|
||||
properties_panel_open,
|
||||
resource_storage,
|
||||
fonts,
|
||||
} = context;
|
||||
|
||||
match message {
|
||||
@@ -233,11 +233,12 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
|
||||
}
|
||||
DocumentMessage::PropertiesPanel(message) => {
|
||||
let context = PropertiesPanelMessageContext {
|
||||
executor,
|
||||
network_interface: &mut self.network_interface,
|
||||
resources: &self.resources,
|
||||
selection_network_path: &self.selection_network_path,
|
||||
document_name: self.name.as_str(),
|
||||
executor,
|
||||
cached_data,
|
||||
fonts,
|
||||
properties_panel_open,
|
||||
};
|
||||
self.properties_panel_message_handler.process_message(message, responses, context);
|
||||
@@ -282,7 +283,7 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
|
||||
graph_operation_message_handler.process_message(message, responses, context);
|
||||
}
|
||||
DocumentMessage::Resource(message) => {
|
||||
let context = ResourceMessageContext {};
|
||||
let context = ResourceMessageContext { document_id, fonts };
|
||||
self.resources.process_message(message, responses, context);
|
||||
}
|
||||
DocumentMessage::AlignSelectedLayers { axis, aggregate } => {
|
||||
@@ -933,21 +934,19 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
|
||||
let mut document = self.clone();
|
||||
let resources_load_handle = resource_storage.resources();
|
||||
|
||||
responses.add(FutureMessage::Await {
|
||||
future: MessageFuture::new(async move {
|
||||
document.resources.garbage_collect(document.used_resources(false).as_ref());
|
||||
document.resources.embed_resources(resources_load_handle).await;
|
||||
responses.add(async move {
|
||||
document.resources.collect_garbage(document.used_resources(false).as_ref());
|
||||
document.resources.embed_resources(resources_load_handle).await;
|
||||
|
||||
let content = document.serialize_document().into_bytes().into();
|
||||
let content = document.serialize_document().into_bytes().into();
|
||||
|
||||
Message::Frontend(FrontendMessage::TriggerSaveDocument {
|
||||
document_id,
|
||||
name,
|
||||
path,
|
||||
folder,
|
||||
content,
|
||||
})
|
||||
}),
|
||||
Message::Frontend(FrontendMessage::TriggerSaveDocument {
|
||||
document_id,
|
||||
name,
|
||||
path,
|
||||
folder,
|
||||
content,
|
||||
})
|
||||
});
|
||||
}
|
||||
DocumentMessage::SavedDocument { path } => {
|
||||
@@ -2658,23 +2657,6 @@ impl DocumentMessageHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads all of the fonts in the document.
|
||||
pub fn load_layer_resources(&self, responses: &mut VecDeque<Message>) {
|
||||
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 {
|
||||
responses.add(PortfolioMessage::LoadFontData { font });
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_document_widgets(&self, responses: &mut VecDeque<Message>, animation_is_playing: bool, time: Duration) {
|
||||
let mut snapping_state = self.snapping_state.clone();
|
||||
let mut snapping_state2 = self.snapping_state.clone();
|
||||
@@ -3470,7 +3452,7 @@ impl DocumentMessageHandler {
|
||||
|
||||
pub fn garbage_collect_resources(&mut self) {
|
||||
let used_resources = self.used_resources(true);
|
||||
self.resources.garbage_collect(&used_resources);
|
||||
self.resources.collect_garbage(&used_resources);
|
||||
}
|
||||
|
||||
pub fn used_resources(&self, include_history: bool) -> Box<[ResourceId]> {
|
||||
|
||||
@@ -249,12 +249,13 @@ impl<'a> ModifyInputsContext<'a> {
|
||||
}
|
||||
|
||||
pub fn insert_text(&mut self, text: String, font: Font, typesetting: TypesettingConfig, layer: LayerNodeIdentifier) {
|
||||
let font_resource_id = ResourceId::new();
|
||||
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")),
|
||||
Some(NodeInput::value(TaggedValue::None, false)),
|
||||
Some(NodeInput::value(TaggedValue::String(text), false)),
|
||||
Some(NodeInput::value(TaggedValue::Font(font), false)),
|
||||
Some(NodeInput::value(TaggedValue::Resource(font_resource_id), 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)),
|
||||
@@ -264,6 +265,7 @@ impl<'a> ModifyInputsContext<'a> {
|
||||
Some(NodeInput::value(TaggedValue::F64(typesetting.max_height.unwrap_or(100.)), false)),
|
||||
Some(NodeInput::value(TaggedValue::F64(typesetting.tilt), false)),
|
||||
Some(NodeInput::value(TaggedValue::TextAlign(typesetting.align), false)),
|
||||
Some(NodeInput::value(TaggedValue::Bool(false), false)),
|
||||
]);
|
||||
let transform = resolve_proto_node_type(graphene_std::transform_nodes::transform::IDENTIFIER)
|
||||
.expect("Transform node does not exist")
|
||||
@@ -276,6 +278,8 @@ impl<'a> ModifyInputsContext<'a> {
|
||||
self.network_interface.insert_node(text_id, text, &[]);
|
||||
self.network_interface.move_node_to_chain_start(&text_id, layer, &[], self.import);
|
||||
|
||||
self.responses.add(DocumentMessage::Resource(ResourceMessage::AddFont { resource_id: font_resource_id, font }));
|
||||
|
||||
let transform_id = NodeId::new();
|
||||
self.network_interface.insert_node(transform_id, transform, &[]);
|
||||
self.network_interface.move_node_to_chain_start(&transform_id, layer, &[], self.import);
|
||||
|
||||
@@ -8,8 +8,7 @@ use crate::messages::portfolio::document::utility_types::network_interface::{
|
||||
DocumentNodeMetadata, DocumentNodePersistentMetadata, InputMetadata, NodeNetworkInterface, NodeNetworkMetadata, NodeNetworkPersistentMetadata, NodeTemplate, NodeTypePersistentMetadata,
|
||||
Vec2InputSettings, WidgetOverride,
|
||||
};
|
||||
use crate::messages::portfolio::utility_types::CachedData;
|
||||
use crate::messages::prelude::Message;
|
||||
use crate::messages::prelude::{FontsMessageHandler, Message, ResourceMessageHandler};
|
||||
use crate::node_graph_executor::NodeGraphExecutor;
|
||||
use glam::DVec2;
|
||||
use graph_craft::ProtoNodeIdentifier;
|
||||
@@ -28,10 +27,11 @@ use serde_json::Value;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
|
||||
pub struct NodePropertiesContext<'a> {
|
||||
pub cached_data: &'a CachedData,
|
||||
pub responses: &'a mut VecDeque<Message>,
|
||||
pub executor: &'a mut NodeGraphExecutor,
|
||||
pub network_interface: &'a mut NodeNetworkInterface,
|
||||
pub resources: &'a ResourceMessageHandler,
|
||||
pub fonts: &'a FontsMessageHandler,
|
||||
pub selection_network_path: &'a [NodeId],
|
||||
pub document_name: &'a str,
|
||||
}
|
||||
|
||||
@@ -6,12 +6,13 @@ use crate::messages::layout::utility_types::widget_prelude::*;
|
||||
use crate::messages::portfolio::document::node_graph::document_node_definitions::resolve_document_node_type;
|
||||
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
|
||||
use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, NodeNetworkInterface};
|
||||
use crate::messages::portfolio::utility_types::{CachedData, FontCatalogStyle};
|
||||
use crate::messages::portfolio::fonts::utility_types::FontCatalogStyle;
|
||||
use crate::messages::prelude::*;
|
||||
use crate::messages::tool::common_functionality::graph_modification_utils;
|
||||
use choice::enum_choice;
|
||||
use dyn_any::DynAny;
|
||||
use glam::{DAffine2, DVec2};
|
||||
use graph_craft::application_io::resource::ResourceId;
|
||||
use graph_craft::document::value::TaggedValue;
|
||||
use graph_craft::document::{DocumentNode, DocumentNodeImplementation, NodeId, NodeInput};
|
||||
use graph_craft::{Type, concrete};
|
||||
@@ -822,11 +823,27 @@ pub fn array_of_number_widget(parameter_widgets_info: ParameterWidgetsInfo, text
|
||||
}
|
||||
|
||||
pub fn font_inputs(parameter_widgets_info: ParameterWidgetsInfo) -> (Vec<WidgetInstance>, Option<Vec<WidgetInstance>>) {
|
||||
pub fn assign_font_message(node_id: NodeId, font: Font) -> Message {
|
||||
let resource_id = ResourceId::new();
|
||||
Message::Batched {
|
||||
messages: Box::new([
|
||||
DocumentMessage::Resource(ResourceMessage::AddFont { resource_id, font }).into(),
|
||||
NodeGraphMessage::SetInputValue {
|
||||
node_id,
|
||||
input_index: graphene_std::text::text::FontInput::INDEX,
|
||||
value: TaggedValue::Resource(resource_id),
|
||||
}
|
||||
.into(),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
let ParameterWidgetsInfo {
|
||||
cached_data,
|
||||
document_node,
|
||||
node_id,
|
||||
index,
|
||||
resources,
|
||||
fonts,
|
||||
..
|
||||
} = parameter_widgets_info;
|
||||
|
||||
@@ -839,66 +856,32 @@ pub fn font_inputs(parameter_widgets_info: ParameterWidgetsInfo) -> (Vec<WidgetI
|
||||
return (vec![], None);
|
||||
};
|
||||
|
||||
if let Some(TaggedValue::Font(font)) = &input.as_non_exposed_value() {
|
||||
if let Some(TaggedValue::Resource(resource_id)) = input.as_non_exposed_value() {
|
||||
let font = fonts.id_font(resources, *resource_id).unwrap_or_default();
|
||||
first_widgets.extend_from_slice(&[
|
||||
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
|
||||
DropdownInput::new(vec![
|
||||
cached_data
|
||||
fonts
|
||||
.font_catalog
|
||||
.0
|
||||
.iter()
|
||||
.map(|family| {
|
||||
let FontCatalogStyle { weight, italic, .. } = FontCatalogStyle::from_named_style(&font.font_style, "");
|
||||
let new_font = Font::new(family.name.clone(), family.closest_style(weight, italic).to_named_style());
|
||||
let commit_font = new_font.clone();
|
||||
MenuListEntry::new(family.name.clone())
|
||||
.label(family.name.clone())
|
||||
.font(family.closest_style(400, false).preview_url(&family.name))
|
||||
.on_update({
|
||||
// Construct the new font using the new family and the initial or previous style, although this style might not exist in the catalog
|
||||
let mut new_font = Font::new(family.name.clone(), font.font_style_to_restore.clone().unwrap_or_else(|| font.font_style.clone()));
|
||||
new_font.font_style_to_restore = font.font_style_to_restore.clone();
|
||||
|
||||
// If not already, store the initial style so it can be restored if the user switches to another family
|
||||
if new_font.font_style_to_restore.is_none() {
|
||||
new_font.font_style_to_restore = Some(new_font.font_style.clone());
|
||||
}
|
||||
|
||||
// Use the closest style available in the family for the new font to ensure the style exists
|
||||
let FontCatalogStyle { weight, italic, .. } = FontCatalogStyle::from_named_style(&new_font.font_style, "");
|
||||
new_font.font_style = family.closest_style(weight, italic).to_named_style();
|
||||
|
||||
move |_| {
|
||||
let new_font = new_font.clone();
|
||||
|
||||
Message::Batched {
|
||||
messages: Box::new([
|
||||
PortfolioMessage::LoadFontData { font: new_font.clone() }.into(),
|
||||
update_value(move |_| TaggedValue::Font(new_font.clone()), node_id, index)(&()),
|
||||
]),
|
||||
}
|
||||
}
|
||||
})
|
||||
.on_commit({
|
||||
// Use the new value from the user selection
|
||||
let font_family = family.name.clone();
|
||||
|
||||
// Use the previous style selection and extract its weight and italic properties, then find the closest style in the new family
|
||||
let FontCatalogStyle { weight, italic, .. } = FontCatalogStyle::from_named_style(&font.font_style, "");
|
||||
let font_style = family.closest_style(weight, italic).to_named_style();
|
||||
|
||||
move |_| {
|
||||
// Intentionally drop `font_style_to_restore` on commit so the committed style becomes the new basis
|
||||
// for subsequent family switches. Preserving the original style intent is hover-only behavior.
|
||||
let new_font = Font::new(font_family.clone(), font_style.clone());
|
||||
|
||||
DeferMessage::AfterGraphRun {
|
||||
messages: vec![update_value(move |_| TaggedValue::Font(new_font.clone()), node_id, index)(&()), commit_value(&())],
|
||||
}
|
||||
.into()
|
||||
.on_update(move |_| assign_font_message(node_id, new_font.clone()))
|
||||
.on_commit(move |_| {
|
||||
DeferMessage::AfterGraphRun {
|
||||
messages: vec![assign_font_message(node_id, commit_font.clone()), commit_value(&())],
|
||||
}
|
||||
.into()
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
])
|
||||
.selected_index(cached_data.font_catalog.0.iter().position(|family| family.name == font.font_family).map(|i| i as u32))
|
||||
.selected_index(fonts.font_catalog.iter().position(|family| family.name == font.font_family).map(|i| i as u32))
|
||||
.virtual_scrolling(true)
|
||||
.widget_instance(),
|
||||
]);
|
||||
@@ -908,32 +891,18 @@ pub fn font_inputs(parameter_widgets_info: ParameterWidgetsInfo) -> (Vec<WidgetI
|
||||
second_row.extend_from_slice(&[
|
||||
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
|
||||
DropdownInput::new({
|
||||
cached_data
|
||||
fonts
|
||||
.font_catalog
|
||||
.0
|
||||
.iter()
|
||||
.find(|family| family.name == font.font_family)
|
||||
.map(|family| {
|
||||
let build_entry = |style: &FontCatalogStyle| {
|
||||
let font_style = style.to_named_style();
|
||||
let font_family = font.font_family.clone();
|
||||
let new_font = Font::new(font_family, font_style.clone());
|
||||
MenuListEntry::new(font_style.clone())
|
||||
.label(font_style.clone())
|
||||
.on_update({
|
||||
let font_family = font.font_family.clone();
|
||||
let font_style = font_style.clone();
|
||||
|
||||
move |_| {
|
||||
// Keep the existing family
|
||||
let new_font = Font::new(font_family.clone(), font_style.clone());
|
||||
|
||||
Message::Batched {
|
||||
messages: Box::new([
|
||||
PortfolioMessage::LoadFontData { font: new_font.clone() }.into(),
|
||||
update_value(move |_| TaggedValue::Font(new_font.clone()), node_id, index)(&()),
|
||||
]),
|
||||
}
|
||||
}
|
||||
})
|
||||
.label(font_style)
|
||||
.on_update(move |_| assign_font_message(node_id, new_font.clone()))
|
||||
.on_commit(commit_value)
|
||||
};
|
||||
|
||||
@@ -946,9 +915,8 @@ pub fn font_inputs(parameter_widgets_info: ParameterWidgetsInfo) -> (Vec<WidgetI
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.selected_index(
|
||||
cached_data
|
||||
fonts
|
||||
.font_catalog
|
||||
.0
|
||||
.iter()
|
||||
.find(|family| family.name == font.font_family)
|
||||
.and_then(|family| {
|
||||
@@ -2813,8 +2781,8 @@ pub fn math_properties(node_id: NodeId, context: &mut NodePropertiesContext) ->
|
||||
}
|
||||
|
||||
pub struct ParameterWidgetsInfo<'a> {
|
||||
cached_data: &'a CachedData,
|
||||
network_interface: &'a NodeNetworkInterface,
|
||||
resources: &'a ResourceMessageHandler,
|
||||
selection_network_path: &'a [NodeId],
|
||||
document_node: Option<&'a DocumentNode>,
|
||||
node_id: NodeId,
|
||||
@@ -2824,6 +2792,7 @@ pub struct ParameterWidgetsInfo<'a> {
|
||||
input_type: FrontendGraphDataType,
|
||||
blank_assist: bool,
|
||||
exposable: bool,
|
||||
fonts: &'a FontsMessageHandler,
|
||||
}
|
||||
|
||||
impl<'a> ParameterWidgetsInfo<'a> {
|
||||
@@ -2836,9 +2805,10 @@ impl<'a> ParameterWidgetsInfo<'a> {
|
||||
let document_node = context.network_interface.document_node(&node_id, context.selection_network_path);
|
||||
|
||||
ParameterWidgetsInfo {
|
||||
cached_data: context.cached_data,
|
||||
network_interface: context.network_interface,
|
||||
resources: context.resources,
|
||||
selection_network_path: context.selection_network_path,
|
||||
fonts: context.fonts,
|
||||
document_node,
|
||||
node_id,
|
||||
index,
|
||||
|
||||
@@ -2,11 +2,12 @@ use super::utility_types::{DrawHandles, OverlayContext};
|
||||
use crate::consts::HIDE_HANDLE_DISTANCE;
|
||||
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
|
||||
use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface;
|
||||
use crate::messages::portfolio::fonts::FALLBACK_FONT_RESOURCE;
|
||||
use crate::messages::tool::common_functionality::shape_editor::{SelectedLayerState, ShapeState};
|
||||
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::{TextAlign, TextContext, TypesettingConfig};
|
||||
use graphene_std::vector::misc::ManipulatorPointId;
|
||||
use graphene_std::vector::{PointId, SegmentId, Vector};
|
||||
use std::collections::HashMap;
|
||||
@@ -221,16 +222,6 @@ pub fn path_endpoint_overlays(document: &DocumentMessageHandler, shape_editor: &
|
||||
}
|
||||
}
|
||||
|
||||
// Global lazy initialized font cache and text context
|
||||
pub static GLOBAL_FONT_CACHE: LazyLock<FontCache> = 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
|
||||
});
|
||||
|
||||
pub static GLOBAL_TEXT_CONTEXT: LazyLock<Mutex<TextContext>> = LazyLock::new(|| Mutex::new(TextContext::default()));
|
||||
|
||||
pub fn text_width(text: &str, font_size: f64) -> f64 {
|
||||
@@ -244,12 +235,8 @@ pub fn text_width(text: &str, font_size: f64) -> f64 {
|
||||
align: TextAlign::AlignLeft,
|
||||
};
|
||||
|
||||
// Load Source Sans Pro font data
|
||||
// TODO: Grab this from the node_modules folder (either with `include_bytes!` or ideally at runtime) instead of checking the font file into the repo.
|
||||
// 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, &FALLBACK_FONT_RESOURCE, typesetting, false);
|
||||
bounds.x
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@ 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, hex_to_rgba_u8};
|
||||
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
|
||||
use crate::messages::portfolio::fonts::FALLBACK_FONT_RESOURCE;
|
||||
use crate::messages::prelude::Message;
|
||||
use crate::messages::prelude::ViewportMessageHandler;
|
||||
use core::borrow::Borrow;
|
||||
@@ -14,7 +15,7 @@ use graphene_std::ATTR_TRANSFORM;
|
||||
use graphene_std::list::List;
|
||||
use graphene_std::math::quad::Quad;
|
||||
use graphene_std::subpath::{self, Subpath};
|
||||
use graphene_std::text::{Font, TextAlign, TypesettingConfig};
|
||||
use graphene_std::text::{TextAlign, TypesettingConfig};
|
||||
use graphene_std::vector::click_target::ClickTargetType;
|
||||
use graphene_std::vector::misc::point_to_dvec2;
|
||||
use graphene_std::vector::{PointId, SegmentId, Vector};
|
||||
@@ -1115,21 +1116,16 @@ impl OverlayContextInternal {
|
||||
align: TextAlign::AlignLeft,
|
||||
};
|
||||
|
||||
// Load Source Sans Pro font data
|
||||
// TODO: Grab this from the node_modules folder (either with `include_bytes!` or ideally at runtime) instead of checking the font file into the repo.
|
||||
// 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());
|
||||
|
||||
// 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, &FALLBACK_FONT_RESOURCE, 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, &FALLBACK_FONT_RESOURCE, typesetting, false);
|
||||
|
||||
// Calculate position based on pivot
|
||||
let mut position = DVec2::ZERO;
|
||||
|
||||
+9
-7
@@ -3,17 +3,17 @@ use graphene_std::uuid::NodeId;
|
||||
use crate::messages::layout::utility_types::widget_prelude::*;
|
||||
use crate::messages::portfolio::document::node_graph::document_node_definitions::NodePropertiesContext;
|
||||
use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface;
|
||||
use crate::messages::portfolio::utility_types::CachedData;
|
||||
use crate::messages::prelude::*;
|
||||
use crate::node_graph_executor::NodeGraphExecutor;
|
||||
|
||||
#[derive(ExtractField)]
|
||||
pub struct PropertiesPanelMessageContext<'a> {
|
||||
pub executor: &'a mut NodeGraphExecutor,
|
||||
pub network_interface: &'a mut NodeNetworkInterface,
|
||||
pub resources: &'a ResourceMessageHandler,
|
||||
pub selection_network_path: &'a [NodeId],
|
||||
pub document_name: &'a str,
|
||||
pub executor: &'a mut NodeGraphExecutor,
|
||||
pub cached_data: &'a CachedData,
|
||||
pub fonts: &'a FontsMessageHandler,
|
||||
pub properties_panel_open: bool,
|
||||
}
|
||||
|
||||
@@ -24,11 +24,12 @@ pub struct PropertiesPanelMessageHandler {}
|
||||
impl MessageHandler<PropertiesPanelMessage, PropertiesPanelMessageContext<'_>> for PropertiesPanelMessageHandler {
|
||||
fn process_message(&mut self, message: PropertiesPanelMessage, responses: &mut VecDeque<Message>, context: PropertiesPanelMessageContext) {
|
||||
let PropertiesPanelMessageContext {
|
||||
executor,
|
||||
network_interface,
|
||||
resources,
|
||||
selection_network_path,
|
||||
document_name,
|
||||
executor,
|
||||
cached_data,
|
||||
fonts,
|
||||
properties_panel_open,
|
||||
} = context;
|
||||
|
||||
@@ -46,12 +47,13 @@ impl MessageHandler<PropertiesPanelMessage, PropertiesPanelMessageContext<'_>> f
|
||||
}
|
||||
|
||||
let mut node_properties_context = NodePropertiesContext {
|
||||
cached_data,
|
||||
responses,
|
||||
executor,
|
||||
network_interface,
|
||||
resources,
|
||||
selection_network_path,
|
||||
document_name,
|
||||
executor,
|
||||
fonts,
|
||||
};
|
||||
let layout = Layout(NodeGraphMessageHandler::collate_properties(&mut node_properties_context));
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
use crate::messages::prelude::*;
|
||||
use graph_craft::application_io::resource::ResourceId;
|
||||
use graphene_std::text::Font;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[impl_message(Message, DocumentMessage, Resource)]
|
||||
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub enum ResourceMessage {
|
||||
StoreEmbedded { resource_id: ResourceId, data: Arc<[u8]> },
|
||||
AddFont { resource_id: ResourceId, font: Font },
|
||||
Resolve,
|
||||
ResolveStep { resource_id: ResourceId },
|
||||
Resolved { resource_id: ResourceId, data: Arc<[u8]> },
|
||||
}
|
||||
|
||||
@@ -3,25 +3,149 @@ use crate::messages::prelude::*;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use graph_craft::application_io::resource::{DataSource, LoadResource, Resource, ResourceHash, ResourceId, ResourceRegistry};
|
||||
use graphene_std::text::Font;
|
||||
|
||||
#[derive(ExtractField)]
|
||||
pub struct ResourceMessageContext {}
|
||||
pub struct ResourceMessageContext<'a> {
|
||||
pub document_id: DocumentId,
|
||||
pub fonts: &'a FontsMessageHandler,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, ExtractField)]
|
||||
pub struct ResourceMessageHandler {
|
||||
pub registry: ResourceRegistry,
|
||||
pub embedded: EmbeddedResources,
|
||||
#[serde(skip)]
|
||||
pending_resolves: HashMap<ResourceId, Option<ResolveProgress>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
struct ResolveProgress {
|
||||
index: usize,
|
||||
source: DataSource,
|
||||
}
|
||||
|
||||
#[message_handler_data]
|
||||
impl MessageHandler<ResourceMessage, ResourceMessageContext> for ResourceMessageHandler {
|
||||
fn process_message(&mut self, message: ResourceMessage, responses: &mut VecDeque<Message>, _context: ResourceMessageContext) {
|
||||
impl MessageHandler<ResourceMessage, ResourceMessageContext<'_>> for ResourceMessageHandler {
|
||||
fn process_message(&mut self, message: ResourceMessage, responses: &mut VecDeque<Message>, context: ResourceMessageContext) {
|
||||
let ResourceMessageContext { document_id, fonts } = context;
|
||||
|
||||
match message {
|
||||
ResourceMessage::StoreEmbedded { resource_id, data } => {
|
||||
let hash = ResourceHash::from(data.as_ref());
|
||||
self.registry.push_source_back(&resource_id, DataSource::Embedded);
|
||||
self.registry.resolve(&resource_id, hash);
|
||||
responses.add(ResourceStorageMessage::Store { data });
|
||||
responses.add(ResourceMessage::Resolve);
|
||||
}
|
||||
ResourceMessage::AddFont { resource_id, font } => {
|
||||
let style = fonts.font_catalog.find_font_style_in_catalog(&font);
|
||||
let style_name = style.map(|style| style.to_named_style()).unwrap_or_else(|| font.font_style.clone());
|
||||
self.registry.push_source_back(&resource_id, DataSource::Embedded);
|
||||
self.registry.push_source_back(
|
||||
&resource_id,
|
||||
DataSource::Font {
|
||||
family: font.font_family,
|
||||
style: Some(style_name),
|
||||
},
|
||||
);
|
||||
responses.add(ResourceMessage::Resolve);
|
||||
}
|
||||
ResourceMessage::Resolve => {
|
||||
let unresolved_ids: Vec<ResourceId> = self.registry.unresolved().map(|info| info.id).collect();
|
||||
for id in unresolved_ids {
|
||||
if self.pending_resolves.contains_key(&id) {
|
||||
continue;
|
||||
}
|
||||
self.pending_resolves.insert(id, None);
|
||||
responses.add(ResourceMessage::ResolveStep { resource_id: id });
|
||||
}
|
||||
}
|
||||
ResourceMessage::ResolveStep { resource_id } => {
|
||||
let Some(progress) = self.pending_resolves.get_mut(&resource_id) else { return };
|
||||
|
||||
let Some(info) = self.registry.info(&resource_id) else {
|
||||
log::error!("ResolveStep for {resource_id}: no registry entry");
|
||||
self.pending_resolves.remove(&resource_id);
|
||||
return;
|
||||
};
|
||||
|
||||
let index = if let Some(progress) = progress { progress.index + 1 } else { 0 };
|
||||
let Some(source) = info.sources.get(index).cloned() else {
|
||||
log::error!("ResolveStep for {resource_id}: no more sources to try");
|
||||
self.pending_resolves.remove(&resource_id);
|
||||
return;
|
||||
};
|
||||
*progress = Some(ResolveProgress { index, source: source.clone() });
|
||||
|
||||
match source {
|
||||
DataSource::Embedded => {
|
||||
// Embedded resources are loaded on document load.
|
||||
// If we get to this point, it means the resource was not embedded and we should try the next source.
|
||||
responses.add(ResourceMessage::ResolveStep { resource_id });
|
||||
}
|
||||
DataSource::Url(url) => {
|
||||
responses.add(FrontendMessage::TriggerResolveResource {
|
||||
document_id,
|
||||
resource_id,
|
||||
url: url.to_string(),
|
||||
});
|
||||
}
|
||||
DataSource::Font { family, style } => {
|
||||
let font = match style {
|
||||
Some(style) => Font::new(family, style),
|
||||
None => Font::new_with_default_style(family),
|
||||
};
|
||||
if let Some(hash) = fonts.cached_hash(&font) {
|
||||
self.registry.resolve(&resource_id, hash);
|
||||
self.pending_resolves.remove(&resource_id);
|
||||
responses.add(NodeGraphMessage::RunDocumentGraph);
|
||||
return;
|
||||
}
|
||||
if let Some(url) = fonts.cached_url(&font) {
|
||||
responses.add(FrontendMessage::TriggerResolveResource { document_id, resource_id, url });
|
||||
return;
|
||||
}
|
||||
responses.add(FrontendMessage::TriggerFontCatalogLoad);
|
||||
self.pending_resolves.remove(&resource_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
ResourceMessage::Resolved { resource_id, data } => {
|
||||
let hash = ResourceHash::from(data.as_ref());
|
||||
let Some(progress) = self.pending_resolves.remove(&resource_id).and_then(|p| p) else {
|
||||
log::error!("Resolved message for {resource_id} with no pending resolve");
|
||||
return;
|
||||
};
|
||||
let Some(info) = self.registry.info(&resource_id) else {
|
||||
// ResourceId was removed from registry after resolve started.
|
||||
// This can happen if the document was modified while resolves were in-flight.
|
||||
// Likely safe to ignore for now.
|
||||
// TODO: Consider adding cleaner cancelation for in-flight resolves.
|
||||
return;
|
||||
};
|
||||
let Some(source) = info.sources.get(progress.index).cloned() else {
|
||||
log::error!("Resolved message for {resource_id} with no current source");
|
||||
return;
|
||||
};
|
||||
if progress.source != source {
|
||||
log::error!("Resolved message for {resource_id} with mismatched source");
|
||||
return;
|
||||
}
|
||||
|
||||
self.registry.resolve(&resource_id, hash);
|
||||
responses.add(ResourceStorageMessage::Store { data });
|
||||
|
||||
if let DataSource::Font { family, style } = source {
|
||||
let font = match style {
|
||||
Some(style) => Font::new(family, style),
|
||||
None => Font::new_with_default_style(family),
|
||||
};
|
||||
responses.add(FontsMessage::ResourceResolved { font, hash });
|
||||
}
|
||||
|
||||
responses.add(ResourceMessage::Resolve);
|
||||
responses.add(NodeGraphMessage::RunDocumentGraph);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,7 +178,7 @@ impl ResourceMessageHandler {
|
||||
self.embedded = EmbeddedResources::from_iter(futures::future::join_all(embedded).await.into_iter().flatten());
|
||||
}
|
||||
|
||||
pub fn garbage_collect(&mut self, used: &[ResourceId]) {
|
||||
pub fn collect_garbage(&mut self, used: &[ResourceId]) {
|
||||
let used = HashSet::<ResourceId>::from_iter(used.iter().cloned());
|
||||
let unused = self.registry.ids().filter(|id| !used.contains(id)).collect::<Vec<_>>();
|
||||
unused.into_iter().for_each(|id| {
|
||||
|
||||
@@ -1685,6 +1685,28 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
|
||||
}
|
||||
}
|
||||
|
||||
// Convert text nodes from the old `editor-api` scope + `Font` input to a single font `Resource` input.
|
||||
// The chosen typeface is recorded as a `DataSource::Font` in the document's resource registry and loaded on open.
|
||||
if reference == DefinitionIdentifier::ProtoNode(graphene_std::text::text::IDENTIFIER) && inputs_count == 13 && matches!(node.inputs.first(), Some(NodeInput::Scope(_))) {
|
||||
document
|
||||
.network_interface
|
||||
.set_input(&InputConnector::node(*node_id, 0), NodeInput::value(TaggedValue::None, false), network_path);
|
||||
|
||||
if let Some(TaggedValue::Font(font)) = node.inputs.get(2).and_then(|input| input.as_value()) {
|
||||
let resource_id = ResourceId::new();
|
||||
document.resources.registry.push_source_back(
|
||||
&resource_id,
|
||||
DataSource::Font {
|
||||
family: font.font_family.clone(),
|
||||
style: Some(font.font_style.clone()),
|
||||
},
|
||||
);
|
||||
document
|
||||
.network_interface
|
||||
.set_input(&InputConnector::node(*node_id, 2), NodeInput::value(TaggedValue::Resource(resource_id), false), network_path);
|
||||
}
|
||||
}
|
||||
|
||||
if reference == DefinitionIdentifier::ProtoNode(graphene_std::raster_nodes::std_nodes::noise_pattern::IDENTIFIER) && inputs_count == 15 {
|
||||
let mut node_template = resolve_document_node_type(&reference)?.default_node_template();
|
||||
document.network_interface.replace_implementation(node_id, network_path, &mut node_template);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
use graph_craft::application_io::resource::Resource;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
const FALLBACK_FONT_BYTES: &[u8] = include_bytes!("source-sans-pro-regular.ttf");
|
||||
pub static FALLBACK_FONT_RESOURCE: LazyLock<Resource> = LazyLock::new(|| Resource::new(FALLBACK_FONT_BYTES));
|
||||
@@ -0,0 +1,25 @@
|
||||
use crate::messages::portfolio::fonts::utility_types::FontCatalog;
|
||||
use crate::messages::prelude::*;
|
||||
use graph_craft::application_io::resource::{Resource, ResourceHash};
|
||||
use graphene_std::text::Font;
|
||||
|
||||
#[impl_message(Message, PortfolioMessage, Fonts)]
|
||||
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub enum FontsMessage {
|
||||
CatalogLoaded {
|
||||
catalog: FontCatalog,
|
||||
},
|
||||
ResourceResolved {
|
||||
font: Font,
|
||||
hash: ResourceHash,
|
||||
},
|
||||
Load {
|
||||
font: Font,
|
||||
response: Box<Message>,
|
||||
},
|
||||
Cached {
|
||||
hash: ResourceHash,
|
||||
#[serde(skip, default = "Resource::empty")]
|
||||
resource: Resource,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
use crate::messages::portfolio::fonts::FALLBACK_FONT_RESOURCE;
|
||||
use crate::messages::portfolio::fonts::utility_types::FontCatalog;
|
||||
use crate::messages::prelude::*;
|
||||
use graph_craft::application_io::resource::{DataSource, Resource, ResourceHash, ResourceId};
|
||||
use graphene_std::text::Font;
|
||||
|
||||
#[derive(ExtractField)]
|
||||
pub struct FontsMessageContext<'a> {
|
||||
pub resource_storage: &'a ResourceStorageMessageHandler,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, ExtractField)]
|
||||
pub struct FontsMessageHandler {
|
||||
pub font_catalog: FontCatalog,
|
||||
font_hashes: HashMap<Font, ResourceHash>,
|
||||
font_data: HashMap<ResourceHash, Resource>,
|
||||
}
|
||||
|
||||
#[message_handler_data]
|
||||
impl MessageHandler<FontsMessage, FontsMessageContext<'_>> for FontsMessageHandler {
|
||||
fn process_message(&mut self, message: FontsMessage, responses: &mut VecDeque<Message>, context: FontsMessageContext) {
|
||||
let FontsMessageContext { resource_storage } = context;
|
||||
|
||||
match message {
|
||||
FontsMessage::CatalogLoaded { catalog } => {
|
||||
self.font_catalog = catalog;
|
||||
responses.add(PortfolioMessage::ResolveResources);
|
||||
}
|
||||
FontsMessage::ResourceResolved { font, hash } => {
|
||||
let font = self.normalize(font);
|
||||
self.font_hashes.insert(font, hash);
|
||||
}
|
||||
FontsMessage::Load { font, response } => {
|
||||
let font = self.normalize(font);
|
||||
let Some(hash) = self.font_hashes.get(&font).copied() else {
|
||||
log::warn!("FontsMessage::Load for {font:?} with no known hash; ignoring");
|
||||
return;
|
||||
};
|
||||
if self.font_data.contains_key(&hash) {
|
||||
responses.add(*response);
|
||||
return;
|
||||
}
|
||||
let loader = resource_storage.resources();
|
||||
responses.add(async move {
|
||||
let resource = loader.load(hash).await;
|
||||
match resource {
|
||||
Some(resource) => Message::Batched {
|
||||
messages: Box::new([FontsMessage::Cached { hash, resource }.into(), *response]),
|
||||
},
|
||||
None => {
|
||||
log::warn!("Storage missing data for font hash {hash}");
|
||||
*response
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
FontsMessage::Cached { hash, resource } => {
|
||||
self.font_data.insert(hash, resource);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
advertise_actions!(FontsMessageDiscriminant;);
|
||||
}
|
||||
|
||||
impl FontsMessageHandler {
|
||||
pub fn cached_hash(&self, font: &Font) -> Option<ResourceHash> {
|
||||
let font = self.normalize(font.clone());
|
||||
self.font_hashes.get(&font).copied()
|
||||
}
|
||||
|
||||
pub fn cached_url(&self, font: &Font) -> Option<String> {
|
||||
let font = self.normalize(font.clone());
|
||||
self.font_catalog.download_url(&font)
|
||||
}
|
||||
|
||||
pub fn get_resource_or_queue_load(&self, font: &Font, responses: &mut VecDeque<Message>) -> Resource {
|
||||
let font = self.normalize(font.clone());
|
||||
if let Some(hash) = self.font_hashes.get(&font) {
|
||||
if let Some(resource) = self.font_data.get(hash) {
|
||||
return resource.clone();
|
||||
}
|
||||
responses.add(FontsMessage::Load {
|
||||
font: font.clone(),
|
||||
response: Message::NoOp.into(),
|
||||
});
|
||||
}
|
||||
FALLBACK_FONT_RESOURCE.clone()
|
||||
}
|
||||
|
||||
pub fn id_font(&self, resources: &ResourceMessageHandler, resource_id: ResourceId) -> Option<Font> {
|
||||
let info = resources.registry.info(&resource_id)?;
|
||||
info.sources.iter().find_map(|source| match source {
|
||||
DataSource::Font { family, style } => Some(match style {
|
||||
Some(style) => Font::new(family.clone(), style.clone()),
|
||||
None => Font::new_with_default_style(family.clone()),
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn used_resources(&self) -> impl Iterator<Item = ResourceHash> + '_ {
|
||||
self.font_hashes.values().copied().chain(self.font_data.keys().copied())
|
||||
}
|
||||
|
||||
fn normalize(&self, font: Font) -> Font {
|
||||
match self.font_catalog.find_font_style_in_catalog(&font) {
|
||||
Some(style) => Font::new(font.font_family, style.to_named_style()),
|
||||
None => font,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
mod fallback;
|
||||
mod fonts_message;
|
||||
mod fonts_message_handler;
|
||||
|
||||
pub mod utility_types;
|
||||
|
||||
#[doc(inline)]
|
||||
pub use fallback::FALLBACK_FONT_RESOURCE;
|
||||
#[doc(inline)]
|
||||
pub use fonts_message::{FontsMessage, FontsMessageDiscriminant};
|
||||
#[doc(inline)]
|
||||
pub use fonts_message_handler::{FontsMessageContext, FontsMessageHandler};
|
||||
@@ -0,0 +1,104 @@
|
||||
use graphene_std::text::Font;
|
||||
|
||||
// TODO: Should this be a BTreeMap instead?
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct FontCatalog(Vec<FontCatalogFamily>);
|
||||
|
||||
impl FontCatalog {
|
||||
pub fn find_font_style_in_catalog(&self, font: &Font) -> Option<FontCatalogStyle> {
|
||||
let family = self.0.iter().find(|family| family.name == font.font_family);
|
||||
|
||||
let found_style = family.map(|family| {
|
||||
let FontCatalogStyle { weight, italic, .. } = FontCatalogStyle::from_named_style(&font.font_style, "");
|
||||
family.closest_style(weight, italic).clone()
|
||||
});
|
||||
|
||||
if found_style.is_none() {
|
||||
log::warn!("Font not found in catalog: {:?}", font);
|
||||
}
|
||||
|
||||
found_style
|
||||
}
|
||||
|
||||
pub fn download_url(&self, font: &Font) -> Option<String> {
|
||||
let catalog_family = self.0.iter().find(|catalog_family| catalog_family.name == font.font_family)?;
|
||||
let FontCatalogStyle { weight, italic, .. } = FontCatalogStyle::from_named_style(&font.font_style, "");
|
||||
Some(catalog_family.closest_style(weight, italic).url.clone())
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = &FontCatalogFamily> {
|
||||
self.0.iter()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<FontCatalogFamily>> for FontCatalog {
|
||||
fn from(value: Vec<FontCatalogFamily>) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))]
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct FontCatalogFamily {
|
||||
/// The font family name.
|
||||
pub name: String,
|
||||
/// The font styles (variants) available for the font family.
|
||||
pub styles: Vec<FontCatalogStyle>,
|
||||
}
|
||||
|
||||
impl FontCatalogFamily {
|
||||
/// Finds the closest style to the given weight and italic setting.
|
||||
/// Aims to find the nearest weight while maintaining the italic setting if possible, but italic may change if no other option is available.
|
||||
pub fn closest_style(&self, weight: u32, italic: bool) -> &FontCatalogStyle {
|
||||
static FALLBACK_STYLE: FontCatalogStyle = FontCatalogStyle {
|
||||
weight: 400,
|
||||
italic: false,
|
||||
url: String::new(),
|
||||
};
|
||||
|
||||
self.styles
|
||||
.iter()
|
||||
.map(|style| ((style.weight as i32 - weight as i32).unsigned_abs() + 10000 * (style.italic != italic) as u32, style))
|
||||
.min_by_key(|(distance, _)| *distance)
|
||||
.map(|(_, style)| style)
|
||||
.unwrap_or(self.styles.first().unwrap_or(&FALLBACK_STYLE))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct FontCatalogStyle {
|
||||
pub weight: u32,
|
||||
pub italic: bool,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
impl FontCatalogStyle {
|
||||
pub fn to_named_style(&self) -> String {
|
||||
let weight = self.weight;
|
||||
let italic = self.italic;
|
||||
|
||||
let named_weight = Font::named_weight(weight);
|
||||
let maybe_italic = if italic { " Italic" } else { "" };
|
||||
|
||||
format!("{named_weight}{maybe_italic} ({weight})")
|
||||
}
|
||||
|
||||
pub fn from_named_style(named_style: &str, url: impl Into<String>) -> FontCatalogStyle {
|
||||
let weight = named_style.split_terminator(['(', ')']).next_back().and_then(|x| x.parse::<u32>().ok()).unwrap_or(400);
|
||||
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>) -> 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}")
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,12 @@ mod portfolio_message_handler;
|
||||
|
||||
pub mod document;
|
||||
pub mod document_migration;
|
||||
pub mod fonts;
|
||||
pub mod persistent_state;
|
||||
pub mod utility_types;
|
||||
|
||||
#[doc(inline)]
|
||||
pub use fonts::{FontsMessage, FontsMessageContext, FontsMessageHandler};
|
||||
#[doc(inline)]
|
||||
pub use persistent_state::{PersistentStateMessage, PersistentStateMessageContext, PersistentStateMessageHandler};
|
||||
#[doc(inline)]
|
||||
|
||||
@@ -52,3 +52,9 @@ impl MessageHandler<PersistentStateMessage, PersistentStateMessageContext> for P
|
||||
|
||||
advertise_actions!(PersistentStateMessageDiscriminant;);
|
||||
}
|
||||
|
||||
impl PersistentStateMessageHandler {
|
||||
pub fn loaded(&self) -> bool {
|
||||
self.loaded
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,9 @@ use super::persistent_state::PersistentStateMessage;
|
||||
use super::utility_types::{DockingSplitDirection, PanelGroupId, PanelType};
|
||||
use crate::messages::frontend::utility_types::{ExportBounds, FileType, PersistedState};
|
||||
use crate::messages::portfolio::document::utility_types::clipboards::Clipboard;
|
||||
use crate::messages::portfolio::utility_types::FontCatalog;
|
||||
use crate::messages::prelude::*;
|
||||
use graphene_std::Color;
|
||||
use graphene_std::raster::Image;
|
||||
use graphene_std::text::Font;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[impl_message(Message, Portfolio)]
|
||||
@@ -17,6 +15,8 @@ pub enum PortfolioMessage {
|
||||
#[child]
|
||||
Document(DocumentMessage),
|
||||
#[child]
|
||||
Fonts(FontsMessage),
|
||||
#[child]
|
||||
PersistentState(PersistentStateMessage),
|
||||
|
||||
// Messages
|
||||
@@ -51,20 +51,10 @@ pub enum PortfolioMessage {
|
||||
DestroyAllDocuments,
|
||||
EditorPreferences,
|
||||
GarbageCollectResources,
|
||||
FontCatalogLoaded {
|
||||
catalog: FontCatalog,
|
||||
},
|
||||
LoadFontData {
|
||||
font: Font,
|
||||
},
|
||||
FontLoaded {
|
||||
font_family: String,
|
||||
font_style: String,
|
||||
data: Vec<u8>,
|
||||
},
|
||||
LoadDocumentResources {
|
||||
ResolveDocumentResources {
|
||||
document_id: DocumentId,
|
||||
},
|
||||
ResolveResources,
|
||||
LoadPersistedState {
|
||||
state: PersistedState,
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::document::utility_types::document_metadata::LayerNodeIdentifier;
|
||||
use super::document::utility_types::network_interface;
|
||||
use super::persistent_state::{PersistentStateMessage, PersistentStateMessageContext, PersistentStateMessageHandler};
|
||||
use super::utility_types::{CachedData, PanelLayoutSubdivision, PanelType, WorkspacePanelLayout};
|
||||
use super::utility_types::{PanelLayoutSubdivision, PanelType, WorkspacePanelLayout};
|
||||
use crate::application::{Editor, generate_uuid};
|
||||
use crate::consts::{DEFAULT_DOCUMENT_NAME, DEFAULT_STROKE_WIDTH, FILE_EXTENSION};
|
||||
use crate::messages::animation::TimingInformation;
|
||||
@@ -32,7 +32,6 @@ use graphene_std::Color;
|
||||
use graphene_std::raster_types::Image;
|
||||
use graphene_std::renderer::Quad;
|
||||
use graphene_std::subpath::BezierHandles;
|
||||
use graphene_std::text::Font;
|
||||
use graphene_std::vector::misc::HandleId;
|
||||
use graphene_std::vector::{PointId, SegmentId, Vector, VectorModificationType};
|
||||
use std::path::PathBuf;
|
||||
@@ -68,7 +67,7 @@ pub struct PortfolioMessageHandler {
|
||||
document_ids: VecDeque<DocumentId>,
|
||||
pub(crate) active_document_id: Option<DocumentId>,
|
||||
persistent_state: PersistentStateMessageHandler,
|
||||
pub cached_data: CachedData,
|
||||
pub fonts: FontsMessageHandler,
|
||||
copy_buffer: [Vec<CopyBufferEntry>; INTERNAL_CLIPBOARD_COUNT as usize],
|
||||
pub executor: NodeGraphExecutor,
|
||||
pub selection_mode: SelectionMode,
|
||||
@@ -99,7 +98,7 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
let document_inputs = DocumentMessageContext {
|
||||
document_id,
|
||||
ipp,
|
||||
cached_data: &self.cached_data,
|
||||
fonts: &self.fonts,
|
||||
executor: &mut self.executor,
|
||||
current_tool,
|
||||
preferences,
|
||||
@@ -118,6 +117,10 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
};
|
||||
self.persistent_state.process_message(message, responses, context);
|
||||
}
|
||||
PortfolioMessage::Fonts(message) => {
|
||||
let context = FontsMessageContext { resource_storage };
|
||||
self.fonts.process_message(message, responses, context);
|
||||
}
|
||||
|
||||
// Messages
|
||||
PortfolioMessage::Init => {
|
||||
@@ -169,7 +172,7 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
let document_inputs = DocumentMessageContext {
|
||||
document_id,
|
||||
ipp,
|
||||
cached_data: &self.cached_data,
|
||||
fonts: &self.fonts,
|
||||
executor: &mut self.executor,
|
||||
current_tool,
|
||||
preferences,
|
||||
@@ -208,6 +211,7 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
document_id,
|
||||
document: document.serialize_document(),
|
||||
});
|
||||
responses.add(PersistentStateMessage::WriteState);
|
||||
}
|
||||
PortfolioMessage::CloseActiveDocumentWithConfirmation => {
|
||||
if let Some(document_id) = self.active_document_id {
|
||||
@@ -386,78 +390,16 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
responses.add(MenuBarMessage::SendLayout);
|
||||
responses.add(PersistentStateMessage::WriteState);
|
||||
}
|
||||
PortfolioMessage::FontCatalogLoaded { catalog } => {
|
||||
self.cached_data.font_catalog = catalog;
|
||||
|
||||
if let Some(document_id) = self.active_document_id {
|
||||
responses.add(PortfolioMessage::LoadDocumentResources { document_id });
|
||||
}
|
||||
|
||||
// Load the default font
|
||||
let font = Font::new(graphene_std::consts::DEFAULT_FONT_FAMILY.into(), graphene_std::consts::DEFAULT_FONT_STYLE.into());
|
||||
responses.add(PortfolioMessage::LoadFontData { font });
|
||||
}
|
||||
PortfolioMessage::LoadFontData { font } => {
|
||||
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) {
|
||||
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 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();
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
if self.active_document_mut().is_some() {
|
||||
responses.add(NodeGraphMessage::RunDocumentGraph);
|
||||
}
|
||||
|
||||
if current_tool == &ToolType::Text {
|
||||
responses.add(TextToolMessage::RefreshEditingFontData);
|
||||
}
|
||||
}
|
||||
PortfolioMessage::EditorPreferences => self.executor.update_editor_preferences(preferences.editor_preferences()),
|
||||
PortfolioMessage::GarbageCollectResources => {
|
||||
if !self.persistent_state.loaded() {
|
||||
// We don't know what can be safely garbage collected
|
||||
return;
|
||||
}
|
||||
|
||||
let mut used_resources = HashSet::new();
|
||||
for (id, info) in self.unloaded_documents.iter() {
|
||||
log::info!("Checking resources for unloaded document {:?}: {:?}", info.name, info.resources);
|
||||
if let Some(resources) = &info.resources {
|
||||
used_resources.extend(resources.iter());
|
||||
} else {
|
||||
@@ -469,21 +411,26 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
document.garbage_collect_resources();
|
||||
used_resources.extend(document.resources.registry.resolved().filter_map(|info| info.hash.cloned()));
|
||||
}
|
||||
used_resources.extend(self.fonts.used_resources());
|
||||
responses.add(ResourceStorageMessage::GarbageCollect {
|
||||
used: Vec::from_iter(used_resources).into_boxed_slice(),
|
||||
});
|
||||
}
|
||||
PortfolioMessage::LoadDocumentResources { document_id } => {
|
||||
let catalog = &self.cached_data.font_catalog;
|
||||
|
||||
if catalog.0.is_empty() {
|
||||
PortfolioMessage::ResolveResources => {
|
||||
for document_id in self.document_ids.iter().copied().collect::<Vec<_>>() {
|
||||
responses.add(PortfolioMessage::ResolveDocumentResources { document_id });
|
||||
}
|
||||
}
|
||||
PortfolioMessage::ResolveDocumentResources { document_id } => {
|
||||
if self.fonts.font_catalog.is_empty() {
|
||||
responses.add_front(FrontendMessage::TriggerFontCatalogLoad);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(document) = self.documents.get_mut(&document_id) {
|
||||
document.load_layer_resources(responses);
|
||||
}
|
||||
responses.add(PortfolioMessage::DocumentPassMessage {
|
||||
document_id,
|
||||
message: DocumentMessage::Resource(ResourceMessage::Resolve),
|
||||
});
|
||||
}
|
||||
PortfolioMessage::LoadPersistedState { state } => {
|
||||
if let Some(layout) = state.workspace_layout {
|
||||
@@ -884,6 +831,7 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
let mut document = match document {
|
||||
Ok(document) => document,
|
||||
Err(e) => {
|
||||
log::error!("{e}");
|
||||
// TODO: Eventually remove this document upgrade code
|
||||
// TODO: (Only the `if` branch, the `else` branch's manual-open dialog stays)
|
||||
if document_is_auto_saved {
|
||||
@@ -908,7 +856,6 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
responses.add(PortfolioMessage::UpdateOpenDocumentsList);
|
||||
self.tick_autosave_load_progress(responses, true);
|
||||
} else {
|
||||
log::error!("{e}");
|
||||
let name = document_name
|
||||
.filter(|n| !n.trim().is_empty())
|
||||
.or_else(|| document_path.as_ref().and_then(|p| p.file_stem()).map(|s| s.to_string_lossy().into_owned()))
|
||||
@@ -1134,7 +1081,6 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
added_nodes = true;
|
||||
}
|
||||
|
||||
document.load_layer_resources(responses);
|
||||
let new_ids: HashMap<_, _> = entry.nodes.iter().map(|(id, _)| (*id, NodeId::new())).collect();
|
||||
let layer = LayerNodeIdentifier::new_unchecked(new_ids[&NodeId(0)]);
|
||||
all_new_ids.extend(new_ids.values().cloned());
|
||||
@@ -1619,7 +1565,7 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
};
|
||||
if !document.is_loaded {
|
||||
document.is_loaded = true;
|
||||
responses.add(PortfolioMessage::LoadDocumentResources { document_id });
|
||||
responses.add(PortfolioMessage::ResolveDocumentResources { document_id });
|
||||
responses.add(PortfolioMessage::UpdateDocumentWidgets);
|
||||
responses.add(PropertiesPanelMessage::Clear);
|
||||
}
|
||||
@@ -1665,6 +1611,13 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
return;
|
||||
};
|
||||
|
||||
// Skip rendering while any resource is still unresolved — the preprocessor would otherwise fail with
|
||||
// `ResourceNotFound`. `ResourceMessage::Resolved` queues `RunDocumentGraph` once each id resolves,
|
||||
// so the render fires automatically once the registry is complete.
|
||||
if document.resources.registry.unresolved().next().is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let document_to_viewport = document
|
||||
.navigation_handler
|
||||
.calculate_offset_transform(viewport.center_in_viewport_space().into(), &document.document_ptz);
|
||||
|
||||
@@ -1,95 +1,11 @@
|
||||
use graphene_std::Color;
|
||||
use graphene_std::raster::Image;
|
||||
use graphene_std::text::{Font, FontCache};
|
||||
|
||||
/// Proportional share (0-1) for the document panel's side when splitting adjacent to non-document panels.
|
||||
const DOCUMENT_PANEL_SHARE: f64 = 0.8;
|
||||
/// Proportional share for each side when neither (or both) contain the document panel.
|
||||
const EQUAL_PANEL_SHARE: f64 = 0.5;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct CachedData {
|
||||
pub font_cache: FontCache,
|
||||
pub font_catalog: FontCatalog,
|
||||
}
|
||||
|
||||
// TODO: Should this be a BTreeMap instead?
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct FontCatalog(pub Vec<FontCatalogFamily>);
|
||||
|
||||
impl FontCatalog {
|
||||
pub fn find_font_style_in_catalog(&self, font: &Font) -> Option<FontCatalogStyle> {
|
||||
let family = self.0.iter().find(|family| family.name == font.font_family);
|
||||
|
||||
let found_style = family.map(|family| {
|
||||
let FontCatalogStyle { weight, italic, .. } = FontCatalogStyle::from_named_style(&font.font_style, "");
|
||||
family.closest_style(weight, italic).clone()
|
||||
});
|
||||
|
||||
if found_style.is_none() {
|
||||
log::warn!("Font not found in catalog: {:?}", font);
|
||||
}
|
||||
|
||||
found_style
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))]
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct FontCatalogFamily {
|
||||
/// The font family name.
|
||||
pub name: String,
|
||||
/// The font styles (variants) available for the font family.
|
||||
pub styles: Vec<FontCatalogStyle>,
|
||||
}
|
||||
|
||||
impl FontCatalogFamily {
|
||||
/// Finds the closest style to the given weight and italic setting.
|
||||
/// Aims to find the nearest weight while maintaining the italic setting if possible, but italic may change if no other option is available.
|
||||
pub fn closest_style(&self, weight: u32, italic: bool) -> &FontCatalogStyle {
|
||||
self.styles
|
||||
.iter()
|
||||
.map(|style| ((style.weight as i32 - weight as i32).unsigned_abs() + 10000 * (style.italic != italic) as u32, style))
|
||||
.min_by_key(|(distance, _)| *distance)
|
||||
.map(|(_, style)| style)
|
||||
.unwrap_or(&self.styles[0])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct FontCatalogStyle {
|
||||
pub weight: u32,
|
||||
pub italic: bool,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
impl FontCatalogStyle {
|
||||
pub fn to_named_style(&self) -> String {
|
||||
let weight = self.weight;
|
||||
let italic = self.italic;
|
||||
|
||||
let named_weight = Font::named_weight(weight);
|
||||
let maybe_italic = if italic { " Italic" } else { "" };
|
||||
|
||||
format!("{named_weight}{maybe_italic} ({weight})")
|
||||
}
|
||||
|
||||
pub fn from_named_style(named_style: &str, url: impl Into<String>) -> FontCatalogStyle {
|
||||
let weight = named_style.split_terminator(['(', ')']).next_back().and_then(|x| x.parse::<u32>().ok()).unwrap_or(400);
|
||||
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>) -> 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))]
|
||||
#[derive(PartialEq, Eq, Hash, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub enum PanelType {
|
||||
|
||||
Reference in New Issue
Block a user