From bb1cd85466a6fc224cca5ae7519a8d04503aad92 Mon Sep 17 00:00:00 2001 From: David Richardson Date: Sat, 24 Oct 2026 10:17:42 -0600 Subject: [PATCH] AppKit: Add justified text alignment to CPTypesetter CPTypesetter lacked handling for CPJustifiedTextAlignment, causing justified paragraphs to fall back to left alignment. Additionally, centred and right-aligned text improperly spanned the full container width by ignoring paragraph indents, and trailing spaces on wrapped lines incorrectly shifted line positioning. * AppKit/CPTextView/CPTypesetter.j: - Implement CPJustifiedTextAlignment layout by distributing spare width evenly across line spaces. - Preserve left alignment for paragraph final lines and segments preceding a tab, matching Cocoa behavior. - Constrain centred, right-aligned, and justified lines between head and tail indents. - Exclude trailing spaces from visible line width to allow proper hanging alignment. * AppKit/CPTextView/CPLayoutManager.j: - Update _CPLineFragment to record extra space widths and render spans with matching CSS word-spacing. - Ensure fragment equality checks account for word-spacing variations. * Tests/Manual/TextEditor/WORKLIST.md: - Mark justified and centred text layout tests as completed. Centred and justified paragraphs now conform to standard Cocoa layout behavior. --- AppKit/CPTextView/CPLayoutManager.j | 9 +++++ AppKit/CPTextView/CPTypesetter.j | 61 ++++++++++++++++++++++++++--- Tests/Manual/TextEditor/WORKLIST.md | 4 +- 3 files changed, 66 insertions(+), 8 deletions(-) diff --git a/AppKit/CPTextView/CPLayoutManager.j b/AppKit/CPTextView/CPLayoutManager.j index 2a14bd204..8d3572a35 100644 --- a/AppKit/CPTextView/CPLayoutManager.j +++ b/AppKit/CPTextView/CPLayoutManager.j @@ -1125,6 +1125,7 @@ var _objectsInRange = function(aList, aRange) BOOL _isInvalid; BOOL _isLast; + float _wordSpacing; CGRect _fragmentRect; CGRect _usedRect; CGPoint _location; @@ -1162,6 +1163,10 @@ var _objectsInRange = function(aList, aRange) style.whiteSpace = "pre"; style.backgroundColor = "transparent"; style.font = [aFont cssString]; + + // A justified line widens its spaces; the typesetter has added the same width to their advancements. + if (_wordSpacing) + style.wordSpacing = _wordSpacing + "px"; if (aUnderline) { @@ -1214,6 +1219,7 @@ var _objectsInRange = function(aList, aRange) _range = CPMakeRangeCopy(aRange); _textContainer = aContainer; _isInvalid = NO; + _wordSpacing = 0.0; _runs = []; _glyphsFrames = []; _glyphsOffsets = []; @@ -1494,6 +1500,9 @@ var _objectsInRange = function(aList, aRange) if (!oldFragmentRuns || !newFragmentRuns || oldFragmentRuns.length !== newFragmentRuns.length) return NO; + if (newLineFragment._wordSpacing !== _wordSpacing) + return NO; + var l = oldFragmentRuns.length; for (var i = 0; i < l; i++) diff --git a/AppKit/CPTextView/CPTypesetter.j b/AppKit/CPTextView/CPTypesetter.j index 85c3f1991..b9e6ac6d6 100644 --- a/AppKit/CPTextView/CPTypesetter.j +++ b/AppKit/CPTextView/CPTypesetter.j @@ -186,13 +186,32 @@ var _CPTypesetterDecimalSeparator = function() advancements:(CPArray)advancements lineCount:(unsigned)lineCount sameLine:(BOOL)sameLine + wordWrapped:(BOOL)wordWrapped + rightMargin:(float)rightMargin { // Line positions are computed inside the line fragment padding. The padding is added here, where // the line fragment rectangle is stored, and alignment uses the width inside the padding. var padding = [aContainer lineFragmentPadding], myX = 0, - rect = CGRectMake(lineOrigin.x + padding, lineOrigin.y, _lineWidth, _lineHeight), - usableWidth = aContainer._size.width - 2.0 * padding; + rect = CGRectMake(lineOrigin.x + padding, lineOrigin.y, _lineWidth, _lineHeight); + + // As in Cocoa, text is aligned between the indents, and whitespace at the end of the line hangs + // outside the alignment: it does not count in the line's visible width. + var theString = [_textStorage string], + availableWidth = rightMargin - lineOrigin.x, + visibleLength = lineRange.length, + visibleWidth = _lineWidth; + + while (visibleLength > 0) + { + var trailingCharCode = theString.charCodeAt(lineRange.location + visibleLength - 1); + + if (trailingCharCode !== 32 && trailingCharCode !== 10 && trailingCharCode !== 13) + break; + + visibleLength--; + visibleWidth -= advancements[visibleLength].width; + } [_layoutManager _appendNewLineFragmentInTextContainer:_currentTextContainer forGlyphRange:lineRange]; @@ -209,11 +228,41 @@ var _CPTypesetterDecimalSeparator = function() break; case CPCenterTextAlignment: - myX = (usableWidth - _lineWidth) / 2; + myX = (availableWidth - visibleWidth) / 2; break; case CPRightTextAlignment: - myX = usableWidth - _lineWidth; + myX = availableWidth - visibleWidth; + break; + + case CPJustifiedTextAlignment: + // Only a line that wraps is justified. The last line of a paragraph, and the text + // before a tab on the same line, stay left aligned, as in Cocoa. The spare width is + // spread evenly over the spaces inside the visible part of the line. The line fragment + // draws its spans with the same extra word spacing. + if (wordWrapped && !sameLine) + { + var spaceIndexes = []; + + for (var i = 0; i < visibleLength; i++) + { + var charCode = theString.charCodeAt(lineRange.location + i); + + if (charCode === 32 || charCode === 160) + spaceIndexes.push(i); + } + + var spaceCount = spaceIndexes.length, + extraWidth = spaceCount ? (availableWidth - visibleWidth) / spaceCount : 0.0; + + if (extraWidth > 0.0) + { + for (var i = 0; i < spaceCount; i++) + advancements[spaceIndexes[i]].width += extraWidth; + + fragment._wordSpacing = extraWidth; + } + } break; } @@ -567,7 +616,7 @@ var _CPTypesetterDecimalSeparator = function() if (isNewline || isTabStop || isAttachment) { - if ([self _flushRange:lineRange lineOrigin:lineOrigin currentContainer:_currentTextContainer advancements:advancements lineCount:numLines sameLine:!isNewline]) + if ([self _flushRange:lineRange lineOrigin:lineOrigin currentContainer:_currentTextContainer advancements:advancements lineCount:numLines sameLine:!isNewline wordWrapped:isWordWrapped rightMargin:rightMargin]) return; if (isTabStop || isAttachment) @@ -622,7 +671,7 @@ var _CPTypesetterDecimalSeparator = function() // Flush remaining characters if (lineRange.length) - [self _flushRange:lineRange lineOrigin:lineOrigin currentContainer:_currentTextContainer advancements:advancements lineCount:numLines sameLine:NO]; + [self _flushRange:lineRange lineOrigin:lineOrigin currentContainer:_currentTextContainer advancements:advancements lineCount:numLines sameLine:NO wordWrapped:NO rightMargin:rightMargin]; // The empty line after the text starts inside the padding, and at least 1 point in, so the insertion point stays visible. var rect = CGRectMake(MAX(1.0, lineFragmentPadding), lineOrigin.y - descent, containerSizeWidth, currentFontLineHeight); diff --git a/Tests/Manual/TextEditor/WORKLIST.md b/Tests/Manual/TextEditor/WORKLIST.md index 4debc3809..01afce87b 100644 --- a/Tests/Manual/TextEditor/WORKLIST.md +++ b/Tests/Manual/TextEditor/WORKLIST.md @@ -29,7 +29,6 @@ TextEditor is a document-based test application and a tutorial. When the app use How the text system works, for the entries below: `CPTextView` does not use CSS text layout. `CPSimpleTypesetter` measures text with the canvas `measureText` (`sizeWithFont:inWidth:` in `CPStringDrawing.j`), measuring growing substrings so that each character's advancement includes the browser's kerning. It then breaks lines, places tabs, and applies indents and alignment itself (`CPTypesetter.j`). `_CPLineFragment` (`CPLayoutManager.j`) draws each run of identical attributes as one absolutely positioned ``, at the computed position, with `white-space: pre` and the run's font, colour and underline in CSS. Inside a span the browser shapes the glyphs, including its own kerning and ligatures. - **Format > Font > Copy Style and Paste Style raise an exception.** They send `copyFont:` and `pasteFont:`, which `CPText` declares as abstract methods that raise (`CPText.j:210` and `:261`). `CPTextView` does not implement them, so the menu items are enabled and choosing one raises. `CPTextView` needs Cocoa's implementations: copy the font and character attributes at the selection to the font pasteboard (`CPFontPboard`), and apply them to the selection. -- **Format > Text > Justify crashes the application.** `alignJustified:` sets the paragraph style's alignment to `CPJustifiedTextAlignment`, but the typesetter's alignment handling has cases only for left, centred and right text (`_flushRange:` in `CPTypesetter.j`). The exception text from the browser console will show where the crash occurs. Justified layout needs a full implementation: spreading each line's spare width across its spaces, except on the last line of a paragraph. - **Kerning and ligature settings are not implemented.** Format > Font > Kern sends `useStandardKerning:`, `turnOffKerning:`, `tightenKerning:` and `loosenKerning:`, and Format > Font > Ligatures sends `useStandardLigatures:`, `turnOffLigatures:` and `useAllLigatures:`. `CPTextView` implements none of them, so the items are disabled. `CPText.j` defines `CPKernAttributeName` and `CPLigatureAttributeName`, but only the RTF producer reads them. A complete implementation needs: the seven actions, changing the attributes over the selection or the typing attributes with undo, as the baseline actions do; the typesetter adding a run's `CPKernAttributeName` value to each character's advancement, and measuring with the same ligature setting it draws with; and `_CPLineFragment` setting CSS `letter-spacing` and `font-variant-ligatures` on each run's span to match. - **The font panel needs polish.** Found during the Format menu review on 24 October: the panel works with the text view, but its appearance and behaviour need refinement. The specific problems are not yet recorded; list them here, compared with the native font panel, when the panel is reviewed. `CPFontPanel` is in `AppKit/CPTextView/CPFontPanel.j`. @@ -52,7 +51,7 @@ How the text system works, for the entries below: `CPTextView` does not use CSS ## Opportunities for improvement -- **Line breaking is greedy.** `CPSimpleTypesetter` fills each line as far as it fits, as Cocoa does. Typesetting quality, especially in justified text, needs paragraph-wide line breaking in the manner of Knuth-Plass, as Typst also uses, with language-aware hyphenation. Cappuccino measures and lays out text itself, so this belongs in the typesetter, as its only behaviour, with no setting for users or developers. It follows justification. +- **Justification is nominally functional, but its quality is limited.** `CPSimpleTypesetter` breaks lines greedily: it fills each line as far as it fits, as Cocoa does, and then spreads the spare width of each wrapped line evenly over that line's spaces. This naive algorithm does not and cannot produce the quality that modern typesetting expects. It decides each line alone, so word spacing varies widely from line to line, and loose lines are common. Quality needs paragraph-wide line breaking in the manner of Knuth-Plass, as Typst also uses, with language-aware hyphenation. Cappuccino measures and lays out text itself, so this belongs in the typesetter, as its only behaviour, with no setting for users or developers. ## Planned additions to the app @@ -83,4 +82,5 @@ How the text system works, for the entries below: `CPTextView` does not use CSS - Tab characters: the Tab key inserts a tab in the text view, and left, centre, right and decimal tab stops align their columns. - Format > Text > Copy Ruler and Paste Ruler apply one paragraph's indents and tab stops to others. - Format > Font > Baseline: superscript, subscript, raised and lowered baselines lay out correctly, and Use Default restores the text, for an exact or a wider selection. +- Format > Text > Justify: wrapped lines fill the width between the indents, with the spare width spread over the spaces; the last line of a paragraph stays left aligned. Centred text is centred between the indents, and spaces at the end of a wrapped line hang outside the alignment. - Builds with both the legacy toolchain and capp-build.