diff --git a/AppKit/CPTextView/CPLayoutManager.j b/AppKit/CPTextView/CPLayoutManager.j index 74cd87b40..32810373b 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; @@ -1095,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"); @@ -1175,18 +1216,16 @@ 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) { 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, paragraphStyle:paragraphStyle}; _runs.push(run); } } @@ -1194,10 +1233,63 @@ var _objectsInRange = function(aList, aRange) { 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)) @@ -1269,13 +1361,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 +1378,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 +1395,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 +1417,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); } @@ -1358,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; @@ -1378,6 +1486,9 @@ var _objectsInRange = function(aList, aRange) if (_runs[i].view) _runs[i].view._frame.origin.y += verticalOffset; + if (_runs[i].elem) + _runs[i].elem.top = (_runs[i].elem.top + verticalOffset) + 'px'; + _runs[i].DOMpatched = YES; } } 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; diff --git a/AppKit/CPTextView/CPTextView.j b/AppKit/CPTextView/CPTextView.j index 9f0fa95bb..bc82462e7 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 @@ -1402,7 +1403,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 @@ -1424,7 +1428,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 @@ -1658,7 +1665,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 @@ -2139,9 +2149,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 { @@ -2637,6 +2653,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 @@ -2856,11 +2876,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/_CPRTFParser.j b/AppKit/CPTextView/_CPRTFParser.j index df1569481..d8f95f027 100644 --- a/AppKit/CPTextView/_CPRTFParser.j +++ b/AppKit/CPTextView/_CPRTFParser.j @@ -21,8 +21,10 @@ @import @import +@import "CPTextStorage.j" @import "CPFontManager.j" @import "CPParagraphStyle.j" +@import "_CPTableTextAttachment.j" @global CPLeftTextAlignment @global CPRightTextAlignment @@ -34,6 +36,8 @@ @global CPForegroundColorAttributeName @global CPBackgroundColorAttributeName @global CPParagraphStyleAttributeName +@global CPAttachmentAttributeName +@global CPUnderlineStyleAttributeName @global CPLeftTabStopType @global CPRightTabStopType @@ -111,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) @@ -125,7 +124,6 @@ var cp1252Map = { font = [CPFont fontWithName:fontFamily size:fontSize]; } - /* Last resort, default font. :-( */ if (font == nil) font = [CPFont systemFontOfSize:fontSize]; @@ -175,7 +173,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; + + var tab = [[CPTextTab alloc] initWithType:alignment location:location]; if (!_tabChanged) @@ -203,25 +209,23 @@ var cp1252Map = { if (bgColour) [ret setObject:bgColour forKey:CPBackgroundColorAttributeName]; + if (underline) + [ret setObject:[CPNumber numberWithInt:1] forKey:CPUnderlineStyleAttributeName]; + return ret; } @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"], @@ -263,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"], @@ -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,15 @@ var kRgsymRtf = { _fontArray = ['Arial']; // FIXME: should be name of system font _freename = ""; _parsingFontTable = NO; + + _inTableActive = NO; + _waitingForNextRow = NO; + _tableRows = nil; + _currentRow = nil; + _currentCellText = ""; + + // Safe Initialization + _currentRun = [_RTFAttribute new]; } return self; @@ -338,7 +361,6 @@ var kRgsymRtf = { return sym[4]; case 1: - // CPLogConsole("skipped : " + sym[4]); return ''; default: @@ -349,7 +371,6 @@ var kRgsymRtf = { - (BOOL)pushState { - // Push stack as an object containing scoping context _states.push({ curState: _curState, run: [_currentRun copy] @@ -366,7 +387,18 @@ var kRgsymRtf = { [self _flushCurrentRun]; _currentRun = state.run; + + if (!_currentRun) + { + _currentRun = [_RTFAttribute new]; + } + _currentRun._range = CPMakeRange([_result length], 0); + + if (_curState == 0) + { + _parsingFontTable = NO; + } } return YES; } @@ -383,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); @@ -420,11 +451,88 @@ 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) + { + [self _flushCurrentRun]; + + 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]; + + // Use standard Cocoa/Cappuccino NSAttachmentCharacter creation method + var tableAttrStr = [CPTextStorage attributedStringWithAttachment:attachment]; + + [_result appendAttributedString:tableAttrStr]; + + if (_currentRun) + { + _currentRun._range = CPMakeRange([_result length], 0); + } + + _tableRows = nil; + _currentRow = nil; + _currentCellText = ""; + _inTableActive = NO; + _waitingForNextRow = NO; + } +} + - (void)_flushCurrentRun { var newOffset = 0; @@ -441,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 @@ -454,8 +561,6 @@ var kRgsymRtf = { - (CPString)_applyPropChange:sym parameter:param { - //console.log("prop : " + sym[0] + " / param : " + param+ ' '); - switch (sym[0]) { case "pard": @@ -499,6 +604,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; @@ -543,7 +665,6 @@ var kRgsymRtf = { if (sym[4] == "destSkip") { - CPLogConsole("Dest skip start : [" + sym[0] + "]"); _curState++; } @@ -552,6 +673,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]; @@ -687,8 +816,7 @@ var kRgsymRtf = { break; default: - CPLogConsole("skip : " + keyword + " param: " + param); - + break; } return ''; @@ -744,9 +872,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 +901,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,19 +928,21 @@ var kRgsymRtf = { break; case "{": - if ([self pushState]) - CPLogConsole("push"); + if (_waitingForNextRow) + [self _flushTableIfAny]; + if ([self pushState]) + break; case "}": + if (_waitingForNextRow) + [self _flushTableIfAny]; + if ([self popState]) - CPLogConsole("pop"); if (_freename) { - CPLogConsole(_freename); - if (_parsingFontTable) { _fontArray.push(_freename); @@ -829,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; } @@ -855,15 +996,288 @@ var kRgsymRtf = { lastchar = 0; if (_curState == 0) + { [self _appendPlainString:tmp]; - else if (tmp !== ';') - _freename += tmp; + } + else + { + if (tmp === ';') + { + if (_parsingFontTable && _freename) + { + var cleanFontName = _freename.trim(); + var lastSpaceIdx = cleanFontName.lastIndexOf(' '); + if (lastSpaceIdx !== -1) + { + cleanFontName = cleanFontName.substring(lastSpaceIdx + 1); + } + _fontArray.push(cleanFontName); + _freename = ""; + } + } + else + { + _freename += tmp; + } + } break; } } + [self _flushTableIfAny]; + return _result; } @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]; + + 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]; + } + + 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; + + 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); + } + + 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; + } + + var matrixView = [[_CPTableTextAttachment alloc] initWithHeaders:headers rows:rows width:500.0]; + + // Render utilizing the correct atomic attachment character string + var tableAttrStr = [CPTextStorage attributedStringWithAttachment:matrixView]; + [result appendAttributedString:tableAttrStr]; + continue; + } + + var isHeader = false; + var headerLevel = 0; + + var headerMatch = line.match(/^(#{1,6})\s+(.*)$/); + if (headerMatch) { + headerLevel = headerMatch[1].length; + line = headerMatch[2]; + isHeader = true; + } + + 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/AppKit/CPTextView/_CPRTFProducer.j b/AppKit/CPTextView/_CPRTFProducer.j index 7333b2c55..1e48afda8 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 @@ -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", @@ -83,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]; @@ -98,7 +99,6 @@ function _points2twips(a) { return (a) * 20.0; } return self; } -// private stuff follows - (CPString)fontTable { if (![fontDict count]) @@ -313,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) @@ -351,26 +350,29 @@ 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: - // no tabkind emission needed + case CPLeftTextAlignment: break; -/* case NSRightTabStopType: + case CPRightTabStopType: + case CPRightTextAlignment: headerString += @"\\tqr"; - break; - case NSCenterTabStopType: - headerString += @"\\tqc"; - break; - case NSDecimalTabStopType: + break; + case CPCenterTabStopType: + case CPCenterTextAlignment: + 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; @@ -380,6 +382,194 @@ function _points2twips(a) { return (a) * 20.0; } attributes:(CPDictionary) attributes paragraphStart:(BOOL) first { + var unwrap = function(obj) { + if (!obj) return null; + + 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)]) { + 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]; + } + + tableAttachment = unwrap(tableAttachment); + + if (!tableAttachment && (substring === "\uFFFC" || substring === "")) + { + var keys = [attributes allKeys], + count = [keys 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; + + // 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 (!headers) { + headers = activeView._headers || activeView.headers; + } + if (!rows) { + rows = activeView._rows || activeView.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) { + 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) + { + 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 < 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, '\\}'); + 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 (getCount(headers) > 0) { + tableRTF += writeRow(headers, YES); + } + var rowCount = getCount(rows); + for (var r = 0; r < rowCount; r++) { + tableRTF += writeRow(getObjectAtIndex(rows, r), NO); + } + + return tableRTF; + } + } + var result = "", headerString = "", trailerString = "", @@ -392,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; @@ -417,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, @@ -434,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"; @@ -522,11 +693,9 @@ 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, '\\}'); - // FIXME: All characters not in the standard encoding must be - // replaced by \'xx if (!first) { @@ -544,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; @@ -562,10 +731,9 @@ function _points2twips(a) { return (a) * 20.0; } length = [string length], currRange = CPMakeRange(loc, 0), completeRange = CPMakeRange(0, length), - first = YES; + paragraphStart = YES; - // FIXME split along newline characters and run as outer loop - while (CPMaxRange(currRange) < CPMaxRange(completeRange)) // save all "runs" + while (CPMaxRange(currRange) < CPMaxRange(completeRange)) { var attributes, substring, @@ -577,10 +745,14 @@ 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; + + if (substring.length > 0 && substring.charAt(substring.length - 1) === '\n') + paragraphStart = YES; + else + paragraphStart = NO; } return result; @@ -598,9 +770,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]; diff --git a/AppKit/CPTextView/_CPTableTextAttachment.j b/AppKit/CPTextView/_CPTableTextAttachment.j new file mode 100644 index 000000000..7a7ffba92 --- /dev/null +++ b/AppKit/CPTextView/_CPTableTextAttachment.j @@ -0,0 +1,537 @@ +/* _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 "CPView.j" +@import "CPTextView.j" +@import "CPTextField.j" +@import + +@implementation _CPTableTextAttachment : CPView +{ + 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 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]; + } + + return self; +} + +- (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 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 = @""; + + if (c < [rowData count]) + cellText = [rowData objectAtIndex:c]; + + var cellView = [self createCellWithText:cellText frame:CGRectMakeZero() isHeader:NO]; + [self addSubview:cellView]; + } + } + } + + [self resizeToWidth:totalWidth]; + + // 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]; + 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 +{ + 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 +{ + [self resizeToWidth:width]; + return self; +} + +- (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; + + 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:_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]]; + + // 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]; + 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 +{ + if (_isResizing) + return; + + _isResizing = YES; + + // 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; + 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]; + + 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 = plainText.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; + } + }; + + if (currentHeaders) { + for (var c = 0; c < [currentHeaders count]; c++) { + measureCell([currentHeaders objectAtIndex:c], YES, c); + } + } + + 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]) { + 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 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; + } + } + } + } + + 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 (currentHeaders && [currentHeaders count] > 0) { + var headerHeight = layoutRow(cellIndex); + cellIndex += numCols; + currentY += headerHeight; + } + + if (currentRows) { + for (var r = 0; r < [currentRows count]; r++) { + var rowHeight = layoutRow(cellIndex); + cellIndex += numCols; + currentY += rowHeight; + } + } + + // 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; +} + +@end diff --git a/Tests/Manual/CPTextView/AppController.j b/Tests/Manual/CPTextView/AppController.j index 7abb6d277..3fab7a14c 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 @@ -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], @@ -133,10 +154,18 @@ 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; + // 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"]; @@ -145,6 +174,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 +247,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]; @@ -225,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]; @@ -257,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"]; @@ -321,6 +357,21 @@ [_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"]; + + // 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]; } @@ -334,4 +385,49 @@ [_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]; + + // Safe fallback sequence + [_textView setEditable:YES]; + [_textView setString:@""]; + [_textView insertText:roundTrippedString]; +} + +// 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]; +} + @end