Skip to content

Implement CSS text-indent support#551

Open
devunt wants to merge 2 commits intolinebender:mainfrom
devunt:feature/text-indent
Open

Implement CSS text-indent support#551
devunt wants to merge 2 commits intolinebender:mainfrom
devunt:feature/text-indent

Conversation

@devunt
Copy link

@devunt devunt commented Feb 14, 2026

Closes #545

Summary

Adds CSS text-indent support to parley. The indent is treated as a margin on the start edge of the line box (per CSS spec), reducing available width during line breaking and offsetting content during alignment.

Design decisions

  • Layout-level property, not StylePropertytext-indent is a block-level CSS property, so it follows the same pattern as Alignment: stored in LayoutData, configured via a method on Layout.
  • API mirrors Layout::align()Layout::indent(amount, options) separates the core value from behavioral options.
  • Per-line indent stored in LineData — computed once during line breaking, reused during alignment without recalculation.
  • IndentOptions in layout module — since text-indent is a layout-level property (not a per-span style), IndentOptions lives in layout/mod.rs alongside Alignment, AlignmentOptions, and ContentWidths.

Features

  • Positive indent — first line offset inward, available width reduced.
  • Negative indent — first line protrudes beyond the start edge (overflow, matching CSS behavior where negative indent doesn't affect box size).
  • each-line keyword — indent applies after every hard line break (BreakReason::Explicit), not just the first line.
  • hanging keyword — inverts which lines are indented: continuation lines instead of first line(s).
  • Alignment integration — works correctly with Start, Center, Right, and Justify alignments. Indent acts as a start-edge margin, and alignment operates within the remaining space.

API

layout.indent(50.0, IndentOptions::default());
layout.indent(50.0, IndentOptions { each_line: true, ..Default::default() });
layout.indent(50.0, IndentOptions { hanging: true, ..Default::default() });
layout.break_all_lines(Some(300.0));
layout.align(None, Alignment::Center, AlignmentOptions::default());

How it works

Line breaking (line_break.rs):

  • Before processing each line, determines whether the line should be indented based on each_line and hanging options.
  • Subtracts the indent from max_advance to reduce available width for the indented line.
  • Stores the computed per-line indent in LineData::indent.
  • Width calculation in Drop adds positive indent to line advance (negative indent causes overflow, not box growth).

Alignment (alignment.rs):

  • For LTR text, sets offset = indent (previously always 0).
  • Subtracts indent from free_space so alignment algorithms (center, end, justify) operate within the reduced space.
  • RTL trailing whitespace handling is preserved.

Changes

File Change
parley/src/layout/mod.rs Add IndentOptions struct
parley/src/layout/data.rs Add indent_amount/indent_options to LayoutData, indent to LineData
parley/src/layout/layout.rs Add Layout::indent() public API
parley/src/layout/line_break.rs Indent calculation in break_next, propagation through try_commit_line, width accounting in Drop
parley/src/layout/alignment.rs Indent-aware offset and free space calculation
parley_tests/tests/text_indent.rs 22 test cases with snapshot verification

Test plan

  • cargo check -p parley — compiles
  • cargo test -p parley — 49 passed (existing unit tests unaffected)
  • cargo clippy -p parley -p parley_tests — no warnings
  • cargo test -p parley_tests — 125 passed (103 existing + 22 new)

New test cases

Test What it verifies
text_indent_basic Positive indent on wrapping text, only first line indented
text_indent_no_wrap Single line with indent
text_indent_each_line Indent after every hard break (\n)
text_indent_hanging Continuation lines indented, first line not
text_indent_hanging_each_line Combined hanging + each-line
text_indent_negative Negative indent (first line protrudes)
text_indent_negative_hanging Negative indent on continuation lines
text_indent_center_alignment Indent + center alignment
text_indent_right_alignment Indent + right alignment
text_indent_justify Indent + justified alignment
text_indent_zero Zero indent preserves existing behavior
text_indent_line_breaking Indent causes additional line breaks
text_indent_rtl_basic RTL text with positive indent
text_indent_rtl_hanging RTL hanging indent
text_indent_rtl_each_line RTL each-line indent
text_indent_rtl_negative RTL negative indent
text_indent_rtl_center_alignment RTL indent + center alignment
text_indent_rtl_justify RTL indent + justified alignment
text_indent_cursor_geometry Cursor position accounts for indent
text_indent_hit_testing Hit testing in indent area and after indent
text_indent_negative_hit_testing Hit testing in negative indent overflow area
text_indent_selection_geometry Selection geometry accounts for indent

@devunt devunt force-pushed the feature/text-indent branch 2 times, most recently from 8715454 to d5acb19 Compare February 14, 2026 15:05
@devunt devunt force-pushed the feature/text-indent branch from d5acb19 to d84175b Compare February 14, 2026 15:18
Copy link
Collaborator

@nicoburns nicoburns left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This mostly looks good to me. But we also need to make sure the RTL case is working correctly. Can we get tests for RTL case? And I think the fix I have suggested for the offset may be required.

We should also check that hit testing / cursor placement / selection works correctly with indented text (including text that overflows the bounds of it's box due to negative text indent).

- Simplify hanging indent logic using XOR (is_scope_line ^ hanging)
- Add RTL text indent tests (basic, hanging, each_line, negative, center, justify)
- Add hit testing, cursor placement, and selection geometry tests for indented text
- Add negative indent overflow hit testing test
@devunt devunt force-pushed the feature/text-indent branch from c318240 to ea76822 Compare February 14, 2026 16:33
@devunt
Copy link
Author

devunt commented Feb 14, 2026

Addressed review feedback:

  • Simplified hanging indent logic using XOR (is_scope_line ^ hanging)
  • Added a comment explaining why indent is not subtracted in the RTL initial offset (already accounted for in free_space)
  • Added RTL text indent tests: basic, hanging, each-line, negative, center alignment, justify
  • Added cursor geometry, hit testing, selection geometry tests for indented text (including negative indent overflow)

@devunt devunt requested a review from nicoburns February 14, 2026 16:41
Copy link
Collaborator

@nicoburns nicoburns left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have one suggestion around method naming. Otherwise this LGTM now. But I'll give other maintainers a chance to review before merging.

I've tested that hit testing / mouse selection logic works correctly in Blitz.

///
/// This must be called before [`Layout::break_all_lines`] or [`Layout::break_lines`],
/// and before [`Layout::align`].
pub fn indent(&mut self, amount: f32, options: IndentOptions) {
Copy link
Collaborator

@nicoburns nicoburns Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we name this set_text_indent. Because unlike the align method it doesn't actually apply the indent to the text (it just sets the style).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support text-indent

2 participants

Comments