mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 14:18:04 +08:00
Add text last-line alignment modes for "Justify Center", "Justify Right", and "Justify All" (#4024)
* feat: Add justify proper alignments(right/left/centre/all) * chore: fmt * chore: refactor * Cleanup * Fix crash when changing selected layer's alignment from the Text tool control bar --------- Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
@@ -1728,6 +1728,7 @@ impl<'a> MessageHandler<NodeGraphMessage, NodeGraphMessageContext<'a>> for NodeG
|
|||||||
}
|
}
|
||||||
NodeGraphMessage::SetInputValue { node_id, input_index, value } => {
|
NodeGraphMessage::SetInputValue { node_id, input_index, value } => {
|
||||||
let is_fill = matches!(value, TaggedValue::Fill(_));
|
let is_fill = matches!(value, TaggedValue::Fill(_));
|
||||||
|
let is_text_align = matches!(value, TaggedValue::TextAlign(_));
|
||||||
let input = NodeInput::value(value, false);
|
let input = NodeInput::value(value, false);
|
||||||
responses.add(NodeGraphMessage::SetInput {
|
responses.add(NodeGraphMessage::SetInput {
|
||||||
input_connector: InputConnector::node(node_id, input_index),
|
input_connector: InputConnector::node(node_id, input_index),
|
||||||
@@ -1737,6 +1738,9 @@ impl<'a> MessageHandler<NodeGraphMessage, NodeGraphMessageContext<'a>> for NodeG
|
|||||||
if is_fill {
|
if is_fill {
|
||||||
responses.add(OverlaysMessage::Draw);
|
responses.add(OverlaysMessage::Draw);
|
||||||
}
|
}
|
||||||
|
if is_text_align {
|
||||||
|
responses.add(TextToolMessage::SelectionChanged);
|
||||||
|
}
|
||||||
if network_interface.connected_to_output(&node_id, selection_network_path) {
|
if network_interface.connected_to_output(&node_id, selection_network_path) {
|
||||||
responses.add(NodeGraphMessage::RunDocumentGraph);
|
responses.add(NodeGraphMessage::RunDocumentGraph);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2953,7 +2953,7 @@ pub mod choice {
|
|||||||
if let Some(icon) = var_meta.icon { entry.icon(icon) } else { entry.label(var_meta.label) }
|
if let Some(icon) = var_meta.icon { entry.icon(icon) } else { entry.label(var_meta.label) }
|
||||||
})
|
})
|
||||||
.collect();
|
.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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ pub fn text_width(text: &str, font_size: f64) -> f64 {
|
|||||||
max_width: None,
|
max_width: None,
|
||||||
max_height: None,
|
max_height: None,
|
||||||
tilt: 0.0,
|
tilt: 0.0,
|
||||||
align: TextAlign::Left,
|
align: TextAlign::AlignLeft,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load Source Sans Pro font data
|
// Load Source Sans Pro font data
|
||||||
|
|||||||
@@ -1112,7 +1112,7 @@ impl OverlayContextInternal {
|
|||||||
max_width: None,
|
max_width: None,
|
||||||
max_height: None,
|
max_height: None,
|
||||||
tilt: 0.,
|
tilt: 0.,
|
||||||
align: TextAlign::Left, // We'll handle alignment manually via pivot
|
align: TextAlign::AlignLeft,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load Source Sans Pro font data
|
// Load Source Sans Pro font data
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ pub enum TextToolMessage {
|
|||||||
Interact,
|
Interact,
|
||||||
PointerMove { center: Key, lock_ratio: Key },
|
PointerMove { center: Key, lock_ratio: Key },
|
||||||
PointerOutsideViewport { center: Key, lock_ratio: Key },
|
PointerOutsideViewport { center: Key, lock_ratio: Key },
|
||||||
|
SelectionChanged,
|
||||||
TextChange { new_text: String, is_left_or_right_click: bool },
|
TextChange { new_text: String, is_left_or_right_click: bool },
|
||||||
UpdateBounds { new_text: String },
|
UpdateBounds { new_text: String },
|
||||||
UpdateOptions { options: TextOptionsUpdate },
|
UpdateOptions { options: TextOptionsUpdate },
|
||||||
@@ -203,17 +204,25 @@ fn create_text_widgets(tool: &TextTool, font_catalog: &FontCatalog) -> Vec<Widge
|
|||||||
.into()
|
.into()
|
||||||
})
|
})
|
||||||
.widget_instance();
|
.widget_instance();
|
||||||
let align_entries: Vec<_> = [TextAlign::Left, TextAlign::Center, TextAlign::Right, TextAlign::JustifyLeft]
|
let align_entries: Vec<_> = [
|
||||||
.into_iter()
|
TextAlign::AlignLeft,
|
||||||
.map(|align| {
|
TextAlign::AlignCenter,
|
||||||
RadioEntryData::new(format!("{align:?}")).label(align.to_string()).on_update(move |_| {
|
TextAlign::AlignRight,
|
||||||
TextToolMessage::UpdateOptions {
|
TextAlign::JustifyLeft,
|
||||||
options: TextOptionsUpdate::Align(align),
|
TextAlign::JustifyCenter,
|
||||||
}
|
TextAlign::JustifyRight,
|
||||||
.into()
|
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();
|
let align = RadioInput::new(align_entries).selected_index(Some(tool.options.align as u32)).widget_instance();
|
||||||
vec![
|
vec![
|
||||||
font,
|
font,
|
||||||
@@ -279,9 +288,24 @@ impl TextTool {
|
|||||||
#[message_handler_data]
|
#[message_handler_data]
|
||||||
impl<'a> MessageHandler<ToolMessage, &mut ToolActionMessageContext<'a>> for TextTool {
|
impl<'a> MessageHandler<ToolMessage, &mut ToolActionMessageContext<'a>> for TextTool {
|
||||||
fn process_message(&mut self, message: ToolMessage, responses: &mut VecDeque<Message>, context: &mut ToolActionMessageContext<'a>) {
|
fn process_message(&mut self, message: ToolMessage, responses: &mut VecDeque<Message>, context: &mut ToolActionMessageContext<'a>) {
|
||||||
let ToolMessage::Text(TextToolMessage::UpdateOptions { options }) = message else {
|
let options = match message {
|
||||||
self.fsm_state.process_event(message, &mut self.tool_data, context, &self.options, responses, true);
|
ToolMessage::Text(TextToolMessage::UpdateOptions { options }) => options,
|
||||||
return;
|
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 {
|
match options {
|
||||||
TextOptionsUpdate::Font { font } => {
|
TextOptionsUpdate::Font { font } => {
|
||||||
@@ -289,7 +313,21 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionMessageContext<'a>> for Text
|
|||||||
}
|
}
|
||||||
TextOptionsUpdate::FontSize(font_size) => self.options.font_size = font_size,
|
TextOptionsUpdate::FontSize(font_size) => self.options.font_size = font_size,
|
||||||
TextOptionsUpdate::LineHeightRatio(line_height_ratio) => self.options.line_height_ratio = line_height_ratio,
|
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) => {
|
TextOptionsUpdate::FillColor(color) => {
|
||||||
self.options.fill.custom_color = color;
|
self.options.fill.custom_color = color;
|
||||||
self.options.fill.color_type = ToolColorType::Custom;
|
self.options.fill.color_type = ToolColorType::Custom;
|
||||||
@@ -335,10 +373,10 @@ impl ToolTransition for TextTool {
|
|||||||
fn event_to_message_map(&self) -> EventToMessageMap {
|
fn event_to_message_map(&self) -> EventToMessageMap {
|
||||||
EventToMessageMap {
|
EventToMessageMap {
|
||||||
canvas_transformed: None,
|
canvas_transformed: None,
|
||||||
|
selection_changed: Some(TextToolMessage::SelectionChanged.into()),
|
||||||
tool_abort: Some(TextToolMessage::Abort.into()),
|
tool_abort: Some(TextToolMessage::Abort.into()),
|
||||||
working_color_changed: Some(TextToolMessage::WorkingColorChanged.into()),
|
working_color_changed: Some(TextToolMessage::WorkingColorChanged.into()),
|
||||||
overlay_provider: Some(|context| TextToolMessage::Overlays { context }.into()),
|
overlay_provider: Some(|context| TextToolMessage::Overlays { context }.into()),
|
||||||
..Default::default()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,21 +31,37 @@ pub use vector_types;
|
|||||||
#[widget(Radio)]
|
#[widget(Radio)]
|
||||||
pub enum TextAlign {
|
pub enum TextAlign {
|
||||||
#[default]
|
#[default]
|
||||||
Left,
|
AlignLeft,
|
||||||
Center,
|
AlignCenter,
|
||||||
Right,
|
AlignRight,
|
||||||
#[label("Justify")]
|
|
||||||
JustifyLeft,
|
JustifyLeft,
|
||||||
// TODO: JustifyCenter, JustifyRight, JustifyAll
|
JustifyCenter,
|
||||||
|
JustifyRight,
|
||||||
|
JustifyAll,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<TextAlign> for parley::Alignment {
|
impl From<TextAlign> for parley::Alignment {
|
||||||
fn from(val: TextAlign) -> Self {
|
fn from(val: TextAlign) -> Self {
|
||||||
match val {
|
match val {
|
||||||
TextAlign::Left => parley::Alignment::Left,
|
TextAlign::AlignLeft => parley::Alignment::Left,
|
||||||
TextAlign::Center => parley::Alignment::Center,
|
TextAlign::AlignCenter => parley::Alignment::Center,
|
||||||
TextAlign::Right => parley::Alignment::Right,
|
TextAlign::AlignRight => parley::Alignment::Right,
|
||||||
TextAlign::JustifyLeft => parley::Alignment::Justify,
|
_ => 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<parley::Alignment> {
|
||||||
|
match self {
|
||||||
|
Self::JustifyCenter => Some(parley::Alignment::Center),
|
||||||
|
Self::JustifyRight => Some(parley::Alignment::Right),
|
||||||
|
Self::JustifyAll => Some(parley::Alignment::Justify),
|
||||||
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,10 +52,20 @@ impl PathBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn draw_glyph(&mut self, glyph: &OutlineGlyph<'_>, size: f32, normalized_coords: &[NormalizedCoord], glyph_offset: DVec2, style_skew: Option<DAffine2>, skew: DAffine2, per_glyph_items: bool) {
|
fn draw_glyph(
|
||||||
|
&mut self,
|
||||||
|
glyph: &OutlineGlyph<'_>,
|
||||||
|
size: f32,
|
||||||
|
normalized_coords: &[NormalizedCoord],
|
||||||
|
glyph_offset: DVec2,
|
||||||
|
style_skew: Option<DAffine2>,
|
||||||
|
skew: DAffine2,
|
||||||
|
per_glyph_items: bool,
|
||||||
|
) -> bool {
|
||||||
let location_ref = LocationRef::new(normalized_coords);
|
let location_ref = LocationRef::new(normalized_coords);
|
||||||
let settings = DrawSettings::unhinted(Size::new(size), location_ref);
|
let settings = DrawSettings::unhinted(Size::new(size), location_ref);
|
||||||
glyph.draw(settings, self).unwrap();
|
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
|
// Apply transforms in correct order: style-based skew first, then user-requested skew
|
||||||
// This ensures font synthesis (italic) is applied before user transformations
|
// 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);
|
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) {
|
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();
|
let mut run_x = glyph_run.offset() + x_offset;
|
||||||
let run_y = glyph_run.baseline();
|
let run_y = glyph_run.baseline();
|
||||||
|
|
||||||
let run = glyph_run.run();
|
let run = glyph_run.run();
|
||||||
@@ -142,7 +154,11 @@ impl PathBuilder {
|
|||||||
if !per_glyph_items {
|
if !per_glyph_items {
|
||||||
self.origin = glyph_offset;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,14 +108,50 @@ impl TextContext {
|
|||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.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);
|
let mut path_builder = PathBuilder::new(per_glyph_items, layout.scale() as f64, text_frame_size, first_glyph_offset);
|
||||||
|
|
||||||
for line in layout.lines() {
|
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() {
|
for item in line.items() {
|
||||||
if let PositionedLayoutItem::GlyphRun(glyph_run) = item
|
if let PositionedLayoutItem::GlyphRun(glyph_run) = item
|
||||||
&& typesetting.max_height.filter(|&max_height| glyph_run.baseline() > max_height as f32).is_none()
|
&& 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user