From 78d2e2ce4cd650e17c791e4cfafebfac87e2b4e7 Mon Sep 17 00:00:00 2001 From: Ziad Ashraf Date: Tue, 17 Mar 2026 12:36:21 +0200 Subject: [PATCH 1/2] Feat(Text Vertical Align): Added a new feature to enable the vertical text alignment --- .../src/messages/frontend/frontend_message.rs | 4 ++- .../document/graph_operation/utility_types.rs | 1 + .../node_graph/document_node_definitions.rs | 7 +++++ .../document/overlays/utility_functions.rs | 1 + .../document/overlays/utility_types_native.rs | 1 + .../messages/portfolio/document_migration.rs | 27 +++++++++++++++++-- .../graph_modification_utils.rs | 4 +++ .../messages/tool/tool_messages/text_tool.rs | 24 ++++++++++++++++- .../src/components/panels/Document.svelte | 25 +++++++++++++++++ node-graph/graph-craft/src/document/value.rs | 1 + node-graph/libraries/core-types/src/text.rs | 14 ++++++++++ node-graph/nodes/gstd/src/text.rs | 4 +++ node-graph/nodes/text/src/lib.rs | 14 ++++++++++ node-graph/nodes/text/src/path_builder.rs | 4 +-- node-graph/nodes/text/src/text_context.rs | 19 +++++++++++-- 15 files changed, 142 insertions(+), 8 deletions(-) diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index 743d6ed2c2..933cffd2ff 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -14,7 +14,7 @@ use crate::messages::tool::tool_messages::eyedropper_tool::PrimarySecondary; use graph_craft::document::NodeId; use graphene_std::raster::Image; use graphene_std::raster::color::Color; -use graphene_std::text::{Font, TextAlign}; +use graphene_std::text::{Font, TextAlign, VerticalAlign}; use std::path::PathBuf; #[cfg(not(target_family = "wasm"))] @@ -50,6 +50,8 @@ pub enum FrontendMessage { #[serde(rename = "maxHeight")] max_height: Option, align: TextAlign, + #[serde(rename = "verticalAlign")] + vertical_align: VerticalAlign, }, DisplayEditableTextboxUpdateFontData { #[serde(rename = "fontData")] 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 f74fb67c15..d84640c05c 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -205,6 +205,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::VerticalAlign(typesetting.vertical_align), false)), ]); let transform = resolve_network_node_type("Transform").expect("Transform node does not exist").default_node_template(); let stroke = resolve_proto_node_type(graphene_std::vector_nodes::stroke::IDENTIFIER) diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs index b7db96ec65..ada7be0c5a 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs @@ -2586,6 +2586,13 @@ fn static_input_properties() -> InputProperties { Ok(vec![choices]) }), ); + map.insert( + "text_vertical_align".to_string(), + Box::new(|node_id, index, context| { + let choices = enum_choice::().for_socket(ParameterWidgetsInfo::new(node_id, index, true, context)).property_row(); + Ok(vec![choices]) + }), + ); map } diff --git a/editor/src/messages/portfolio/document/overlays/utility_functions.rs b/editor/src/messages/portfolio/document/overlays/utility_functions.rs index dec2fda58e..d59a797f1c 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_functions.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_functions.rs @@ -239,6 +239,7 @@ pub fn text_width(text: &str, font_size: f64) -> f64 { max_height: None, tilt: 0.0, align: TextAlign::Left, + ..Default::default() }; // Load Source Sans Pro font data 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 9991d39c57..f979f7e30f 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_native.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_native.rs @@ -1111,6 +1111,7 @@ impl OverlayContextInternal { max_height: None, tilt: 0., align: TextAlign::Left, // We'll handle alignment manually via pivot + ..Default::default() }; // Load Source Sans Pro font data diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index ac5ec052c0..cc1e158701 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -1281,11 +1281,34 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], network_path, ); - // Copy over old inputs + // Copy over old inputs (tilt, align) #[allow(clippy::needless_range_loop)] - for i in 10..=12 { + for i in 10..=11 { document.network_interface.set_input(&InputConnector::node(*node_id, i), old_inputs[i - 2].clone(), network_path); } + + // vertical_align at index 12 gets its default from the template + + // Copy over separate_glyph_elements from old index 10 to new index 13 + document.network_interface.set_input(&InputConnector::node(*node_id, 13), old_inputs[10].clone(), network_path); + } + + // Insert vertical_align parameter between align and separate_glyph_elements: + // https://github.com/GraphiteEditor/Graphite/issues/3883 + if reference == DefinitionIdentifier::ProtoNode(graphene_std::text::text::IDENTIFIER) && inputs_count == 13 { + 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)?; + + #[allow(clippy::needless_range_loop)] + for i in 0..=11 { + document.network_interface.set_input(&InputConnector::node(*node_id, i), old_inputs[i].clone(), network_path); + } + + // vertical_align at index 12 gets its default from the template + + // Shift separate_glyph_elements from old index 12 to new index 13 + document.network_interface.set_input(&InputConnector::node(*node_id, 13), old_inputs[12].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/tool/common_functionality/graph_modification_utils.rs b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs index c71f199078..e527480f48 100644 --- a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs +++ b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs @@ -421,6 +421,9 @@ pub fn get_text(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInter let Some(&TaggedValue::TextAlign(align)) = inputs[graphene_std::text::text::AlignInput::INDEX].as_value() else { return None; }; + let Some(&TaggedValue::VerticalAlign(vertical_align)) = inputs[graphene_std::text::text::VerticalAlignInput::INDEX].as_value() else { + return None; + }; let Some(&TaggedValue::Bool(per_glyph_instances)) = inputs[graphene_std::text::text::SeparateGlyphElementsInput::INDEX].as_value() else { return None; }; @@ -433,6 +436,7 @@ pub fn get_text(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInter character_spacing, tilt, align, + vertical_align, }; Some((text, font, typesetting, per_glyph_instances)) } diff --git a/editor/src/messages/tool/tool_messages/text_tool.rs b/editor/src/messages/tool/tool_messages/text_tool.rs index c5471f0bff..72228add0d 100644 --- a/editor/src/messages/tool/tool_messages/text_tool.rs +++ b/editor/src/messages/tool/tool_messages/text_tool.rs @@ -19,7 +19,7 @@ use crate::messages::tool::utility_types::ToolRefreshOptions; use graph_craft::document::value::TaggedValue; use graph_craft::document::{NodeId, NodeInput}; use graphene_std::renderer::Quad; -use graphene_std::text::{Font, FontCache, TextAlign, TypesettingConfig, lines_clipping}; +use graphene_std::text::{Font, FontCache, TextAlign, TypesettingConfig, VerticalAlign, lines_clipping}; use graphene_std::vector::style::Fill; use graphene_std::{Color, NodeInputDecleration}; @@ -38,6 +38,7 @@ pub struct TextOptions { fill: ToolColorOptions, tilt: f64, align: TextAlign, + vertical_align: VerticalAlign, } impl Default for TextOptions { @@ -50,6 +51,7 @@ impl Default for TextOptions { fill: ToolColorOptions::new_primary(), tilt: 0., align: TextAlign::default(), + vertical_align: VerticalAlign::default(), } } } @@ -85,6 +87,7 @@ pub enum TextOptionsUpdate { FontSize(f64), LineHeightRatio(f64), Align(TextAlign), + VerticalAlign(VerticalAlign), WorkingColors(Option, Option), } @@ -216,6 +219,20 @@ fn create_text_widgets(tool: &TextTool, font_catalog: &FontCatalog) -> Vec = [VerticalAlign::Top, VerticalAlign::Center, VerticalAlign::Bottom] + .into_iter() + .map(|va| { + RadioEntryData::new(format!("{va:?}")).label(va.to_string()).on_update(move |_| { + TextToolMessage::UpdateOptions { + options: TextOptionsUpdate::VerticalAlign(va), + } + .into() + }) + }) + .collect(); + let vertical_align = RadioInput::new(vertical_align_entries).selected_index(Some(tool.options.vertical_align as u32)).widget_instance(); + vec![ font, Separator::new(SeparatorStyle::Related).widget_instance(), @@ -226,6 +243,8 @@ fn create_text_widgets(tool: &TextTool, font_catalog: &FontCatalog) -> Vec MessageHandler> for Text TextOptionsUpdate::FontSize(font_size) => self.options.font_size = font_size, TextOptionsUpdate::LineHeightRatio(line_height_ratio) => self.options.line_height_ratio = line_height_ratio, TextOptionsUpdate::Align(align) => self.options.align = align, + TextOptionsUpdate::VerticalAlign(vertical_align) => self.options.vertical_align = vertical_align, TextOptionsUpdate::FillColor(color) => { self.options.fill.custom_color = color; self.options.fill.color_type = ToolColorType::Custom; @@ -420,6 +440,7 @@ impl TextToolData { max_width: editing_text.typesetting.max_width, max_height: editing_text.typesetting.max_height, align: editing_text.typesetting.align, + vertical_align: editing_text.typesetting.vertical_align, }); } else { // Check if DisplayRemoveEditableTextbox is already in the responses queue @@ -904,6 +925,7 @@ impl Fsm for TextToolFsmState { max_height: constraint_size.map(|size| size.y), tilt: tool_options.tilt, align: tool_options.align, + vertical_align: tool_options.vertical_align, }, font: Font::new(tool_options.font.font_family.clone(), tool_options.font.font_style.clone()), color: tool_options.fill.active_color(), diff --git a/frontend/src/components/panels/Document.svelte b/frontend/src/components/panels/Document.svelte index 1767c18e6c..5c47a366b4 100644 --- a/frontend/src/components/panels/Document.svelte +++ b/frontend/src/components/panels/Document.svelte @@ -366,6 +366,31 @@ textInput.style.color = data.color; textInput.style.textAlign = data.align; + // Apply vertical alignment using padding-top to offset text within the container + if (data.maxHeight !== undefined) { + if (data.verticalAlign === "Center" || data.verticalAlign === "Bottom") { + // Reset any existing padding first + textInput.style.paddingTop = "0px"; + + // Wait for layout to settle so we can measure the content height + await tick(); + + const contentHeight = textInput.scrollHeight; + const containerHeight = Math.floor(data.maxHeight / (data.lineHeightRatio * data.fontSize)) * (data.lineHeightRatio * data.fontSize); + const freeSpace = Math.max(containerHeight - contentHeight, 0); + + if (data.verticalAlign === "Center") { + textInput.style.paddingTop = `${freeSpace / 2}px`; + } else { + textInput.style.paddingTop = `${freeSpace}px`; + } + } else { + textInput.style.paddingTop = "0px"; + } + } else { + textInput.style.paddingTop = "0px"; + } + textInput.oninput = () => { if (!textInput) return; editor.handle.updateBounds(textInputCleanup(textInput.innerText)); diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 91b6831809..8cebc5b4c7 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -269,6 +269,7 @@ tagged_value! { CentroidType(vector::misc::CentroidType), BooleanOperation(vector::misc::BooleanOperation), TextAlign(text_nodes::TextAlign), + VerticalAlign(text_nodes::VerticalAlign), } impl TaggedValue { diff --git a/node-graph/libraries/core-types/src/text.rs b/node-graph/libraries/core-types/src/text.rs index 29917694b6..960481d3b8 100644 --- a/node-graph/libraries/core-types/src/text.rs +++ b/node-graph/libraries/core-types/src/text.rs @@ -34,6 +34,18 @@ impl From for parley::Alignment { } } +/// Vertical alignment of text within its bounding box. +#[repr(C)] +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, Hash, DynAny, node_macro::ChoiceType)] +#[widget(Radio)] +pub enum VerticalAlign { + #[default] + Top, + Center, + Bottom, +} + #[derive(PartialEq, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)] pub struct TypesettingConfig { pub font_size: f64, @@ -43,6 +55,7 @@ pub struct TypesettingConfig { pub max_height: Option, pub tilt: f64, pub align: TextAlign, + pub vertical_align: VerticalAlign, } impl Default for TypesettingConfig { @@ -55,6 +68,7 @@ impl Default for TypesettingConfig { max_height: None, tilt: 0., align: TextAlign::default(), + vertical_align: VerticalAlign::default(), } } } diff --git a/node-graph/nodes/gstd/src/text.rs b/node-graph/nodes/gstd/src/text.rs index 4cb280ba30..1b6f059972 100644 --- a/node-graph/nodes/gstd/src/text.rs +++ b/node-graph/nodes/gstd/src/text.rs @@ -59,6 +59,9 @@ fn text<'i: 'n>( /// To have an effect on a single line of text, *Max Width* must be set. #[widget(ParsedWidgetOverride::Custom = "text_align")] align: TextAlign, + /// The vertical alignment of text within the text box. Requires *Max Height* to be set. + #[widget(ParsedWidgetOverride::Custom = "text_vertical_align")] + vertical_align: VerticalAlign, /// Whether to split every letterform into its own vector path element. Otherwise, a single compound path is produced. separate_glyph_elements: bool, ) -> Table { @@ -70,6 +73,7 @@ fn text<'i: 'n>( max_height: has_max_height.then_some(max_height), tilt, align, + vertical_align, }; to_path(&text, &font, &editor_resources.font_cache, typesetting, separate_glyph_elements) diff --git a/node-graph/nodes/text/src/lib.rs b/node-graph/nodes/text/src/lib.rs index ca4738ffa1..2efc0ef3cf 100644 --- a/node-graph/nodes/text/src/lib.rs +++ b/node-graph/nodes/text/src/lib.rs @@ -38,6 +38,18 @@ impl From for parley::Alignment { } } +/// Vertical alignment of text within its bounding box. +#[repr(C)] +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, Hash, DynAny, node_macro::ChoiceType)] +#[widget(Radio)] +pub enum VerticalAlign { + #[default] + Top, + Center, + Bottom, +} + #[derive(PartialEq, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)] pub struct TypesettingConfig { pub font_size: f64, @@ -47,6 +59,7 @@ pub struct TypesettingConfig { pub max_height: Option, pub tilt: f64, pub align: TextAlign, + pub vertical_align: VerticalAlign, } impl Default for TypesettingConfig { @@ -59,6 +72,7 @@ impl Default for TypesettingConfig { max_height: None, tilt: 0., align: TextAlign::default(), + vertical_align: VerticalAlign::default(), } } } diff --git a/node-graph/nodes/text/src/path_builder.rs b/node-graph/nodes/text/src/path_builder.rs index c5ba250409..a5eb5cd2ea 100644 --- a/node-graph/nodes/text/src/path_builder.rs +++ b/node-graph/nodes/text/src/path_builder.rs @@ -64,7 +64,7 @@ impl PathBuilder { } } - pub fn render_glyph_run(&mut self, glyph_run: &GlyphRun<'_, ()>, tilt: f64, per_glyph_instances: bool) { + pub fn render_glyph_run(&mut self, glyph_run: &GlyphRun<'_, ()>, tilt: f64, per_glyph_instances: bool, vertical_offset: f64) { let mut run_x = glyph_run.offset(); let run_y = glyph_run.baseline(); @@ -105,7 +105,7 @@ impl PathBuilder { let outlines = font_ref.outline_glyphs(); for glyph in glyph_run.glyphs() { - let glyph_offset = DVec2::new((run_x + glyph.x) as f64, (run_y - glyph.y) as f64); + let glyph_offset = DVec2::new((run_x + glyph.x) as f64, (run_y - glyph.y) as f64 + vertical_offset); run_x += glyph.advance; let glyph_id = GlyphId::from(glyph.id); diff --git a/node-graph/nodes/text/src/text_context.rs b/node-graph/nodes/text/src/text_context.rs index 7934feb885..90b7cf506f 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, FontCache, TypesettingConfig, VerticalAlign}; use core::cell::RefCell; use core_types::table::Table; use glam::DVec2; @@ -94,12 +94,27 @@ impl TextContext { let mut path_builder = PathBuilder::new(per_glyph_instances, layout.scale() as f64); + let vertical_offset = match typesetting.vertical_align { + VerticalAlign::Top => 0., + VerticalAlign::Center | VerticalAlign::Bottom => { + let layout_height = layout.height() as f64; + let container_height = typesetting.max_height.unwrap_or(layout_height); + let free_space = (container_height - layout_height).max(0.); + + match typesetting.vertical_align { + VerticalAlign::Center => free_space / 2., + VerticalAlign::Bottom => free_space, + _ => unreachable!(), + } + } + }; + for line in layout.lines() { for item in line.items() { if let PositionedLayoutItem::GlyphRun(glyph_run) = item && typesetting.max_height.filter(|&max_height| glyph_run.baseline() > max_height as f32).is_none() { - path_builder.render_glyph_run(&glyph_run, typesetting.tilt, per_glyph_instances); + path_builder.render_glyph_run(&glyph_run, typesetting.tilt, per_glyph_instances, vertical_offset); } } } From adcf4b40f8463ea69c06bf8dbe31df7f4345d531 Mon Sep 17 00:00:00 2001 From: Ziad Ashraf Date: Tue, 17 Mar 2026 14:22:57 +0200 Subject: [PATCH 2/2] Fix: add serde(default) for vertical_align, simplify offset calculation, and match frontend container height to backend --- .../src/components/panels/Document.svelte | 2 +- node-graph/libraries/core-types/src/text.rs | 1 + node-graph/nodes/text/src/lib.rs | 1 + node-graph/nodes/text/src/text_context.rs | 22 +++++++++---------- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/panels/Document.svelte b/frontend/src/components/panels/Document.svelte index 5c47a366b4..2909fa5440 100644 --- a/frontend/src/components/panels/Document.svelte +++ b/frontend/src/components/panels/Document.svelte @@ -376,7 +376,7 @@ await tick(); const contentHeight = textInput.scrollHeight; - const containerHeight = Math.floor(data.maxHeight / (data.lineHeightRatio * data.fontSize)) * (data.lineHeightRatio * data.fontSize); + const containerHeight = data.maxHeight; const freeSpace = Math.max(containerHeight - contentHeight, 0); if (data.verticalAlign === "Center") { diff --git a/node-graph/libraries/core-types/src/text.rs b/node-graph/libraries/core-types/src/text.rs index 960481d3b8..f5de96c30d 100644 --- a/node-graph/libraries/core-types/src/text.rs +++ b/node-graph/libraries/core-types/src/text.rs @@ -55,6 +55,7 @@ pub struct TypesettingConfig { pub max_height: Option, pub tilt: f64, pub align: TextAlign, + #[serde(default)] pub vertical_align: VerticalAlign, } diff --git a/node-graph/nodes/text/src/lib.rs b/node-graph/nodes/text/src/lib.rs index 2efc0ef3cf..68853d72d5 100644 --- a/node-graph/nodes/text/src/lib.rs +++ b/node-graph/nodes/text/src/lib.rs @@ -59,6 +59,7 @@ pub struct TypesettingConfig { pub max_height: Option, pub tilt: f64, pub align: TextAlign, + #[serde(default)] pub vertical_align: VerticalAlign, } diff --git a/node-graph/nodes/text/src/text_context.rs b/node-graph/nodes/text/src/text_context.rs index 90b7cf506f..5118c2da90 100644 --- a/node-graph/nodes/text/src/text_context.rs +++ b/node-graph/nodes/text/src/text_context.rs @@ -94,19 +94,17 @@ impl TextContext { let mut path_builder = PathBuilder::new(per_glyph_instances, layout.scale() as f64); - let vertical_offset = match typesetting.vertical_align { - VerticalAlign::Top => 0., - VerticalAlign::Center | VerticalAlign::Bottom => { - let layout_height = layout.height() as f64; - let container_height = typesetting.max_height.unwrap_or(layout_height); - let free_space = (container_height - layout_height).max(0.); - - match typesetting.vertical_align { - VerticalAlign::Center => free_space / 2., - VerticalAlign::Bottom => free_space, - _ => unreachable!(), - } + let vertical_offset = if let Some(container_height) = typesetting.max_height { + let layout_height = layout.height() as f64; + let free_space = (container_height - layout_height).max(0.); + + match typesetting.vertical_align { + VerticalAlign::Top => 0., + VerticalAlign::Center => free_space / 2., + VerticalAlign::Bottom => free_space, } + } else { + 0. }; for line in layout.lines() {