Skip to content

Commit 5f10094

Browse files
Add String[] as a graphic type for typography (#4141)
* feat: Render List<String> as raw paths in SVG and Vell mode * chore: code review * chore: change the hardcoded layout bounds to parley's * chore: code review * feat: Split text node to text_layer and text_to_vector node * fix: CI fail because of difference in nature of Mac and github action * chore: fix * chore: replace FontStack as it got removed in parley 0.9 * chore: fmt * chore: migrate the rendering as of new resource architechture * chore: add text_layer node to text tool for testing * code review * Make boolean ops support the Text type * chore: Move fallback_font_resource authority from editor to text node * Fix 'Text Layer' node missing font dropdown * Change node doc comments from Vec<T> to T[] * Add migrations from the old Text node to Text -> Text to Vector * Consolidate * Rename the text attributes and reorder tilt to come before max_width/height * Detect legacy Text nodes in the split migration by their trailing separate_glyphs input * Code review * Frame Text layer thumbnails by laying out their text for bounds * Give Text layers click targets and selection outlines via collect_metadata * Route Text tool through Text to Vector with fill, fixing editing-preview placement --------- Co-authored-by: Keavon Chambers <keavon@keavon.com>
1 parent 13abf9f commit 5f10094

39 files changed

Lines changed: 967 additions & 204 deletions

File tree

Cargo.lock

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,7 @@ impl TableItemLayout for Graphic {
336336
Self::RasterGPU(list) => list.identifier(),
337337
Self::Color(list) => list.identifier(),
338338
Self::Gradient(list) => list.identifier(),
339+
Self::Text(list) => list.identifier(),
339340
}
340341
}
341342
// Don't put a breadcrumb for Graphic
@@ -350,6 +351,7 @@ impl TableItemLayout for Graphic {
350351
Self::RasterGPU(list) => list.layout_with_breadcrumb(data),
351352
Self::Color(list) => list.layout_with_breadcrumb(data),
352353
Self::Gradient(list) => list.layout_with_breadcrumb(data),
354+
Self::Text(list) => list.layout_with_breadcrumb(data),
353355
}
354356
}
355357
}

editor/src/messages/portfolio/document/graph_operation/utility_types.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -259,28 +259,35 @@ impl<'a> ModifyInputsContext<'a> {
259259
Some(NodeInput::value(TaggedValue::Resource(font_resource_id), false)),
260260
Some(NodeInput::value(TaggedValue::F64(typesetting.font_size), false)),
261261
Some(NodeInput::value(TaggedValue::F64(typesetting.line_height_ratio), false)),
262-
Some(NodeInput::value(TaggedValue::F64(typesetting.character_spacing), false)),
262+
Some(NodeInput::value(TaggedValue::F64(typesetting.letter_spacing), false)),
263+
Some(NodeInput::value(TaggedValue::F64(typesetting.letter_tilt), false)),
263264
Some(NodeInput::value(TaggedValue::Bool(typesetting.max_width.is_some()), false)),
264265
Some(NodeInput::value(TaggedValue::F64(typesetting.max_width.unwrap_or(100.)), false)),
265266
Some(NodeInput::value(TaggedValue::Bool(typesetting.max_height.is_some()), false)),
266267
Some(NodeInput::value(TaggedValue::F64(typesetting.max_height.unwrap_or(100.)), false)),
267-
Some(NodeInput::value(TaggedValue::F64(typesetting.tilt), false)),
268268
Some(NodeInput::value(TaggedValue::TextAlign(typesetting.align), false)),
269-
Some(NodeInput::value(TaggedValue::Bool(false), false)),
270269
]);
270+
let text_to_vector = resolve_proto_node_type(graphene_std::text::text_to_vector::IDENTIFIER)
271+
.expect("Text to Vector node does not exist")
272+
.default_node_template();
271273
let transform = resolve_proto_node_type(graphene_std::transform_nodes::transform::IDENTIFIER)
272274
.expect("Transform node does not exist")
273275
.default_node_template();
274276
let fill = resolve_proto_node_type(graphene_std::vector_nodes::fill::IDENTIFIER)
275277
.expect("Fill node does not exist")
276278
.default_node_template();
277279

280+
// Build the chain `Text -> Text to Vector -> Transform -> Fill -> layer`
278281
let text_id = NodeId::new();
279282
self.network_interface.insert_node(text_id, text, &[]);
280283
self.network_interface.move_node_to_chain_start(&text_id, layer, &[], self.import);
281284

282285
self.responses.add(DocumentMessage::Resource(ResourceMessage::AddFont { resource_id: font_resource_id, font }));
283286

