diff --git a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs index dfe09d8bd8..49b9fef068 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs @@ -1728,6 +1728,7 @@ impl<'a> MessageHandler> for NodeG } NodeGraphMessage::SetInputValue { node_id, input_index, value } => { let is_fill = matches!(value, TaggedValue::Fill(_)); + let is_text_align = matches!(value, TaggedValue::TextAlign(_)); let input = NodeInput::value(value, false); responses.add(NodeGraphMessage::SetInput { input_connector: InputConnector::node(node_id, input_index), @@ -1737,6 +1738,9 @@ impl<'a> MessageHandler> for NodeG if is_fill { responses.add(OverlaysMessage::Draw); } + if is_text_align { + responses.add(TextToolMessage::SelectionChanged); + } if network_interface.connected_to_output(&node_id, selection_network_path) { responses.add(NodeGraphMessage::RunDocumentGraph); } diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index 69c5c127f4..11a59f8eca 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -2953,7 +2953,7 @@ pub mod choice { if let Some(icon) = var_meta.icon { entry.icon(icon) } else { entry.label(var_meta.label) } }) .collect(); - RadioInput::new(items).selected_index(Some(current.as_u32())).widget_instance() + RadioInput::new(items).selected_index(Some(current.as_u32())).disabled(self.disabled).widget_instance() } } diff --git a/editor/src/messages/portfolio/document/overlays/utility_functions.rs b/editor/src/messages/portfolio/document/overlays/utility_functions.rs index 03a34dcbda..cdc6968ee5 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_functions.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_functions.rs @@ -241,7 +241,7 @@ pub fn text_width(text: &str, font_size: f64) -> f64 { max_width: None, max_height: None, tilt: 0.0, - align: TextAlign::Left, + align: TextAlign::AlignLeft, }; // 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 f03e617772..d4580f834b 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_native.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_native.rs @@ -1112,7 +1112,7 @@ impl OverlayContextInternal { max_width: None, max_height: None, tilt: 0., - align: TextAlign::Left, // We'll handle alignment manually via pivot + align: TextAlign::AlignLeft, }; // Load Source Sans Pro font data diff --git a/editor/src/messages/tool/tool_messages/text_tool.rs b/editor/src/messages/tool/tool_messages/text_tool.rs index 3e28fc8eb9..a91d910189 100644 --- a/editor/src/messages/tool/tool_messages/text_tool.rs +++ b/editor/src/messages/tool/tool_messages/text_tool.rs @@ -69,6 +69,7 @@ pub enum TextToolMessage { Interact, PointerMove { center: Key, lock_ratio: Key }, PointerOutsideViewport { center: Key, lock_ratio: Key }, + SelectionChanged, TextChange { new_text: String, is_left_or_right_click: bool }, UpdateBounds { new_text: String }, UpdateOptions { options: TextOptionsUpdate }, @@ -203,17 +204,25 @@ fn create_text_widgets(tool: &TextTool, font_catalog: &FontCatalog) -> Vec = [TextAlign::Left, TextAlign::Center, TextAlign::Right, TextAlign::JustifyLeft] - .into_iter() - .map(|align| { - RadioEntryData::new(format!("{align:?}")).label(align.to_string()).on_update(move |_| { - TextToolMessage::UpdateOptions { - options: TextOptionsUpdate::Align(align), - } - .into() - }) + let align_entries: Vec<_> = [ + TextAlign::AlignLeft, + TextAlign::AlignCenter, + TextAlign::AlignRight, + TextAlign::JustifyLeft, + TextAlign::JustifyCenter, + TextAlign::JustifyRight, + TextAlign::JustifyAll, + ] + .into_iter() + .map(|align| { + RadioEntryData::new(format!("{align:?}")).label(align.to_string()).on_update(move |_| { + TextToolMessage::UpdateOptions { + options: TextOptionsUpdate::Align(align), + } + .into() }) - .collect(); + }) + .collect(); let align = RadioInput::new(align_entries).selected_index(Some(tool.options.align as u32)).widget_instance(); vec![ font, @@ -279,9 +288,24 @@ impl TextTool { #[message_handler_data] impl<'a> MessageHandler> for TextTool { fn process_message(&mut self, message: ToolMessage, responses: &mut VecDeque, context: &mut ToolActionMessageContext<'a>) { - let ToolMessage::Text(TextToolMessage::UpdateOptions { options }) = message else { - self.fsm_state.process_event(message, &mut self.tool_data, context, &self.options, responses, true); - return; + let options = match message { + ToolMessage::Text(TextToolMessage::UpdateOptions { options }) => options, + ToolMessage::Text(TextToolMessage::SelectionChanged) => { + if let Some(layer) = can_edit_selected(context.document) + && let Some((_, _, typesetting, _)) = graph_modification_utils::get_text(layer, &context.document.network_interface) + { + self.options.align = typesetting.align; + if let Some(editing_text) = self.tool_data.editing_text.as_mut() { + editing_text.typesetting.align = typesetting.align; + } + } + self.send_layout(responses, LayoutTarget::ToolOptions, &context.cached_data.font_catalog); + return; + } + _ => { + self.fsm_state.process_event(message, &mut self.tool_data, context, &self.options, responses, true); + return; + } }; match options { TextOptionsUpdate::Font { font } => { @@ -289,7 +313,21 @@ impl<'a> 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::Align(align) => { + self.options.align = align; + if let Some(editing_text) = self.tool_data.editing_text.as_mut() { + editing_text.typesetting.align = align; + } + if let Some(layer) = can_edit_selected(context.document) + && let Some(node_id) = graph_modification_utils::get_text_id(layer, &context.document.network_interface) + { + responses.add(NodeGraphMessage::SetInput { + input_connector: InputConnector::node(node_id, graphene_std::text::text::AlignInput::INDEX), + input: NodeInput::value(TaggedValue::TextAlign(align), false), + }); + responses.add(NodeGraphMessage::RunDocumentGraph); + } + } TextOptionsUpdate::FillColor(color) => { self.options.fill.custom_color = color; self.options.fill.color_type = ToolColorType::Custom; @@ -335,10 +373,10 @@ impl ToolTransition for TextTool { fn event_to_message_map(&self) -> EventToMessageMap { EventToMessageMap { canvas_transformed: None, + selection_changed: Some(TextToolMessage::SelectionChanged.into()), tool_abort: Some(TextToolMessage::Abort.into()), working_color_changed: Some(TextToolMessage::WorkingColorChanged.into()), overlay_provider: Some(|context| TextToolMessage::Overlays { context }.into()), - ..Default::default() } } } diff --git a/node-graph/nodes/text/src/lib.rs b/node-graph/nodes/text/src/lib.rs index eb57c9e5fe..7d60038dc7 100644 --- a/node-graph/nodes/text/src/lib.rs +++ b/node-graph/nodes/text/src/lib.rs @@ -31,21 +31,37 @@ pub use vector_types; #[widget(Radio)] pub enum TextAlign { #[default] - Left, - Center, - Right, - #[label("Justify")] + AlignLeft, + AlignCenter, + AlignRight, JustifyLeft, - // TODO: JustifyCenter, JustifyRight, JustifyAll + JustifyCenter, + JustifyRight, + JustifyAll, } impl From for parley::Alignment { fn from(val: TextAlign) -> Self { match val { - TextAlign::Left => parley::Alignment::Left, - TextAlign::Center => parley::Alignment::Center, - TextAlign::Right => parley::Alignment::Right, - TextAlign::JustifyLeft => parley::Alignment::Justify, + TextAlign::AlignLeft => parley::Alignment::Left, + TextAlign::AlignCenter => parley::Alignment::Center, + TextAlign::AlignRight => parley::Alignment::Right, + _ => parley::Alignment::Justify, + } + } +} + +impl TextAlign { + /// What `parley::Alignment` to apply as a post-correction to the last line of a paragraph, or `None` if parley's default already handles it. + /// + /// `JustifyLeft` returns `None` because parley already left-aligns the last line of a `Justify` layout. The other justify modes need + /// the last line shifted (`Center`/`Right`) or its inter-word spaces redistributed (`Justify` / `JustifyAll`). + pub fn last_line_correction(self) -> Option { + match self { + Self::JustifyCenter => Some(parley::Alignment::Center), + Self::JustifyRight => Some(parley::Alignment::Right), + Self::JustifyAll => Some(parley::Alignment::Justify), + _ => None, } } } diff --git a/node-graph/nodes/text/src/path_builder.rs b/node-graph/nodes/text/src/path_builder.rs index 9e7684e7be..f1a6865610 100644 --- a/node-graph/nodes/text/src/path_builder.rs +++ b/node-graph/nodes/text/src/path_builder.rs @@ -52,10 +52,20 @@ impl PathBuilder { } #[allow(clippy::too_many_arguments)] - fn draw_glyph(&mut self, glyph: &OutlineGlyph<'_>, size: f32, normalized_coords: &[NormalizedCoord], glyph_offset: DVec2, style_skew: Option, skew: DAffine2, per_glyph_items: bool) { + fn draw_glyph( + &mut self, + glyph: &OutlineGlyph<'_>, + size: f32, + normalized_coords: &[NormalizedCoord], + glyph_offset: DVec2, + style_skew: Option, + skew: DAffine2, + per_glyph_items: bool, + ) -> bool { let location_ref = LocationRef::new(normalized_coords); let settings = DrawSettings::unhinted(Size::new(size), location_ref); glyph.draw(settings, self).unwrap(); + let has_geometry = !self.glyph_subpaths.is_empty(); // Apply transforms in correct order: style-based skew first, then user-requested skew // This ensures font synthesis (italic) is applied before user transformations @@ -91,10 +101,12 @@ impl PathBuilder { self.merged_click_target_baselines.push(glyph_offset.y); } } + + has_geometry } - pub fn render_glyph_run(&mut self, glyph_run: &GlyphRun<'_, ()>, tilt: f64, per_glyph_items: bool) { - let mut run_x = glyph_run.offset(); + pub fn render_glyph_run(&mut self, glyph_run: &GlyphRun<'_, ()>, tilt: f64, per_glyph_items: bool, x_offset: f32, space_extra: f32) { + let mut run_x = glyph_run.offset() + x_offset; let run_y = glyph_run.baseline(); let run = glyph_run.run(); @@ -142,7 +154,11 @@ impl PathBuilder { if !per_glyph_items { self.origin = glyph_offset; } - self.draw_glyph(&glyph_outline, font_size, &normalized_coords, glyph_offset, style_skew, skew, per_glyph_items); + let drew_geometry = self.draw_glyph(&glyph_outline, font_size, &normalized_coords, glyph_offset, style_skew, skew, per_glyph_items); + + if !drew_geometry && space_extra != 0. && glyph.advance > 0. { + run_x += space_extra; + } } } } diff --git a/node-graph/nodes/text/src/text_context.rs b/node-graph/nodes/text/src/text_context.rs index cc16ea8fa7..47b43cc708 100644 --- a/node-graph/nodes/text/src/text_context.rs +++ b/node-graph/nodes/text/src/text_context.rs @@ -108,14 +108,50 @@ impl TextContext { }) .unwrap_or_default(); + let alignment_width = typesetting.max_width.map(|w| w as f32).unwrap_or_else(|| layout.full_width()); + let last_line_correction = typesetting.align.last_line_correction(); + let mut path_builder = PathBuilder::new(per_glyph_items, layout.scale() as f64, text_frame_size, first_glyph_offset); for line in layout.lines() { + let range = line.text_range(); + // Parley always includes a hard-break `\n` as the last byte of the preceding line's range, so the line + // is at the end of a paragraph if it's the very last line of the buffer or its text ends with `\n`. + let is_last_para_line = range.end == text.len() || text.get(range.clone()).is_some_and(|s| s.ends_with('\n')); + + let (x_offset, space_extra) = if let (true, Some(correction)) = (is_last_para_line, last_line_correction) { + let metrics = line.metrics(); + let content_advance = metrics.advance - metrics.trailing_whitespace; + let free_space = alignment_width - content_advance; + + match correction { + parley::Alignment::Center => (free_space * 0.5, 0.), + parley::Alignment::Right => (free_space, 0.), + parley::Alignment::Justify => { + // Exclude trailing-whitespace clusters from the divisor so the redistribution stretches only the internal spaces. + // Parley's `trailing_whitespace` is in advance units, not bytes, so we re-derive the byte boundary here to filter cluster ranges. + let line_text = text.get(range.clone()).unwrap_or(""); + let trailing_len = line_text.len() - line_text.trim_end().len(); + let visible_end_index = range.end - trailing_len; + + let space_count: usize = line + .runs() + .map(|run| run.clusters().filter(|c| c.is_space_or_nbsp() && c.text_range().start < visible_end_index).count()) + .sum(); + let extra = if space_count > 0 { free_space / space_count as f32 } else { 0. }; + (0., extra) + } + _ => (0., 0.), + } + } else { + (0., 0.) + }; + 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_items); + path_builder.render_glyph_run(&glyph_run, typesetting.tilt, per_glyph_items, x_offset, space_extra); } } }