AppKit: Add Copy Style and Paste Style to CPTextView

CPTextView lacked implementations for copyFont: and pasteFont:
(declared as abstract methods in CPText), causing standard Copy Style and Paste Style menu items to trigger exceptions when invoked.

* AppKit/CPTextView/CPTextView.j:
  - Implement copyFont: to copy character attributes (excluding paragraph style, matching Cocoa) of the initial selection character or current typing attributes to the font pasteboard.
  - Implement pasteFont: to apply copied character attributes to the selection or typing attributes with undo support, preserving individual paragraph styles.
  - Update menu validation logic to enable Copy Style in rich text and Paste Style in editable rich text when the font pasteboard contains valid styling.

* Tests/AppKit/CPTextViewTest.j:
  - Add unit tests covering style copying and pasting with and without active selections.
  - Add test coverage for menu validation.

* Tests/Manual/TextEditor/WORKLIST.md:
  - Record Copy Style and Paste Style as tested.

Copy Style and Paste Style now function consistently with Cocoa.
This commit is contained in:
David Richardson committed 2026-10-24 10:36:58 -06:00
1 parent 9d1504704f
commit 178a7d2aec
3 files changed
+171 -3

No files matched your search

+108 -2
View File
@@ -721,8 +721,9 @@ var kDelegateRespondsTo_textShouldBeginEditing
For \c -toggleRuler:, the item's title becomes "Hide Ruler" or "Show Ruler" to reflect the
ruler's visibility, and the item is enabled only when the text view uses a ruler and lies
in a scroll view. \c -copyRuler: is enabled in rich text, and \c -pasteRuler: in editable
rich text when the ruler pasteboard holds a ruler. The superscript, subscript and baseline
actions are enabled in editable rich text. Every other item is enabled.
rich text when the ruler pasteboard holds a ruler. \c -copyFont: is enabled in rich text, and
\c -pasteFont: in editable rich text when the font pasteboard holds a style. The superscript,
subscript and baseline actions are enabled in editable rich text. Every other item is enabled.
@param anItem the item to validate
@return \c YES if the item should be enabled
*/
@@ -748,6 +749,13 @@ var kDelegateRespondsTo_textShouldBeginEditing
if (action === @selector(pasteRuler:))
return [self isRichText] && [self isEditable] && !![[CPPasteboard pasteboardWithName:CPRulerPboard] availableTypeFromArray:[CPRulerPboardType]];
// As in Cocoa, styles can be copied and pasted only in rich text.
if (action === @selector(copyFont:))
return [self isRichText];
if (action === @selector(pasteFont:))
return [self isRichText] && [self isEditable] && !![[CPPasteboard pasteboardWithName:CPFontPboard] availableTypeFromArray:[CPFontPboardType]];
if (action === @selector(toggleRuler:))
{
if ([anItem respondsToSelector:@selector(setTitle:)])
@@ -2080,6 +2088,104 @@ Sets the selection to a range of characters in response to user action.
[[CPFontManager sharedFontManager] addFontTrait:CPItalicFontMask];
}
// MARK: -
// MARK: Style methods
/*!
Copies the character attributes at the start of the selection, or the typing attributes
when nothing is selected, to the font pasteboard, so that Paste Style can apply them to
other text. The attributes include the font, colours, underline and baseline. As in Cocoa,
the paragraph style is not included: Copy Ruler copies it.
@param aSender the object requesting this
*/
- (@action)copyFont:(id)aSender
{
var attributes = _selectionRange.length ? [_textStorage attributesAtIndex:_selectionRange.location effectiveRange:nil] : _typingAttributes,
pasteboard = [CPPasteboard pasteboardWithName:CPFontPboard];
[pasteboard declareTypes:[CPFontPboardType] owner:nil];
[pasteboard setData:[CPKeyedArchiver archivedDataWithRootObject:[self _characterStyleFromAttributes:attributes]] forType:CPFontPboardType];
}
/*!
Applies the character attributes on the font pasteboard to the selected text, or to the
text typed next when nothing is selected. They replace the text's own character
attributes. Each paragraph keeps its paragraph style.
@param aSender the object requesting this
*/
- (@action)pasteFont:(id)aSender
{
var data = [[CPPasteboard pasteboardWithName:CPFontPboard] dataForType:CPFontPboardType];
if (!data)
return;
var characterStyle = [CPKeyedUnarchiver unarchiveObjectWithData:data],
range = CPMakeRangeCopy(_selectionRange);
if (!range.length)
{
var typingAttributes = [characterStyle mutableCopy],
paragraphStyle = [_typingAttributes objectForKey:CPParagraphStyleAttributeName];
if (paragraphStyle)
[typingAttributes setObject:paragraphStyle forKey:CPParagraphStyleAttributeName];
[self setTypingAttributes:typingAttributes];
return;
}
if (![self _beginCharacterAttributeChangeInRange:range])
return;
// Each run keeps the attributes that are not part of a style: its paragraph style and, for an
// attachment character, the attachment.
var location = range.location,
end = CPMaxRange(range);
while (location < end)
{
var effectiveRange = CPMakeRange(0, 0),
runAttributes = [_textStorage attributesAtIndex:location effectiveRange:effectiveRange],
runRange = CPIntersectionRange(effectiveRange, range),
newAttributes = [characterStyle mutableCopy],
keptKeys = [CPParagraphStyleAttributeName, CPAttachmentAttributeName, _CPAttachmentView, _CPAttachmentInvisible];
if (!runRange.length)
runRange = CPMakeRange(location, end - location);
for (var i = 0; i < keptKeys.length; i++)
{
var value = [runAttributes objectForKey:keptKeys[i]];
if (value)
[newAttributes setObject:value forKey:keptKeys[i]];
}
[_textStorage setAttributes:newAttributes range:runRange];
location = CPMaxRange(runRange);
}
[self _endCharacterAttributeChangeInRange:range];
}
/* @ignore
Returns a copy of someAttributes without the attributes that are not part of a character
style: the paragraph style and the attachment.
*/
- (CPDictionary)_characterStyleFromAttributes:(CPDictionary)someAttributes
{
var characterStyle = [someAttributes mutableCopy] || [CPDictionary dictionary];
[characterStyle removeObjectForKey:CPParagraphStyleAttributeName];
[characterStyle removeObjectForKey:CPAttachmentAttributeName];
[characterStyle removeObjectForKey:_CPAttachmentView];
[characterStyle removeObjectForKey:_CPAttachmentInvisible];
return characterStyle;
}
// MARK: -
// MARK: Baseline methods
+62
View File
@@ -208,6 +208,68 @@
[self assertTrue:([[textView typingAttributes] objectForKey:CPSuperscriptAttributeName] == nil)];
}
- (void)testCopyAndPasteStyle
{
var font = [CPFont boldSystemFontOfSize:20.0],
color = [CPColor redColor],
paragraphStyle = [[CPParagraphStyle defaultParagraphStyle] mutableCopy];
[paragraphStyle setAlignment:CPCenterTextAlignment];
[textView setRichText:YES];
[self assertTrue:[theWindow makeFirstResponder:textView]];
[[textView textStorage] addAttributes:@{CPFontAttributeName: font, CPForegroundColorAttributeName: color} range:CPMakeRange(0, 2)];
[[textView textStorage] addAttribute:CPParagraphStyleAttributeName value:paragraphStyle range:CPMakeRange(3, 6)];
[textView setSelectedRange:CPMakeRange(0, 2)];
[textView copyFont:nil];
[textView setSelectedRange:CPMakeRange(3, 6)];
[textView pasteFont:nil];
var storage = [textView textStorage];
[self assert:[font cssString] equals:[[storage attribute:CPFontAttributeName atIndex:5 effectiveRange:nil] cssString]];
[self assert:[color cssString] equals:[[storage attribute:CPForegroundColorAttributeName atIndex:5 effectiveRange:nil] cssString]];
[self assert:CPCenterTextAlignment equals:[[storage attribute:CPParagraphStyleAttributeName atIndex:5 effectiveRange:nil] alignment] message:@"Paste Style keeps the paragraph style"];
[self assertTrue:([storage attribute:CPForegroundColorAttributeName atIndex:9 effectiveRange:nil] == nil) message:@"Paste Style changes only the selection"];
}
- (void)testPasteStyleWithoutSelectionChangesTypingAttributes
{
var font = [CPFont boldSystemFontOfSize:20.0];
[textView setRichText:YES];
[self assertTrue:[theWindow makeFirstResponder:textView]];
[[textView textStorage] addAttribute:CPFontAttributeName value:font range:CPMakeRange(0, 2)];
[textView setSelectedRange:CPMakeRange(0, 2)];
[textView copyFont:nil];
[textView setSelectedRange:CPMakeRange(5, 0)];
[textView pasteFont:nil];
[self assert:[font cssString] equals:[[[textView typingAttributes] objectForKey:CPFontAttributeName] cssString]];
}
- (void)testStyleActionsAreEnabledOnlyInRichText
{
var copyItem = [[CPMenuItem alloc] initWithTitle:@"Copy Style" action:@selector(copyFont:) keyEquivalent:@""],
pasteItem = [[CPMenuItem alloc] initWithTitle:@"Paste Style" action:@selector(pasteFont:) keyEquivalent:@""];
[textView setRichText:NO];
[self assertFalse:[textView validateUserInterfaceItem:copyItem]];
[self assertFalse:[textView validateUserInterfaceItem:pasteItem]];
[textView setRichText:YES];
[textView copyFont:nil];
[self assertTrue:[textView validateUserInterfaceItem:copyItem]];
[self assertTrue:[textView validateUserInterfaceItem:pasteItem]];
[textView setEditable:NO];
[self assertFalse:[textView validateUserInterfaceItem:pasteItem]];
}
- (void)testBaselineActionsAreEnabledOnlyInEditableRichText
{
var item = [[CPMenuItem alloc] initWithTitle:@"Superscript" action:@selector(superscript:) keyEquivalent:@""];
+1 -1
View File
@@ -28,7 +28,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 `<span>`, 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.
- **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`.
@@ -83,4 +82,5 @@ How the text system works, for the entries below: `CPTextView` does not use CSS
- 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.
- Format > Font > Copy Style and Paste Style: the style of the first character of the selection is applied to the whole target selection, which keeps its paragraph style.
- Builds with both the legacy toolchain and capp-build.