287+
let text_to_vector_id = NodeId::new();
288+
self.network_interface.insert_node(text_to_vector_id, text_to_vector, &[]);
289+
self.network_interface.move_node_to_chain_start(&text_to_vector_id, layer, &[], self.import);
290+
284291
let transform_id = NodeId::new();
285292
self.network_interface.insert_node(transform_id, transform, &[]);
286293
self.network_interface.move_node_to_chain_start(&transform_id, layer, &[], self.import);

editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use crate::messages::portfolio::document::utility_types::network_interface::{
88
DocumentNodeMetadata, DocumentNodePersistentMetadata, InputMetadata, NodeNetworkInterface, NodeNetworkMetadata, NodeNetworkPersistentMetadata, NodeTemplate, NodeTypePersistentMetadata,
99
Vec2InputSettings, WidgetOverride,
1010
};
11-
use crate::messages::prelude::{FontsMessageHandler, Message, ResourceMessageHandler};
11+
use crate::messages::prelude::{FontsMessage, FontsMessageHandler, Message, ResourceMessageHandler, Responses};
1212
use crate::node_graph_executor::NodeGraphExecutor;
1313
use glam::DVec2;
1414
use graph_craft::ProtoNodeIdentifier;
@@ -2047,6 +2047,10 @@ fn static_input_properties() -> InputProperties {
20472047
map.insert(
20482048
"text_font".to_string(),
20492049
Box::new(|node_id, index, context| {
2050+
// Lazily load the font catalog (like the Text tool) so the dropdown has entries
2051+
if context.fonts.font_catalog.is_empty() {
2052+
context.responses.add(FontsMessage::LoadCatalog);
2053+
}
20502054
let (font, style) = node_properties::font_inputs(ParameterWidgetsInfo::new(node_id, index, true, context));
20512055
let mut result = vec![LayoutGroup::row(font)];
20522056
if let Some(style) = style {

editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,9 +292,21 @@ impl<'a> MessageHandler<NodeGraphMessage, NodeGraphMessageContext<'a>> for NodeG
292292
return;
293293
};
294294

295-
let node_template = document_node_type.default_node_template();
295+
let mut node_template = document_node_type.default_node_template();
296296
self.context_menu = None;
297297

298+
// A freshly added Text node carries no font, so give it the default font (registered like the Text tool does)
299+
if node_type == DefinitionIdentifier::ProtoNode(graphene_std::text::text::IDENTIFIER) {
300+
let font_resource_id = graph_craft::application_io::resource::ResourceId::new();
301+
if let Some(font_input) = node_template.document_node.inputs.get_mut(graphene_std::text::text::FontInput::INDEX) {
302+
*font_input = NodeInput::value(TaggedValue::Resource(font_resource_id), false);
303+
}
304+
responses.add(DocumentMessage::Resource(ResourceMessage::AddFont {
305+
resource_id: font_resource_id,
306+
font: graphene_std::text::Font::default(),
307+
}));
308+
}
309+
298310
if add_transaction {
299311
responses.add(DocumentMessage::AddTransaction);
300312
}

editor/src/messages/portfolio/document/node_graph/node_properties.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -856,8 +856,13 @@ pub fn font_inputs(parameter_widgets_info: ParameterWidgetsInfo) -> (Vec<WidgetI
856856
return (vec![], None);
857857
};
858858

859-
if let Some(TaggedValue::Resource(resource_id)) = input.as_non_exposed_value() {
860-
let font = fonts.id_font(resources, *resource_id).unwrap_or_default();
859+
// A freshly added node carries the empty-resource `TypeDefault` placeholder until a font is chosen
860+
let font = match input.as_non_exposed_value() {
861+
Some(TaggedValue::Resource(resource_id)) => fonts.id_font(resources, *resource_id).unwrap_or_default(),
862+
Some(TaggedValue::TypeDefault(_)) => Font::default(),
863+
_ => return (first_widgets, second_widgets),
864+
};
865+
{
861866
first_widgets.extend_from_slice(&[
862867
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
863868
DropdownInput::new(vec![

editor/src/messages/portfolio/document/overlays/utility_functions.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,10 +228,10 @@ pub fn text_width(text: &str, font_size: f64) -> f64 {
228228
let typesetting = TypesettingConfig {
229229
font_size,
230230
line_height_ratio: 1.2,
231-
character_spacing: 0.,
231+
letter_spacing: 0.,
232+
letter_tilt: 0.,
232233
max_width: None,
233234
max_height: None,
234-
tilt: 0.,
235235
align: TextAlign::AlignLeft,
236236
};
237237

editor/src/messages/portfolio/document/overlays/utility_types_native.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1109,10 +1109,10 @@ impl OverlayContextInternal {
11091109
let typesetting = TypesettingConfig {
11101110
font_size: FONT_SIZE,
11111111
line_height_ratio: 1.2,
1112-
character_spacing: 0.,
1112+
letter_spacing: 0.,
1113+
letter_tilt: 0.,
11131114
max_width: None,
11141115
max_height: None,
1115-
tilt: 0.,
11161116
align: TextAlign::AlignLeft,
11171117
};
11181118

editor/src/messages/portfolio/document_migration.rs

Lines changed: 116 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -644,7 +644,7 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[
644644
// text
645645
// ================================
646646
NodeReplacement {
647-
node: graphene_std::text::text::IDENTIFIER,
647+
node: ProtoNodeIdentifier::new("graphene_std::text::TextNode"),
648648
aliases: &["graphene_core::text::text::TextNode", "graphene_core::text::TextGeneratorNode", "graphene_core::text::TextNode"],
649649
},
650650
NodeReplacement {
@@ -978,6 +978,16 @@ pub fn document_migration_string_preprocessing(document_serialized_content: Stri
978978
.fold(document_serialized_content, |document_serialized_content, (old, new)| document_serialized_content.replace(old, new))
979979
}
980980

981+
/// Rebuilds the old 13-input "Text" node template from the current `text` template plus the trailing `separate_glyphs` input it dropped,
982+
/// so the staged input-count migrations can still upgrade old text nodes before the split.
983+
fn legacy_text_node_template() -> Option<NodeTemplate> {
984+
let mut template = resolve_document_node_type(&DefinitionIdentifier::ProtoNode(graphene_std::text::text::IDENTIFIER))?.default_node_template();
985+
template.document_node.implementation = DocumentNodeImplementation::ProtoNode(ProtoNodeIdentifier::new("graphene_std::text::TextNode"));
986+
template.document_node.inputs.push(NodeInput::value(TaggedValue::Bool(false), false));
987+
template.persistent_node_metadata.input_metadata.push(Default::default());
988+
Some(template)
989+
}
990+
981991
fn replace_optional_f64_null(input: &str) -> String {
982992
let mut result = String::new();
983993
let mut last_end = 0;
@@ -1250,6 +1260,19 @@ pub fn document_migration_upgrades(document: &mut DocumentMessageHandler, reset_
12501260
}
12511261
}
12521262

1263+
// Record which old text nodes are chain-positioned now, before `migrate_node`'s staged input-count migrations run, since those set
1264+
// the upstream chain to absolute; the split below re-chains exactly the nodes that were originally part of a layer chain.
1265+
let text_nodes_in_chain: std::collections::HashSet<NodeId> = document
1266+
.network_interface
1267+
.document_network()
1268+
.recursive_nodes()
1269+
.filter_map(|(node_id, _, path)| {
1270+
(document.network_interface.reference(node_id, &path) == Some(DefinitionIdentifier::ProtoNode(ProtoNodeIdentifier::new("graphene_std::text::TextNode")))
1271+
&& document.network_interface.is_chain(node_id, &path))
1272+
.then_some(*node_id)
1273+
})
1274+
.collect();
1275+
12531276
// Apply upgrades to each unmodified node.
12541277
let nodes = document
12551278
.network_interface
@@ -1260,6 +1283,91 @@ pub fn document_migration_upgrades(document: &mut DocumentMessageHandler, reset_
12601283
for (node_id, node, network_path) in &nodes {
12611284
migrate_node(node_id, node, network_path, document, reset_node_definitions_on_open);
12621285
}
1286+
1287+
// The old geometry-producing "Text" node was split into the current "Text" (`String[]`) -> "Text to Vector" pair, which reuses the same
1288+
// proto identifier. Runs after `migrate_node` normalizes old text nodes to the legacy 13-input layout, distinguished from the current
1289+
// 12-input node by the trailing `separate_glyphs` input (index 12): forward inputs 0..=11 onto the new node and move it onto `text_to_vector`.
1290+
let old_text_nodes: Vec<(NodeId, Vec<NodeId>)> = document
1291+
.network_interface
1292+
.document_network()
1293+
.recursive_nodes()
1294+
.filter_map(|(node_id, node, path)| {
1295+
// `separate_glyphs` is a `Bool` value or a wire feeding one; only a different value type there means a newer input, not the old node
1296+
let has_legacy_separate_glyphs = node.inputs.len() == 13 && node.inputs.get(12).is_some_and(|input| matches!(input.as_value(), None | Some(TaggedValue::Bool(_))));
1297+
(has_legacy_separate_glyphs && document.network_interface.reference(node_id, &path) == Some(DefinitionIdentifier::ProtoNode(ProtoNodeIdentifier::new("graphene_std::text::TextNode"))))
1298+
.then_some((*node_id, path))
1299+
})
1300+
.collect();
1301+
for (node_id, network_path) in &old_text_nodes {
1302+
// Pre-load `outward_wires` so the splice below resolves the original downstream wiring from cache rather than a mutated state.
1303+
let _ = document.network_interface.outward_wires(network_path);
1304+
1305+
// Convert the old node in place to the current `text` node (12 inputs), capturing its old inputs.
1306+
let Some(text_definition) = resolve_document_node_type(&DefinitionIdentifier::ProtoNode(graphene_std::text::text::IDENTIFIER)) else {
1307+
continue;
1308+
};
1309+
let mut text_template = text_definition.default_node_template();
1310+
document.network_interface.replace_implementation(node_id, network_path, &mut text_template);
1311+
let Some(old_inputs) = document.network_interface.replace_inputs(node_id, network_path, &mut text_template) else {
1312+
continue;
1313+
};
1314+
// The current `text` node reorders the legacy inputs (Letter Tilt moved up to sit right after Letter Spacing), so map each new
1315+
// input index to the legacy 13-input index it sources from. Legacy order:
1316+
// [primary, text, font, size, line_height, letter_spacing, has_max_width, max_width, has_max_height, max_height, letter_tilt, align, separate_glyphs].
1317+
const LEGACY_INPUT_FOR_NEW: [usize; 12] = [0, 1, 2, 3, 4, 5, 10, 6, 7, 8, 9, 11];
1318+
for (new_index, &legacy_index) in LEGACY_INPUT_FOR_NEW.iter().enumerate() {
1319+
if let Some(input) = old_inputs.get(legacy_index) {
1320+
document.network_interface.set_input(&InputConnector::node(*node_id, new_index), input.clone(), network_path);
1321+
}
1322+
}
1323+
let separate_glyphs = old_inputs.get(12).cloned();
1324+
1325+
// Collect the inputs reading the old text node's output before any rewiring so the new node can be spliced onto those wires.
1326+
let downstream_consumers: Vec<InputConnector> = document
1327+
.network_interface
1328+
.outward_wires(network_path)
1329+
.and_then(|wires| wires.get(&OutputConnector::node(*node_id, 0)))
1330+
.cloned()
1331+
.unwrap_or_default();
1332+
1333+
let text_was_in_chain = text_nodes_in_chain.contains(node_id);
1334+
1335+
// Insert the `text_to_vector` node that converts the `text` `String[]` output back into vector geometry.
1336+
let Some(text_to_vector_definition) = resolve_document_node_type(&DefinitionIdentifier::ProtoNode(graphene_std::text::text_to_vector::IDENTIFIER)) else {
1337+
continue;
1338+
};
1339+
let text_to_vector_id = NodeId::new();
1340+
document
1341+
.network_interface
1342+
.insert_node(text_to_vector_id, text_to_vector_definition.default_node_template(), network_path);
1343+
1344+
// Splice `text_to_vector` onto the wire(s) leaving `text` (`insert_node_between` is the pure wire-splice the editor uses for
1345+
// dropping a node on a wire), then carry the old `separate_glyphs` value onto its second input.
1346+
if let Some((first_consumer, remaining_consumers)) = downstream_consumers.split_first() {
1347+
document.network_interface.insert_node_between(&text_to_vector_id, first_consumer, 0, network_path);
1348+
for consumer in remaining_consumers {
1349+
document.network_interface.set_input(consumer, NodeInput::node(text_to_vector_id, 0), network_path);
1350+
}
1351+
} else {
1352+
document
1353+
.network_interface
1354+
.set_input(&InputConnector::node(text_to_vector_id, 0), NodeInput::node(*node_id, 0), network_path);
1355+
}
1356+
if let Some(separate_glyphs) = separate_glyphs {
1357+
document.network_interface.set_input(&InputConnector::node(text_to_vector_id, 1), separate_glyphs, network_path);
1358+
}
1359+
1360+
// If `text` was in a layer chain, re-chain `text_to_vector` and its upstream so both lay out by distance from the layer (the splice
1361+
// broke the chain, like `move_node_to_chain_start`). Otherwise `text` is absolute, so place `text_to_vector` beside it instead of
1362+
// leaving it at the origin.
1363+
if text_was_in_chain {
1364+
document.network_interface.force_set_upstream_to_chain(&text_to_vector_id, network_path);
1365+
} else if let Some(text_position) = document.network_interface.position(node_id, network_path) {
1366+
document
1367+
.network_interface
1368+
.shift_absolute_node_position(&text_to_vector_id, text_position + IVec2::new(7, 0), network_path);
1369+
}
1370+
}
12631371
}
12641372

12651373
fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], document: &mut DocumentMessageHandler, reset_node_definitions_on_open: bool) -> Option<()> {
@@ -1484,8 +1592,8 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
14841592
}
14851593

14861594
// Upgrade Text node to include line height and character spacing, which were previously hardcoded to 1, from https://github.com/GraphiteEditor/Graphite/pull/2016
1487-
if reference == DefinitionIdentifier::ProtoNode(graphene_std::text::text::IDENTIFIER) && inputs_count == 8 {
1488-
let mut template: NodeTemplate = resolve_document_node_type(&reference)?.default_node_template();
1595+
if reference == DefinitionIdentifier::ProtoNode(ProtoNodeIdentifier::new("graphene_std::text::TextNode")) && inputs_count == 8 {
1596+
let mut template: NodeTemplate = legacy_text_node_template()?;
14891597
document.network_interface.replace_implementation(node_id, network_path, &mut template);
14901598
let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut template)?;
14911599

@@ -1507,7 +1615,7 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
15071615
if inputs_count == 6 {
15081616
old_inputs[5].clone()
15091617
} else {
1510-
NodeInput::value(TaggedValue::F64(TypesettingConfig::default().character_spacing), false)
1618+
NodeInput::value(TaggedValue::F64(TypesettingConfig::default().letter_spacing), false)
15111619
},
15121620
network_path,
15131621
);
@@ -1534,7 +1642,7 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
15341642
if inputs_count >= 9 {
15351643
old_inputs[8].clone()
15361644
} else {
1537-
NodeInput::value(TaggedValue::F64(TypesettingConfig::default().tilt), false)
1645+
NodeInput::value(TaggedValue::F64(TypesettingConfig::default().letter_tilt), false)
15381646
},
15391647
network_path,
15401648
);
@@ -1561,8 +1669,8 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
15611669

15621670
// Insert bool parameters for `has_max_width` and `has_max_height`:
15631671
// https://github.com/GraphiteEditor/Graphite/pull/3643
1564-
if reference == DefinitionIdentifier::ProtoNode(graphene_std::text::text::IDENTIFIER) && inputs_count == 11 {
1565-
let mut template: NodeTemplate = resolve_document_node_type(&reference)?.default_node_template();
1672+
if reference == DefinitionIdentifier::ProtoNode(ProtoNodeIdentifier::new("graphene_std::text::TextNode")) && inputs_count == 11 {
1673+
let mut template: NodeTemplate = legacy_text_node_template()?;
15661674
document.network_interface.replace_implementation(node_id, network_path, &mut template);
15671675
let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut template)?;
15681676

@@ -1714,7 +1822,7 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
17141822

17151823
// Convert text nodes from the old `editor-api` scope + `Font` input to a single font `Resource` input.
17161824
// The chosen typeface is recorded as a `DataSource::Font` in the document's resource registry and loaded on open.
1717-
if reference == DefinitionIdentifier::ProtoNode(graphene_std::text::text::IDENTIFIER) && inputs_count == 13 && matches!(node.inputs.first(), Some(NodeInput::Scope(_))) {
1825+
if reference == DefinitionIdentifier::ProtoNode(ProtoNodeIdentifier::new("graphene_std::text::TextNode")) && inputs_count == 13 && matches!(node.inputs.first(), Some(NodeInput::Scope(_))) {
17181826
document
17191827
.network_interface
17201828
.set_input(&InputConnector::node(*node_id, 0), NodeInput::value(TaggedValue::None, false), network_path);
Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
use graph_craft::application_io::resource::Resource;
2-
use std::sync::LazyLock;
3-
4-
const FALLBACK_FONT_BYTES: &[u8] = include_bytes!("source-sans-pro-regular.ttf");
5-
pub static FALLBACK_FONT_RESOURCE: LazyLock<Resource> = LazyLock::new(|| Resource::new(FALLBACK_FONT_BYTES));
1+
// Re-export the fallback font resource from text-nodes, which is the authoritative location.
2+
// This avoids duplicating the font bytes in the editor binary.
3+
// This file can be removed after deciding where to place the authority of fallback_resource.
4+
pub use graphene_std::text_nodes::FALLBACK_FONT_RESOURCE;

0 commit comments

Comments
 (0)