From 861c504c4153e1def1632cc05dceff10f3233a62 Mon Sep 17 00:00:00 2001 From: daboe01 Date: Sat, 13 Jun 2026 19:47:24 +0200 Subject: [PATCH 01/13] NEW: add native table support via dynamic protocol detection --- AppKit/CPTextView/_CPRTFProducer.j | 166 +++++++++++++++++++++++++---- 1 file changed, 147 insertions(+), 19 deletions(-) diff --git a/AppKit/CPTextView/_CPRTFProducer.j b/AppKit/CPTextView/_CPRTFProducer.j index 7333b2c55..9d9d0e6c3 100644 --- a/AppKit/CPTextView/_CPRTFProducer.j +++ b/AppKit/CPTextView/_CPRTFProducer.j @@ -1,11 +1,11 @@ /* _CPRTFProducer.j - Serialize CPAttributedString to a RTF String + Serialize CPAttributedString to an RTF String Copyright (C) 2014 Daniel Boehringer This file is based on the RTFProducer from GNUStep - (which i co-authored with Fred Kiefer in 1999) + (which I co-authored with Fred Kiefer in 1999) * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public @@ -77,28 +77,30 @@ function _points2twips(a) { return (a) * 20.0; } #pragma mark - -#pragma mark init methods +#pragma mark Init methods - (id)init { if (self = [super init]) { - // maintain a dictionary for the used colours - // (for rtf-header generation) + // Maintain a dictionary for the used colors + // (for RTF-header generation) colorDict = [CPMutableDictionary new]; - //maintain a dictionary for the used fonts - //(for rtf-header generation) + // Maintain a dictionary for the used fonts + // (for RTF-header generation) fontDict = [CPMutableDictionary new]; fgColor = [CPColor blackColor]; - bgColor= [CPColor whiteColor]; + bgColor = [CPColor whiteColor]; } return self; } -// private stuff follows +#pragma mark - +#pragma mark Header & Formatting Support + - (CPString)fontTable { if (![fontDict count]) @@ -313,7 +315,7 @@ function _points2twips(a) { return (a) * 20.0; } break; } - // write first line indent and left indent + // Write first line indent and left indent var twips = _points2twips([paraStyle firstLineHeadIndent]); if (twips != 0.0) @@ -354,7 +356,7 @@ function _points2twips(a) { return (a) * 20.0; } switch ([tab tabStopType]) { case CPLeftTabStopType: - // no tabkind emission needed + // No tabkind emission needed break; /* case NSRightTabStopType: headerString += @"\\tqr"; @@ -376,6 +378,104 @@ function _points2twips(a) { return (a) * 20.0; } return headerString; } +#pragma mark - +#pragma mark Duck-Typed Table Engine + +/** + * Generates a native RTF table row structure from a conforming table data object. + * This helper isolates the native RTF grid structure to ensure decoupling. + * + * @param tableObject An object implementing the dynamic getters `headers` and `rows`. + * @return A raw RTF CPString containing cell bounds, row boundaries, and escaped strings. + */ +- (CPString)rtfStringForTable:(id)tableObject +{ + var headers = [tableObject headers], + rows = [tableObject rows]; + + var numCols = [headers count]; + if (numCols == 0 && [rows count] > 0) + { + numCols = [[rows objectAtIndex:0] count]; + } + + if (numCols == 0) + { + return @""; + } + + var rtf = @""; + + // Divide printable horizontal space evenly (approx. 5.5 inches or 7920 Twips total) + var totalWidthTwips = 7920, + colWidth = Math.floor(totalWidthTwips / numCols); + + var makeRowRTF = function(cells, isHeader) { + // \trowd clears existing cell definitions. + // \trleft360 applies an indent matching a standard page margin. + var rowStr = @"\\trowd\\trgaph108\\trleft360"; + + // 1. Declare row boundaries and configure cell border widths + var currentX = 360; + for (var c = 0; c < numCols; c++) { + currentX += colWidth; + rowStr += @"\\clbrdrt\\brdrs\\brdrw10\\clbrdrb\\brdrs\\brdrw10"; + rowStr += @"\\clbrdrl\\brdrs\\brdrw10\\clbrdrr\\brdrs\\brdrw10"; + rowStr += [CPString stringWithFormat:@"\\cellx%d", currentX]; + } + + // 2. Escape, format, and serialize cell text content + for (var c = 0; c < numCols; c++) { + var textVal = @""; + if (c < [cells count]) { + textVal = [cells objectAtIndex:c]; + } + + // Core RTF escaping (Backslashes escaped first to preserve subsequent tokens) + textVal = textVal.replace(/\\/g, '\\\\'); + textVal = textVal.replace(/{/g, '\\{'); + textVal = textVal.replace(/}/g, '\\}'); + textVal = textVal.replace(/\n/g, '\\par '); + + // Standard visual inline Markdown conversions for cellular parity + textVal = textVal.replace(/\*\*([^*]+)\*\*/g, '\\b $1\\b0 '); + textVal = textVal.replace(/\*([^*]+)\*/g, '\\i $1\\i0 '); + + rowStr += @"\\intbl "; + if (isHeader) { + rowStr += @"\\b "; + } + rowStr += textVal; + if (isHeader) { + rowStr += @"\\b0 "; + } + rowStr += @"\\cell "; + } + + rowStr += @"\\row\n"; + return rowStr; + }; + + // Construct headers + if ([headers count] > 0) { + rtf += makeRowRTF(headers, YES); + } + + // Construct content rows + for (var r = 0; r < [rows count]; r++) { + var rowData = [rows objectAtIndex:r]; + rtf += makeRowRTF(rowData, NO); + } + + // Explicitly restore standard paragraph attributes to escape table scope + rtf += @"\\pard\n"; + + return rtf; +} + +#pragma mark - +#pragma mark Attribute Processing + - (CPString)runStringForString:(CPString) substring attributes:(CPDictionary) attributes paragraphStart:(BOOL) first @@ -386,6 +486,33 @@ function _points2twips(a) { return (a) * 20.0; } attribEnum, currAttrib; + // --- DUCK-TYPING PROTOCOL DETECTION --- + // Safely scan all incoming attributes for an object supporting headers & rows. + // If found, completely divert processing to native RTF table construction. + var keys = [attributes allKeys], + count = [keys count], + tableObject = nil; + + for (var i = 0; i < count; i++) + { + var key = [keys objectAtIndex:i], + val = [attributes objectForKey:key]; + + if (val && typeof val.respondsToSelector === "function" && + [val respondsToSelector:@selector(headers)] && + [val respondsToSelector:@selector(rows)]) + { + tableObject = val; + break; + } + } + + if (tableObject) + { + return [self rtfStringForTable:tableObject]; + } + // -------------------------------------- + if (first) { var paraStyle = [attributes objectForKey:CPParagraphStyleAttributeName]; @@ -393,10 +520,10 @@ function _points2twips(a) { return (a) * 20.0; } } /* - * analyze attributes of current run + * Analyze attributes of current run * * FIXME: All the character attributes should be output relative to the font - * attributes of the paragraph. So if the paragraph has underline on it should + * attributes of the paragraph. So if the paragraph has underline on, it should * still be possible to switch it off for some characters, which currently is * not possible. */ @@ -407,7 +534,7 @@ function _points2twips(a) { return (a) * 20.0; } if ([currAttrib isEqualToString:CPFontAttributeName]) { /* - * handle fonts + * Handle fonts */ var font, fontName, @@ -418,13 +545,13 @@ function _points2twips(a) { return (a) * 20.0; } traits = [[CPFontManager sharedFontManager] traitsOfFont:font]; /* - * font name + * Font name */ if (currentFont == nil || ![fontName isEqualToString:[currentFont familyName]]) headerString += [self fontToken:fontName]; /* - * font size + * Font size */ if (currentFont == nil || [font size] != [currentFont size]) { @@ -435,7 +562,7 @@ function _points2twips(a) { return (a) * 20.0; } headerString += pString; } /* - * font attributes + * Font attributes */ if (traits & CPItalicFontMask) { @@ -565,7 +692,7 @@ function _points2twips(a) { return (a) * 20.0; } first = YES; // FIXME split along newline characters and run as outer loop - while (CPMaxRange(currRange) < CPMaxRange(completeRange)) // save all "runs" + while (CPMaxRange(currRange) < CPMaxRange(completeRange)) // Save all "runs" { var attributes, substring, @@ -599,7 +726,7 @@ function _points2twips(a) { return (a) * 20.0; } docDict = dict; /* - * do not change order! (esp. body has to be generated first; builds context) + * Do not change order! (esp. body has to be generated first; builds context) */ bodyString = [self bodyString]; trailerString = [self trailerString]; @@ -610,4 +737,5 @@ function _points2twips(a) { return (a) * 20.0; } output += trailerString; return output; } + @end From a0fc7723fcde82e6b6e72fd500eda814354d16a4 Mon Sep 17 00:00:00 2001 From: daboe01 Date: Sat, 13 Jun 2026 22:36:09 +0200 Subject: [PATCH 02/13] new: table support --- AppKit/CPTextView/_CPRTFParser.j | 134 ++++++++- AppKit/CPTextView/_CPRTFProducer.j | 257 ++++++---------- AppKit/CPTextView/_CPTableTextAttachment.j | 332 +++++++++++++++++++++ 3 files changed, 557 insertions(+), 166 deletions(-) create mode 100644 AppKit/CPTextView/_CPTableTextAttachment.j diff --git a/AppKit/CPTextView/_CPRTFParser.j b/AppKit/CPTextView/_CPRTFParser.j index df1569481..4b730d62b 100644 --- a/AppKit/CPTextView/_CPRTFParser.j +++ b/AppKit/CPTextView/_CPRTFParser.j @@ -23,6 +23,7 @@ @import @import "CPFontManager.j" @import "CPParagraphStyle.j" +@import "_CPTableTextAttachment.j" @global CPLeftTextAlignment @global CPRightTextAlignment @@ -34,6 +35,7 @@ @global CPForegroundColorAttributeName @global CPBackgroundColorAttributeName @global CPParagraphStyleAttributeName +@global CPAttachmentAttributeName @global CPLeftTabStopType @global CPRightTabStopType @@ -289,7 +291,12 @@ var kRgsymRtf = { "]" : [ "]", 0, false, kRTFParserType_char, ']'], "{" : [ "{", 0, false, kRTFParserType_char, '{'], "}" : [ "}", 0, false, kRTFParserType_char, '}'], - "\\" : [ "\\", 0, false, kRTFParserType_char, '\\'] + "\\" : [ "\\", 0, false, kRTFParserType_char, '\\'], + "trowd" : [ "trowd", 0, false, kRTFParserType_spec, "ipfnTrowd"], + "cell" : [ "cell", 0, false, kRTFParserType_spec, "ipfnCell"], + "row" : [ "row", 0, false, kRTFParserType_spec, "ipfnRow"], + "cellx" : [ "cellx", 0, false, kRTFParserType_spec, "ipfnCellx"], + "intbl" : [ "intbl", 0, false, kRTFParserType_spec, "ipfnIntbl"] }; @implementation _CPRTFParser : CPObject @@ -307,6 +314,13 @@ var kRgsymRtf = { CPArray _fontArray; CPString _freename; BOOL _parsingFontTable; + + // Table parsing state + BOOL _inTableActive; + BOOL _waitingForNextRow; + CPMutableArray _tableRows; + CPMutableArray _currentRow; + CPString _currentCellText; } - (id)init @@ -324,6 +338,12 @@ var kRgsymRtf = { _fontArray = ['Arial']; // FIXME: should be name of system font _freename = ""; _parsingFontTable = NO; + + _inTableActive = NO; + _waitingForNextRow = NO; + _tableRows = nil; + _currentRow = nil; + _currentCellText = ""; } return self; @@ -420,11 +440,91 @@ var kRgsymRtf = { _codePage = code; _currentParseIndex--; break; + + case "ipfnTrowd": + if (_waitingForNextRow) + { + _waitingForNextRow = NO; + } + if (!_inTableActive) + { + _inTableActive = YES; + _tableRows = [CPMutableArray array]; + _currentRow = [CPMutableArray array]; + _currentCellText = ""; + } + else + { + _currentRow = [CPMutableArray array]; + } + return ''; + + case "ipfnIntbl": + return ''; + + case "ipfnCell": + if (_inTableActive) + { + if (!_currentRow) + _currentRow = [CPMutableArray array]; + [_currentRow addObject:_currentCellText]; + _currentCellText = ""; + } + return ''; + + case "ipfnRow": + if (_inTableActive) + { + if (!_currentRow) + _currentRow = [CPMutableArray array]; + [_tableRows addObject:_currentRow]; + _waitingForNextRow = YES; + } + return ''; + + case "ipfnCellx": + return ''; } return ''; } +- (void)_flushTableIfAny +{ + if (_tableRows && [_tableRows count] > 0) + { + var headers = [_tableRows objectAtIndex:0]; + var rows = [CPMutableArray array]; + for (var idx = 1; idx < [_tableRows count]; idx++) + { + [rows addObject:[_tableRows objectAtIndex:idx]]; + } + + var attachment = [[_CPTableTextAttachment alloc] initWithHeaders:headers rows:rows width:500.0]; + + // Linear height allocation estimation + var numCols = [headers count]; + var estimatedHeight = 36.0 + ([rows count] * 28.0); + var lineCount = Math.ceil(estimatedHeight / 16.0) + 1; + var newlineStr = ""; + for (var nl = 0; nl < lineCount; nl++) { + newlineStr += "\n"; + } + + var tableAttrStr = [[CPMutableAttributedString alloc] initWithString:newlineStr]; + [tableAttrStr addAttribute:@"TableAttachmentAttribute" value:attachment range:CPMakeRange(0, [tableAttrStr length])]; + [tableAttrStr addAttribute:CPAttachmentAttributeName value:attachment range:CPMakeRange(0, [tableAttrStr length])]; + + [_result appendAttributedString:tableAttrStr]; + + _tableRows = nil; + _currentRow = nil; + _currentCellText = ""; + _inTableActive = NO; + _waitingForNextRow = NO; + } +} + - (void)_flushCurrentRun { var newOffset = 0; @@ -552,6 +652,14 @@ var kRgsymRtf = { - (CPString)_translateKeyword:(CPString)keyword parameter:(CPString)param fParameter:(BOOL)fParam { + if (_waitingForNextRow) + { + if (keyword !== "trowd" && keyword !== "cell" && keyword !== "row" && keyword !== "intbl" && keyword !== "cellx") + { + [self _flushTableIfAny]; + } + } + if (kRgsymRtf[keyword] !== undefined) { var sym = kRgsymRtf[keyword]; @@ -744,9 +852,16 @@ var kRgsymRtf = { - (void)_appendPlainString:(CPString) aString { - [_result replaceCharactersInRange:CPMakeRange([_result length], 0) withString:aString]; - + if (_inTableActive) + { + _currentCellText += aString; + } + else + { + [_result replaceCharactersInRange:CPMakeRange([_result length], 0) withString:aString]; + } } + - (CPAttributedString)parseRTF:(CPString)rtf { rtf = rtf.replace(/\\\n/g, "\\par\n"); @@ -766,6 +881,11 @@ var kRgsymRtf = { { tmp = rtf.charAt(++_currentParseIndex); + if (_waitingForNextRow && tmp !== "\\" && tmp !== " " && tmp !== "\n" && tmp !== "\r" && tmp !== "\t") + { + [self _flushTableIfAny]; + } + if (tmp !== "\\" && hex.length > 0) { [self _appendPlainString: String.fromCharCode(parseInt((hex), 16))]; @@ -788,12 +908,18 @@ var kRgsymRtf = { break; case "{": + if (_waitingForNextRow) + [self _flushTableIfAny]; + if ([self pushState]) CPLogConsole("push"); break; case "}": + if (_waitingForNextRow) + [self _flushTableIfAny]; + if ([self popState]) CPLogConsole("pop"); @@ -863,6 +989,8 @@ var kRgsymRtf = { } } + [self _flushTableIfAny]; + return _result; } diff --git a/AppKit/CPTextView/_CPRTFProducer.j b/AppKit/CPTextView/_CPRTFProducer.j index 9d9d0e6c3..05e082636 100644 --- a/AppKit/CPTextView/_CPRTFProducer.j +++ b/AppKit/CPTextView/_CPRTFProducer.j @@ -1,7 +1,7 @@ /* _CPRTFProducer.j - Serialize CPAttributedString to an RTF String + Serialize CPAttributedString to a RTF String Copyright (C) 2014 Daniel Boehringer This file is based on the RTFProducer from GNUStep @@ -27,6 +27,7 @@ @import "CPColor.j" @import "CPGraphics.j" @import "CPFontManager.j" +@import "_CPTableTextAttachment.j" @global CPForegroundColorAttributeName @global CPBackgroundColorAttributeName @@ -43,6 +44,11 @@ @global CPJustifiedTextAlignment @global CPNaturalTextAlignment +@global CPLeftTabStopType +@global CPRightTabStopType +@global CPCenterTabStopType +@global CPDecimalTabStopType + var PAPERSIZE = @"PaperSize", LEFTMARGIN = @"LeftMargin", RIGHTMARGIN = @"RightMargin", @@ -77,30 +83,28 @@ function _points2twips(a) { return (a) * 20.0; } #pragma mark - -#pragma mark Init methods +#pragma mark init methods - (id)init { if (self = [super init]) { - // Maintain a dictionary for the used colors - // (for RTF-header generation) + // maintain a dictionary for the used colours + // (for rtf-header generation) colorDict = [CPMutableDictionary new]; - // Maintain a dictionary for the used fonts - // (for RTF-header generation) + //maintain a dictionary for the used fonts + //(for rtf-header generation) fontDict = [CPMutableDictionary new]; fgColor = [CPColor blackColor]; - bgColor = [CPColor whiteColor]; + bgColor= [CPColor whiteColor]; } return self; } -#pragma mark - -#pragma mark Header & Formatting Support - +// private stuff follows - (CPString)fontTable { if (![fontDict count]) @@ -315,7 +319,7 @@ function _points2twips(a) { return (a) * 20.0; } break; } - // Write first line indent and left indent + // write first line indent and left indent var twips = _points2twips([paraStyle firstLineHeadIndent]); if (twips != 0.0) @@ -356,163 +360,97 @@ function _points2twips(a) { return (a) * 20.0; } switch ([tab tabStopType]) { case CPLeftTabStopType: - // No tabkind emission needed break; -/* case NSRightTabStopType: + case CPRightTabStopType: headerString += @"\\tqr"; - break; - case NSCenterTabStopType: - headerString += @"\\tqc"; - break; - case NSDecimalTabStopType: + break; + case CPCenterTabStopType: + headerString += @"\\tqc"; + break; + case CPDecimalTabStopType: headerString += @"\\tqdec"; - break; - default: - NSLog(@"Unknown tab stop type."); -*/ - } + break; + } - headerString += [CPString stringWithFormat:@"\\tx%d",_points2twips([tab location])]; + headerString += [CPString stringWithFormat:@"\\tx%d",_points2twips([tab location])]; } return headerString; } -#pragma mark - -#pragma mark Duck-Typed Table Engine - -/** - * Generates a native RTF table row structure from a conforming table data object. - * This helper isolates the native RTF grid structure to ensure decoupling. - * - * @param tableObject An object implementing the dynamic getters `headers` and `rows`. - * @return A raw RTF CPString containing cell bounds, row boundaries, and escaped strings. - */ -- (CPString)rtfStringForTable:(id)tableObject -{ - var headers = [tableObject headers], - rows = [tableObject rows]; - - var numCols = [headers count]; - if (numCols == 0 && [rows count] > 0) - { - numCols = [[rows objectAtIndex:0] count]; - } - - if (numCols == 0) - { - return @""; - } - - var rtf = @""; - - // Divide printable horizontal space evenly (approx. 5.5 inches or 7920 Twips total) - var totalWidthTwips = 7920, - colWidth = Math.floor(totalWidthTwips / numCols); - - var makeRowRTF = function(cells, isHeader) { - // \trowd clears existing cell definitions. - // \trleft360 applies an indent matching a standard page margin. - var rowStr = @"\\trowd\\trgaph108\\trleft360"; - - // 1. Declare row boundaries and configure cell border widths - var currentX = 360; - for (var c = 0; c < numCols; c++) { - currentX += colWidth; - rowStr += @"\\clbrdrt\\brdrs\\brdrw10\\clbrdrb\\brdrs\\brdrw10"; - rowStr += @"\\clbrdrl\\brdrs\\brdrw10\\clbrdrr\\brdrs\\brdrw10"; - rowStr += [CPString stringWithFormat:@"\\cellx%d", currentX]; - } - - // 2. Escape, format, and serialize cell text content - for (var c = 0; c < numCols; c++) { - var textVal = @""; - if (c < [cells count]) { - textVal = [cells objectAtIndex:c]; - } - - // Core RTF escaping (Backslashes escaped first to preserve subsequent tokens) - textVal = textVal.replace(/\\/g, '\\\\'); - textVal = textVal.replace(/{/g, '\\{'); - textVal = textVal.replace(/}/g, '\\}'); - textVal = textVal.replace(/\n/g, '\\par '); - - // Standard visual inline Markdown conversions for cellular parity - textVal = textVal.replace(/\*\*([^*]+)\*\*/g, '\\b $1\\b0 '); - textVal = textVal.replace(/\*([^*]+)\*/g, '\\i $1\\i0 '); - - rowStr += @"\\intbl "; - if (isHeader) { - rowStr += @"\\b "; - } - rowStr += textVal; - if (isHeader) { - rowStr += @"\\b0 "; - } - rowStr += @"\\cell "; - } - - rowStr += @"\\row\n"; - return rowStr; - }; - - // Construct headers - if ([headers count] > 0) { - rtf += makeRowRTF(headers, YES); - } - - // Construct content rows - for (var r = 0; r < [rows count]; r++) { - var rowData = [rows objectAtIndex:r]; - rtf += makeRowRTF(rowData, NO); - } - - // Explicitly restore standard paragraph attributes to escape table scope - rtf += @"\\pard\n"; - - return rtf; -} - -#pragma mark - -#pragma mark Attribute Processing - - (CPString)runStringForString:(CPString) substring attributes:(CPDictionary) attributes paragraphStart:(BOOL) first { + var tableAttachment = [attributes objectForKey:CPAttachmentAttributeName]; + if (!tableAttachment) + tableAttachment = [attributes objectForKey:@"TableAttachmentAttribute"]; + + if (tableAttachment && [tableAttachment isKindOfClass:[_CPTableTextAttachment class]]) + { + var headers = [tableAttachment headers], + rows = [tableAttachment rows]; + + var numCols = [headers count]; + if (numCols == 0 && [rows count] > 0) + numCols = [[rows objectAtIndex:0] count]; + + if (numCols > 0) + { + var totalWidthTwips = 10000; + var colWidthTwips = Math.floor(totalWidthTwips / numCols); + var cellBoundaries = []; + var currentBoundary = 0; + for (var i = 0; i < numCols; i++) + { + currentBoundary += colWidthTwips; + cellBoundaries.push(currentBoundary); + } + + var tableRTF = ""; + var writeRow = function(rowData, isHeaderRow) { + var rowRTF = "\\trowd\\trgaph115\\trleft0"; + for (var c = 0; c < numCols; c++) { + rowRTF += "\\clbrdrt\\brdrs\\brdrw10\\clbrdrb\\brdrs\\brdrw10\\clbrdrl\\brdrs\\brdrw10\\clbrdrr\\brdrs\\brdrw10"; + rowRTF += "\\cellx" + cellBoundaries[c]; + } + for (var c = 0; c < numCols; c++) { + var cellText = ""; + if (c < [rowData count]) { + cellText = [rowData objectAtIndex:c]; + } + cellText = cellText.replace(/\\/g, '\\\\'); + cellText = cellText.replace(/{/g, '\\{'); + cellText = cellText.replace(/}/g, '\\}'); + cellText = cellText.replace(/\n/g, '\\line '); + + if (isHeaderRow) { + rowRTF += "{\\intbl\\b " + cellText + "\\b0\\cell}"; + } else { + rowRTF += "{\\intbl " + cellText + "\\cell}"; + } + } + rowRTF += "\\row\n"; + return rowRTF; + }; + + if ([headers count] > 0) { + tableRTF += writeRow(headers, YES); + } + for (var r = 0; r < [rows count]; r++) { + tableRTF += writeRow([rows objectAtIndex:r], NO); + } + + return tableRTF; + } + } + var result = "", headerString = "", trailerString = "", attribEnum, currAttrib; - // --- DUCK-TYPING PROTOCOL DETECTION --- - // Safely scan all incoming attributes for an object supporting headers & rows. - // If found, completely divert processing to native RTF table construction. - var keys = [attributes allKeys], - count = [keys count], - tableObject = nil; - - for (var i = 0; i < count; i++) - { - var key = [keys objectAtIndex:i], - val = [attributes objectForKey:key]; - - if (val && typeof val.respondsToSelector === "function" && - [val respondsToSelector:@selector(headers)] && - [val respondsToSelector:@selector(rows)]) - { - tableObject = val; - break; - } - } - - if (tableObject) - { - return [self rtfStringForTable:tableObject]; - } - // -------------------------------------- - if (first) { var paraStyle = [attributes objectForKey:CPParagraphStyleAttributeName]; @@ -520,10 +458,10 @@ function _points2twips(a) { return (a) * 20.0; } } /* - * Analyze attributes of current run + * analyze attributes of current run * * FIXME: All the character attributes should be output relative to the font - * attributes of the paragraph. So if the paragraph has underline on, it should + * attributes of the paragraph. So if the paragraph has underline on it should * still be possible to switch it off for some characters, which currently is * not possible. */ @@ -534,7 +472,7 @@ function _points2twips(a) { return (a) * 20.0; } if ([currAttrib isEqualToString:CPFontAttributeName]) { /* - * Handle fonts + * handle fonts */ var font, fontName, @@ -545,13 +483,13 @@ function _points2twips(a) { return (a) * 20.0; } traits = [[CPFontManager sharedFontManager] traitsOfFont:font]; /* - * Font name + * font name */ if (currentFont == nil || ![fontName isEqualToString:[currentFont familyName]]) headerString += [self fontToken:fontName]; /* - * Font size + * font size */ if (currentFont == nil || [font size] != [currentFont size]) { @@ -562,7 +500,7 @@ function _points2twips(a) { return (a) * 20.0; } headerString += pString; } /* - * Font attributes + * font attributes */ if (traits & CPItalicFontMask) { @@ -652,8 +590,6 @@ function _points2twips(a) { return (a) * 20.0; } substring = substring.replace(/\t/g, '\\tab'); substring = substring.replace(/{/g, '\\{'); substring = substring.replace(/}/g, '\\}'); - // FIXME: All characters not in the standard encoding must be - // replaced by \'xx if (!first) { @@ -691,8 +627,7 @@ function _points2twips(a) { return (a) * 20.0; } completeRange = CPMakeRange(0, length), first = YES; - // FIXME split along newline characters and run as outer loop - while (CPMaxRange(currRange) < CPMaxRange(completeRange)) // Save all "runs" + while (CPMaxRange(currRange) < CPMaxRange(completeRange)) // save all "runs" { var attributes, substring, @@ -725,9 +660,6 @@ function _points2twips(a) { return (a) * 20.0; } text = aText; docDict = dict; - /* - * Do not change order! (esp. body has to be generated first; builds context) - */ bodyString = [self bodyString]; trailerString = [self trailerString]; headerString = [self headerString]; @@ -737,5 +669,4 @@ function _points2twips(a) { return (a) * 20.0; } output += trailerString; return output; } - @end diff --git a/AppKit/CPTextView/_CPTableTextAttachment.j b/AppKit/CPTextView/_CPTableTextAttachment.j new file mode 100644 index 000000000..00b671aac --- /dev/null +++ b/AppKit/CPTextView/_CPTableTextAttachment.j @@ -0,0 +1,332 @@ +/* _CPTableTextAttachment.j + * A self-contained, renderable text attachment representation of a table. + * + * Copyright (C) 2026 Daniel Boehringer + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +*/ + +@import +@import +@import +@import + +@implementation _CPTableTextAttachment : CPView +{ + CPArray _headers; + CPArray _rows; +} + +- (id)initWithHeaders:(CPArray)headers rows:(CPArray)rows +{ + return [self initWithHeaders:headers rows:rows width:500.0]; +} + +- (id)initWithHeaders:(CPArray)headers rows:(CPArray)rows width:(float)totalWidth +{ + self = [super initWithFrame:CGRectMake(0, 0, totalWidth, 20)]; + if (self) + { + _headers = headers; + _rows = rows; + + var numCols = [headers count]; + + if (numCols == 0 && [rows count] > 0) + numCols = [[rows objectAtIndex:0] count]; + + // Apply borders on the outer left and top container edges + if (self._DOMElement) { + self._DOMElement.style.borderTop = "1px solid #e0e0e0"; + self._DOMElement.style.borderLeft = "1px solid #e0e0e0"; + } + + // Initialize header cells + if ([headers count] > 0) { + for (var c = 0; c < numCols; c++) { + var headerText = [headers objectAtIndex:c]; + var cellView = [self createCellWithText:headerText frame:CGRectMakeZero() isHeader:YES]; + [self addSubview:cellView]; + } + } + + // Initialize body rows + for (var r = 0; r < [rows count]; r++) { + var rowData = [rows objectAtIndex:r]; + for (var c = 0; c < numCols; c++) { + var cellText = @""; + + if (c < [rowData count]) + cellText = [rowData objectAtIndex:c]; + + var cellView = [self createCellWithText:cellText frame:CGRectMakeZero() isHeader:NO]; + [self addSubview:cellView]; + } + } + + [self resizeToWidth:totalWidth]; + } + return self; +} + +- (CPArray)headers +{ + return _headers; +} + +- (CPArray)rows +{ + return _rows; +} + +- (CPView)viewForWidth:(float)width +{ + [self resizeToWidth:width]; + return self; +} + +- (CPView)createCellWithText:(CPString)text frame:(CGRect)frame isHeader:(BOOL)isHeader +{ + var initialWidth = (frame.size.width > 0) ? frame.size.width : 120.0; + var initialHeight = (frame.size.height > 0) ? frame.size.height : 28.0; + + var cellContainer = [[CPView alloc] initWithFrame:CGRectMake(frame.origin.x, frame.origin.y, initialWidth, initialHeight)]; + [cellContainer setBackgroundColor:isHeader ? [CPColor colorWithWhite:0.92 alpha:1.0] : [CPColor whiteColor]]; + + // Bottom and right edge borders to construct the grid + var borderView = [[CPView alloc] initWithFrame:CGRectMake(0, 0, initialWidth, initialHeight)]; + [borderView setBackgroundColor:[CPColor clearColor]]; + [borderView setAutoresizingMask:CPViewWidthSizable | CPViewHeightSizable]; + if (borderView._DOMElement) { + borderView._DOMElement.style.borderBottom = "1px solid #e0e0e0"; + borderView._DOMElement.style.borderRight = "1px solid #e0e0e0"; + borderView._DOMElement.style.boxSizing = "border-box"; + } + [cellContainer addSubview:borderView]; + + var textContainer = [[CPTextContainer alloc] initWithContainerSize:CGSizeMake(initialWidth - 8, 1e7)]; + var textView = [[CPTextView alloc] initWithFrame:CGRectMake(4, 2, initialWidth - 8, initialHeight - 4) textContainer:textContainer]; + [textView setEditable:NO]; + [textView setSelectable:YES]; + [textView setBackgroundColor:[CPColor clearColor]]; + [textView setVerticallyResizable:YES]; + [textView setHorizontallyResizable:NO]; + [[textView textContainer] setWidthTracksTextView:YES]; + + // Construct cell text using standard fonts + var cellFont = isHeader ? [CPFont boldSystemFontOfSize:11.0] : [CPFont systemFontOfSize:11.0]; + var parsedText = [[CPAttributedString alloc] initWithString:text attributes:@{ + CPFontAttributeName: cellFont, + CPForegroundColorAttributeName: [CPColor blackColor] + }]; + + var storage = [textView textStorage]; + if (storage && [storage respondsToSelector:@selector(setAttributedString:)]) { + [storage setAttributedString:parsedText]; + } else { + [textView setEditable:YES]; + [textView setString:@""]; + [textView insertText:parsedText]; + [textView setEditable:NO]; + } + + [cellContainer addSubview:textView]; + return cellContainer; +} + +- (CPTextView)getTextViewFromCell:(CPView)cellView +{ + var subviews = [cellView subviews]; + for (var i = 0; i < [subviews count]; i++) { + var sub = [subviews objectAtIndex:i]; + if ([sub isKindOfClass:[CPTextView class]]) { + return sub; + } + } + return nil; +} + +- (void)resizeToWidth:(float)newWidth +{ + var numCols = [_headers count]; + if (numCols == 0 && [_rows count] > 0) { + numCols = [[_rows objectAtIndex:0] count]; + } + if (numCols == 0) return; + + var subviews = [self subviews]; + var colNaturalWidths = []; + var colMinWidths = []; + for (var c = 0; c < numCols; c++) { + colNaturalWidths[c] = 80.0; + colMinWidths[c] = 60.0; + } + + var measureTextField = [[CPTextField alloc] initWithFrame:CGRectMake(0, 0, 10000.0, 24.0)]; + [measureTextField setFont:[CPFont systemFontOfSize:13.0]]; + + var measureCell = function(cellText, isHeader, colIndex) { + var cellFont = isHeader ? [CPFont boldSystemFontOfSize:11.0] : [CPFont systemFontOfSize:11.0]; + [measureTextField setFont:cellFont]; + + [measureTextField setStringValue:cellText]; + [measureTextField sizeToFit]; + var naturalW = CGRectGetWidth([measureTextField frame]) + 24.0; + if (naturalW > colNaturalWidths[colIndex]) { + colNaturalWidths[colIndex] = naturalW; + } + + var words = cellText.split(/[\s\-]/); + var maxWordW = 50.0; + for (var w = 0; w < words.length; w++) { + var word = words[w].trim(); + if (word.length === 0) continue; + [measureTextField setStringValue:word]; + [measureTextField sizeToFit]; + var wordW = CGRectGetWidth([measureTextField frame]) + 30.0; + if (wordW > maxWordW) { + maxWordW = wordW; + } + } + if (maxWordW > colMinWidths[colIndex]) { + colMinWidths[colIndex] = maxWordW; + } + }; + + for (var c = 0; c < [_headers count]; c++) { + measureCell([_headers objectAtIndex:c], YES, c); + } + + for (var r = 0; r < [_rows count]; r++) { + var rowData = [_rows objectAtIndex:r]; + for (var c = 0; c < numCols; c++) { + var cellText = @""; + if (c < [rowData count]) { + cellText = [rowData objectAtIndex:c]; + } + measureCell(cellText, NO, c); + } + } + + var totalMinWidth = 0.0; + for (var c = 0; c < numCols; c++) { + totalMinWidth += colMinWidths[c]; + } + + var colWidths = []; + + if (newWidth <= totalMinWidth) { + var remainingWidth = newWidth; + for (var c = 0; c < numCols; c++) { + var w = Math.floor((colMinWidths[c] / totalMinWidth) * newWidth); + colWidths[c] = w; + remainingWidth -= w; + } + if (numCols > 0) colWidths[numCols - 1] += remainingWidth; + } else { + for (var c = 0; c < numCols; c++) { + colWidths[c] = colMinWidths[c]; + } + + var totalGrowthCapacity = 0.0; + var growthCapacities = []; + for (var c = 0; c < numCols; c++) { + var capacity = Math.max(0.0, colNaturalWidths[c] - colMinWidths[c]); + growthCapacities[c] = capacity; + totalGrowthCapacity += capacity; + } + + var extraWidth = newWidth - totalMinWidth; + var remainingExtra = extraWidth; + + for (var c = 0; c < numCols; c++) { + if (totalGrowthCapacity > 0) { + var w = Math.floor((growthCapacities[c] / totalGrowthCapacity) * extraWidth); + colWidths[c] += w; + remainingExtra -= w; + } + } + if (numCols > 0) { + colWidths[numCols - 1] += remainingExtra; + } + } + + var cellIndex = 0; + var currentY = 0; + + var layoutRow = function(startIndex) { + var maxCellHeight = 28.0; + + for (var c = 0; c < numCols; c++) { + var idx = startIndex + c; + if (idx < [subviews count]) { + var cellView = [subviews objectAtIndex:idx]; + var textView = [self getTextViewFromCell:cellView]; + if (textView) { + var targetWidth = Math.max(10.0, colWidths[c] - 8); + [[textView textContainer] setContainerSize:CGSizeMake(targetWidth, 1e7)]; + + var usedRect = [[textView layoutManager] usedRectForTextContainer:[textView textContainer]]; + var wrappedHeight = CGRectGetHeight(usedRect) + 12.0; + if (wrappedHeight > maxCellHeight) { + maxCellHeight = wrappedHeight; + } + } + } + } + + var currentX = 0; + for (var c = 0; c < numCols; c++) { + var idx = startIndex + c; + if (idx < [subviews count]) { + var cellView = [subviews objectAtIndex:idx]; + [cellView setFrame:CGRectMake(currentX, currentY, colWidths[c], maxCellHeight)]; + + var textView = [self getTextViewFromCell:cellView]; + if (textView) { + var targetWidth = Math.max(10.0, colWidths[c] - 8); + var textY = 4.0; + var finalTextViewHeight = maxCellHeight - 8.0; + [textView setFrame:CGRectMake(4, textY, targetWidth, finalTextViewHeight)]; + } + + var cellSubviews = [cellView subviews]; + if ([cellSubviews count] > 0) { + [[cellSubviews objectAtIndex:0] setFrame:CGRectMake(0, 0, colWidths[c], maxCellHeight)]; + } + } + currentX += colWidths[c]; + } + + return maxCellHeight; + }; + + if ([_headers count] > 0) { + var headerHeight = layoutRow(cellIndex); + cellIndex += numCols; + currentY += headerHeight; + } + + for (var r = 0; r < [_rows count]; r++) { + var rowHeight = layoutRow(cellIndex); + cellIndex += numCols; + currentY += rowHeight; + } + + [self setFrameSize:CGSizeMake(newWidth, currentY)]; +} + +@end From 5897d76c0449efc2970641bcad3bc8608d52deaf Mon Sep 17 00:00:00 2001 From: daboe01 Date: Sat, 13 Jun 2026 22:38:36 +0200 Subject: [PATCH 03/13] fixed: imports --- AppKit/CPTextView/_CPTableTextAttachment.j | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/AppKit/CPTextView/_CPTableTextAttachment.j b/AppKit/CPTextView/_CPTableTextAttachment.j index 00b671aac..8f56f684e 100644 --- a/AppKit/CPTextView/_CPTableTextAttachment.j +++ b/AppKit/CPTextView/_CPTableTextAttachment.j @@ -19,9 +19,9 @@ */ -@import -@import -@import +@import "CPView.j" +@import "CPTextView.j" +@import "CPTextField.j" @import @implementation _CPTableTextAttachment : CPView From 1cad83aba8b7812064a06ea1f55478c84313b115 Mon Sep 17 00:00:00 2001 From: daboe01 Date: Sun, 14 Jun 2026 21:06:51 +0200 Subject: [PATCH 04/13] initial display of the table --- AppKit/CPTextView/CPLayoutManager.j | 179 ++++++++++++------- AppKit/CPTextView/CPTextView.j | 24 ++- AppKit/CPTextView/_CPTableTextAttachment.j | 193 ++++++++++++++------- Tests/Manual/CPTextView/AppController.j | 34 +++- 4 files changed, 297 insertions(+), 133 deletions(-) diff --git a/AppKit/CPTextView/CPLayoutManager.j b/AppKit/CPTextView/CPLayoutManager.j index fd0accf43..22fb6ba19 100644 --- a/AppKit/CPTextView/CPLayoutManager.j +++ b/AppKit/CPTextView/CPLayoutManager.j @@ -292,7 +292,6 @@ _oncontextmenuhandler = function () { return false; }; // We erased all lines if (!startIndex) [self setExtraLineFragmentRect:CGRectMake(0, 0) usedRect:CGRectMake(0, 0) textContainer:nil]; - // document.title=startIndex; [_typesetter layoutGlyphsInLayoutManager:self startingAtGlyphIndex:startIndex maxNumberOfLineFragments:-1 nextGlyphIndex:nil]; @@ -532,20 +531,26 @@ _oncontextmenuhandler = function () { return false; }; var frames = [fragment glyphFrames], len = fragment._range.length; - for (var j = 0; j < len; j++) + if (frames) { - if (CGRectContainsPoint(frames[j], point)) - { - if (partialFraction) - partialFraction[0] = (point.x - frames[j].origin.x) / frames[j].size.width; + var maxLen = MIN(len, frames.length); - return fragment._range.location + j; + for (var j = 0; j < maxLen; j++) + { + var frame = frames[j]; + + if (frame && CGRectContainsPoint(frame, point)) + { + if (partialFraction) + partialFraction[0] = (point.x - frame.origin.x) / frame.size.width; + + return fragment._range.location + j; + } } } } } - // Not found, maybe a point left to the last character was clicked -> search again with broader constraints if ([[_textStorage string] length]) { for (var i = 0; i < c; i++) @@ -554,30 +559,33 @@ _oncontextmenuhandler = function () { return false; }; if (fragment._textContainer === container) { - // Within the horizontal territory of the current (not-empty) line? - if (fragment._range.length > 0 && point.y > fragment._fragmentRect.origin.y && - point.y <= fragment._fragmentRect.origin.y + fragment._fragmentRect.size.height) + if (fragment._range.length > 0 && point.y > fragment._fragmentRect.origin.y && + point.y <= fragment._fragmentRect.origin.y + fragment._fragmentRect.size.height) + { + if (i < c - 1 && _lineFragments[i + 1]._fragmentRect.origin.y === fragment._fragmentRect.origin.y) + continue; + + var nlLoc = CPMaxRange(fragment._range), + frames = [fragment glyphFrames]; + + if (frames && frames.length > 0) { - // Skip tabs and move on the last fragment in this line - if (i < c - 1 && _lineFragments[i + 1]._fragmentRect.origin.y === fragment._fragmentRect.origin.y) - continue; + var lastFrame = frames[frames.length - 1], + firstFrame = frames[0]; - var nlLoc = CPMaxRange(fragment._range), - lastFrame = [fragment glyphFrames][fragment._range.length - 1], - firstFrame = [fragment glyphFrames][0]; + if (lastFrame && firstFrame) + { + if (_isNewlineCharacter([[_textStorage string] characterAtIndex:nlLoc > 0 ? nlLoc - 1 : 0])) + nlLoc--; - // stay on the line the newline character belongs to - if (_isNewlineCharacter([[_textStorage string] characterAtIndex:nlLoc > 0 ? nlLoc - 1 : 0])) - nlLoc--; - - // Clicked right to the last character - if (point.x > CGRectGetMaxX(lastFrame)) - return nlLoc; - // Clicked left to the last character - else if (point.x <= CGRectGetMinX(firstFrame)) - return fragment._range.location; - else - return nlLoc; + if (point.x > CGRectGetMaxX(lastFrame)) + return nlLoc; + else if (point.x <= CGRectGetMinX(firstFrame)) + return fragment._range.location; + else + return nlLoc; + } + } } } } @@ -707,7 +715,11 @@ _oncontextmenuhandler = function () { return false; }; var index = location - lineFragment._range.location; - return lineFragment._glyphsOffsets[index]; + if (index < 0 || !lineFragment._glyphsOffsets || index >= lineFragment._glyphsOffsets.length) + return 0.0; + + var offset = lineFragment._glyphsOffsets[index]; + return (offset === undefined) ? 0.0 : offset; } - (double)_descentAtLocation:(unsigned)location @@ -719,7 +731,11 @@ _oncontextmenuhandler = function () { return false; }; var index = location - lineFragment._range.location; - return lineFragment._glyphsFrames[index]._descent; + if (index < 0 || !lineFragment._glyphsFrames || index >= lineFragment._glyphsFrames.length) + return 0.0; + + var frame = lineFragment._glyphsFrames[index]; + return (frame && frame._descent !== undefined) ? frame._descent : 0.0; } - (void)setLineFragmentRect:(CGRect)fragmentRect forGlyphRange:(CPRange)glyphRange usedRect:(CGRect)usedRect @@ -843,11 +859,16 @@ _oncontextmenuhandler = function () { return false; }; { if (_lineFragments.length > 0 && index >= [self numberOfGlyphs] - 1) { - var lineFragment= _lineFragments[_lineFragments.length - 1], + var lineFragment = _lineFragments[_lineFragments.length - 1], glyphFrames = [lineFragment glyphFrames]; - if (glyphFrames.length > 0) - return CGPointCreateCopy(glyphFrames[glyphFrames.length - 1].origin); + if (glyphFrames && glyphFrames.length > 0) + { + var frame = glyphFrames[glyphFrames.length - 1]; + + if (frame) + return CGPointCreateCopy(frame.origin); + } } var lineFragment = _objectWithLocationInRange(_lineFragments, index); @@ -858,8 +879,17 @@ _oncontextmenuhandler = function () { return false; }; return CGPointCreateCopy(lineFragment._location); var glyphFrames = [lineFragment glyphFrames]; + var relativeIndex = index - lineFragment._range.location; - return CGPointCreateCopy(glyphFrames[index - lineFragment._range.location].origin); + if (glyphFrames && relativeIndex >= 0 && relativeIndex < glyphFrames.length) + { + var frame = glyphFrames[relativeIndex]; + + if (frame) + return CGPointCreateCopy(frame.origin); + } + + return CGPointCreateCopy(lineFragment._location); } return CGPointMakeZero(); @@ -905,7 +935,6 @@ _oncontextmenuhandler = function () { return false; }; inTextContainer:(CPTextContainer)container rectCount:(CGRectPointer)rectCount { - var rectArray = [], lineFragments = _objectsInRange(_lineFragments, selectedCharRange); @@ -924,21 +953,29 @@ _oncontextmenuhandler = function () { return false; }; rect = nil, len = fragment._range.length; - for (var j = 0; j < len; j++) + if (frames) { - if (CPLocationInRange(fragment._range.location + j, selectedCharRange)) + for (var j = 0; j < len; j++) { - var correctedRect = CGRectCreateCopy(frames[j]); - correctedRect.size.height -= frames[j]._descent; - correctedRect.origin.y -= frames[j]._descent; + if (j < frames.length && CPLocationInRange(fragment._range.location + j, selectedCharRange)) + { + var frame = frames[j]; - if (!rect) - rect = CGRectCreateCopy(correctedRect); - else - rect = CGRectUnion(rect, correctedRect); + if (frame) + { + var correctedRect = CGRectCreateCopy(frame); + correctedRect.size.height -= frame._descent; + correctedRect.origin.y -= frame._descent; - if (_isNewlineCharacter([[_textStorage string] characterAtIndex:MAX(0, CPMaxRange(selectedCharRange) - 1)])) - rect.size.width = containerSize.width - rect.origin.x; + if (!rect) + rect = CGRectCreateCopy(correctedRect); + else + rect = CGRectUnion(rect, correctedRect); + + if (_isNewlineCharacter([[_textStorage string] characterAtIndex:MAX(0, CPMaxRange(selectedCharRange) - 1)])) + rect.size.width = containerSize.width - rect.origin.x; + } + } } } @@ -949,7 +986,7 @@ _oncontextmenuhandler = function () { return false; }; var len = rectArray.length; - for (var i = 0; i < len - 1; i++) // extend the width of all but the last one + for (var i = 0; i < len - 1; i++) { if (FLOOR(CGRectGetMaxY(rectArray[i])) == FLOOR(CGRectGetMaxY(rectArray[i + 1]))) continue; @@ -1182,13 +1219,9 @@ var _objectsInRange = function(aList, aRange) { if (![attributes objectForKey:_CPAttachmentInvisible]) { - var view = [attributes objectForKey:_CPAttachmentView], - viewCopy = [CPKeyedUnarchiver unarchiveObjectWithData:[CPKeyedArchiver archivedDataWithRootObject:view]], - elem = viewCopy._DOMElement, - run = {_range:CPMakeRangeCopy(effectiveRange), color:nil, font:nil, elem:elem, string:nil, view:viewCopy}; - + var view = [attributes objectForKey:_CPAttachmentView]; + var run = {_range:CPMakeRangeCopy(effectiveRange), color:nil, font:nil, elem:nil, string:nil, view:view}; } _runs.push(run); - } } else { @@ -1269,13 +1302,11 @@ var _objectsInRange = function(aList, aRange) for (var i = 0; i < l; i++) { + if (_runs[i].view && _runs[i].DOMactive) + [_runs[i].view removeFromSuperview]; + if (_runs[i].elem && _runs[i].DOMactive) - { - if (_runs[i].view) - [_runs[i].view removeFromSuperview]; - else - _textContainer._textView._DOMElement.removeChild(_runs[i].elem); - } + _textContainer._textView._DOMElement.removeChild(_runs[i].elem); _runs[i].elem = nil; _runs[i].DOMactive = NO; @@ -1288,6 +1319,9 @@ var _objectsInRange = function(aList, aRange) c = runs.length, orig = CGPointMake(_fragmentRect.origin.x, _fragmentRect.origin.y); + if (_runs.length === 0) + return; + for (var i = 0; i < c; i++) { var run = runs[i]; @@ -1302,13 +1336,21 @@ var _objectsInRange = function(aList, aRange) continue; var loc = run._range.location - _runs[0]._range.location; + + // Safety bounds check to protect against uninitialized/empty glyph frames or offsets + if (loc < 0 || loc >= _glyphsFrames.length || !_glyphsFrames[loc] || !_glyphsOffsets || loc >= _glyphsOffsets.length) + continue; + orig.x = _glyphsFrames[loc].origin.x + aPoint.x; orig.y = _glyphsFrames[loc].origin.y + aPoint.y + _glyphsOffsets[loc]; - if(run.elem) + if(run.elem || run.view) { - run.elem.style.left = (orig.x) + "px"; - run.elem.style.top = (orig.y) + "px"; + if (run.elem) + { + run.elem.style.left = (orig.x) + "px"; + run.elem.style.top = (orig.y) + "px"; + } if (run.view) [run.view setFrameOrigin:orig]; @@ -1316,8 +1358,9 @@ var _objectsInRange = function(aList, aRange) if (!run.DOMactive) { if (run.view) - [self._textContainer._textView addSubview:run.view]; - else + [_textContainer._textView addSubview:run.view]; + + if (run.elem) _textContainer._textView._DOMElement.appendChild(run.elem); } @@ -1373,12 +1416,14 @@ var _objectsInRange = function(aList, aRange) { _runs[i]._range.location += rangeOffset; - if (verticalOffset && _runs[i].elem) + if (verticalOffset) { if (_runs[i].view) _runs[i].view._frame.origin.y += verticalOffset; - _runs[i].elem.top = (_runs[i].elem.top + verticalOffset) + 'px'; + if (_runs[i].elem) + _runs[i].elem.top = (_runs[i].elem.top + verticalOffset) + 'px'; + _runs[i].DOMpatched = YES; } } diff --git a/AppKit/CPTextView/CPTextView.j b/AppKit/CPTextView/CPTextView.j index cfde9194a..16151f9e0 100644 --- a/AppKit/CPTextView/CPTextView.j +++ b/AppKit/CPTextView/CPTextView.j @@ -401,6 +401,7 @@ var kDelegateRespondsTo_textShouldBeginEditing - (void)superviewFrameChanged:(CPNotification)aNotification { _exposedRect = nil; + [self sizeToFit]; } - (void)viewWillMoveToSuperview:(CPView)aView @@ -1397,7 +1398,10 @@ Sets the selection to a range of characters in response to user action. - (void)moveLeftAndModifySelection:(id)sender { if ([self isSelectable]) + { [self _extendSelectionIntoDirection:-1 granularity:CPSelectByCharacter]; + [self scrollRangeToVisible:_selectionRange]; + } } - (void)moveBackward:(id)sender @@ -1419,7 +1423,10 @@ Sets the selection to a range of characters in response to user action. - (void)moveLeft:(id)sender { if ([self isSelectable]) + { [self _establishSelection:CPMakeRange(_selectionRange.location - (_selectionRange.length ? 0 : 1), 0) byExtending:NO]; + [self scrollRangeToVisible:_selectionRange]; + } } - (void)moveToEndOfParagraph:(id)sender @@ -2134,9 +2141,15 @@ Sets the selection to a range of characters in response to user action. if (CPEmptyRange(aRange)) { if (aRange.location >= [_layoutManager numberOfCharacters]) - rect = [_layoutManager extraLineFragmentRect]; + { + rect = CGRectCreateCopy([_layoutManager extraLineFragmentRect]); + rect.size.width = 1.0; + } else - rect = [_layoutManager lineFragmentRectForGlyphAtIndex:aRange.location effectiveRange:nil]; + { + rect = CGRectCreateCopy([_layoutManager boundingRectForGlyphRange:CPMakeRange(aRange.location, 1) inTextContainer:_textContainer]); + rect.size.width = 1.0; + } } else { @@ -2851,11 +2864,16 @@ var CPTextViewAllowsUndoKey = @"CPTextViewAllowsUndoKey", _typingAttributes = [[_textStorage attributesAtIndex:0 effectiveRange:nil] copy]; + if (!_typingAttributes) + _typingAttributes = [CPMutableDictionary dictionary]; + if (![_typingAttributes valueForKey:CPForegroundColorAttributeName]) [_typingAttributes setObject:[CPColor blackColor] forKey:CPForegroundColorAttributeName]; _textColor = [_typingAttributes valueForKey:CPForegroundColorAttributeName]; - [self setFont:[_typingAttributes valueForKey:CPFontAttributeName]]; + + var decodedFont = [_typingAttributes valueForKey:CPFontAttributeName] || [CPFont systemFontOfSize:12.0]; + [self setFont:decodedFont]; [self setString:[_textStorage string]]; diff --git a/AppKit/CPTextView/_CPTableTextAttachment.j b/AppKit/CPTextView/_CPTableTextAttachment.j index 8f56f684e..5984ab11f 100644 --- a/AppKit/CPTextView/_CPTableTextAttachment.j +++ b/AppKit/CPTextView/_CPTableTextAttachment.j @@ -16,8 +16,7 @@ * You should have received a copy of the GNU Lesser General Public * License along with this library; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -*/ + */ @import "CPView.j" @import "CPTextView.j" @@ -28,6 +27,7 @@ { CPArray _headers; CPArray _rows; + BOOL _isResizing; } - (id)initWithHeaders:(CPArray)headers rows:(CPArray)rows @@ -42,30 +42,41 @@ { _headers = headers; _rows = rows; + _isResizing = NO; - var numCols = [headers count]; + [self _rebuildTableWithWidth:totalWidth]; + } - if (numCols == 0 && [rows count] > 0) - numCols = [[rows objectAtIndex:0] count]; + return self; +} - // Apply borders on the outer left and top container edges - if (self._DOMElement) { - self._DOMElement.style.borderTop = "1px solid #e0e0e0"; - self._DOMElement.style.borderLeft = "1px solid #e0e0e0"; +- (void)_rebuildTableWithWidth:(float)totalWidth +{ + var numCols = _headers ? [_headers count] : 0; + + if (numCols == 0 && _rows && [_rows count] > 0) + numCols = [[_rows objectAtIndex:0] count]; + + // Apply borders on the outer left and top container edges + if (self._DOMElement) { + self._DOMElement.style.borderTop = "1px solid #e0e0e0"; + self._DOMElement.style.borderLeft = "1px solid #e0e0e0"; + self._DOMElement.style.boxSizing = "border-box"; + } + + // Initialize header cells + if (_headers && [_headers count] > 0) { + for (var c = 0; c < numCols; c++) { + var headerText = [_headers objectAtIndex:c]; + var cellView = [self createCellWithText:headerText frame:CGRectMakeZero() isHeader:YES]; + [self addSubview:cellView]; } - - // Initialize header cells - if ([headers count] > 0) { - for (var c = 0; c < numCols; c++) { - var headerText = [headers objectAtIndex:c]; - var cellView = [self createCellWithText:headerText frame:CGRectMakeZero() isHeader:YES]; - [self addSubview:cellView]; - } - } - - // Initialize body rows - for (var r = 0; r < [rows count]; r++) { - var rowData = [rows objectAtIndex:r]; + } + + // Initialize body rows + if (_rows) { + for (var r = 0; r < [_rows count]; r++) { + var rowData = [_rows objectAtIndex:r]; for (var c = 0; c < numCols; c++) { var cellText = @""; @@ -76,10 +87,53 @@ [self addSubview:cellView]; } } - - [self resizeToWidth:totalWidth]; } - return self; + + [self resizeToWidth:totalWidth]; + + // Trigger a parent layout manager re-layout once the table is fully reconstructed and sized. + var textView = [self superview]; + if (textView && [textView isKindOfClass:[CPTextView class]]) + { + var layoutManager = [textView layoutManager]; + if (layoutManager) + { + var charRange = [self _findCharacterRangeInLayoutManager:layoutManager]; + if (charRange && charRange.location !== CPNotFound) + { + [layoutManager invalidateLayoutForCharacterRange:charRange isSoft:NO actualCharacterRange:nil]; + [layoutManager invalidateDisplayForGlyphRange:charRange]; + [layoutManager _validateLayoutAndGlyphs]; + [textView sizeToFit]; + } + } + } +} + +- (CPRange)_findCharacterRangeInLayoutManager:(CPLayoutManager)layoutManager +{ + var lineFragments = layoutManager._lineFragments; + if (lineFragments) + { + var l = lineFragments.length; + for (var i = 0; i < l; i++) + { + var fragment = lineFragments[i]; + var runs = fragment._runs; + if (runs) + { + var rc = runs.length; + for (var j = 0; j < rc; j++) + { + if (runs[j].view === self) + { + return runs[j]._range; + } + } + } + } + } + return CPMakeRange(CPNotFound, 0); } - (CPArray)headers @@ -94,6 +148,7 @@ - (CPView)viewForWidth:(float)width { + // Always call resize to ensure cell views are constructed, but do not guard here [self resizeToWidth:width]; return self; } @@ -110,7 +165,9 @@ var borderView = [[CPView alloc] initWithFrame:CGRectMake(0, 0, initialWidth, initialHeight)]; [borderView setBackgroundColor:[CPColor clearColor]]; [borderView setAutoresizingMask:CPViewWidthSizable | CPViewHeightSizable]; - if (borderView._DOMElement) { + + if (borderView._DOMElement) + { borderView._DOMElement.style.borderBottom = "1px solid #e0e0e0"; borderView._DOMElement.style.borderRight = "1px solid #e0e0e0"; borderView._DOMElement.style.boxSizing = "border-box"; @@ -119,29 +176,18 @@ var textContainer = [[CPTextContainer alloc] initWithContainerSize:CGSizeMake(initialWidth - 8, 1e7)]; var textView = [[CPTextView alloc] initWithFrame:CGRectMake(4, 2, initialWidth - 8, initialHeight - 4) textContainer:textContainer]; - [textView setEditable:NO]; + [textView setEditable:YES]; [textView setSelectable:YES]; [textView setBackgroundColor:[CPColor clearColor]]; [textView setVerticallyResizable:YES]; [textView setHorizontallyResizable:NO]; [[textView textContainer] setWidthTracksTextView:YES]; - // Construct cell text using standard fonts + // Configure cell text style using standard CPTextView APIs var cellFont = isHeader ? [CPFont boldSystemFontOfSize:11.0] : [CPFont systemFontOfSize:11.0]; - var parsedText = [[CPAttributedString alloc] initWithString:text attributes:@{ - CPFontAttributeName: cellFont, - CPForegroundColorAttributeName: [CPColor blackColor] - }]; - - var storage = [textView textStorage]; - if (storage && [storage respondsToSelector:@selector(setAttributedString:)]) { - [storage setAttributedString:parsedText]; - } else { - [textView setEditable:YES]; - [textView setString:@""]; - [textView insertText:parsedText]; - [textView setEditable:NO]; - } + [textView setFont:cellFont]; + [textView setTextColor:[CPColor blackColor]]; + [textView setString:text]; [cellContainer addSubview:textView]; return cellContainer; @@ -161,11 +207,19 @@ - (void)resizeToWidth:(float)newWidth { - var numCols = [_headers count]; - if (numCols == 0 && [_rows count] > 0) { + if (_isResizing) + return; + + _isResizing = YES; + + var numCols = _headers ? [_headers count] : 0; + if (numCols == 0 && _rows && [_rows count] > 0) { numCols = [[_rows objectAtIndex:0] count]; } - if (numCols == 0) return; + if (numCols == 0) { + _isResizing = NO; + return; + } var subviews = [self subviews]; var colNaturalWidths = []; @@ -206,18 +260,22 @@ } }; - for (var c = 0; c < [_headers count]; c++) { - measureCell([_headers objectAtIndex:c], YES, c); + if (_headers) { + for (var c = 0; c < [_headers count]; c++) { + measureCell([_headers objectAtIndex:c], YES, c); + } } - for (var r = 0; r < [_rows count]; r++) { - var rowData = [_rows objectAtIndex:r]; - for (var c = 0; c < numCols; c++) { - var cellText = @""; - if (c < [rowData count]) { - cellText = [rowData objectAtIndex:c]; + if (_rows) { + for (var r = 0; r < [_rows count]; r++) { + var rowData = [_rows objectAtIndex:r]; + for (var c = 0; c < numCols; c++) { + var cellText = @""; + if (c < [rowData count]) { + cellText = [rowData objectAtIndex:c]; + } + measureCell(cellText, NO, c); } - measureCell(cellText, NO, c); } } @@ -279,8 +337,9 @@ var targetWidth = Math.max(10.0, colWidths[c] - 8); [[textView textContainer] setContainerSize:CGSizeMake(targetWidth, 1e7)]; - var usedRect = [[textView layoutManager] usedRectForTextContainer:[textView textContainer]]; - var wrappedHeight = CGRectGetHeight(usedRect) + 12.0; + var layoutManager = [textView layoutManager]; + var usedRect = layoutManager ? [layoutManager usedRectForTextContainer:[textView textContainer]] : nil; + var wrappedHeight = (usedRect ? CGRectGetHeight(usedRect) : 0.0) + 12.0; if (wrappedHeight > maxCellHeight) { maxCellHeight = wrappedHeight; } @@ -314,19 +373,29 @@ return maxCellHeight; }; - if ([_headers count] > 0) { + if (_headers && [_headers count] > 0) { var headerHeight = layoutRow(cellIndex); cellIndex += numCols; currentY += headerHeight; } - for (var r = 0; r < [_rows count]; r++) { - var rowHeight = layoutRow(cellIndex); - cellIndex += numCols; - currentY += rowHeight; + if (_rows) { + for (var r = 0; r < [_rows count]; r++) { + var rowHeight = layoutRow(cellIndex); + cellIndex += numCols; + currentY += rowHeight; + } } - [self setFrameSize:CGSizeMake(newWidth, currentY)]; + // GUARD FRAME SIZE MUTATIONS: Only execute setFrameSize if dimensions actually change. + // This stops infinite layout passes while ensuring subviews are laid out. + var currentSize = [self frame].size; + if (ABS(currentSize.width - newWidth) > 0.1 || ABS(currentSize.height - currentY) > 0.1) + { + [self setFrameSize:CGSizeMake(newWidth, currentY)]; + } + + _isResizing = NO; } @end diff --git a/Tests/Manual/CPTextView/AppController.j b/Tests/Manual/CPTextView/AppController.j index 7abb6d277..f35d0730c 100755 --- a/Tests/Manual/CPTextView/AppController.j +++ b/Tests/Manual/CPTextView/AppController.j @@ -14,6 +14,8 @@ @import @import @import +@import +@import @implementation AppController : CPObject { @@ -90,6 +92,25 @@ [_textView insertText:@" "]; } +- (void)insertTable:(id)sender +{ + var headers = [@"Item Description", @"Quantity", @"Unit Price"]; + var rows = [ + [@"Cappuccino Web Framework Lic.", @"2", @"$199.00"], + [@"Objective-J Development Support", @"5", @"$150.00"], + [@"Cloud Compilation VM Server", @"1", @"$49.00"] + ]; + + var tableAttachment = [[_CPTableTextAttachment alloc] initWithHeaders:headers rows:rows width:500.0]; + + // Insert single-character atomic text attachment + var tableAttrStr = [CPTextStorage attributedStringWithAttachment:tableAttachment]; + + [_textView insertText:@"\ntable (own line)\n"]; + [_textView insertText:tableAttrStr]; + [_textView insertText:@"\nend table"]; +} + - (void)applicationDidFinishLaunching:(CPNotification)aNotification { var theWindow = [[CPWindow alloc] initWithContentRect:CGRectMakeZero() styleMask:CPBorderlessBridgeWindowMask], @@ -145,6 +166,14 @@ [toolbarView addSubview:attachButton]; currentX += 150; + // Add Table Trigger + var tableButton = [[CPButton alloc] initWithFrame:CGRectMake(currentX, 15, 100, 30)]; + [tableButton setTitle:@"Add Table"]; + [tableButton setTarget:self]; + [tableButton setAction:@selector(insertTable:)]; + [toolbarView addSubview:tableButton]; + currentX += 110; + // Text Alignment Group var labelAlign = [[CPTextField alloc] initWithFrame:CGRectMake(currentX, 22, 45, 20)]; [labelAlign setStringValue:@"Align:"]; @@ -210,9 +239,10 @@ [leftLabel setAutoresizingMask:CPViewWidthSizable]; [leftContainer addSubview:leftLabel]; - _textView = [[CPTextView alloc] initWithFrame:CGRectMake(0, 0, CGRectGetWidth([leftContainer bounds]) - 30, CGRectGetHeight([leftContainer bounds]) - 70)]; + _textView = [[CPTextView alloc] initWithFrame:CGRectMake(0, 0, CGRectGetWidth([leftContainer bounds]) - 30, 0)]; [_textView setRichText:YES]; [_textView setBackgroundColor:[CPColor whiteColor]]; + [[_textView textContainer] setWidthTracksTextView:YES]; _scrollView = [[CPScrollView alloc] initWithFrame:CGRectMake(15, 40, CGRectGetWidth([leftContainer bounds]) - 30, CGRectGetHeight([leftContainer bounds]) - 65)]; [_scrollView setAutoresizingMask:CPViewWidthSizable | CPViewHeightSizable]; @@ -321,6 +351,8 @@ [_textView insertText:[[CPAttributedString alloc] initWithString:@"This paragraph has a first-line indent of 30pt, a head indent of 50pt, and a tail indent of -30pt. Check the horizontal ruler above to see how the indent markers align with this paragraph, and adjust them directly!\n" attributes:[CPDictionary dictionaryWithObject:indentParagraph forKey:CPParagraphStyleAttributeName]]]; + [_textView insertText:@"\n"]; + [theWindow orderFront:self]; [CPMenu setMenuBarVisible:YES]; } From 49b18000278509e62af9fb97233762f233cc60f2 Mon Sep 17 00:00:00 2001 From: daboe01 Date: Sun, 14 Jun 2026 22:42:33 +0200 Subject: [PATCH 05/13] improved: table generation --- AppKit/CPTextView/_CPRTFParser.j | 56 ++++++++++- AppKit/CPTextView/_CPRTFProducer.j | 145 ++++++++++++++++++++++++----- 2 files changed, 173 insertions(+), 28 deletions(-) diff --git a/AppKit/CPTextView/_CPRTFParser.j b/AppKit/CPTextView/_CPRTFParser.j index 4b730d62b..17000ca7e 100644 --- a/AppKit/CPTextView/_CPRTFParser.j +++ b/AppKit/CPTextView/_CPRTFParser.j @@ -205,8 +205,12 @@ var cp1252Map = { if (bgColour) [ret setObject:bgColour forKey:CPBackgroundColorAttributeName]; + if (underline) + [ret setObject:[CPNumber numberWithInt:1] forKey:CPUnderlineStyleAttributeName]; + return ret; } + @end @@ -387,7 +391,13 @@ var kRgsymRtf = { [self _flushCurrentRun]; _currentRun = state.run; _currentRun._range = CPMakeRange([_result length], 0); + + if (_curState == 0) + { + _parsingFontTable = NO; + } } + return YES; } @@ -622,6 +632,23 @@ var kRgsymRtf = { case "paperh": _paper.height = param; break; + + case "ul": // underline + if (param === 0) + { + if (_currentRun && _currentRun.underline) + [self _flushCurrentRun]; + + _currentRun.underline = NO; + } + else + { + if (_currentRun && !_currentRun.underline) + [self _flushCurrentRun]; + + _currentRun.underline = YES; + } + break; } return ''; @@ -981,11 +1008,32 @@ var kRgsymRtf = { lastchar = 0; if (_curState == 0) + { [self _appendPlainString:tmp]; - else if (tmp !== ';') - _freename += tmp; - - break; + } + else + { + if (tmp === ';') + { + if (_parsingFontTable && _freename) + { + var cleanFontName = _freename.trim(); + // strip family name prefix if present (e.g., "swiss Helvetica" -> "Helvetica") + var lastSpaceIdx = cleanFontName.lastIndexOf(' '); + if (lastSpaceIdx !== -1) + { + cleanFontName = cleanFontName.substring(lastSpaceIdx + 1); + } + _fontArray.push(cleanFontName); + _freename = ""; + } + } + else + { + _freename += tmp; + } + } + break; } } diff --git a/AppKit/CPTextView/_CPRTFProducer.j b/AppKit/CPTextView/_CPRTFProducer.j index 05e082636..b10a11000 100644 --- a/AppKit/CPTextView/_CPRTFProducer.j +++ b/AppKit/CPTextView/_CPRTFProducer.j @@ -1,12 +1,12 @@ /* - _CPRTFProducer.j - - Serialize CPAttributedString to a RTF String - - Copyright (C) 2014 Daniel Boehringer - This file is based on the RTFProducer from GNUStep - (which I co-authored with Fred Kiefer in 1999) - + * _CPRTFProducer.j + * + * Serialize CPAttributedString to a RTF String + * + * Copyright (C) 2014 Daniel Boehringer + * This file is based on the RTFProducer from GNUStep + * (which I co-authored with Fred Kiefer in 1999) + * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either @@ -382,18 +382,104 @@ function _points2twips(a) { return (a) * 20.0; } attributes:(CPDictionary) attributes paragraphStart:(BOOL) first { - var tableAttachment = [attributes objectForKey:CPAttachmentAttributeName]; - if (!tableAttachment) + var unwrap = function(obj) { + if (!obj) return null; + var unwrapped = null; + if (typeof obj.respondsToSelector === "function") { + if ([obj respondsToSelector:@selector(attachmentCell)]) { + unwrapped = [obj attachmentCell]; + } else if ([obj respondsToSelector:@selector(content)]) { + unwrapped = [obj content]; + } else if ([obj respondsToSelector:@selector(view)]) { + unwrapped = [obj view]; + } + } + if (!unwrapped) { + unwrapped = obj._attachmentCell || obj._content || obj._view || obj.attachmentCell || obj.content || obj.view; + } + return unwrapped ? unwrapped : obj; + }; + + var tableAttachment = null; + + if (typeof CPAttachmentAttributeName !== "undefined") { + tableAttachment = [attributes objectForKey:CPAttachmentAttributeName]; + } + if (!tableAttachment) { + tableAttachment = [attributes objectForKey:@"CPAttachmentAttributeName"]; + } + if (!tableAttachment) { tableAttachment = [attributes objectForKey:@"TableAttachmentAttribute"]; + } + if (!tableAttachment) { + tableAttachment = [attributes objectForKey:@"_CPAttachmentView"]; + } + if (!tableAttachment && typeof _CPAttachmentView !== "undefined") { + tableAttachment = [attributes objectForKey:_CPAttachmentView]; + } - if (tableAttachment && [tableAttachment isKindOfClass:[_CPTableTextAttachment class]]) + tableAttachment = unwrap(tableAttachment); + + // Ultimate fallback scanner for layout character placeholder sequences + if (!tableAttachment && (substring === "\uFFFC" || substring === "")) { - var headers = [tableAttachment headers], - rows = [tableAttachment rows]; + var keys = [attributes allKeys], + count = [keys count]; - var numCols = [headers count]; - if (numCols == 0 && [rows count] > 0) - numCols = [[rows objectAtIndex:0] count]; + for (var i = 0; i < count; i++) + { + var key = [keys objectAtIndex:i], + val = unwrap([attributes objectForKey:key]); + + if (val && ( + (typeof val.respondsToSelector === "function" && [val respondsToSelector:@selector(headers)]) || + val._headers || + val.headers || + (typeof _CPTableTextAttachment !== "undefined" && [val isKindOfClass:[_CPTableTextAttachment class]]) + )) { + tableAttachment = val; + break; + } + } + } + + if (tableAttachment) + { + var headers = null, + rows = null; + + if (typeof tableAttachment.respondsToSelector === "function") { + if ([tableAttachment respondsToSelector:@selector(headers)]) { + headers = [tableAttachment headers]; + } + if ([tableAttachment respondsToSelector:@selector(rows)]) { + rows = [tableAttachment rows]; + } + } + + if (!headers) { + headers = tableAttachment._headers || tableAttachment.headers; + } + if (!rows) { + rows = tableAttachment._rows || tableAttachment.rows; + } + + var getCount = function(arr) { + if (!arr) return 0; + if (typeof arr.count === "function") return [arr count]; + return arr.length; + }; + + var getObjectAtIndex = function(arr, idx) { + if (!arr) return null; + if (typeof arr.objectAtIndex === "function") return [arr objectAtIndex:idx]; + return arr[idx]; + }; + + var numCols = getCount(headers); + if (numCols == 0 && getCount(rows) > 0) { + numCols = getCount(getObjectAtIndex(rows, 0)); + } if (numCols > 0) { @@ -416,9 +502,14 @@ function _points2twips(a) { return (a) * 20.0; } } for (var c = 0; c < numCols; c++) { var cellText = ""; - if (c < [rowData count]) { - cellText = [rowData objectAtIndex:c]; + if (c < getCount(rowData)) { + cellText = getObjectAtIndex(rowData, c); } + if (cellText === null || cellText === undefined) { + cellText = ""; + } + + cellText = String(cellText); cellText = cellText.replace(/\\/g, '\\\\'); cellText = cellText.replace(/{/g, '\\{'); cellText = cellText.replace(/}/g, '\\}'); @@ -434,11 +525,12 @@ function _points2twips(a) { return (a) * 20.0; } return rowRTF; }; - if ([headers count] > 0) { + if (getCount(headers) > 0) { tableRTF += writeRow(headers, YES); } - for (var r = 0; r < [rows count]; r++) { - tableRTF += writeRow([rows objectAtIndex:r], NO); + var rowCount = getCount(rows); + for (var r = 0; r < rowCount; r++) { + tableRTF += writeRow(getObjectAtIndex(rows, r), NO); } return tableRTF; @@ -625,7 +717,7 @@ function _points2twips(a) { return (a) * 20.0; } length = [string length], currRange = CPMakeRange(loc, 0), completeRange = CPMakeRange(0, length), - first = YES; + paragraphStart = YES; while (CPMaxRange(currRange) < CPMaxRange(completeRange)) // save all "runs" { @@ -639,10 +731,15 @@ function _points2twips(a) { return (a) * 20.0; } substring = [string substringWithRange:currRange]; runString = [self runStringForString:substring attributes:attributes - paragraphStart:YES]; + paragraphStart:paragraphStart]; result += runString; - first = NO; + + // Dynamically compute paragraph boundaries based on standard line feeds + if (substring.length > 0 && substring.charAt(substring.length - 1) === '\n') + paragraphStart = YES; + else + paragraphStart = NO; } return result; From 5dc66c5b4e91365d7acab20f0db2bb0288b030d6 Mon Sep 17 00:00:00 2001 From: daboe01 Date: Sun, 14 Jun 2026 22:56:23 +0200 Subject: [PATCH 06/13] fixed: roundtrip --- AppKit/CPTextView/_CPRTFParser.j | 50 +++++++++++++++---------- Tests/Manual/CPTextView/AppController.j | 42 ++++++++++++++++++++- 2 files changed, 71 insertions(+), 21 deletions(-) diff --git a/AppKit/CPTextView/_CPRTFParser.j b/AppKit/CPTextView/_CPRTFParser.j index 17000ca7e..00bd18053 100644 --- a/AppKit/CPTextView/_CPRTFParser.j +++ b/AppKit/CPTextView/_CPRTFParser.j @@ -36,6 +36,7 @@ @global CPBackgroundColorAttributeName @global CPParagraphStyleAttributeName @global CPAttachmentAttributeName +@global CPUnderlineStyleAttributeName @global CPLeftTabStopType @global CPRightTabStopType @@ -210,7 +211,6 @@ var cp1252Map = { return ret; } - @end @@ -348,6 +348,9 @@ var kRgsymRtf = { _tableRows = nil; _currentRow = nil; _currentCellText = ""; + + // Safe Initialization + _currentRun = [_RTFAttribute new]; } return self; @@ -390,6 +393,13 @@ var kRgsymRtf = { [self _flushCurrentRun]; _currentRun = state.run; + + // Safety guard to prevent setting properties on null + if (!_currentRun) + { + _currentRun = [_RTFAttribute new]; + } + _currentRun._range = CPMakeRange([_result length], 0); if (_curState == 0) @@ -397,7 +407,6 @@ var kRgsymRtf = { _parsingFontTable = NO; } } - return YES; } @@ -609,6 +618,23 @@ var kRgsymRtf = { break; + case "ul": // underline + if (param === 0) + { + if (_currentRun && _currentRun.underline) + [self _flushCurrentRun]; + + _currentRun.underline = NO; + } + else + { + if (_currentRun && !_currentRun.underline) + [self _flushCurrentRun]; + + _currentRun.underline = YES; + } + break; + case "qc": // paragraph center [_currentRun.paragraph setAlignment:CPCenterTextAlignment]; break; @@ -632,23 +658,6 @@ var kRgsymRtf = { case "paperh": _paper.height = param; break; - - case "ul": // underline - if (param === 0) - { - if (_currentRun && _currentRun.underline) - [self _flushCurrentRun]; - - _currentRun.underline = NO; - } - else - { - if (_currentRun && !_currentRun.underline) - [self _flushCurrentRun]; - - _currentRun.underline = YES; - } - break; } return ''; @@ -1033,7 +1042,8 @@ var kRgsymRtf = { _freename += tmp; } } - break; + + break; } } diff --git a/Tests/Manual/CPTextView/AppController.j b/Tests/Manual/CPTextView/AppController.j index f35d0730c..a34d50663 100755 --- a/Tests/Manual/CPTextView/AppController.j +++ b/Tests/Manual/CPTextView/AppController.j @@ -154,7 +154,7 @@ var rtfButton = [[CPButton alloc] initWithFrame:CGRectMake(currentX, 15, 150, 30)]; [rtfButton setTitle:@"RTF Round-trip ➔"]; [rtfButton setTarget:self]; - [rtfButton setAction:@selector(makeRTF:)]; + [rtfButton setAction:@selector(rtfRoundTrip:)]; [toolbarView addSubview:rtfButton]; currentX += 160; @@ -366,4 +366,44 @@ [_textView insertText: mystr]; } +// Action tied to the "RTF Round-trip ->" button in the demo app +- (void)rtfRoundTrip:(id)sender +{ + // 1. Retrieve the rich text storage from the editor on the left + var textStorage = [_textView textStorage]; + if (!textStorage || [textStorage length] == 0) + { + return; + } + + // 2. Serialize the CPAttributedString into an RTF string + var docAttributes = @{ @"PaperSize": CPMakeSize(612, 792) }; + var generatedRTF = [_CPRTFProducer produceRTF:textStorage documentAttributes:docAttributes]; + + // 3. Set the generated RTF string into the raw output pane on the right + [_textView2 setString:generatedRTF]; + + // 4. Parse that exact RTF text back into a new CPAttributedString + var parser = [[_CPRTFParser alloc] init]; + var roundTrippedString = [parser parseRTF:generatedRTF]; + + // 5. Replace the editor's text storage with the round-tripped version + var editorStorage = [_textView textStorage]; + if (editorStorage && [editorStorage respondsToSelector:@selector(setAttributedString:)]) + { + [editorStorage setAttributedString:roundTrippedString]; + } + else + { + // Safe fallback sequence + [_textView setEditable:YES]; + [_textView setString:@""]; + [_textView insertText:roundTrippedString]; + [_textView setEditable:NO]; + } + + // 6. Force a layout pass and render update + [_textView setNeedsDisplay:YES]; + [_textView2 setNeedsDisplay:YES]; +} @end From 1b23bb8a67845ca829ef0b1310368904d5930995 Mon Sep 17 00:00:00 2001 From: daboe01 Date: Mon, 15 Jun 2026 16:43:58 +0200 Subject: [PATCH 07/13] new: CPMarkdownParser --- AppKit/CPTextView/_CPRTFParser.j | 293 ++++++++++++++++++++++++ Tests/Manual/CPTextView/AppController.j | 47 +++- 2 files changed, 336 insertions(+), 4 deletions(-) diff --git a/AppKit/CPTextView/_CPRTFParser.j b/AppKit/CPTextView/_CPRTFParser.j index 00bd18053..9cc99df34 100644 --- a/AppKit/CPTextView/_CPRTFParser.j +++ b/AppKit/CPTextView/_CPRTFParser.j @@ -1053,3 +1053,296 @@ var kRgsymRtf = { } @end + +/* + * CPMarkdownParser.j + * + * Parse a Markdown string into a CPAttributedString with inline style attributes + * and embedded _CPTableTextAttachment objects. + * + * Copyright (C) 2026 by Daniel Böhringer + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + */ + +@implementation CPMarkdownParser : CPObject + ++ (CPAttributedString)attributedStringFromMarkdown:(CPString)markdown +{ + if (!markdown) { + return [[CPAttributedString alloc] initWithString:@""]; + } + + var result = [[CPMutableAttributedString alloc] initWithString:@""]; + var lines = markdown.split(/\r?\n/); + + var i = 0; + while (i < lines.length) { + var line = lines[i]; + + // Tabellen-Erkennung + if ([self isTableHeaderLine:line] && i + 1 < lines.length && [self isTableSeparatorLine:lines[i+1]]) { + var headers = [self parseTableCells:line]; + var separatorLine = lines[i+1]; + var rows = [CPMutableArray array]; + + i += 2; + while (i < lines.length && [self isTableRowLine:lines[i]]) { + [rows addObject:[self parseTableCells:lines[i]]]; + i++; + } + + var numCols = [headers count]; + if (numCols == 0 && [rows count] > 0) { + numCols = [[rows objectAtIndex:0] count]; + } + + // 1. Zuerst die absolute Summe der Natural-Breiten zur Spalten-Proportionsbestimmung ermitteln + var totalNaturalW = 0.0; + var colNaturalWidths = []; + var measureTextField = [[CPTextField alloc] initWithFrame:CGRectMake(0, 0, 10000.0, 24.0)]; + [measureTextField setFont:[CPFont systemFontOfSize:11.0]]; + + for (var c = 0; c < numCols; c++) { + var cellW = 80.0; + + // Headers prüfen + if (c < headers.length) { + var parsedText = [self parseInlineMarkdown:headers[c] isHeader:YES headerLevel:3]; + [measureTextField setStringValue:[parsedText string]]; + [measureTextField sizeToFit]; + cellW = Math.max(cellW, CGRectGetWidth([measureTextField frame]) + 24.0); + } + + // Reihen prüfen + for (var r = 0; r < [rows count]; r++) { + var rowData = [rows objectAtIndex:r]; + if (c < [rowData count]) { + var parsedText = [self parseInlineMarkdown:rowData[c] isHeader:NO headerLevel:3]; + [measureTextField setStringValue:[parsedText string]]; + [measureTextField sizeToFit]; + cellW = Math.max(cellW, CGRectGetWidth([measureTextField frame]) + 24.0); + } + } + colNaturalWidths[c] = cellW; + totalNaturalW += cellW; + } + + // 2. Präzise adaptive Zeilenhöhen-Schätzung für das Newline-Sizing (Verhindert zu große Abstände) + var estimatedHeight = 36.0; // Startwert für Header-Zeile mit Padding + for (var r = 0; r < [rows count]; r++) { + var rowData = [rows objectAtIndex:r]; + var maxCellHeight = 28.0; + + for (var c = 0; c < numCols; c++) { + var cellText = @""; + if (c < [rowData count]) { + cellText = [rowData objectAtIndex:c]; + } + var charCount = cellText.length; + + // Schätzung basierend auf realistischer Spaltenbreitenverteilung + var proportion = totalNaturalW > 0 ? (colNaturalWidths[c] / totalNaturalW) : (1.0 / numCols); + var estimatedColWidth = proportion * 500.0; + var charsPerLine = Math.max(10.0, Math.floor(estimatedColWidth / 6.5)); // ca. 6.5px pro Zeichen + + var estimatedLines = Math.ceil(charCount / charsPerLine); + if (estimatedLines < 1) estimatedLines = 1; + + var cellHeight = (estimatedLines * 16.0) + 12.0; + if (cellHeight > maxCellHeight) { + maxCellHeight = cellHeight; + } + } + estimatedHeight += maxCellHeight; + } + + // Berechne die benötigten Leerzeilen (\n Zeilenhöhe ist ca. 16px) + var lineCount = Math.ceil(estimatedHeight / 16.0) + 1; // Minimaler Sicherheitsabstand (+1) + var newlineStr = ""; + for (var nl = 0; nl < lineCount; nl++) { + newlineStr += "\n"; + } + + var tableAttrStr = [[CPMutableAttributedString alloc] initWithString:newlineStr]; + var matrixView = [[_CPTableTextAttachment alloc] initWithHeaders:headers rows:rows width:500.0]; + + [tableAttrStr addAttribute:@"TableAttachmentAttribute" value:matrixView range:CPMakeRange(0, [tableAttrStr length])]; + [tableAttrStr addAttribute:CPAttachmentAttributeName value:matrixView range:CPMakeRange(0, [tableAttrStr length])]; + [result appendAttributedString:tableAttrStr]; + continue; + } + + var isHeader = false; + var headerLevel = 0; + + // Überschriften (#) + var headerMatch = line.match(/^(#{1,6})\s+(.*)$/); + if (headerMatch) { + headerLevel = headerMatch[1].length; + line = headerMatch[2]; + isHeader = true; + } + + // Listenpunkte (- oder *) + var isListItem = false; + var listMatch = line.match(/^(\*|-)\s+(.*)$/); + if (listMatch) { + line = " • " + listMatch[2]; + isListItem = true; + } + + var parsedLine = [self parseInlineMarkdown:line isHeader:isHeader headerLevel:headerLevel]; + [result appendAttributedString:parsedLine]; + + if (i < lines.length - 1) { + [result appendAttributedString:[[CPAttributedString alloc] initWithString:@"\n"]]; + } + + i++; + } + + return result; +} + ++ (BOOL)isTableHeaderLine:(CPString)line +{ + var trimmed = line.trim(); + return trimmed.indexOf('|') !== -1; +} + ++ (BOOL)isTableSeparatorLine:(CPString)line +{ + var trimmed = line.trim(); + if (trimmed.indexOf('|') === -1) return NO; + var stripped = trimmed.replace(/[\s|:\-]/g, ''); + return stripped.length === 0; +} + ++ (BOOL)isTableRowLine:(CPString)line +{ + var trimmed = line.trim(); + return trimmed.indexOf('|') !== -1; +} + ++ (CPArray)parseTableCells:(CPString)line +{ + var parts = line.split('|'); + var cells = [CPMutableArray array]; + var startIdx = 0; + var endIdx = parts.length; + if (parts[0].trim() === "") startIdx = 1; + if (parts[parts.length - 1].trim() === "") endIdx = parts.length - 1; + + for (var j = startIdx; j < endIdx; j++) { + [cells addObject:parts[j].trim()]; + } + return cells; +} + ++ (CPAttributedString)parseInlineMarkdown:(CPString)text isHeader:(BOOL)isHeader headerLevel:(int)level +{ + var baseFontSize = 11.0; + var fontSize = baseFontSize; + var isBold = isHeader; + var isItalic = NO; + + if (isHeader) { + if (level == 1) fontSize = 15.0; + else if (level == 2) fontSize = 13.0; + else fontSize = 12.0; + } + + var result = [[CPMutableAttributedString alloc] initWithString:@""]; + var currentSegment = ""; + var i = 0; + var len = text.length; + + var defaultFont = [CPFont systemFontOfSize:fontSize]; + if (isBold) { + defaultFont = [CPFont boldSystemFontOfSize:fontSize]; + } + + while (i < len) { + if (i + 2 < len && text.substr(i, 3) === "***") { + if (currentSegment.length > 0) { + [result appendAttributedString:[self attributedStringWithText:currentSegment font:defaultFont bold:isBold italic:isItalic code:NO]]; + currentSegment = ""; + } + isBold = !isBold; + isItalic = !isItalic; + i += 3; + continue; + } + if (i + 1 < len && text.substr(i, 2) === "**") { + if (currentSegment.length > 0) { + [result appendAttributedString:[self attributedStringWithText:currentSegment font:defaultFont bold:isBold italic:isItalic code:NO]]; + currentSegment = ""; + } + isBold = !isBold; + i += 2; + continue; + } + if (text.charAt(i) === "*") { + if (currentSegment.length > 0) { + [result appendAttributedString:[self attributedStringWithText:currentSegment font:defaultFont bold:isBold italic:isItalic code:NO]]; + currentSegment = ""; + } + isItalic = !isItalic; + i++; + continue; + } + if (text.charAt(i) === "`") { + if (currentSegment.length > 0) { + [result appendAttributedString:[self attributedStringWithText:currentSegment font:defaultFont bold:isBold italic:isItalic code:NO]]; + currentSegment = ""; + } + var codeText = ""; + i++; + while (i < len && text.charAt(i) !== "`") { + codeText += text.charAt(i); + i++; + } + [result appendAttributedString:[self attributedStringWithText:codeText font:defaultFont bold:NO italic:NO code:YES]]; + i++; + continue; + } + + currentSegment += text.charAt(i); + i++; + } + + if (currentSegment.length > 0) { + [result appendAttributedString:[self attributedStringWithText:currentSegment font:defaultFont bold:isBold italic:isItalic code:NO]]; + } + + return result; +} + ++ (CPAttributedString)attributedStringWithText:(CPString)text font:(CPFont)baseFont bold:(BOOL)b italic:(BOOL)it code:(BOOL)c +{ + var fontName = [baseFont familyName]; + var fontSize = [baseFont size]; + var finalFont = baseFont; + + if (c) { + finalFont = [CPFont fontWithName:@"Courier" size:fontSize]; + } else { + finalFont = [CPFont _fontWithName:fontName size:fontSize bold:b italic:it]; + } + + if (!finalFont) { + finalFont = [CPFont systemFontOfSize:fontSize]; + } + + var dict = [CPDictionary dictionaryWithObjectsAndKeys: + finalFont, CPFontAttributeName, + [CPColor blackColor], CPForegroundColorAttributeName + ]; + return [[CPAttributedString alloc] initWithString:text attributes:dict]; +} + +@end diff --git a/Tests/Manual/CPTextView/AppController.j b/Tests/Manual/CPTextView/AppController.j index a34d50663..de1d27100 100755 --- a/Tests/Manual/CPTextView/AppController.j +++ b/Tests/Manual/CPTextView/AppController.j @@ -2,7 +2,7 @@ * AppController.j * * Manual test application for the cappuccino text system - * Copyright (C) 2014 Daniel Boehringer + * Copyright (C) 2026 Daniel Boehringer */ @import @@ -158,6 +158,14 @@ [toolbarView addSubview:rtfButton]; currentX += 160; + // NEW: Markdown Converter Trigger + var mdButton = [[CPButton alloc] initWithFrame:CGRectMake(currentX, 15, 120, 30)]; + [mdButton setTitle:@"← Markdown"]; + [mdButton setTarget:self]; + [mdButton setAction:@selector(convertMarkdownToRichText:)]; + [toolbarView addSubview:mdButton]; + currentX += 130; + // Insert Attachment Trigger var attachButton = [[CPButton alloc] initWithFrame:CGRectMake(currentX, 15, 140, 30)]; [attachButton setTitle:@"Insert Attachment"]; @@ -255,7 +263,7 @@ // Right Container: RTF Plain-Text Source and Parser Window var rightLabel = [[CPTextField alloc] initWithFrame:CGRectMake(15, 10, CGRectGetWidth([rightContainer bounds]) - 30, 20)]; - [rightLabel setStringValue:@"RTF Raw Output & Source Parser Window"]; + [rightLabel setStringValue:@"Markdown Source & RTF Source Code Window"]; [rightLabel setFont:[CPFont boldSystemFontOfSize:14]]; [rightLabel setAutoresizingMask:CPViewWidthSizable]; [rightContainer addSubview:rightLabel]; @@ -287,8 +295,6 @@ [editMenu addItemWithTitle:@"Redo" action:@selector(redo:) keyEquivalent:@"Z"]; [mainMenu setSubmenu:editMenu forItem:item]; - item = [mainMenu insertItemWithTitle:@"Format" action:nil keyEquivalent:nil atIndex:0]; - // Format Menu item = [mainMenu insertItemWithTitle:@"Format" action:nil keyEquivalent:nil atIndex:0]; var formatMenu = [[CPMenu alloc] initWithTitle:@"Format Menu"]; @@ -353,6 +359,19 @@ [_textView insertText:@"\n"]; + // 5. Pre-populate Markdown editor with rich table sample content + [_textView2 setString:@"# Markdown Parser Output\n\n" + + "You can type markdown directly in this side panel and click **← Markdown** above to convert it!\n\n" + + "## Inline styling showcase\n\n" + + "• Combine ***bold and italic*** styles.\n" + + "• Monospaced `code elements` represent code blocks.\n\n" + + "## Data Table\n\n" + + "| Item Description | Quantity | Unit Price |\n" + + "| :--- | :---: | :---: |\n" + + "| Cappuccino Web Framework Lic. | 2 | $199.00 |\n" + + "| Objective-J Development Support | 5 | $150.00 |\n" + + "| Cloud Compilation VM Server | 1 | $49.00 |"]; + [theWindow orderFront:self]; [CPMenu setMenuBarVisible:YES]; } @@ -406,4 +425,24 @@ [_textView setNeedsDisplay:YES]; [_textView2 setNeedsDisplay:YES]; } + +// Action tied to the "Markdown ->" button to generate rich text +- (void)convertMarkdownToRichText:(id)sender +{ + // 1. Retrieve markdown string from the right pane + var markdownInput = [_textView2 string]; + if (!markdownInput || [markdownInput length] == 0) + { + return; + } + + // 2. Parse the markdown using the updated MarkdownParser class + var parsedAttrStr = [CPMarkdownParser attributedStringFromMarkdown:markdownInput]; + + [_textView setEditable:YES]; + [_textView setString:@""]; + [_textView insertText:parsedAttrStr]; + [_textView setEditable:NO]; +} + @end From 2a0cebb8516ba13cecb916beebb6302f325ba7b7 Mon Sep 17 00:00:00 2001 From: daboe01 Date: Mon, 15 Jun 2026 19:42:53 +0200 Subject: [PATCH 08/13] improved: rtf roundtrip --- AppKit/CPTextView/_CPRTFParser.j | 19 ++++++++++++++++++- AppKit/CPTextView/_CPRTFProducer.j | 18 ++++++++++++++++-- Tests/Manual/CPTextView/AppController.j | 19 +++++-------------- 3 files changed, 39 insertions(+), 17 deletions(-) diff --git a/AppKit/CPTextView/_CPRTFParser.j b/AppKit/CPTextView/_CPRTFParser.j index 9cc99df34..a68d7af5f 100644 --- a/AppKit/CPTextView/_CPRTFParser.j +++ b/AppKit/CPTextView/_CPRTFParser.j @@ -178,7 +178,15 @@ var cp1252Map = { - (void)addTab:(float)location type:(CPTextTabType)type { - var tab = [[CPTextTab alloc] initWithType:type + var alignment = CPLeftTextAlignment; + if (type === CPCenterTabStopType || type === CPCenterTextAlignment) + alignment = CPCenterTextAlignment; + else if (type === CPRightTabStopType || type === CPRightTextAlignment) + alignment = CPRightTextAlignment; + else if (type === CPDecimalTabStopType) + alignment = CPRightTextAlignment; // Fallback alignment for decimal tab + + var tab = [[CPTextTab alloc] initWithType:alignment location:location]; if (!_tabChanged) @@ -512,6 +520,9 @@ var kRgsymRtf = { { if (_tableRows && [_tableRows count] > 0) { + // Flush active character styling runs before appending table layout changes + [self _flushCurrentRun]; + var headers = [_tableRows objectAtIndex:0]; var rows = [CPMutableArray array]; for (var idx = 1; idx < [_tableRows count]; idx++) @@ -536,6 +547,12 @@ var kRgsymRtf = { [_result appendAttributedString:tableAttrStr]; + // Update range offset of active attributes tracker + if (_currentRun) + { + _currentRun._range = CPMakeRange([_result length], 0); + } + _tableRows = nil; _currentRow = nil; _currentCellText = ""; diff --git a/AppKit/CPTextView/_CPRTFProducer.j b/AppKit/CPTextView/_CPRTFProducer.j index b10a11000..8d06ad104 100644 --- a/AppKit/CPTextView/_CPRTFProducer.j +++ b/AppKit/CPTextView/_CPRTFProducer.j @@ -357,14 +357,21 @@ function _points2twips(a) { return (a) * 20.0; } while ((tab = [enumerator nextObject])) { - switch ([tab tabStopType]) + var tabType = [tab respondsToSelector:@selector(tabStopType)] ? [tab tabStopType] : nil; + if (tabType === nil && [tab respondsToSelector:@selector(alignment)]) + tabType = [tab alignment]; + + switch (tabType) { case CPLeftTabStopType: + case CPLeftTextAlignment: break; case CPRightTabStopType: + case CPRightTextAlignment: headerString += @"\\tqr"; break; case CPCenterTabStopType: + case CPCenterTextAlignment: headerString += @"\\tqc"; break; case CPDecimalTabStopType: @@ -384,6 +391,13 @@ function _points2twips(a) { return (a) * 20.0; } { var unwrap = function(obj) { if (!obj) return null; + + // If the object contains the table structures, bypass unwrapping + if ((typeof obj.respondsToSelector === "function" && ([obj respondsToSelector:@selector(headers)] || [obj respondsToSelector:@selector(rows)])) || + obj.headers || obj._headers || obj.rows || obj._rows) { + return obj; + } + var unwrapped = null; if (typeof obj.respondsToSelector === "function") { if ([obj respondsToSelector:@selector(attachmentCell)]) { @@ -679,7 +693,7 @@ function _points2twips(a) { return (a) * 20.0; } substring = substring.replace(/\\/g, '\\\\'); substring = substring.replace(/\n/g, '\\par\n'); - substring = substring.replace(/\t/g, '\\tab'); + substring = substring.replace(/\t/g, '\\tab '); substring = substring.replace(/{/g, '\\{'); substring = substring.replace(/}/g, '\\}'); diff --git a/Tests/Manual/CPTextView/AppController.j b/Tests/Manual/CPTextView/AppController.j index de1d27100..a88dff417 100755 --- a/Tests/Manual/CPTextView/AppController.j +++ b/Tests/Manual/CPTextView/AppController.j @@ -406,20 +406,11 @@ var parser = [[_CPRTFParser alloc] init]; var roundTrippedString = [parser parseRTF:generatedRTF]; - // 5. Replace the editor's text storage with the round-tripped version - var editorStorage = [_textView textStorage]; - if (editorStorage && [editorStorage respondsToSelector:@selector(setAttributedString:)]) - { - [editorStorage setAttributedString:roundTrippedString]; - } - else - { - // Safe fallback sequence - [_textView setEditable:YES]; - [_textView setString:@""]; - [_textView insertText:roundTrippedString]; - [_textView setEditable:NO]; - } + // Safe fallback sequence + [_textView setEditable:YES]; + [_textView setString:@""]; + [_textView insertText:roundTrippedString]; + [_textView setEditable:NO]; // 6. Force a layout pass and render update [_textView setNeedsDisplay:YES]; From 4aaa32833ab7440bbfb34ef69845d655af63e13d Mon Sep 17 00:00:00 2001 From: daboe01 Date: Mon, 15 Jun 2026 22:33:03 +0200 Subject: [PATCH 09/13] fixed: table roundtrip --- AppKit/CPTextView/_CPRTFParser.j | 100 +++--------------------- Tests/Manual/CPTextView/AppController.j | 6 -- 2 files changed, 9 insertions(+), 97 deletions(-) diff --git a/AppKit/CPTextView/_CPRTFParser.j b/AppKit/CPTextView/_CPRTFParser.j index a68d7af5f..d8f95f027 100644 --- a/AppKit/CPTextView/_CPRTFParser.j +++ b/AppKit/CPTextView/_CPRTFParser.j @@ -21,6 +21,7 @@ @import @import +@import "CPTextStorage.j" @import "CPFontManager.j" @import "CPParagraphStyle.j" @import "_CPTableTextAttachment.j" @@ -114,11 +115,6 @@ var cp1252Map = { if (font) return font; - //Before giving up and using a default font, we try if this is - //not the case of a font with a composite name, such as - //'Helvetica-Light'. In that case, even if we don't have - //exactly an 'Helvetica-Light' font family, we might have an - //'Helvetica' one. var range = [fontName rangeOfString:@"-"]; if (range.location != CPNotFound) @@ -128,7 +124,6 @@ var cp1252Map = { font = [CPFont fontWithName:fontFamily size:fontSize]; } - /* Last resort, default font. :-( */ if (font == nil) font = [CPFont systemFontOfSize:fontSize]; @@ -184,7 +179,7 @@ var cp1252Map = { else if (type === CPRightTabStopType || type === CPRightTextAlignment) alignment = CPRightTextAlignment; else if (type === CPDecimalTabStopType) - alignment = CPRightTextAlignment; // Fallback alignment for decimal tab + alignment = CPRightTextAlignment; var tab = [[CPTextTab alloc] initWithType:alignment location:location]; @@ -222,20 +217,15 @@ var cp1252Map = { @end -// based on https://github.com/lazygyu/RTF-parser - var kRTFParserType_char = 0, kRTFParserType_dest = 1, kRTFParserType_prop = 2, kRTFParserType_spec = 3; -// Keyword descriptions var kRgsymRtf = { - // keyword dflt fPassDflt kwd idx "b" : [ "b", 1, false, kRTFParserType_prop, "propBold"], "ul" : [ "ul", 1, false, kRTFParserType_prop, "propUnderline"], "i" : [ "i", 1, false, kRTFParserType_prop, "propItalic"], -// "li" : [ "li", 0, false, kRTFParserType_prop, "propPgnFormat"], "pgnucltr" : [ "pgnucltr", "pgULtr", true, kRTFParserType_prop, "propPgnFormat"], "pgnlcltr" : [ "pgnlcltr", "pgLLtr", true, kRTFParserType_prop, "propPgnFormat"], "qc" : [ "qc", "justC", true, kRTFParserType_prop, "propJust"], @@ -277,9 +267,7 @@ var kRgsymRtf = { "ftnsep" : [ "ftnsep", 0, false, kRTFParserType_dest, "destSkip"], "ftnsepc" : [ "ftnsepc", 0, false, kRTFParserType_dest, "destSkip"], "fprq" : [ "fprq", 0, false, kRTFParserType_dest, "destSkip"], -// "fcharset" : [ "fcharset", 0, false, kRTFParserType_dest, "destSkip"], "rquote" : [ "rquote", 0, false, kRTFParserType_char, "'"], -// "s" : [ "s", 0, false, kRTFParserType_dest, "destSkip"], "header" : [ "header", 0, false, kRTFParserType_dest, "destSkip"], "headerf" : [ "headerf", 0, false, kRTFParserType_dest, "destSkip"], "headerl" : [ "headerl", 0, false, kRTFParserType_dest, "destSkip"], @@ -373,7 +361,6 @@ var kRgsymRtf = { return sym[4]; case 1: - // CPLogConsole("skipped : " + sym[4]); return ''; default: @@ -384,7 +371,6 @@ var kRgsymRtf = { - (BOOL)pushState { - // Push stack as an object containing scoping context _states.push({ curState: _curState, run: [_currentRun copy] @@ -402,7 +388,6 @@ var kRgsymRtf = { [self _flushCurrentRun]; _currentRun = state.run; - // Safety guard to prevent setting properties on null if (!_currentRun) { _currentRun = [_RTFAttribute new]; @@ -430,7 +415,6 @@ var kRgsymRtf = { case "ipfnHex": var hex = ''; - // Konsumiere exakt 2 Zeichen nach dem \' for (var i = 0; i < 2; i++) { var nextCh = _rtf.charAt(++_currentParseIndex); @@ -520,7 +504,6 @@ var kRgsymRtf = { { if (_tableRows && [_tableRows count] > 0) { - // Flush active character styling runs before appending table layout changes [self _flushCurrentRun]; var headers = [_tableRows objectAtIndex:0]; @@ -532,27 +515,16 @@ var kRgsymRtf = { var attachment = [[_CPTableTextAttachment alloc] initWithHeaders:headers rows:rows width:500.0]; - // Linear height allocation estimation - var numCols = [headers count]; - var estimatedHeight = 36.0 + ([rows count] * 28.0); - var lineCount = Math.ceil(estimatedHeight / 16.0) + 1; - var newlineStr = ""; - for (var nl = 0; nl < lineCount; nl++) { - newlineStr += "\n"; - } - - var tableAttrStr = [[CPMutableAttributedString alloc] initWithString:newlineStr]; - [tableAttrStr addAttribute:@"TableAttachmentAttribute" value:attachment range:CPMakeRange(0, [tableAttrStr length])]; - [tableAttrStr addAttribute:CPAttachmentAttributeName value:attachment range:CPMakeRange(0, [tableAttrStr length])]; + // Use standard Cocoa/Cappuccino NSAttachmentCharacter creation method + var tableAttrStr = [CPTextStorage attributedStringWithAttachment:attachment]; [_result appendAttributedString:tableAttrStr]; - // Update range offset of active attributes tracker if (_currentRun) { _currentRun._range = CPMakeRange([_result length], 0); } - + _tableRows = nil; _currentRow = nil; _currentCellText = ""; @@ -577,7 +549,6 @@ var kRgsymRtf = { [_result setAttributes:dict range:_currentRun._range]; // flush previous run - // Deep copy the current run style for the next sequence of characters _currentRun = [_currentRun copy]; } else @@ -590,8 +561,6 @@ var kRgsymRtf = { - (CPString)_applyPropChange:sym parameter:param { - //console.log("prop : " + sym[0] + " / param : " + param+ ' '); - switch (sym[0]) { case "pard": @@ -696,7 +665,6 @@ var kRgsymRtf = { if (sym[4] == "destSkip") { - CPLogConsole("Dest skip start : [" + sym[0] + "]"); _curState++; } @@ -848,8 +816,7 @@ var kRgsymRtf = { break; default: - CPLogConsole("skip : " + keyword + " param: " + param); - + break; } return ''; @@ -965,8 +932,7 @@ var kRgsymRtf = { [self _flushTableIfAny]; if ([self pushState]) - CPLogConsole("push"); - + break; case "}": @@ -974,12 +940,9 @@ var kRgsymRtf = { [self _flushTableIfAny]; if ([self popState]) - CPLogConsole("pop"); if (_freename) { - CPLogConsole(_freename); - if (_parsingFontTable) { _fontArray.push(_freename); @@ -1008,7 +971,6 @@ var kRgsymRtf = { var byteVal = parseInt(ch, 16); var unicodeVal = byteVal; - // Windows-1252 Mapping für den Bereich 0x80 - 0x9F anwenden if (byteVal >= 0x80 && byteVal <= 0x9F) { unicodeVal = cp1252Map[byteVal] || byteVal; } @@ -1044,7 +1006,6 @@ var kRgsymRtf = { if (_parsingFontTable && _freename) { var cleanFontName = _freename.trim(); - // strip family name prefix if present (e.g., "swiss Helvetica" -> "Helvetica") var lastSpaceIdx = cleanFontName.lastIndexOf(' '); if (lastSpaceIdx !== -1) { @@ -1100,7 +1061,6 @@ var kRgsymRtf = { while (i < lines.length) { var line = lines[i]; - // Tabellen-Erkennung if ([self isTableHeaderLine:line] && i + 1 < lines.length && [self isTableSeparatorLine:lines[i+1]]) { var headers = [self parseTableCells:line]; var separatorLine = lines[i+1]; @@ -1117,7 +1077,6 @@ var kRgsymRtf = { numCols = [[rows objectAtIndex:0] count]; } - // 1. Zuerst die absolute Summe der Natural-Breiten zur Spalten-Proportionsbestimmung ermitteln var totalNaturalW = 0.0; var colNaturalWidths = []; var measureTextField = [[CPTextField alloc] initWithFrame:CGRectMake(0, 0, 10000.0, 24.0)]; @@ -1126,7 +1085,6 @@ var kRgsymRtf = { for (var c = 0; c < numCols; c++) { var cellW = 80.0; - // Headers prüfen if (c < headers.length) { var parsedText = [self parseInlineMarkdown:headers[c] isHeader:YES headerLevel:3]; [measureTextField setStringValue:[parsedText string]]; @@ -1134,7 +1092,6 @@ var kRgsymRtf = { cellW = Math.max(cellW, CGRectGetWidth([measureTextField frame]) + 24.0); } - // Reihen prüfen for (var r = 0; r < [rows count]; r++) { var rowData = [rows objectAtIndex:r]; if (c < [rowData count]) { @@ -1148,47 +1105,10 @@ var kRgsymRtf = { totalNaturalW += cellW; } - // 2. Präzise adaptive Zeilenhöhen-Schätzung für das Newline-Sizing (Verhindert zu große Abstände) - var estimatedHeight = 36.0; // Startwert für Header-Zeile mit Padding - for (var r = 0; r < [rows count]; r++) { - var rowData = [rows objectAtIndex:r]; - var maxCellHeight = 28.0; - - for (var c = 0; c < numCols; c++) { - var cellText = @""; - if (c < [rowData count]) { - cellText = [rowData objectAtIndex:c]; - } - var charCount = cellText.length; - - // Schätzung basierend auf realistischer Spaltenbreitenverteilung - var proportion = totalNaturalW > 0 ? (colNaturalWidths[c] / totalNaturalW) : (1.0 / numCols); - var estimatedColWidth = proportion * 500.0; - var charsPerLine = Math.max(10.0, Math.floor(estimatedColWidth / 6.5)); // ca. 6.5px pro Zeichen - - var estimatedLines = Math.ceil(charCount / charsPerLine); - if (estimatedLines < 1) estimatedLines = 1; - - var cellHeight = (estimatedLines * 16.0) + 12.0; - if (cellHeight > maxCellHeight) { - maxCellHeight = cellHeight; - } - } - estimatedHeight += maxCellHeight; - } - - // Berechne die benötigten Leerzeilen (\n Zeilenhöhe ist ca. 16px) - var lineCount = Math.ceil(estimatedHeight / 16.0) + 1; // Minimaler Sicherheitsabstand (+1) - var newlineStr = ""; - for (var nl = 0; nl < lineCount; nl++) { - newlineStr += "\n"; - } - - var tableAttrStr = [[CPMutableAttributedString alloc] initWithString:newlineStr]; var matrixView = [[_CPTableTextAttachment alloc] initWithHeaders:headers rows:rows width:500.0]; - [tableAttrStr addAttribute:@"TableAttachmentAttribute" value:matrixView range:CPMakeRange(0, [tableAttrStr length])]; - [tableAttrStr addAttribute:CPAttachmentAttributeName value:matrixView range:CPMakeRange(0, [tableAttrStr length])]; + // Render utilizing the correct atomic attachment character string + var tableAttrStr = [CPTextStorage attributedStringWithAttachment:matrixView]; [result appendAttributedString:tableAttrStr]; continue; } @@ -1196,7 +1116,6 @@ var kRgsymRtf = { var isHeader = false; var headerLevel = 0; - // Überschriften (#) var headerMatch = line.match(/^(#{1,6})\s+(.*)$/); if (headerMatch) { headerLevel = headerMatch[1].length; @@ -1204,7 +1123,6 @@ var kRgsymRtf = { isHeader = true; } - // Listenpunkte (- oder *) var isListItem = false; var listMatch = line.match(/^(\*|-)\s+(.*)$/); if (listMatch) { diff --git a/Tests/Manual/CPTextView/AppController.j b/Tests/Manual/CPTextView/AppController.j index a88dff417..3fab7a14c 100755 --- a/Tests/Manual/CPTextView/AppController.j +++ b/Tests/Manual/CPTextView/AppController.j @@ -410,11 +410,6 @@ [_textView setEditable:YES]; [_textView setString:@""]; [_textView insertText:roundTrippedString]; - [_textView setEditable:NO]; - - // 6. Force a layout pass and render update - [_textView setNeedsDisplay:YES]; - [_textView2 setNeedsDisplay:YES]; } // Action tied to the "Markdown ->" button to generate rich text @@ -433,7 +428,6 @@ [_textView setEditable:YES]; [_textView setString:@""]; [_textView insertText:parsedAttrStr]; - [_textView setEditable:NO]; } @end From 77433a3a1cb50620ffc66d59250d5cb168aeb9a3 Mon Sep 17 00:00:00 2001 From: daboe01 Date: Tue, 16 Jun 2026 07:16:14 +0200 Subject: [PATCH 10/13] fixed: scrolling glitch --- AppKit/CPTextView/CPTextView.j | 3 +++ 1 file changed, 3 insertions(+) diff --git a/AppKit/CPTextView/CPTextView.j b/AppKit/CPTextView/CPTextView.j index 16151f9e0..fa6db4378 100644 --- a/AppKit/CPTextView/CPTextView.j +++ b/AppKit/CPTextView/CPTextView.j @@ -1660,7 +1660,10 @@ Sets the selection to a range of characters in response to user action. - (void)moveRight:(id)sender { if ([self isSelectable]) + { [self _establishSelection:CPMakeRange(CPMaxRange(_selectionRange) + (_selectionRange.length ? 0 : 1), 0) byExtending:NO]; + [self scrollRangeToVisible:_selectionRange]; + } } - (void)_deleteForRange:(CPRange)changedRange From da061aac1dc12e83680c069e495791377ee74d37 Mon Sep 17 00:00:00 2001 From: daboe01 Date: Tue, 16 Jun 2026 10:30:00 +0200 Subject: [PATCH 11/13] improved: table generation --- AppKit/CPTextView/_CPRTFProducer.j | 85 +++++----- AppKit/CPTextView/_CPTableTextAttachment.j | 180 ++++++++++++++++++--- 2 files changed, 200 insertions(+), 65 deletions(-) diff --git a/AppKit/CPTextView/_CPRTFProducer.j b/AppKit/CPTextView/_CPRTFProducer.j index 8d06ad104..1e48afda8 100644 --- a/AppKit/CPTextView/_CPRTFProducer.j +++ b/AppKit/CPTextView/_CPRTFProducer.j @@ -89,12 +89,7 @@ function _points2twips(a) { return (a) * 20.0; } { if (self = [super init]) { - // maintain a dictionary for the used colours - // (for rtf-header generation) colorDict = [CPMutableDictionary new]; - - //maintain a dictionary for the used fonts - //(for rtf-header generation) fontDict = [CPMutableDictionary new]; fgColor = [CPColor blackColor]; @@ -104,7 +99,6 @@ function _points2twips(a) { return (a) * 20.0; } return self; } -// private stuff follows - (CPString)fontTable { if (![fontDict count]) @@ -319,7 +313,6 @@ function _points2twips(a) { return (a) * 20.0; } break; } - // write first line indent and left indent var twips = _points2twips([paraStyle firstLineHeadIndent]); if (twips != 0.0) @@ -392,7 +385,6 @@ function _points2twips(a) { return (a) * 20.0; } var unwrap = function(obj) { if (!obj) return null; - // If the object contains the table structures, bypass unwrapping if ((typeof obj.respondsToSelector === "function" && ([obj respondsToSelector:@selector(headers)] || [obj respondsToSelector:@selector(rows)])) || obj.headers || obj._headers || obj.rows || obj._rows) { return obj; @@ -434,7 +426,6 @@ function _points2twips(a) { return (a) * 20.0; } tableAttachment = unwrap(tableAttachment); - // Ultimate fallback scanner for layout character placeholder sequences if (!tableAttachment && (substring === "\uFFFC" || substring === "")) { var keys = [attributes allKeys], @@ -462,20 +453,48 @@ function _points2twips(a) { return (a) * 20.0; } var headers = null, rows = null; - if (typeof tableAttachment.respondsToSelector === "function") { - if ([tableAttachment respondsToSelector:@selector(headers)]) { - headers = [tableAttachment headers]; + // Try to fetch from the active live view of the attachment first to capture user edits + var activeView = null; + if (typeof tableAttachment.respondsToSelector === "function" && [tableAttachment respondsToSelector:@selector(view)]) { + activeView = [tableAttachment view]; + } + if (!activeView) { + activeView = tableAttachment._view || tableAttachment.view; + } + + if (activeView) { + if (typeof activeView.respondsToSelector === "function") { + if ([activeView respondsToSelector:@selector(headers)]) { + headers = [activeView headers]; + } + if ([activeView respondsToSelector:@selector(rows)]) { + rows = [activeView rows]; + } } - if ([tableAttachment respondsToSelector:@selector(rows)]) { - rows = [tableAttachment rows]; + if (!headers) { + headers = activeView._headers || activeView.headers; + } + if (!rows) { + rows = activeView._rows || activeView.rows; } } - - if (!headers) { - headers = tableAttachment._headers || tableAttachment.headers; - } - if (!rows) { - rows = tableAttachment._rows || tableAttachment.rows; + + // Fall back to the attachment's parsed properties if the view is nil or lacks the properties + if (!headers || !rows) { + if (typeof tableAttachment.respondsToSelector === "function") { + if ([tableAttachment respondsToSelector:@selector(headers)]) { + headers = [tableAttachment headers]; + } + if ([tableAttachment respondsToSelector:@selector(rows)]) { + rows = [tableAttachment rows]; + } + } + if (!headers) { + headers = tableAttachment._headers || tableAttachment.headers; + } + if (!rows) { + rows = tableAttachment._rows || tableAttachment.rows; + } } var getCount = function(arr) { @@ -563,23 +582,12 @@ function _points2twips(a) { return (a) * 20.0; } headerString += [self paragraphStyle:paraStyle]; } - /* - * analyze attributes of current run - * - * FIXME: All the character attributes should be output relative to the font - * attributes of the paragraph. So if the paragraph has underline on it should - * still be possible to switch it off for some characters, which currently is - * not possible. - */ attribEnum = [attributes keyEnumerator]; while ((currAttrib = [attribEnum nextObject]) != nil) { if ([currAttrib isEqualToString:CPFontAttributeName]) { - /* - * handle fonts - */ var font, fontName, traits; @@ -588,15 +596,9 @@ function _points2twips(a) { return (a) * 20.0; } fontName = [font familyName]; traits = [[CPFontManager sharedFontManager] traitsOfFont:font]; - /* - * font name - */ if (currentFont == nil || ![fontName isEqualToString:[currentFont familyName]]) headerString += [self fontToken:fontName]; - /* - * font size - */ if (currentFont == nil || [font size] != [currentFont size]) { var points = [font size] * 2, @@ -605,9 +607,7 @@ function _points2twips(a) { return (a) * 20.0; } pString = [CPString stringWithFormat:@"\\fs%d", points]; headerString += pString; } - /* - * font attributes - */ + if (traits & CPItalicFontMask) { headerString += @"\\i"; @@ -713,7 +713,7 @@ function _points2twips(a) { return (a) * 20.0; } var nobraces; if ([headerString length]) - nobraces = [CPString stringWithFormat:@"%@ %@", headerString, substring]; + nobraces = [CPString stringWithFormat:@"%@ %@}", headerString, substring]; else nobraces = substring; @@ -733,7 +733,7 @@ function _points2twips(a) { return (a) * 20.0; } completeRange = CPMakeRange(0, length), paragraphStart = YES; - while (CPMaxRange(currRange) < CPMaxRange(completeRange)) // save all "runs" + while (CPMaxRange(currRange) < CPMaxRange(completeRange)) { var attributes, substring, @@ -749,7 +749,6 @@ function _points2twips(a) { return (a) * 20.0; } result += runString; - // Dynamically compute paragraph boundaries based on standard line feeds if (substring.length > 0 && substring.charAt(substring.length - 1) === '\n') paragraphStart = YES; else diff --git a/AppKit/CPTextView/_CPTableTextAttachment.j b/AppKit/CPTextView/_CPTableTextAttachment.j index 5984ab11f..7a7ffba92 100644 --- a/AppKit/CPTextView/_CPTableTextAttachment.j +++ b/AppKit/CPTextView/_CPTableTextAttachment.j @@ -28,20 +28,29 @@ CPArray _headers; CPArray _rows; BOOL _isResizing; + BOOL _isEditable; + BOOL _acceptsRichText; } - (id)initWithHeaders:(CPArray)headers rows:(CPArray)rows { - return [self initWithHeaders:headers rows:rows width:500.0]; + return [self initWithHeaders:headers rows:rows width:500.0 isEditable:YES acceptsRichText:YES]; } - (id)initWithHeaders:(CPArray)headers rows:(CPArray)rows width:(float)totalWidth +{ + return [self initWithHeaders:headers rows:rows width:totalWidth isEditable:YES acceptsRichText:NO]; +} + +- (id)initWithHeaders:(CPArray)headers rows:(CPArray)rows width:(float)totalWidth isEditable:(BOOL)isEditable acceptsRichText:(BOOL)acceptsRichText { self = [super initWithFrame:CGRectMake(0, 0, totalWidth, 20)]; if (self) { _headers = headers; _rows = rows; + _isEditable = isEditable; + _acceptsRichText = acceptsRichText; _isResizing = NO; [self _rebuildTableWithWidth:totalWidth]; @@ -91,11 +100,13 @@ [self resizeToWidth:totalWidth]; - // Trigger a parent layout manager re-layout once the table is fully reconstructed and sized. + // Trigger parent layout engine re-calculation var textView = [self superview]; + if (textView && [textView isKindOfClass:[CPTextView class]]) { var layoutManager = [textView layoutManager]; + if (layoutManager) { var charRange = [self _findCharacterRangeInLayoutManager:layoutManager]; @@ -138,22 +149,81 @@ - (CPArray)headers { + var numCols = _headers ? [_headers count] : 0; + if (numCols == 0 && _rows && [_rows count] > 0) + numCols = [[_rows objectAtIndex:0] count]; + + if (numCols == 0) + return _headers; + + var subviews = [self subviews]; + if ([subviews count] < numCols) + return _headers; // Subviews are not yet rendered, return cached fallback + + var currentHeaders = [CPMutableArray array]; + for (var c = 0; c < numCols; c++) + { + var cellView = [subviews objectAtIndex:c]; + var textView = [self getTextViewFromCell:cellView]; + var text = @""; + if (textView) + { + text = _acceptsRichText ? [[textView textStorage] copy] : [textView string]; + } + [currentHeaders addObject:text]; + } + + _headers = currentHeaders; return _headers; } - (CPArray)rows { + var numCols = _headers ? [_headers count] : 0; + if (numCols == 0 && _rows && [_rows count] > 0) + numCols = [[_rows objectAtIndex:0] count]; + + if (numCols == 0 || !_rows) + return _rows; + + var subviews = [self subviews]; + var headerOffset = (_headers && [_headers count] > 0) ? numCols : 0; + var expectedCount = headerOffset + ([_rows count] * numCols); + + if ([subviews count] < expectedCount) + return _rows; // Subviews are not yet rendered, return cached fallback + + var currentRows = [CPMutableArray array]; + var cellIndex = headerOffset; + + for (var r = 0; r < [_rows count]; r++) + { + var rowData = [CPMutableArray array]; + for (var c = 0; c < numCols; c++) + { + var cellView = [subviews objectAtIndex:cellIndex++]; + var textView = [self getTextViewFromCell:cellView]; + var text = @""; + if (textView) + { + text = _acceptsRichText ? [[textView textStorage] copy] : [textView string]; + } + [rowData addObject:text]; + } + [currentRows addObject:rowData]; + } + + _rows = currentRows; return _rows; } - (CPView)viewForWidth:(float)width { - // Always call resize to ensure cell views are constructed, but do not guard here [self resizeToWidth:width]; return self; } -- (CPView)createCellWithText:(CPString)text frame:(CGRect)frame isHeader:(BOOL)isHeader +- (CPView)createCellWithText:(id)text frame:(CGRect)frame isHeader:(BOOL)isHeader { var initialWidth = (frame.size.width > 0) ? frame.size.width : 120.0; var initialHeight = (frame.size.height > 0) ? frame.size.height : 28.0; @@ -176,23 +246,48 @@ var textContainer = [[CPTextContainer alloc] initWithContainerSize:CGSizeMake(initialWidth - 8, 1e7)]; var textView = [[CPTextView alloc] initWithFrame:CGRectMake(4, 2, initialWidth - 8, initialHeight - 4) textContainer:textContainer]; - [textView setEditable:YES]; + [textView setEditable:_isEditable]; [textView setSelectable:YES]; [textView setBackgroundColor:[CPColor clearColor]]; [textView setVerticallyResizable:YES]; [textView setHorizontallyResizable:NO]; [[textView textContainer] setWidthTracksTextView:YES]; + // Configure cell rich text mode + [textView setRichText:_acceptsRichText]; + + // Intercept changes within cell TextViews to notify the table + [textView setDelegate:self]; + // Configure cell text style using standard CPTextView APIs var cellFont = isHeader ? [CPFont boldSystemFontOfSize:11.0] : [CPFont systemFontOfSize:11.0]; [textView setFont:cellFont]; [textView setTextColor:[CPColor blackColor]]; - [textView setString:text]; + + // Populate either rich text or plain text safely + if (text && [text isKindOfClass:[CPAttributedString class]]) + { + [[textView textStorage] setAttributedString:text]; + } + else if (text) + { + [textView setString:String(text)]; + } + else + { + [textView setString:@""]; + } [cellContainer addSubview:textView]; return cellContainer; } +- (void)textDidChange:(CPNotification)aNotification +{ + // Live update cell layouts and heights when changes are typed + [self resizeToWidth:CGRectGetWidth([self frame])]; +} + - (CPTextView)getTextViewFromCell:(CPView)cellView { var subviews = [cellView subviews]; @@ -212,9 +307,13 @@ _isResizing = YES; - var numCols = _headers ? [_headers count] : 0; - if (numCols == 0 && _rows && [_rows count] > 0) { - numCols = [[_rows objectAtIndex:0] count]; + // Use dynamic getters to fetch live edited strings + var currentHeaders = [self headers]; + var currentRows = [self rows]; + + var numCols = currentHeaders ? [currentHeaders count] : 0; + if (numCols == 0 && currentRows && [currentRows count] > 0) { + numCols = [[currentRows objectAtIndex:0] count]; } if (numCols == 0) { _isResizing = NO; @@ -236,14 +335,26 @@ var cellFont = isHeader ? [CPFont boldSystemFontOfSize:11.0] : [CPFont systemFontOfSize:11.0]; [measureTextField setFont:cellFont]; - [measureTextField setStringValue:cellText]; + var plainText = (cellText && typeof cellText.string === "function") ? [cellText string] : (cellText || @""); + plainText = String(plainText); + + if (cellText && typeof cellText.string === "function") { + if ([measureTextField respondsToSelector:@selector(setAttributedStringValue:)]) { + [measureTextField setAttributedStringValue:cellText]; + } else { + [measureTextField setStringValue:plainText]; + } + } else { + [measureTextField setStringValue:plainText]; + } + [measureTextField sizeToFit]; var naturalW = CGRectGetWidth([measureTextField frame]) + 24.0; if (naturalW > colNaturalWidths[colIndex]) { colNaturalWidths[colIndex] = naturalW; } - var words = cellText.split(/[\s\-]/); + var words = plainText.split(/[\s\-]/); var maxWordW = 50.0; for (var w = 0; w < words.length; w++) { var word = words[w].trim(); @@ -260,15 +371,15 @@ } }; - if (_headers) { - for (var c = 0; c < [_headers count]; c++) { - measureCell([_headers objectAtIndex:c], YES, c); + if (currentHeaders) { + for (var c = 0; c < [currentHeaders count]; c++) { + measureCell([currentHeaders objectAtIndex:c], YES, c); } } - if (_rows) { - for (var r = 0; r < [_rows count]; r++) { - var rowData = [_rows objectAtIndex:r]; + if (currentRows) { + for (var r = 0; r < [currentRows count]; r++) { + var rowData = [currentRows objectAtIndex:r]; for (var c = 0; c < numCols; c++) { var cellText = @""; if (c < [rowData count]) { @@ -373,26 +484,51 @@ return maxCellHeight; }; - if (_headers && [_headers count] > 0) { + if (currentHeaders && [currentHeaders count] > 0) { var headerHeight = layoutRow(cellIndex); cellIndex += numCols; currentY += headerHeight; } - if (_rows) { - for (var r = 0; r < [_rows count]; r++) { + if (currentRows) { + for (var r = 0; r < [currentRows count]; r++) { var rowHeight = layoutRow(cellIndex); cellIndex += numCols; currentY += rowHeight; } } - // GUARD FRAME SIZE MUTATIONS: Only execute setFrameSize if dimensions actually change. - // This stops infinite layout passes while ensuring subviews are laid out. + // GUARD FRAME SIZE MUTATIONS var currentSize = [self frame].size; if (ABS(currentSize.width - newWidth) > 0.1 || ABS(currentSize.height - currentY) > 0.1) { [self setFrameSize:CGSizeMake(newWidth, currentY)]; + + // we need to re-layout the textview here. + // but this is not easy as the layout engine is not re-entrant + // this does not work: (delay does not matter), layout is always off +// setTimeout(function() { +// var textView = [self superview]; +// +// if (textView && [textView isKindOfClass:[CPTextView class]]) +// { +// var layoutManager = [textView layoutManager]; +// +// if (layoutManager) +// { +// var charRange = [self _findCharacterRangeInLayoutManager:layoutManager]; +// +// if (charRange && charRange.location !== CPNotFound) +// { +// [layoutManager invalidateLayoutForCharacterRange:charRange isSoft:NO actualCharacterRange:nil]; +// [layoutManager invalidateDisplayForGlyphRange:charRange]; +// [layoutManager _validateLayoutAndGlyphs]; +// [textView sizeToFit]; +// } +// } +// } +// }, 0); + } _isResizing = NO; From d75c0ac452b0b7bfa18c4f0355fa6890fe66986a Mon Sep 17 00:00:00 2001 From: daboe01 Date: Tue, 16 Jun 2026 17:28:03 +0200 Subject: [PATCH 12/13] improved: tab management --- AppKit/CPTextView/CPLayoutManager.j | 75 +++++++++++++++++++++++++++-- AppKit/CPTextView/CPTextView.j | 4 ++ 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/AppKit/CPTextView/CPLayoutManager.j b/AppKit/CPTextView/CPLayoutManager.j index 22fb6ba19..32810373b 100644 --- a/AppKit/CPTextView/CPLayoutManager.j +++ b/AppKit/CPTextView/CPLayoutManager.j @@ -1132,6 +1132,10 @@ var _objectsInRange = function(aList, aRange) - (id)createDOMElementWithText:(CPString)aString andFont:(CPFont)aFont andColor:(CPColor)fgColor andBackgroundColor:(CPColor)bgColor andUnderline:(CPUnderlineStyle)aUnderline { + + if (!aString || aString.length === 0) + return nil; + #if PLATFORM(DOM) var style, span = document.createElement("span"); @@ -1212,7 +1216,8 @@ var _objectsInRange = function(aList, aRange) effectiveRange = attributes ? CPIntersectionRange(aRange, effectiveRange) : aRange; var string = [textStorage._string substringWithRange:effectiveRange], - underline = [attributes objectForKey:CPUnderlineStyleAttributeName] || CPUnderlineStyleNone; + underline = [attributes objectForKey:CPUnderlineStyleAttributeName] || CPUnderlineStyleNone, + paragraphStyle = [attributes objectForKey:CPParagraphStyleAttributeName] || [CPParagraphStyle defaultParagraphStyle]; // this is an attachment -> create a run for it if (string === _CPAttachmentCharacterAsString) @@ -1220,17 +1225,71 @@ var _objectsInRange = function(aList, aRange) if (![attributes objectForKey:_CPAttachmentInvisible]) { var view = [attributes objectForKey:_CPAttachmentView]; - var run = {_range:CPMakeRangeCopy(effectiveRange), color:nil, font:nil, elem:nil, string:nil, view:view}; } + var run = {_range:CPMakeRangeCopy(effectiveRange), color:nil, font:nil, elem:nil, string:nil, view:view, paragraphStyle:paragraphStyle}; _runs.push(run); + } } else { var color = [attributes objectForKey:CPForegroundColorAttributeName], bgcolor = [attributes objectForKey:CPBackgroundColorAttributeName], - font = [attributes objectForKey:CPFontAttributeName] || [textStorage font] || [CPFont systemFontOfSize:12.0], - run = {_range:CPMakeRangeCopy(effectiveRange), color:color, font:font, elem:nil, string:string, bgcolor:bgcolor}; + font = [attributes objectForKey:CPFontAttributeName] || [textStorage font] || [CPFont systemFontOfSize:12.0]; - _runs.push(run); + var currentLoc = effectiveRange.location, + strLen = string.length, + startIdx = 0; + + for (var i = 0; i < strLen; i++) + { + if (string.charCodeAt(i) === 9) // Tabulator-Zeichen '\t' + { + if (i > startIdx) + { + var subString = string.substring(startIdx, i), + subRange = CPMakeRange(currentLoc + startIdx, i - startIdx), + run = { + _range: subRange, + color: color, + font: font, + elem: nil, + string: subString, + bgcolor: bgcolor, + paragraphStyle: paragraphStyle + }; + _runs.push(run); + } + + var tabRange = CPMakeRange(currentLoc + i, 1), + tabRun = { + _range: tabRange, + color: nil, + font: nil, + elem: nil, + string: nil, + bgcolor: nil, + paragraphStyle: paragraphStyle + }; + _runs.push(tabRun); + + startIdx = i + 1; + } + } + + if (startIdx < strLen) + { + var subString = string.substring(startIdx, strLen), + subRange = CPMakeRange(currentLoc + startIdx, strLen - startIdx), + run = { + _range: subRange, + color: color, + font: font, + elem: nil, + string: subString, + bgcolor: bgcolor, + paragraphStyle: paragraphStyle + }; + _runs.push(run); + } } if (!CPMaxRange(effectiveRange)) @@ -1401,6 +1460,12 @@ var _objectsInRange = function(aList, aRange) if (newFragmentRuns[i].color !== oldFragmentRuns[i].color || newFragmentRuns[i].bgcolor !== oldFragmentRuns[i].bgcolor || newFragmentRuns[i].font !== oldFragmentRuns[i].font) return NO; + + var oldStyle = oldFragmentRuns[i].paragraphStyle || [CPParagraphStyle defaultParagraphStyle], + newStyle = newFragmentRuns[i].paragraphStyle || [CPParagraphStyle defaultParagraphStyle]; + + if (![oldStyle isEqual:newStyle]) + return NO; } return YES; diff --git a/AppKit/CPTextView/CPTextView.j b/AppKit/CPTextView/CPTextView.j index fa6db4378..ddba66801 100644 --- a/AppKit/CPTextView/CPTextView.j +++ b/AppKit/CPTextView/CPTextView.j @@ -2648,6 +2648,10 @@ var compareTabStops = function(obj1, obj2, context) { [_typingAttributes setObject:mutableStyle forKey:CPParagraphStyleAttributeName]; [[CPNotificationCenter defaultCenter] postNotificationName:CPTextViewDidChangeTypingAttributesNotification object:self]; } + + [_layoutManager _validateLayoutAndGlyphs]; + [self sizeToFit]; + [self setNeedsDisplay:YES]; } - (void)rulerView:(CPRulerView)rulerView didRemoveMarker:(CPRulerMarker)marker From a981b8b04da66f238331a6540654764f47cd22fc Mon Sep 17 00:00:00 2001 From: daboe01 Date: Tue, 16 Jun 2026 19:17:44 +0200 Subject: [PATCH 13/13] improved: indentation markers --- AppKit/CPTextView/CPRulerView.j | 162 ++++++++++++++++++++++++++------ 1 file changed, 133 insertions(+), 29 deletions(-) diff --git a/AppKit/CPTextView/CPRulerView.j b/AppKit/CPTextView/CPRulerView.j index d72e9803a..82ed13ab7 100644 --- a/AppKit/CPTextView/CPRulerView.j +++ b/AppKit/CPTextView/CPRulerView.j @@ -46,6 +46,7 @@ CPRulerOrientationVertical = 1 float _imageValue @accessors(property=imageValue); id _representedObject @accessors(property=representedObject); CPTextField _label; + CPView _customHandleView; } - (id)initWithRulerView:(CPRulerView)aRulerView markerLocation:(float)aLocation imageValue:(float)anImageValue representedObject:(id)anObject @@ -62,6 +63,9 @@ CPRulerOrientationVertical = 1 [_label setAlignment:CPCenterTextAlignment]; [self addSubview:_label]; + _customHandleView = [[CPView alloc] initWithFrame:CGRectMakeZero()]; + [self addSubview:_customHandleView]; + [self updateMarkerIcon]; } return self; @@ -78,34 +82,76 @@ CPRulerOrientationVertical = 1 [self updateMarkerIcon]; } -// Dynamically sets the Unicode triangle direction based on the alignment or indent type -// Dynamically sets the Unicode arrow direction/type based on the alignment or indent type +- (void)setFrame:(CGRect)aFrame +{ + [super setFrame:aFrame]; + [self updateMarkerIcon]; +} + +// Dynamically sets the Unicode triangle direction based on the alignment or indent type, +// or draws custom split-height grab handles for indentation controls. - (void)updateMarkerIcon { - if ([_representedObject isKindOfClass:[CPTextTab class]]) + var isIndentMarker = (_representedObject === @"CPFirstLineIndent" || _representedObject === @"CPHeadIndent"); + + if (isIndentMarker) { - var align = [_representedObject alignment]; - if (align === CPLeftTextAlignment) - [_label setStringValue:@"▶"]; // Left-aligned points Right - else if (align === CPCenterTextAlignment) - [_label setStringValue:@"▼"]; // Center-aligned points Down - else if (align === CPRightTextAlignment) - [_label setStringValue:@"◀"]; // Right-aligned points Left - } - else if ([_representedObject isKindOfClass:[CPString class]]) - { - if (_representedObject === @"CPFirstLineIndent") - [_label setStringValue:@"⥔"]; // Dotted shaft arrow pointing down for first-line indent - else if (_representedObject === @"CPHeadIndent") - [_label setStringValue:@"⥜"]; // Solid shaft arrow pointing down for following-lines (head) indent - else if (_representedObject === @"CPTailIndent") - [_label setStringValue:@"⥘"]; // Solid downward triangle for tail indent + [_label setHidden:YES]; + [_customHandleView setHidden:NO]; + + var frame = [self bounds]; + [_customHandleView setFrame:CGRectMake(0, 0, frame.size.width, frame.size.height)]; + + // Remove old internal rendering to update cleanly + [[_customHandleView subviews] makeObjectsPerformSelector:@selector(removeFromSuperview)]; + + var isFirstLine = (_representedObject === @"CPFirstLineIndent"); + + // Dark outline/border representation + [_customHandleView setBackgroundColor:[CPColor colorWithWhite:0.5 alpha:1.0]]; + + // Inner fill (top handle is lighter, bottom is slightly darker) + var innerView = [[CPView alloc] initWithFrame:CGRectMake(1.0, 1.0, frame.size.width - 2.0, frame.size.height - 2.0)]; + + if (isFirstLine) + [innerView setBackgroundColor:[CPColor colorWithWhite:0.92 alpha:1.0]]; else - [_label setStringValue:@"⇡"]; // Fallback standard up marker + [innerView setBackgroundColor:[CPColor colorWithWhite:0.80 alpha:1.0]]; + + [_customHandleView addSubview:innerView]; + + // Horizontal indicator line to visually guide drag interactions + var gripLine = [[CPView alloc] initWithFrame:CGRectMake(Math.floor(frame.size.width / 2.0) - 1.0, 2.0, 1.0, frame.size.height - 4.0)]; + [gripLine setBackgroundColor:[CPColor colorWithWhite:0.6 alpha:1.0]]; + [innerView addSubview:gripLine]; } else { - [_label setStringValue:@"⇡"]; // Fallback standard up marker + [_label setHidden:NO]; + [_customHandleView setHidden:YES]; + [_label setFrame:[self bounds]]; + + if ([_representedObject isKindOfClass:[CPTextTab class]]) + { + var align = [_representedObject alignment]; + if (align === CPLeftTextAlignment) + [_label setStringValue:@"▶"]; // Left-aligned points Right + else if (align === CPCenterTextAlignment) + [_label setStringValue:@"▼"]; // Center-aligned points Down + else if (align === CPRightTextAlignment) + [_label setStringValue:@"◀"]; // Right-aligned points Left + } + else if ([_representedObject isKindOfClass:[CPString class]]) + { + if (_representedObject === @"CPTailIndent") + [_label setStringValue:@"⥘"]; // Solid downward triangle for tail indent + else + [_label setStringValue:@"⇡"]; // Fallback standard up marker + } + else + { + [_label setStringValue:@"⇡"]; // Fallback standard up marker + } } } #pragma mark - @@ -297,15 +343,31 @@ CPRulerOrientationVertical = 1 if (isHorizontal) { var x = markerLocation - scrollPoint.x - 6.0, // Center the 12px wide marker - y = rulerHeight - 11.0; // Sit perfectly above bottom border + y = rulerHeight - 11.0, + w = 12.0, + h = 12.0; - // Keep horizontal marker within the bounds of the ruler to prevent clipping - if (x < 0.0) - x = 0.0; - else if (x + 12.0 > rulerWidth) - x = rulerWidth - 12.0; + // Align the First Line Indent (upper half) and Head Indent (lower half) controls + if ([aMarker representedObject] === @"CPFirstLineIndent") + { + y = 0.0; + h = Math.floor(rulerHeight / 2.0); + } + else if ([aMarker representedObject] === @"CPHeadIndent") + { + y = Math.floor(rulerHeight / 2.0); + h = rulerHeight - y - 1.0; // Subtract 1px to stay cleanly above bottom border + } + else + { + // Keep normal horizontal markers within the bounds of the ruler to prevent clipping + if (x < 0.0) + x = 0.0; + else if (x + 12.0 > rulerWidth) + x = rulerWidth - 12.0; + } - [aMarker setFrame:CGRectMake(x, y, 12.0, 12.0)]; + [aMarker setFrame:CGRectMake(x, y, w, h)]; } else { @@ -379,7 +441,9 @@ CPRulerOrientationVertical = 1 if (newLocation < 0) newLocation = 0; [_draggingMarker setImageValue:newLocation]; - [self _positionMarker:_draggingMarker]; + + // Smoothly redraw ruler and margin bounds on every drag step + [self updateRuler]; // Check if dragged off the ruler (more than 15px off the boundary) var draggedOff = isHorizontal ? (localPoint.y < -15 || localPoint.y > CGRectGetHeight([self bounds]) + 15) @@ -432,6 +496,7 @@ CPRulerOrientationVertical = 1 } _draggingMarker = nil; + [self updateRuler]; } @@ -464,6 +529,45 @@ CPRulerOrientationVertical = 1 [bottomBorder setBackgroundColor:[CPColor colorWithWhite:0.75 alpha:1.0]]; [self addSubview:bottomBorder]; + // Find indent markers to determine background highlight boundaries + var firstLineMarker = nil, + headMarker = nil; + for (var i = 0; i < [_markers count]; i++) + { + var m = [_markers objectAtIndex:i]; + if ([m representedObject] === @"CPFirstLineIndent") + firstLineMarker = m; + else if ([m representedObject] === @"CPHeadIndent") + headMarker = m; + } + + var halfHeight = Math.floor(rulerHeight / 2.0); + + // Draw First Line Indent background - top half (lighter gray) + if (firstLineMarker) + { + var firstLineX = [firstLineMarker imageValue] - scrollPoint.x; + if (firstLineX > 0) + { + var firstLineBg = [[CPView alloc] initWithFrame:CGRectMake(0, 0, firstLineX, halfHeight)]; + [firstLineBg setBackgroundColor:[CPColor colorWithWhite:0.93 alpha:1.0]]; + [self addSubview:firstLineBg]; + } + } + + // Draw Head Indent background - bottom half (slightly darker gray) + if (headMarker) + { + var headX = [headMarker imageValue] - scrollPoint.x; + if (headX > 0) + { + var headBg = [[CPView alloc] initWithFrame:CGRectMake(0, halfHeight, headX, rulerHeight - halfHeight - 1.0)]; + [headBg setBackgroundColor:[CPColor colorWithWhite:0.86 alpha:1.0]]; + [self addSubview:headBg]; + } + } + + // Render ruler tick lines and labels on top of shaded areas for (var val = start; val <= end; val += 10) { if (val < 0) continue;