diff --git a/AppKit/CPFont.j b/AppKit/CPFont.j index fa8884278..0bbd3d448 100644 --- a/AppKit/CPFont.j +++ b/AppKit/CPFont.j @@ -464,6 +464,22 @@ following: return _CPRealFontSize(_size); } +/*! + Returns the font size. Cocoa/AppKit compatibility alias for -size. +*/ +- (float)pointSize +{ + return [self size]; +} + +/*! + Returns the font name. Cocoa/AppKit compatibility alias for -familyName. +*/ +- (CPString)fontName +{ + return [self familyName]; +} + /*! Returns the font as a CSS string */ diff --git a/AppKit/CPKeyBinding.j b/AppKit/CPKeyBinding.j index 970973a3a..7c80685a9 100644 --- a/AppKit/CPKeyBinding.j +++ b/AppKit/CPKeyBinding.j @@ -30,6 +30,7 @@ CPStandardKeyBindings = { @"@.": @"cancelOperation:", @"@a": @"selectAll:", + @"@~$v": @"pasteAsPlainText:", @"^a": @"moveToBeginningOfParagraph:", @"^$a": @"moveToBeginningOfParagraphAndModifySelection:", @"^b": @"moveBackward:", diff --git a/AppKit/CPRuleEditor/CPRuleEditor.j b/AppKit/CPRuleEditor/CPRuleEditor.j index 1910d6153..b5e57c601 100644 --- a/AppKit/CPRuleEditor/CPRuleEditor.j +++ b/AppKit/CPRuleEditor/CPRuleEditor.j @@ -80,6 +80,35 @@ var CPRuleEditorItemPBoardType = @"CPRuleEditorItemPBoardType", @n An ordered to-many relation containing the display values for the row. @n@n @c @@"criteria" @n An ordered to-many relation containing the criteria for the row. + @n@n + Localization & Positional Reordering + @n + CPRuleEditor supports complete localization of menu items and grammatical positional reordering (sentence structure layout adjustment) via strings resource files (.strings) or custom programmatic CPDictionary tables. + @n@n + Since sentence structures vary significantly across languages, the editor can dynamically reposition views (such as popups, static labels, and text fields) from left to right to form grammatically correct sentences. + @n@n + Formatting Keys (English representation): + @n @c %[%]@@ + @n Represents a popup button displaying its selected value (e.g. @c %[firstName]@@). + @n @c %@@ + @n Represents an editable text input field. + @n Static text represents a literal label placed directly inside the formatting key. + @n@n + Example English format key: + @n @c "%[firstName]@ %[is equal to]@ %@" + @n@n + Translation Patterns (Target language): + @n Positional specifiers such as @c %1$@@, @c %2$@@, @c %3$@@ dictate the visual order of views from left to right. + @n Bracketed values inside positional specifiers (e.g. @c %1$[Nombre]@@) define the localized title for popup selection items. + @n Literal text outside the specifiers (such as @c "y" or @c "und") is automatically instantiated as static text labels positioned between controls. + @n@n + Example translations: + @n@n + Spanish (Reorders to: [1: Name] y [3: Value] [2: are equal]): + @n @c "%[firstName]@ %[is equal to]@ %@" = "%1$[Nombre]@ y %3$@ %2$[son iguales]@"; + @n@n + German (Reorders to: [1: First Name] und [3: Value] [2: are equal]): + @n @c "%[firstName]@ %[is equal to]@ %@" = "%1$[Vorname]@ und %3$@ %2$[sind gleich]@"; */ @implementation CPRuleEditor : CPControl @@ -127,7 +156,7 @@ var CPRuleEditorItemPBoardType = @"CPRuleEditorItemPBoardType", BOOL _isKeyDown; BOOL _nestingModeDidChange; - _CPRuleEditorLocalizer _standardLocalizer @accessors(property=standardLocalizer); + _CPRuleEditorLocalizer _standardLocalizer; CPDictionary _itemsAndValuesToAddForRowType; } @@ -207,8 +236,34 @@ var CPRuleEditorItemPBoardType = @"CPRuleEditorItemPBoardType", [self registerForDraggedTypes:[CPArray arrayWithObjects:CPRuleEditorItemPBoardType,nil]]; [_boundArrayOwner addObserver:self forKeyPath:_boundArrayKeyPath options:CPKeyValueObservingOptionOld | CPKeyValueObservingOptionNew context:boundArrayContext]; + + [[CPNotificationCenter defaultCenter] addObserver:self + selector:@selector(_ruleEditorLocalizerDidLoad:) + name:@"_CPRuleEditorLocalizerDidLoadNotification" + object:nil]; } + +- (void)_ruleEditorLocalizerDidLoad:(CPNotification)aNotification +{ + if ([aNotification object] === [self standardLocalizer]) + { + // Defer execution to the next run loop cycle so that any active slice + // insertions have fully completed and are present in the `_slices` array. + [[CPRunLoop mainRunLoop] performBlock:function() { + var count = [_slices count]; + for (var i = 0; i < count; i++) + { + var slice = [_slices objectAtIndex:i]; + [slice _reconfigureSubviews]; + [slice _updateButtonVisibilities]; // Force updates on row button tooltips + } + + [self _updatePredicate]; + [self _sendRuleAction]; + } argument:nil order:0 modes:[CPDefaultRunLoopMode]]; + } +} /*! @endcond */ /*! @@ -384,7 +439,7 @@ var CPRuleEditorItemPBoardType = @"CPRuleEditorItemPBoardType", */ - (CPDictionary)formattingDictionary { - return [_standardLocalizer dictionary]; + return [[self standardLocalizer] dictionary]; } /*! @@ -396,6 +451,9 @@ var CPRuleEditorItemPBoardType = @"CPRuleEditorItemPBoardType", */ - (void)setFormattingDictionary:(CPDictionary)dictionary { + if (_standardLocalizer == nil) + _standardLocalizer = [_CPRuleEditorLocalizer new]; + [_standardLocalizer setDictionary:dictionary]; _stringsFilename = nil; } @@ -440,6 +498,19 @@ var CPRuleEditorItemPBoardType = @"CPRuleEditorItemPBoardType", } } +- (_CPRuleEditorLocalizer)standardLocalizer +{ + if (_standardLocalizer == nil) + _standardLocalizer = [_CPRuleEditorLocalizer new]; + + return _standardLocalizer; +} + +- (void)setStandardLocalizer:(_CPRuleEditorLocalizer)aLocalizer +{ + _standardLocalizer = aLocalizer; +} + /*! @name Providing Data */ @@ -538,7 +609,7 @@ var CPRuleEditorItemPBoardType = @"CPRuleEditorItemPBoardType", if ([self rowTypeForRow:current_index] === CPRuleEditorRowTypeCompound) { var candidate = [[self _rowCacheForIndex:current_index] rowObject], - subObjects = [[self _subrowObjectsOfObject:candidate] _representedObject]; + subObjects = [self _subrowObjectsOfObject:candidate]; // Standard direct array query if ([subObjects indexOfObjectIdenticalTo:targetObject] !== CPNotFound) return current_index; @@ -605,7 +676,7 @@ TODO: implement for (var i = rowIndex + 1; i < count; i++) { var candidate = [[self _rowCacheForIndex:i] rowObject], - indexInSubrows = [[subobjects _representedObject] indexOfObjectIdenticalTo:candidate]; + indexInSubrows = [subobjects indexOfObjectIdenticalTo:candidate]; // Standard direct array query if (indexInSubrows !== CPNotFound) { @@ -774,7 +845,7 @@ TODO: implement while (current_index !== CPNotFound) { var rowObject = [[self _rowCacheForIndex:current_index] rowObject], - relativeChildIndex = [[subrows _representedObject] indexOfObjectIdenticalTo:rowObject]; + relativeChildIndex = [subrows indexOfObjectIdenticalTo:rowObject]; // Standard direct array query if (relativeChildIndex !== CPNotFound) [childsIndexes addIndex:relativeChildIndex]; @@ -831,7 +902,6 @@ TODO: implement for (i = 0; i < count; i++) { var item = [items objectAtIndex:i], - //var displayValue = [self _queryValueForItem:item inRow:aRow]; Ask the delegate or get cached value ?. displayValue = [[self displayValuesForRow:aRow] objectAtIndex:i], predpart = [self _sendDelegateRuleEditorPredicatePartsForCriterion:item withDisplayValue:displayValue inRow:aRow]; @@ -849,6 +919,7 @@ TODO: implement return nil; var current_index = [subrowsIndexes firstIndex]; + while (current_index !== CPNotFound) { var subpredicate = [self predicateForRow:current_index]; @@ -1278,23 +1349,28 @@ TODO: implement while (current_index !== CPNotFound) { - var parentIndex = [self parentRowForRow:current_index], - subrowsIndexes = [self subrowIndexesForRow:parentIndex]; - - if ([subrowsIndexes count] === 1) + var parentIndex = [self parentRowForRow:current_index]; + + // If the row has a valid parent in the editor (i.e. not a root row) + if (parentIndex !== -1) { - if (parentIndex !== -1) - return [CPIndexSet indexSetWithIndex:0]; + var subrowsIndexes = [self subrowIndexesForRow:parentIndex]; - var childlessGranPa = [self _childlessParentsIfSlicesWereDeletedAtIndexes:[CPIndexSet indexSetWithIndex:parentIndex]]; - [childlessParents addIndexes:childlessGranPa]; + // If deleting this row leaves the parent with no remaining child rows + if ([subrowsIndexes count] === 1) + { + [childlessParents addIndex:parentIndex]; + + // Recursively check if deleting this parent row leaves the grandparent childless + var childlessGranPa = [self _childlessParentsIfSlicesWereDeletedAtIndexes:[CPIndexSet indexSetWithIndex:parentIndex]]; + [childlessParents addIndexes:childlessGranPa]; + } } current_index = [indexes indexGreaterThanIndex:current_index]; } return childlessParents; - // (id)-[RuleEditor _includeSubslicesForSlicesAtIndexes:] } - (CPIndexSet)_includeSubslicesForSlicesAtIndexes:(CPIndexSet)indexes @@ -1356,8 +1432,25 @@ TODO: implement if ([self rowTypeForRow:row] === type && itemIndex < [aCriteria count]) { - var crit = [aCriteria objectAtIndex:itemIndex]; - [current_criterions addObject:crit]; + // Verify that this row's parent path matches the path currently being built + var pathMatches = true; + for (var p = 0; p < itemIndex; p++) + { + var criterionA = [aCriteria objectAtIndex:p], + criterionB = [items objectAtIndex:p]; + + if (criterionA !== criterionB && (typeof criterionA.isEqual !== "function" || ![criterionA isEqual:criterionB])) + { + pathMatches = false; + break; + } + } + + if (pathMatches) + { + var crit = [aCriteria objectAtIndex:itemIndex]; + [current_criterions addObject:crit]; + } } } @@ -1924,17 +2017,17 @@ TODO: implement - (CPString)_toolTipForAddCompoundRowButton { - return [_standardLocalizer localizedStringForString:@"Add compound row"]; + return [[self standardLocalizer] localizedStringForString:@"Add compound row"]; } - (CPString)_toolTipForAddSimpleRowButton { - return [_standardLocalizer localizedStringForString:@"Add row"]; + return [[self standardLocalizer] localizedStringForString:@"Add row"]; } - (CPString)_toolTipForDeleteRowButton { - return [_standardLocalizer localizedStringForString:@"Delete row"]; + return [[self standardLocalizer] localizedStringForString:@"Delete row"]; } - (void)_updateSliceIndentations diff --git a/AppKit/CPRuleEditor/_CPRuleEditorLocalizer.j b/AppKit/CPRuleEditor/_CPRuleEditorLocalizer.j index 12bbbe514..14404e0ed 100644 --- a/AppKit/CPRuleEditor/_CPRuleEditorLocalizer.j +++ b/AppKit/CPRuleEditor/_CPRuleEditorLocalizer.j @@ -77,6 +77,9 @@ var LocalizerStringsRegex = new RegExp("\"(.+)\"\\s*=\\s*\"(.+)\"\\s*;\\s*(//.+) } _dictionary = [CPDictionary dictionaryWithDictionary:dict]; + + // Post notification to let the rule editor know the translation dictionary is ready + [[CPNotificationCenter defaultCenter] postNotificationName:@"_CPRuleEditorLocalizerDidLoadNotification" object:self]; } - (CPString)localizedStringForString:(CPString)aString @@ -94,4 +97,231 @@ var LocalizerStringsRegex = new RegExp("\"(.+)\"\\s*=\\s*\"(.+)\"\\s*;\\s*(//.+) return aString; } +#pragma mark - Formatting & Reordering Helpers + +- (CPString)_englishRepresentationForView:(id)aView +{ + if ([aView isKindOfClass:[CPPopUpButton class]]) + { + var selectedItem = [aView selectedItem]; + if (selectedItem) + { + var originalTitle = selectedItem._originalTitle; + + // Fallback: If not cached directly, inspect representedObject payload dictionary + if (!originalTitle) + { + var rep = [selectedItem representedObject]; + if (rep && typeof rep === "object" && [rep respondsToSelector:@selector(objectForKey:)]) + { + originalTitle = [rep objectForKey:@"value"]; + } + else if (rep && typeof rep === "string") + { + originalTitle = rep; + } + } + if (!originalTitle) + { + originalTitle = [selectedItem title]; + } + return "%[" + originalTitle + "]@"; + } + return "%[]@"; + } + else if ([aView isKindOfClass:[CPTextField class]] && ![aView isEditable]) + { + return aView._originalText || [aView stringValue]; + } + else + { + return "%@"; + } +} + +- (CPString)formattingKeyForViews:(CPArray)views +{ + var keyParts = []; + var count = [views count]; + for (var i = 0; i < count; i++) + { + var view = [views objectAtIndex:i]; + [keyParts addObject:[self _englishRepresentationForView:view]]; + } + return [keyParts componentsJoinedByString:@" "]; +} + +- (void)localizeMenuItemsForViews:(CPArray)views +{ + var count = [views count]; + for (var i = 0; i < count; i++) + { + var view = [views objectAtIndex:i]; + if ([view isKindOfClass:[CPPopUpButton class]]) + { + var menuItems = [view itemArray]; + var menuItemsCount = [menuItems count]; + var selectedItem = [view selectedItem]; + + for (var j = 0; j < menuItemsCount; j++) + { + var item = [menuItems objectAtIndex:j]; + + if (!item._originalTitle) + { + var rep = [item representedObject]; + if (rep && typeof rep === "object" && [rep respondsToSelector:@selector(objectForKey:)]) + { + item._originalTitle = [rep objectForKey:@"value"]; + } + else + { + item._originalTitle = [item title]; + } + } + + // Temporarily select item to generate formatting key context + [view selectItem:item]; + + var tempKey = [self formattingKeyForViews:views]; + var tempPattern = [self localizedStringForString:tempKey]; + + if (tempPattern !== tempKey) + { + var regex = /%(\d+)\$(?:\[([^\]]+)\])?@/g; + var match; + while ((match = regex.exec(tempPattern)) !== null) + { + var position = parseInt(match[1], 10) - 1; + var translatedValue = match[2]; + + if (position === i && translatedValue) + { + [item setTitle:translatedValue]; + break; + } + } + } + else + { + [item setTitle:item._originalTitle]; + } + } + + if (selectedItem) + { + [view selectItem:selectedItem]; + } + } + } +} + +- (CPArray)localizeAndReorderViews:(CPArray)views +{ + var key = [self formattingKeyForViews:views]; + var localizedPattern = [self localizedStringForString:key]; + + if (localizedPattern === key) + { + var count = [views count]; + + for (var i = 0; i < count; i++) + { + var originalView = [views objectAtIndex:i]; + if ([originalView isKindOfClass:[CPPopUpButton class]]) + { + var selectedItem = [originalView selectedItem]; + if (selectedItem && selectedItem._originalTitle) + { + [selectedItem setTitle:selectedItem._originalTitle]; + } + } + else if ([originalView respondsToSelector:@selector(setStringValue:)] && originalView._originalText) + { + [originalView setStringValue:originalView._originalText]; + + if ([originalView isKindOfClass:[CPTextField class]] && ![originalView isEditable]) + { + var font = [originalView font] || [CPFont systemFontOfSize:[CPFont systemFontSize]], + size = [originalView._originalText sizeWithFont:font]; + [originalView setFrameSize:CGSizeMake(size.width + 4, CGRectGetHeight([originalView frame]))]; + } + } + } + + return views; + } + + var newViews = [CPMutableArray array]; + var regex = /%(\d+)\$(?:\[([^\]]+)\])?@/g; + var lastIndex = 0; + var match; + + while ((match = regex.exec(localizedPattern)) !== null) + { + var literalText = localizedPattern.substring(lastIndex, match.index); + + // Only add a label if there are actual non-whitespace characters (like 'y') + if (literalText.length > 0 && /\S/.test(literalText)) + { + var label = [CPTextField labelWithTitle:literalText]; + [newViews addObject:label]; + } + + var position = parseInt(match[1], 10) - 1; + var translatedValue = match[2]; + + if (position >= 0 && position < [views count]) + { + var originalView = [views objectAtIndex:position]; + + if (translatedValue !== undefined && translatedValue !== null) + { + if ([originalView isKindOfClass:[CPPopUpButton class]]) + { + var selectedItem = [originalView selectedItem]; + if (selectedItem) + { + if (!selectedItem._originalTitle) + { + selectedItem._originalTitle = [selectedItem title]; + } + [selectedItem setTitle:translatedValue]; + } + } + else if ([originalView respondsToSelector:@selector(setStringValue:)]) + { + [originalView setStringValue:translatedValue]; + + // Recalculate frame size if it is a static CPTextField to avoid visual clipping + if ([originalView isKindOfClass:[CPTextField class]] && ![originalView isEditable]) + { + var font = [originalView font] || [CPFont systemFontOfSize:[CPFont systemFontSize]], + size = [translatedValue sizeWithFont:font]; + [originalView setFrameSize:CGSizeMake(size.width + 4, CGRectGetHeight([originalView frame]))]; + } + } + } + + [newViews addObject:originalView]; + } + + lastIndex = regex.lastIndex; + } + + if (lastIndex < localizedPattern.length) + { + var literalText = localizedPattern.substring(lastIndex); + + // Only add a label if there are actual non-whitespace characters + if (literalText.length > 0 && /\S/.test(literalText)) + { + var label = [CPTextField labelWithTitle:literalText]; + [newViews addObject:label]; + } + } + + return newViews; +} + @end diff --git a/AppKit/CPRuleEditor/_CPRuleEditorViewSliceRow.j b/AppKit/CPRuleEditor/_CPRuleEditorViewSliceRow.j index 8866095d4..ee21be432 100644 --- a/AppKit/CPRuleEditor/_CPRuleEditorViewSliceRow.j +++ b/AppKit/CPRuleEditor/_CPRuleEditorViewSliceRow.j @@ -114,8 +114,32 @@ - (CPMenuItem)_createMenuItemWithTitle:(CPString )title { - title = [[_ruleEditor standardLocalizer] localizedStringForString:title]; - return [[CPMenuItem alloc] initWithTitle:title action:nil keyEquivalent:@""]; + var originalTitle = title; + var localizedTitle = [[_ruleEditor standardLocalizer] localizedStringForString:title]; + var item = [[CPMenuItem alloc] initWithTitle:localizedTitle action:nil keyEquivalent:@""]; + + // Cache the raw English title for pattern-matching + item._originalTitle = originalTitle; + return item; +} + +- (CPTextField)_createStaticTextFieldWithStringValue:(CPString)text +{ + var textField = [[CPTextField alloc] initWithFrame:CGRectMakeZero()], + ruleEditorFont = [_ruleEditor font], + font = [CPFont fontWithName:[ruleEditorFont familyName] size:[ruleEditorFont size] + 2], + localizedText = [[_ruleEditor standardLocalizer] localizedStringForString:text], + size = [localizedText sizeWithFont:font]; + + [textField setFrameSize:CGSizeMake(size.width + 4, [_ruleEditor rowHeight])]; + [textField setValue:font forThemeAttribute:@"font"]; + [textField setValue:[_ruleEditor _verticalAlignment] forThemeAttribute:@"vertical-alignment"]; + [textField setStringValue:localizedText]; + + // Cache the raw English text for pattern-matching + textField._originalText = text; + + return textField; } - (CPPopUpButton)_createPopUpButtonWithItems:(CPArray)itemsArray selectedItemIndex:(int)index @@ -142,22 +166,6 @@ return [CPMenuItem separatorItem]; } -- (CPTextField)_createStaticTextFieldWithStringValue:(CPString)text -{ - var textField = [[CPTextField alloc] initWithFrame:CGRectMakeZero()], - ruleEditorFont = [_ruleEditor font], - font = [CPFont fontWithName:[ruleEditorFont familyName] size:[ruleEditorFont size] + 2], - localizedText = [[_ruleEditor standardLocalizer] localizedStringForString:text], - size = [localizedText sizeWithFont:font]; - - [textField setFrameSize:CGSizeMake(size.width + 4, [_ruleEditor rowHeight])]; - [textField setValue:font forThemeAttribute:@"font"]; - [textField setValue:[_ruleEditor _verticalAlignment] forThemeAttribute:@"vertical-alignment"]; - [textField setStringValue:localizedText]; - - return textField; -} - - (void)_addOption:(id)sender { if (_rowIndex == [_ruleEditor numberOfRows] - 1) @@ -334,6 +342,28 @@ [_correspondingRuleItems setArray:ruleItems]; + // Localize drop-down options in context and reorder/insert intermediate labels natively + var localizer = [_ruleEditor standardLocalizer]; + if (localizer) + { + [localizer localizeMenuItemsForViews:_ruleOptionViews]; + _ruleOptionViews = [localizer localizeAndReorderViews:_ruleOptionViews]; + } + + // Rebuild frame configurations to match the new localized layout order + [_ruleOptionFrames removeAllObjects]; + [_ruleOptionInitialViewFrames removeAllObjects]; + + var newCount = [_ruleOptionViews count]; + for (var i = 0; i < newCount; i++) + { + var view = [_ruleOptionViews objectAtIndex:i], + frame = [view frame]; + + [_ruleOptionFrames addObject:frame]; + [_ruleOptionInitialViewFrames addObject:frame]; + } + if (!_editable) [self _updateEnabledStateForSubviews]; @@ -410,6 +440,9 @@ { [_addButton setHidden:[_ruleEditor _shouldHideAddButtonForSlice:self]]; [_subtractButton setHidden:[_ruleEditor _shouldHideSubtractButtonForSlice:self]]; + + [_addButton setToolTip:[_ruleEditor _toolTipForAddSimpleRowButton]]; + [_subtractButton setToolTip:[_ruleEditor _toolTipForDeleteRowButton]]; } - (void)_configurePlusButtonByRowType:(CPRuleEditorRowType)type diff --git a/AppKit/CPTextField.j b/AppKit/CPTextField.j index 7e51b40b4..37fea460b 100644 --- a/AppKit/CPTextField.j +++ b/AppKit/CPTextField.j @@ -2004,6 +2004,43 @@ CPTextFieldStatePlaceholder = CPThemeState("placeholder"); [self _setCSSStyleForInputElement]; } +#pragma mark Overrides + +/*! + Sets the font of the receiver. + + @param aFont - A CPFont object. +*/ +- (void)setFont:(CPFont)aFont +{ + if ([self currentValueForThemeAttribute:@"font"] === aFont) + return; + + // Apply the font to the default/normal state + [self setValue:aFont forThemeAttribute:@"font"]; + + // Apply to standard editing and border states + [self setValue:aFont forThemeAttribute:@"font" inState:CPThemeStateEditing]; + [self setValue:aFont forThemeAttribute:@"font" inState:CPThemeStateBezeled]; + [self setValue:aFont forThemeAttribute:@"font" inState:CPThemeStateBordered]; + [self setValue:aFont forThemeAttribute:@"font" inState:CPTextFieldStateRounded]; + + // Use CPThemeState() function to create composite states instead of array literals + [self setValue:aFont forThemeAttribute:@"font" inState:CPThemeState(CPTextFieldStateRounded, CPThemeStateEditing)]; + + // Apply across all standard control size states + [self setValue:aFont forThemeAttribute:@"font" inState:CPThemeStateControlSizeRegular]; + [self setValue:aFont forThemeAttribute:@"font" inState:CPThemeStateControlSizeSmall]; + [self setValue:aFont forThemeAttribute:@"font" inState:CPThemeStateControlSizeMini]; + + // Apply to table data view states (ensuring Interface Builder-style lists respect the font) + [self setValue:aFont forThemeAttribute:@"font" inState:CPThemeStateTableDataView]; + [self setValue:aFont forThemeAttribute:@"font" inState:CPThemeState(CPThemeStateTableDataView, CPThemeStateSelectedDataView)]; + [self setValue:aFont forThemeAttribute:@"font" inState:CPThemeState(CPThemeStateTableDataView, CPThemeStateSelectedDataView, CPThemeStateFirstResponder, CPThemeStateKeyWindow)]; + + [self layoutSubviews]; +} + - (void)takeValueFromKeyPath:(CPString)aKeyPath ofObjects:(CPArray)objects { var count = objects.length, diff --git a/AppKit/CPTextView/CPLayoutManager.j b/AppKit/CPTextView/CPLayoutManager.j index fd0accf43..4bd345dc4 100644 --- a/AppKit/CPTextView/CPLayoutManager.j +++ b/AppKit/CPTextView/CPLayoutManager.j @@ -31,8 +31,9 @@ @import "CPFont.j" @global _MakeRangeFromAbs - @global document +@global CPBaselineOffsetAttributeName +@global CPSuperscriptAttributeName @class CPTextContainer @class CPTextView @@ -292,7 +293,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 +532,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 +560,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 +716,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 +732,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 +860,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 +880,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 +936,6 @@ _oncontextmenuhandler = function () { return false; }; inTextContainer:(CPTextContainer)container rectCount:(CGRectPointer)rectCount { - var rectArray = [], lineFragments = _objectsInRange(_lineFragments, selectedCharRange); @@ -924,21 +954,24 @@ _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); - 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); + } + } } } @@ -949,7 +982,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 +1128,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 +1212,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, underline:underline, baselineOffset:0.0}; _runs.push(run); } } @@ -1194,10 +1229,97 @@ 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 baselineOffset = [attributes objectForKey:CPBaselineOffsetAttributeName], + superscript = [attributes objectForKey:CPSuperscriptAttributeName]; + + if (baselineOffset === nil || baselineOffset === undefined || typeof baselineOffset !== "number") + baselineOffset = 0.0; + + if (superscript === nil || superscript === undefined || typeof superscript !== "number") + superscript = 0; + + if (superscript !== 0) + { + var size = [font size], + scaledSize = size * 0.65, + fontName = [font familyName], + isBold = [font isBold], + isItalic = [font isItalic]; + + font = [CPFont _fontWithName:fontName size:scaledSize bold:isBold italic:isItalic]; + + if (baselineOffset === 0.0) + { + if (superscript > 0) + baselineOffset = size * 0.35; + else + baselineOffset = -size * 0.15; + } + } + + 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, + underline: underline, + baselineOffset: baselineOffset + }; + _runs.push(run); + } + + var tabRange = CPMakeRange(currentLoc + i, 1), + tabRun = { + _range: tabRange, + color: nil, + font: nil, + elem: nil, + string: nil, + bgcolor: nil, + paragraphStyle: paragraphStyle, + underline: underline, + baselineOffset: 0.0 + }; + _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, + underline: underline, + baselineOffset: baselineOffset + }; + _runs.push(run); + } } if (!CPMaxRange(effectiveRange)) @@ -1221,7 +1343,10 @@ var _objectsInRange = function(aList, aRange) { _glyphsFrames[i] = CGRectMake(origin.x, origin.y, someAdvancements[i].width, height); _glyphsFrames[i]._descent = someAdvancements[i].descent; - _glyphsOffsets[i] = height - someAdvancements[i].height; + + // Align the run's baseline with the common line baseline (_location.y) + _glyphsOffsets[i] = _location.y - someAdvancements[i].height; + origin.x += someAdvancements[i].width; } } @@ -1269,13 +1394,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 +1411,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 +1428,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 +1450,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); } @@ -1356,7 +1491,16 @@ var _objectsInRange = function(aList, aRange) if (!_RectEqualToRectHorizontally(newLineFragment._fragmentRect, _fragmentRect)) return NO; - if (newFragmentRuns[i].color !== oldFragmentRuns[i].color || newFragmentRuns[i].bgcolor !== oldFragmentRuns[i].bgcolor || newFragmentRuns[i].font !== oldFragmentRuns[i].font) + if (newFragmentRuns[i].color !== oldFragmentRuns[i].color || + newFragmentRuns[i].bgcolor !== oldFragmentRuns[i].bgcolor || + newFragmentRuns[i].font !== oldFragmentRuns[i].font || + newFragmentRuns[i].baselineOffset !== oldFragmentRuns[i].baselineOffset) + return NO; + + var oldStyle = oldFragmentRuns[i].paragraphStyle || [CPParagraphStyle defaultParagraphStyle], + newStyle = newFragmentRuns[i].paragraphStyle || [CPParagraphStyle defaultParagraphStyle]; + + if (![oldStyle isEqual:newStyle]) return NO; } @@ -1373,12 +1517,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/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/CPTextContainer.j b/AppKit/CPTextView/CPTextContainer.j index 48490579e..e851de4dd 100644 --- a/AppKit/CPTextView/CPTextContainer.j +++ b/AppKit/CPTextView/CPTextContainer.j @@ -148,27 +148,58 @@ CPLineMovesUp = 4; - (void)setWidthTracksTextView:(BOOL)flag { - _widthTracksTextView = flag; - [_textView setPostsFrameChangedNotifications:flag]; + if (_widthTracksTextView === flag) + return; - if (flag && _textView) - { - [[CPNotificationCenter defaultCenter] addObserver:self - selector:@selector(textViewFrameChanged:) - name:CPViewFrameDidChangeNotification - object:_textView]; - } - else + _widthTracksTextView = flag; + [self _updateFrameObserver]; +} + +// Controls whether the receiver adjusts the height of its bounding rectangle when its text view is resized. +- (BOOL)heightTracksTextView +{ + return _heightTracksTextView; +} + +- (void)setHeightTracksTextView:(BOOL)flag +{ + if (_heightTracksTextView === flag) + return; + + _heightTracksTextView = flag; + [self _updateFrameObserver]; +} + +- (void)_updateFrameObserver +{ + if (_textView) { [[CPNotificationCenter defaultCenter] removeObserver:self name:CPViewFrameDidChangeNotification object:_textView]; + + var flag = _widthTracksTextView || _heightTracksTextView; + [_textView setPostsFrameChangedNotifications:flag]; + + if (flag) + { + [[CPNotificationCenter defaultCenter] addObserver:self + selector:@selector(textViewFrameChanged:) + name:CPViewFrameDidChangeNotification + object:_textView]; + } } } - (void)textViewFrameChanged:(CPNotification)aNotification { - var newSize = CGSizeMake([_textView frame].size.width, _size.height); + var newSize = CGSizeMake(_size.width, _size.height); + + if (_widthTracksTextView) + newSize.width = [_textView frame].size.width; + + if (_heightTracksTextView) + newSize.height = [_textView frame].size.height; [self setContainerSize:newSize]; } @@ -177,7 +208,9 @@ CPLineMovesUp = 4; { if (_textView) { - [self setWidthTracksTextView:NO]; // We only support width + [[CPNotificationCenter defaultCenter] removeObserver:self + name:CPViewFrameDidChangeNotification + object:_textView]; [_textView setTextContainer:nil]; } @@ -185,7 +218,7 @@ CPLineMovesUp = 4; if (_textView) { - [self setWidthTracksTextView:_widthTracksTextView]; // We only support width + [self _updateFrameObserver]; [_textView setTextContainer:self]; } diff --git a/AppKit/CPTextView/CPTextView.j b/AppKit/CPTextView/CPTextView.j index 32d9c6b88..82589bb2c 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 @@ -1004,6 +1005,25 @@ Sets the selection to a range of characters in response to user action. if (doOverwrite && _placeholderString == nil && isNewSelection) [self setTypingAttributes:[_textStorage attributesAtIndex:CPMaxRange(range) effectiveRange:nil]]; + // Update the shared CPColorPanel with the active selection color + if ([self _isFirstResponder] && [_textStorage length] > 0) + { + var currentTextColor = [self textColor] || [CPColor blackColor]; + + if ([self isRichText]) + { + var charIndex = _selectionRange.location; + if (charIndex >= [_textStorage length]) + charIndex = MAX(0, charIndex - 1); + + var attributes = [_textStorage attributesAtIndex:charIndex effectiveRange:nil]; + if ([attributes objectForKey:CPForegroundColorAttributeName]) + currentTextColor = [attributes objectForKey:CPForegroundColorAttributeName]; + } + + [[CPColorPanel sharedColorPanel] setColor:currentTextColor]; + } + [[CPNotificationCenter defaultCenter] postNotificationName:CPTextViewDidChangeSelectionNotification object:self]; } @@ -1397,7 +1417,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 +1442,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 @@ -1653,7 +1679,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 @@ -1789,6 +1818,13 @@ Sets the selection to a range of characters in response to user action. // SYNCHRONIZE ACTIVE PARAGRAPH MARKERS ON TYPING ATTRIBUTES CHANGE [self updateRuler]; + // Synchronize CPColorPanel if text view is active + if ([self _isFirstResponder]) + { + var currentTextColor = [_typingAttributes objectForKey:CPForegroundColorAttributeName] || [self textColor] || [CPColor blackColor]; + [[CPColorPanel sharedColorPanel] setColor:currentTextColor]; + } + [[CPNotificationCenter defaultCenter] postNotificationName:CPTextViewDidChangeTypingAttributesNotification object:self]; // We always clear the saved selection range from the last mouse down event here. @@ -2069,8 +2105,16 @@ Sets the selection to a range of characters in response to user action. [self setFrameSize:[self frameSize]]; } +- (void)setBoundsSize:(CGSize)aSize +{ + _exposedRect = nil; // Clear the cached visible rect when bounds change + [super setBoundsSize:aSize]; +} + - (void)setFrameSize:(CGSize)aSize { + _exposedRect = nil; // Clear the cached visible rect so it gets recalculated at the new size + var desiredSize = CGSizeCreateCopy(aSize); if (_isHorizontallyResizable || _isVerticallyResizable) @@ -2126,9 +2170,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 { @@ -2287,17 +2337,15 @@ Sets the selection to a range of characters in response to user action. var loc = (_selectionRange.location == numberOfGlyphs) ? _selectionRange.location - 1 : _selectionRange.location, caretOffset = [_layoutManager _characterOffsetAtLocation:loc], - oldYPosition = CGRectGetMaxY(caretRect), - caretDescend = [_layoutManager _descentAtLocation:loc]; + font = [_textStorage attribute:CPFontAttributeName atIndex:loc effectiveRange:nil] || [self font]; if (caretOffset > 0) { caretRect.origin.y += caretOffset; - caretRect.size.height = oldYPosition - caretRect.origin.y; } - if (caretDescend < 0) - caretRect.size.height -= caretDescend; + // Set the caret height to match the size of the active font + caretRect.size.height = [font size]; if (_selectionRange.location == numberOfGlyphs) caretRect.origin.x += caretRect.size.width; @@ -2306,7 +2354,7 @@ Sets the selection to a range of characters in response to user action. caretRect.origin.y += _textContainerOrigin.y; caretRect.size.width = MAX(1.0, caretRect.size.width); - caretRect.size.height = MAX(1.0, caretRect.size.height); + caretRect.size.height = MAX(1.0, caretRect.size.height) + 2; return caretRect; } @@ -2624,6 +2672,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 @@ -2843,11 +2895,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]]; @@ -3176,6 +3233,11 @@ var _CPCopyPlaceholder = '-'; if (richtext) { + var shouldPastePlainText = [[CPApp currentEvent] modifierFlags] & (CPShiftKeyMask | CPAlternateKeyMask); + + if (shouldPastePlainText && richtext._string) + richtext = richtext._string; + [currentFirstResponder _pasteString:richtext]; return; diff --git a/AppKit/CPTextView/CPTypesetter.j b/AppKit/CPTextView/CPTypesetter.j index 06c8a368f..074c3b175 100644 --- a/AppKit/CPTextView/CPTypesetter.j +++ b/AppKit/CPTextView/CPTypesetter.j @@ -30,6 +30,9 @@ @import "CPTextStorage.j" @import "CPFont.j" +@global CPBaselineOffsetAttributeName +@global CPSuperscriptAttributeName + // forward declare these classes for type matching @class CPLayoutManager @class CPTextContainer @@ -353,9 +356,41 @@ var CPSystemTypesetterFactory, if (!currentFont) currentFont = [_textStorage font] || [CPFont systemFontOfSize:12.0]; - ascent = [currentFont ascender]; - descent = [currentFont descender]; - leading = (ascent - descent) * 0.2; // FAKE leading + // Safely retrieve and validate CPBaselineOffsetAttributeName + var baselineOffset = [_currentAttributes objectForKey:CPBaselineOffsetAttributeName]; + if (baselineOffset === nil || baselineOffset === undefined || typeof baselineOffset !== "number") + baselineOffset = 0.0; + + // Safely retrieve and validate CPSuperscriptAttributeName + var superscript = [_currentAttributes objectForKey:CPSuperscriptAttributeName]; + if (superscript === nil || superscript === undefined || typeof superscript !== "number") + superscript = 0; + + if (superscript !== 0) + { + var size = [currentFont size], + scaledSize = size * 0.65, + fontName = [currentFont familyName], + isBold = [currentFont isBold], + isItalic = [currentFont isItalic]; + + currentFont = [CPFont _fontWithName:fontName size:scaledSize bold:isBold italic:isItalic]; + + if (baselineOffset === 0.0) + { + if (superscript > 0) + baselineOffset = size * 0.35; + else + baselineOffset = -size * 0.15; + } + } + + var fontAscent = [currentFont ascender] || 0.0, + fontDescent = [currentFont descender] || 0.0; + + ascent = fontAscent + baselineOffset; + descent = fontDescent + baselineOffset; + leading = (fontAscent - fontDescent) * 0.2; // FAKE leading currentFontLineHeight = ascent - descent + leading; @@ -368,11 +403,15 @@ var CPSystemTypesetterFactory, } - if (currentFontLineHeight > _lineHeight) - _lineHeight = currentFontLineHeight; + // Clean bounds logic to prevent NaN and layout calculation overhead + var currentAscent = (ascent === undefined || isNaN(ascent)) ? 0.0 : ascent, + currentLineHeight = (currentFontLineHeight === undefined || isNaN(currentFontLineHeight)) ? 12.0 : currentFontLineHeight; - if (ascent > _lineBase) - _lineBase = ascent; + if (currentLineHeight > _lineHeight) + _lineHeight = currentLineHeight; + + if (currentAscent > _lineBase) + _lineBase = currentAscent; lineRange.length++; measuringRange.length++; @@ -546,7 +585,7 @@ var CPSystemTypesetterFactory, isNewline = NO; _lineFragments = []; _lineHeight = 0; - _lineBase = ascent; + _lineBase = 0; isStartOfPhysicalLine = YES; } diff --git a/AppKit/CPTextView/_CPRTFParser.j b/AppKit/CPTextView/_CPRTFParser.j index df1569481..b2e80b295 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,10 @@ @global CPForegroundColorAttributeName @global CPBackgroundColorAttributeName @global CPParagraphStyleAttributeName +@global CPAttachmentAttributeName +@global CPUnderlineStyleAttributeName +@global CPBaselineOffsetAttributeName +@global CPSuperscriptAttributeName @global CPLeftTabStopType @global CPRightTabStopType @@ -68,6 +74,8 @@ var cp1252Map = { BOOL script; BOOL _tabChanged; CPTabStopType _nextTabType; + int superscript; + float baselineOffset; } - (id)init @@ -100,6 +108,8 @@ var cp1252Map = { mynew.ulColour = ulColour; mynew._tabChanged = _tabChanged; mynew._nextTabType = _nextTabType; + mynew.superscript = superscript; + mynew.baselineOffset = baselineOffset; return mynew; } @@ -111,11 +121,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 +130,6 @@ var cp1252Map = { font = [CPFont fontWithName:fontFamily size:fontSize]; } - /* Last resort, default font. :-( */ if (font == nil) font = [CPFont systemFontOfSize:fontSize]; @@ -171,11 +175,21 @@ var cp1252Map = { underline = 0; strikethrough = 0; script = 0; + superscript = 0; + baselineOffset = 0.0; } - (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 +217,35 @@ var cp1252Map = { if (bgColour) [ret setObject:bgColour forKey:CPBackgroundColorAttributeName]; + if (underline) + [ret setObject:[CPNumber numberWithInt:1] forKey:CPUnderlineStyleAttributeName]; + + if (superscript !== 0) + [ret setObject:[CPNumber numberWithInt:superscript] forKey:CPSuperscriptAttributeName]; + + if (baselineOffset !== 0.0) + [ret setObject:[CPNumber numberWithFloat:baselineOffset] forKey:CPBaselineOffsetAttributeName]; + 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"], + "super" : [ "super", 1, true, kRTFParserType_prop, "propSuper"], + "sub" : [ "sub", 1, true, kRTFParserType_prop, "propSub"], + "nosupersub" : [ "nosupersub",1, true, kRTFParserType_prop, "propNoSuperSub"], + "up" : [ "up", 6, false, kRTFParserType_prop, "propUp"], + "dn" : [ "dn", 6, false, kRTFParserType_prop, "propDn"], + "plain" : [ "plain", 0, false, kRTFParserType_prop, "propPlain"], "pgnucltr" : [ "pgnucltr", "pgULtr", true, kRTFParserType_prop, "propPgnFormat"], "pgnlcltr" : [ "pgnlcltr", "pgLLtr", true, kRTFParserType_prop, "propPgnFormat"], "qc" : [ "qc", "justC", true, kRTFParserType_prop, "propJust"], @@ -263,9 +287,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 +311,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 +334,14 @@ var kRgsymRtf = { CPArray _fontArray; CPString _freename; BOOL _parsingFontTable; + BOOL _keywordIsControlWord; + + // Table parsing state + BOOL _inTableActive; + BOOL _waitingForNextRow; + CPMutableArray _tableRows; + CPMutableArray _currentRow; + CPString _currentCellText; } - (id)init @@ -319,11 +354,21 @@ var kRgsymRtf = { _states = []; _currentParseIndex = 0; _hexreturn = NO; + _keywordIsControlWord = NO; _result = [CPAttributedString new]; _colorArray = []; _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 +383,6 @@ var kRgsymRtf = { return sym[4]; case 1: - // CPLogConsole("skipped : " + sym[4]); return ''; default: @@ -349,7 +393,6 @@ var kRgsymRtf = { - (BOOL)pushState { - // Push stack as an object containing scoping context _states.push({ curState: _curState, run: [_currentRun copy] @@ -366,7 +409,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 +437,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 +473,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 +571,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 +583,6 @@ var kRgsymRtf = { - (CPString)_applyPropChange:sym parameter:param { - //console.log("prop : " + sym[0] + " / param : " + param+ ' '); - switch (sym[0]) { case "pard": @@ -499,6 +626,53 @@ 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 "super": + [self _flushCurrentRun]; + _currentRun.superscript = 1; + break; + + case "sub": + [self _flushCurrentRun]; + _currentRun.superscript = -1; + break; + + case "nosupersub": + [self _flushCurrentRun]; + _currentRun.superscript = 0; + break; + + case "up": + [self _flushCurrentRun]; + _currentRun.baselineOffset = parseFloat(param) / 2.0; + break; + + case "dn": + [self _flushCurrentRun]; + _currentRun.baselineOffset = -parseFloat(param) / 2.0; + break; + + case "plain": + [self _flushCurrentRun]; + [_currentRun resetFont]; + break; + case "qc": // paragraph center [_currentRun.paragraph setAlignment:CPCenterTextAlignment]; break; @@ -543,7 +717,6 @@ var kRgsymRtf = { if (sym[4] == "destSkip") { - CPLogConsole("Dest skip start : [" + sym[0] + "]"); _curState++; } @@ -552,6 +725,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]; @@ -616,10 +797,15 @@ var kRgsymRtf = { [self _flushCurrentRun]; var fontIndex = parseInt(param) - 1; - if (_currentRun && fontIndex >= 0) - _currentRun.fgColour = _colorArray[fontIndex]; + if (_currentRun) + { + if (fontIndex >= 0 && fontIndex < _colorArray.length) + _currentRun.fgColour = _colorArray[fontIndex]; + else + _currentRun.fgColour = nil; + } - break; + break; case "cb": // change background color case "highlight": @@ -687,8 +873,7 @@ var kRgsymRtf = { break; default: - CPLogConsole("skip : " + keyword + " param: " + param); - + break; } return ''; @@ -711,7 +896,12 @@ var kRgsymRtf = { ch = rtf.charAt(_currentParseIndex); if (!/[a-zA-Z]/.test(ch)) + { + _keywordIsControlWord = NO; return [self _translateKeyword:ch parameter:nil fParameter:fParam]; + } + + _keywordIsControlWord = YES; while (new RegExp("[a-zA-Z]").test(ch)) { @@ -744,9 +934,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 +963,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))]; @@ -782,25 +984,33 @@ var kRgsymRtf = { else { _freename += tmp; - [self _appendPlainString:tmp]; + // Only append literal spaces to the document if we are in the active body state + if (_curState == 0) + { + [self _appendPlainString:tmp]; + } } break; case "{": - if ([self pushState]) - CPLogConsole("push"); + lastchar = 0; + if (_waitingForNextRow) + [self _flushTableIfAny]; + if ([self pushState]) + break; case "}": + lastchar = 0; + if (_waitingForNextRow) + [self _flushTableIfAny]; + if ([self popState]) - CPLogConsole("pop"); if (_freename) { - CPLogConsole(_freename); - if (_parsingFontTable) { _fontArray.push(_freename); @@ -817,19 +1027,19 @@ var kRgsymRtf = { _freename = ''; ch = [self _parseKeyword:rtf length:len]; - if (!_hexreturn && ch.length == 0) + if (!_hexreturn && _keywordIsControlWord) lastchar = 1; else lastchar = 0; if (_hexreturn) { - if (ch.length > 0) + // Only append decoded characters if we are in the active body state + if (ch.length > 0 && _curState === 0) { 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; } @@ -849,21 +1059,347 @@ var kRgsymRtf = { case 0x0a: case '\n': case '\r': + lastchar = 0; break; default: 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]; + } + + // Convert raw header strings into CPAttributedStrings + var parsedHeaders = [CPMutableArray array]; + for (var c = 0; c < [headers count]; c++) { + var cellText = [headers objectAtIndex:c]; + [parsedHeaders addObject:[self parseTableCellMarkdown:cellText isHeader:YES]]; + } + + // Convert raw row strings into CPAttributedStrings + var parsedRows = [CPMutableArray array]; + for (var r = 0; r < [rows count]; r++) { + var rowData = [rows objectAtIndex:r]; + var parsedRow = [CPMutableArray array]; + for (var c = 0; c < [rowData count]; c++) { + var cellText = [rowData objectAtIndex:c]; + [parsedRow addObject:[self parseTableCellMarkdown:cellText isHeader:NO]]; + } + [parsedRows addObject:parsedRow]; + } + + 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 < [parsedHeaders count]) { + var parsedText = [parsedHeaders objectAtIndex:c]; + [measureTextField setStringValue:parsedText._string]; + [measureTextField sizeToFit]; + cellW = Math.max(cellW, CGRectGetWidth([measureTextField frame]) + 24.0); + } + + for (var r = 0; r < [parsedRows count]; r++) { + var rowData = [parsedRows objectAtIndex:r]; + if (c < [rowData count]) { + var parsedText = [rowData objectAtIndex:c]; + [measureTextField setStringValue:parsedText._string]; + [measureTextField sizeToFit]; + cellW = Math.max(cellW, CGRectGetWidth([measureTextField frame]) + 24.0); + } + } + colNaturalWidths[c] = cellW; + totalNaturalW += cellW; + } + + // Pass the parsed attributed strings to the attachment + var matrixView = [[_CPTableTextAttachment alloc] initWithHeaders:parsedHeaders rows:parsedRows 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; +} + +// Parses a single table cell's Markdown, converting HTML breaks and handling list items ++ (CPAttributedString)parseTableCellMarkdown:(CPString)cellText isHeader:(BOOL)isHeader +{ + if (!cellText) { + return [[CPAttributedString alloc] initWithString:@""]; + } + + // Convert HTML line breaks to standard newlines + var cleanedText = cellText.replace(//gi, "\n"); + var lines = cleanedText.split(/\r?\n/); + var cellResult = [[CPMutableAttributedString alloc] initWithString:@""]; + + for (var i = 0; i < lines.length; i++) { + var line = lines[i].trim(); + + // Convert dash or asterisk lists inside the cell to bullets + var listMatch = line.match(/^(\*|-)\s+(.*)$/); + if (listMatch) { + line = " • " + listMatch[2]; + } + + var parsedLine = [self parseInlineMarkdown:line isHeader:isHeader headerLevel:3]; + [cellResult appendAttributedString:parsedLine]; + + if (i < lines.length - 1) { + [cellResult appendAttributedString:[[CPAttributedString alloc] initWithString:@"\n"]]; + } + } + + return cellResult; +} + ++ (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..a0625f30b 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,205 @@ 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 = ""; + } + + // Safely extract text representation from CPAttributedString / CPTextStorage if present + if (cellText && typeof cellText === "object") { + if (typeof cellText.string === "function") { + cellText = [cellText string]; + } else if (cellText._string !== undefined) { + cellText = cellText._string; + } else if (cellText.string !== undefined) { + cellText = cellText.string; + } + } + + 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 +593,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 +607,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 +618,7 @@ function _points2twips(a) { return (a) * 20.0; } pString = [CPString stringWithFormat:@"\\fs%d", points]; headerString += pString; } - /* - * font attributes - */ + if (traits & CPItalicFontMask) { headerString += @"\\i"; @@ -475,28 +657,29 @@ function _points2twips(a) { return (a) * 20.0; } else if ([currAttrib isEqualToString:CPUnderlineStyleAttributeName]) { headerString += @"\\ul"; - trailerString += @"\\ulnone"; + trailerString += @"\\ulnone "; // trailing space important! } else if ([currAttrib isEqualToString:CPSuperscriptAttributeName]) { var value = [attributes objectForKey:CPSuperscriptAttributeName], - svalue = [value intValue] * 6; + ivalue = [value intValue]; - if (svalue > 0) + if (ivalue > 0) { - headerString += [CPString stringWithFormat:@"\\up%d", svalue]; - trailerString += @"\\up0"; + headerString += @"\\super"; + trailerString += @"\\nosupersub "; // trailing space important! } - else if (svalue < 0) + else if (ivalue < 0) { - headerString += [CPString stringWithFormat:@"\\dn-%d", svalue]; - trailerString += @"\\dn0"; + headerString += @"\\sub"; + trailerString += @"\\nosupersub "; // trailing space important! } } else if ([currAttrib isEqualToString:CPBaselineOffsetAttributeName]) { var value = [attributes objectForKey:CPBaselineOffsetAttributeName], - svalue = [value floatValue] * 2; + fvalue = [value floatValue], + svalue = Math.round(fvalue * 2.0); // Convert standard points to RTF half-points if (svalue > 0) { @@ -505,7 +688,8 @@ function _points2twips(a) { return (a) * 20.0; } } else if (svalue < 0) { - headerString += [CPString stringWithFormat:@"\\dn-%d", svalue]; + // Correct negative formatting using safe positive boundary + headerString += [CPString stringWithFormat:@"\\dn%d", Math.abs(svalue)]; trailerString += @"\\dn0"; } } @@ -522,11 +706,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) { @@ -562,10 +744,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 +758,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 +783,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..a395a31a4 --- /dev/null +++ b/AppKit/CPTextView/_CPTableTextAttachment.j @@ -0,0 +1,557 @@ +/* _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:YES]; +} + +- (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]; + + // Set explicit zero margins inside the text view to keep height measurements aligned with usedRect + [textView setTextContainerInset:CGSizeMake(0, 0)]; + + [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]]; + + if (text) + [textView insertText:text]; + + [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); + + // Update frame size directly so that textContainer auto-resizes. + // Set a large temporary height to allow accurate wrapping measurements. + [textView setFrameSize:CGSizeMake(targetWidth, 1e7)]; + + var layoutManager = [textView layoutManager]; + if (layoutManager) + { + // FORCE LAYOUT RECALCULATION: + // Because the width changed, we must force the lazy layout manager + // to synchronously calculate glyphs and wraps at the new width. + [layoutManager glyphRangeForTextContainer:[textView textContainer]]; + } + + var usedRect = layoutManager ? [layoutManager usedRectForTextContainer:[textView textContainer]] : nil; + var textHeight = usedRect ? CGRectGetHeight(usedRect) : 0.0; + + // Exact height + 8.0px padding (4.0px top, 4.0px bottom margin) inside the cell + var wrappedHeight = textHeight + 8.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/AppKit/Platform/DOM/CPPlatformWindow+DOM.j b/AppKit/Platform/DOM/CPPlatformWindow+DOM.j index 7c714c263..961fb4787 100644 --- a/AppKit/Platform/DOM/CPPlatformWindow+DOM.j +++ b/AppKit/Platform/DOM/CPPlatformWindow+DOM.j @@ -472,9 +472,9 @@ _CPPlatformWindowWillCloseNotification = @"_CPPlatformWindowWillCloseNotificatio theDocument.addEventListener("touchmove", touchEventCallback, {passive: false}); theDocument.addEventListener("touchcancel", touchEventCallback, {passive: false}); - _DOMWindow.addEventListener("DOMMouseScroll", scrollEventCallback, NO); - _DOMWindow.addEventListener("wheel", scrollEventCallback, NO); - _DOMWindow.addEventListener("mousewheel", scrollEventCallback, NO); + _DOMWindow.addEventListener("DOMMouseScroll", scrollEventCallback, { passive: false }); + _DOMWindow.addEventListener("wheel", scrollEventCallback, { passive: false }); + _DOMWindow.addEventListener("mousewheel", scrollEventCallback, { passive: false }); _DOMWindow.addEventListener("resize", resizeEventCallback, NO); @@ -508,9 +508,9 @@ _CPPlatformWindowWillCloseNotification = @"_CPPlatformWindowWillCloseNotificatio _DOMWindow.removeEventListener("focus", onFocusEventCallback, NO); //FIXME: does firefox really need a different value? - _DOMWindow.removeEventListener("DOMMouseScroll", scrollEventCallback, NO); - _DOMWindow.removeEventListener("wheel", scrollEventCallback, NO); - _DOMWindow.removeEventListener("mousewheel", scrollEventCallback, NO); + _DOMWindow.removeEventListener("DOMMouseScroll", scrollEventCallback, { passive: false }); + _DOMWindow.removeEventListener("wheel", scrollEventCallback, { passive: false }); + _DOMWindow.removeEventListener("mousewheel", scrollEventCallback, { passive: false }); [PlatformWindows removeObject:self]; diff --git a/Tests/Manual/CPRuleEditorTestI8N/AppController.j b/Tests/Manual/CPRuleEditorTestI8N/AppController.j new file mode 100644 index 000000000..20c22eeea --- /dev/null +++ b/Tests/Manual/CPRuleEditorTestI8N/AppController.j @@ -0,0 +1,173 @@ +@import +@import +@import +@import +@import +@import +@import "RuleDelegate.j" + +@implementation AppController : CPObject +{ + CPWindow theWindow; + CPRuleEditor ruleEditor; + CPTextField predicateField; + RuleDelegate ruleDelegate; + CPPopUpButton langPopUp; + + CPDictionary englishDict; + CPDictionary spanishDict; + CPDictionary germanDict; +} + +- (void)applicationDidFinishLaunching:(CPNotification)aNotification +{ + theWindow = [[CPWindow alloc] initWithContentRect:CGRectMake(50, 50, 800, 520) + styleMask:CPTitledWindowMask | CPClosableWindowMask | CPResizableWindowMask]; + [theWindow setTitle:@"CPRuleEditor Multi-Language Localization Demo"]; + [theWindow setFullPlatformWindow:YES]; + + var contentView = [theWindow contentView]; + [contentView setBackgroundColor:[CPColor colorWithHexString:@"f3f4f5"]]; + + var label = [CPTextField labelWithTitle:@"CPRuleEditor Sentence Localization & Positional Reordering:"]; + [label setFrame:CGRectMake(20, 20, 500, 24)]; + [label setFont:[CPFont boldSystemFontOfSize:14]]; + [contentView addSubview:label]; + + // Language Selector Label + var langLabel = [CPTextField labelWithTitle:@"Language:"]; + [langLabel setFrame:CGRectMake(530, 20, 80, 24)]; + [langLabel setFont:[CPFont boldSystemFontOfSize:12]]; + [langLabel setAlignment:CPRightTextAlignment]; + [contentView addSubview:langLabel]; + + // Language Selector PopUpButton + langPopUp = [[CPPopUpButton alloc] initWithFrame:CGRectMake(620, 16, 160, 24)]; + [langPopUp addItemWithTitle:@"Spanish"]; + [langPopUp addItemWithTitle:@"German"]; + [langPopUp addItemWithTitle:@"English"]; + [langPopUp setTarget:self]; + [langPopUp setAction:@selector(changeLanguage:)]; + [contentView addSubview:langPopUp]; + + ruleDelegate = [[RuleDelegate alloc] init]; + + // Create Rule Editor + ruleEditor = [[CPRuleEditor alloc] initWithFrame:CGRectMake(20, 55, 760, 250)]; + [ruleEditor setAutoresizingMask:CPViewWidthSizable]; + [ruleEditor setDelegate:ruleDelegate]; + [ruleEditor setEditable:YES]; + [ruleEditor setNestingMode:CPRuleEditorNestingModeList]; + [ruleEditor setRowHeight:28]; + [ruleEditor setTarget:self]; + [ruleEditor setAction:@selector(ruleEditorAction:)]; + + // Programmatic dictionaries setup + englishDict = [CPDictionary dictionary]; // Identity fallback + + spanishDict = [CPDictionary dictionaryWithDictionary:@{ + @"%[firstName]@ %[is equal to]@ %@" : @"%1$[Nombre]@ y %3$@ %2$[son iguales]@", + @"%[firstName]@ %[contains]@ %@" : @"%1$[Nombre]@ %2$[contiene]@ %3$@", + @"%[lastName]@ %[is equal to]@ %@" : @"%1$[Apellido]@ y %3$@ %2$[son iguales]@", + @"%[lastName]@ %[contains]@ %@" : @"%1$[Apellido]@ %2$[contiene]@ %3$@", + @"%[age]@ %[is equal to]@ %@" : @"%1$[Edad]@ y %3$@ %2$[son iguales]@", + @"%[age]@ is equal to %@" : @"%1$[Edad]@ y %3$@ %2$[son iguales]@", + @"Add row" : @"Añadir regla", + @"Delete row" : @"Eliminar regla", + @"Add compound row" : @"Añadir grupo de reglas" + }]; + + germanDict = [CPDictionary dictionaryWithDictionary:@{ + @"%[firstName]@ %[is equal to]@ %@" : @"%1$[Vorname]@ und %3$@ %2$[sind gleich]@", + @"%[firstName]@ %[contains]@ %@" : @"%1$[Vorname]@ %2$[enthält]@ %3$@", + @"%[lastName]@ %[is equal to]@ %@" : @"%1$[Nachname]@ und %3$@ %2$[sind gleich]@", + @"%[lastName]@ %[contains]@ %@" : @"%1$[Nachname]@ %2$[enthält]@ %3$@", + @"%[age]@ %[is equal to]@ %@" : @"%1$[Alter]@ und %3$@ %2$[sind gleich]@", + @"%[age]@ is equal to %@" : @"%1$[Alter]@ und %3$@ %2$[sind gleich]@", + @"Add row" : @"Regel hinzufügen", + @"Delete row" : @"Regel löschen", + @"Add compound row" : @"Regelgruppe hinzufügen" + }]; + + // Initialize with Spanish by default + [[ruleEditor standardLocalizer] setDictionary:spanishDict]; + + [contentView addSubview:ruleEditor]; + + // Populate initial rows + [ruleEditor addRow:self]; + [ruleEditor addRow:self]; + [ruleEditor addRow:self]; + + // Display Output Label + var predLabel = [CPTextField labelWithTitle:@"Evaluated Predicate:"]; + [predLabel setFrame:CGRectMake(20, 320, 760, 20)]; + [predLabel setFont:[CPFont boldSystemFontOfSize:12]]; + [contentView addSubview:predLabel]; + + // Predicate string value display + predicateField = [[CPTextField alloc] initWithFrame:CGRectMake(20, 345, 760, 36)]; + [predicateField setAutoresizingMask:CPViewWidthSizable]; + [predicateField setBezeled:YES]; + [predicateField setEditable:NO]; + [predicateField setStringValue:@""]; + [predicateField setFont:[CPFont systemFontOfSize:13]]; + [contentView addSubview:predicateField]; + + [[CPNotificationCenter defaultCenter] addObserver:self + selector:@selector(ruleEditorRowsDidChange:) + name:CPRuleEditorRowsDidChangeNotification + object:ruleEditor]; + + [theWindow orderFront:self]; + [self ruleEditorAction:nil]; +} + +- (void)changeLanguage:(id)sender +{ + var selectedTitle = [sender titleOfSelectedItem], + targetDict = englishDict; + + if ([selectedTitle isEqualToString:@"Spanish"]) + { + targetDict = spanishDict; + } + else if ([selectedTitle isEqualToString:@"German"]) + { + targetDict = germanDict; + } + + [[ruleEditor standardLocalizer] setDictionary:targetDict]; + + // Post notification to trigger localized redraw across editor slices + [[CPNotificationCenter defaultCenter] postNotificationName:@"_CPRuleEditorLocalizerDidLoadNotification" object:[ruleEditor standardLocalizer]]; +} + +- (void)ruleEditorAction:(id)sender +{ + var predicate = [ruleEditor predicate]; + if (predicate) + { + [predicateField setStringValue:[predicate predicateFormat]]; + } + else + { + [predicateField setStringValue:@"(No predicate evaluated)"]; + } +} + +- (void)ruleEditorRowsDidChange:(CPNotification)note +{ + var predicate = [ruleEditor predicate]; + + if (predicate) + { + [predicateField setStringValue:[predicate predicateFormat]]; + } + else + { + [predicateField setStringValue:@"(Incomplete Predicate)"]; + } +} + +@end diff --git a/Tests/Manual/CPRuleEditorTestI8N/Info.plist b/Tests/Manual/CPRuleEditorTestI8N/Info.plist new file mode 100644 index 000000000..68f9e7d32 --- /dev/null +++ b/Tests/Manual/CPRuleEditorTestI8N/Info.plist @@ -0,0 +1,12 @@ + + + + + CPApplicationDelegateClass + AppController + CPBundleName + CPTextViewTest + CPPrincipalClass + CPApplication + + diff --git a/Tests/Manual/CPRuleEditorTestI8N/Jakefile b/Tests/Manual/CPRuleEditorTestI8N/Jakefile new file mode 100644 index 000000000..c66879a92 --- /dev/null +++ b/Tests/Manual/CPRuleEditorTestI8N/Jakefile @@ -0,0 +1,94 @@ +/* + * Jakefile + * CPRuleEditorCibTest + * + * Created by You on September 3, 2010. + * Copyright 2010, Your Company All rights reserved. + */ + +var ENV = require("system").env, + FILE = require("file"), + JAKE = require("jake"), + task = JAKE.task, + FileList = JAKE.FileList, + app = require("cappuccino/jake").app, + configuration = ENV["CONFIG"] || ENV["CONFIGURATION"] || ENV["c"] || "Debug", + OS = require("os"); + +app ("CPRuleEditorCibTest", function(task) +{ + task.setBuildIntermediatesPath(FILE.join("Build", "CPRuleEditorCibTest.build", configuration)); + task.setBuildPath(FILE.join("Build", configuration)); + + task.setProductName("CPRuleEditorCibTest"); + task.setIdentifier("com.yourcompany.CPRuleEditorCibTest"); + task.setVersion("1.0"); + task.setAuthor("Your Company"); + task.setEmail("feedback @nospam@ yourcompany.com"); + task.setSummary("CPRuleEditorCibTest"); + task.setSources((new FileList("**/*.j")).exclude(FILE.join("Build", "**"))); + task.setResources(new FileList("Resources/**")); + task.setIndexFilePath("index.html"); + task.setInfoPlistPath("Info.plist"); + task.setNib2CibFlags("-R Resources/"); + + if (configuration === "Debug") + task.setCompilerFlags("-DDEBUG -g"); + else + task.setCompilerFlags("-O"); +}); + +task ("default", ["CPRuleEditorCibTest"], function() +{ + printResults(configuration); +}); + +task ("build", ["default"]); + +task ("debug", function() +{ + ENV["CONFIGURATION"] = "Debug"; + JAKE.subjake(["."], "build", ENV); +}); + +task ("release", function() +{ + ENV["CONFIGURATION"] = "Release"; + JAKE.subjake(["."], "build", ENV); +}); + +task ("run", ["debug"], function() +{ + OS.system(["open", FILE.join("Build", "Debug", "CPRuleEditorCibTest", "index.html")]); +}); + +task ("run-release", ["release"], function() +{ + OS.system(["open", FILE.join("Build", "Release", "CPRuleEditorCibTest", "index.html")]); +}); + +task ("deploy", ["release"], function() +{ + FILE.mkdirs(FILE.join("Build", "Deployment", "CPRuleEditorCibTest")); + OS.system(["press", "-f", FILE.join("Build", "Release", "CPRuleEditorCibTest"), FILE.join("Build", "Deployment", "CPRuleEditorCibTest")]); + printResults("Deployment") +}); + +task ("desktop", ["release"], function() +{ + FILE.mkdirs(FILE.join("Build", "Desktop", "CPRuleEditorCibTest")); + require("cappuccino/nativehost").buildNativeHost(FILE.join("Build", "Release", "CPRuleEditorCibTest"), FILE.join("Build", "Desktop", "CPRuleEditorCibTest", "CPRuleEditorCibTest.app")); + printResults("Desktop") +}); + +task ("run-desktop", ["desktop"], function() +{ + OS.system([FILE.join("Build", "Desktop", "CPRuleEditorCibTest", "CPRuleEditorCibTest.app", "Contents", "MacOS", "NativeHost"), "-i"]); +}); + +function printResults(configuration) +{ + print("----------------------------"); + print(configuration+" app built at path: "+FILE.join("Build", configuration, "CPRuleEditorCibTest")); + print("----------------------------"); +} diff --git a/Tests/Manual/CPRuleEditorTestI8N/Resources/spinner.gif b/Tests/Manual/CPRuleEditorTestI8N/Resources/spinner.gif new file mode 100644 index 000000000..a5e705f6c Binary files /dev/null and b/Tests/Manual/CPRuleEditorTestI8N/Resources/spinner.gif differ diff --git a/Tests/Manual/CPRuleEditorTestI8N/RuleDelegate.j b/Tests/Manual/CPRuleEditorTestI8N/RuleDelegate.j new file mode 100644 index 000000000..3835b5e7f --- /dev/null +++ b/Tests/Manual/CPRuleEditorTestI8N/RuleDelegate.j @@ -0,0 +1,135 @@ +@import +@import +@import + +// Ensure the standard operator constants are explicitly defined +var CPEqualToPredicateOperatorType = 4, + CPContainsPredicateOperatorType = 99; + +@implementation RuleDelegate : CPObject +{ +} + +// 1. Root criteria and children +- (id)ruleEditor:(CPRuleEditor)editor child:(CPInteger)index forCriterion:(id)criterion withRowType:(CPRuleEditorRowType)rowType +{ + if (criterion == nil) + { + return [@[@"firstName", @"lastName", @"age"] objectAtIndex:index]; + } + + if ([criterion isEqualToString:@"firstName"] || [criterion isEqualToString:@"lastName"]) + { + return [@[@"is equal to", @"contains"] objectAtIndex:index]; + } + + if ([criterion isEqualToString:@"age"]) + { + return [@[@"is equal to"] objectAtIndex:index]; + } + + if ([criterion isEqualToString:@"contains"] || [criterion isEqualToString:@"is equal to"]) + { + return @"value"; + } + + return nil; +} + +// 2. Number of children +- (CPInteger)ruleEditor:(CPRuleEditor)editor numberOfChildrenForCriterion:(id)criterion withRowType:(CPRuleEditorRowType)rowType +{ + if (criterion == nil) + { + return 3; + } + + if ([criterion isEqualToString:@"firstName"] || [criterion isEqualToString:@"lastName"]) + { + return 2; + } + + if ([criterion isEqualToString:@"age"]) + { + return 1; + } + + if ([criterion isEqualToString:@"contains"] || [criterion isEqualToString:@"is equal to"]) + { + return 1; + } + + return 0; +} + +// 3. Display values +- (id)ruleEditor:(CPRuleEditor)editor displayValueForCriterion:(id)criterion inRow:(CPInteger)row +{ + if ([criterion isEqualToString:@"firstName"]) return @"firstName"; + if ([criterion isEqualToString:@"lastName"]) return @"lastName"; + if ([criterion isEqualToString:@"age"]) return @"age"; + + if ([criterion isEqualToString:@"contains"]) return @"contains"; + if ([criterion isEqualToString:@"is equal to"]) return @"is equal to"; + + if ([criterion isEqualToString:@"value"]) + { + var textField = [[CPTextField alloc] initWithFrame:CGRectMake(0, 0, 120, 24)]; + [textField setBezeled:YES]; + [textField setBezelStyle:CPTextFieldSquareBezel]; + [textField setEditable:YES]; + [textField setStringValue:@""]; + return textField; + } + + return nil; +} + +// 4. Predicate parts +- (CPDictionary)ruleEditor:(CPRuleEditor)editor predicatePartsForCriterion:(id)criterion withDisplayValue:(id)value inRow:(CPInteger)row +{ + var parts = @{}; + + if ([criterion isEqualToString:@"firstName"] || [criterion isEqualToString:@"lastName"] || [criterion isEqualToString:@"age"]) + { + [parts setObject:[CPExpression expressionForKeyPath:criterion] forKey:CPRuleEditorPredicateLeftExpression]; + } + else if ([criterion isEqualToString:@"contains"]) + { + [parts setObject:[CPNumber numberWithUnsignedInt:CPContainsPredicateOperatorType] forKey:CPRuleEditorPredicateOperatorType]; + } + else if ([criterion isEqualToString:@"is equal to"]) + { + [parts setObject:[CPNumber numberWithUnsignedInt:CPEqualToPredicateOperatorType] forKey:CPRuleEditorPredicateOperatorType]; + } + else if ([criterion isEqualToString:@"value"]) + { + var activeValue = value; + var slices = [editor valueForKey:@"_slices"]; + + if (slices && row < [slices count]) + { + var slice = [slices objectAtIndex:row]; + var optionViews = [slice valueForKey:@"_ruleOptionViews"]; + if (optionViews) + { + var count = [optionViews count]; + for (var i = 0; i < count; i++) + { + var view = [optionViews objectAtIndex:i]; + if ([view isKindOfClass:[CPTextField class]] && [view isEditable]) + { + activeValue = view; + break; + } + } + } + } + + [parts setObject:[CPExpression expressionForConstantValue:[activeValue stringValue]] forKey:CPRuleEditorPredicateRightExpression]; + } + + return parts; +} + +@end diff --git a/Tests/Manual/CPRuleEditorTestI8N/index-debug.html b/Tests/Manual/CPRuleEditorTestI8N/index-debug.html new file mode 100644 index 000000000..a36b1d3b9 --- /dev/null +++ b/Tests/Manual/CPRuleEditorTestI8N/index-debug.html @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + __project.name__ + + + + + + + + + + + + + + +
+
+
+ +
+
+ +
+ + diff --git a/Tests/Manual/CPRuleEditorTestI8N/index.html b/Tests/Manual/CPRuleEditorTestI8N/index.html new file mode 100644 index 000000000..ac42c98a7 --- /dev/null +++ b/Tests/Manual/CPRuleEditorTestI8N/index.html @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + + + + + __project.name__ + + + + + + + + + + + + +
+
+
+ +
+
+ +
+ + diff --git a/Tests/Manual/CPRuleEditorTestI8N/main.j b/Tests/Manual/CPRuleEditorTestI8N/main.j new file mode 100644 index 000000000..7424228fa --- /dev/null +++ b/Tests/Manual/CPRuleEditorTestI8N/main.j @@ -0,0 +1,18 @@ +/* + * AppController.j + * CPRuleEditorCibTest + * + * Created by You on September 3, 2010. + * Copyright 2010, Your Company All rights reserved. + */ + +@import +@import + +@import "AppController.j" + + +function main(args, namedArgs) +{ + CPApplicationMain(args, namedArgs); +} diff --git a/Tests/Manual/CPTextView/AppController.j b/Tests/Manual/CPTextView/AppController.j index 7abb6d277..dd7c6f995 100755 --- a/Tests/Manual/CPTextView/AppController.j +++ b/Tests/Manual/CPTextView/AppController.j @@ -2,18 +2,24 @@ * AppController.j * * Manual test application for the cappuccino text system - * Copyright (C) 2014 Daniel Boehringer + * Copyright (C) 2026 Daniel Boehringer */ @import @import @import +@import @import @import @import @import @import @import +@import +@import + +@global CPBaselineOffsetAttributeName +@global CPSuperscriptAttributeName @implementation AppController : CPObject { @@ -50,6 +56,11 @@ [[CPFontManager sharedFontManager] orderFrontFontPanel:self]; } +- (void)orderFrontColorPanel:(id)sender +{ + [[CPColorPanel sharedColorPanel] orderFront:self]; +} + - (void)toggleRuler:(id)sender { [_scrollView setRulersVisible:![_scrollView rulersVisible]]; @@ -75,6 +86,82 @@ [_textView alignJustified:self]; } +- (void)makeSuperscript:(id)sender +{ + var range = [_textView selectedRange]; + if (range.length > 0) + { + var textStorage = [_textView textStorage]; + [textStorage beginEditing]; + [textStorage removeAttribute:CPBaselineOffsetAttributeName range:range]; + [textStorage addAttribute:CPSuperscriptAttributeName value:1 range:range]; + [textStorage endEditing]; + [_textView setNeedsDisplay:YES]; + } +} + +- (void)makeSubscript:(id)sender +{ + var range = [_textView selectedRange]; + if (range.length > 0) + { + var textStorage = [_textView textStorage]; + [textStorage beginEditing]; + [textStorage removeAttribute:CPBaselineOffsetAttributeName range:range]; + [textStorage addAttribute:CPSuperscriptAttributeName value:-1 range:range]; + [textStorage endEditing]; + [_textView setNeedsDisplay:YES]; + } +} + +- (void)raiseBaseline:(id)sender +{ + var range = [_textView selectedRange]; + if (range.length > 0) + { + var textStorage = [_textView textStorage]; + [textStorage beginEditing]; + + var currentOffset = [textStorage attribute:CPBaselineOffsetAttributeName atIndex:range.location effectiveRange:nil] || 0.0; + var newOffset = currentOffset + 2.0; + + [textStorage addAttribute:CPBaselineOffsetAttributeName value:newOffset range:range]; + [textStorage endEditing]; + [_textView setNeedsDisplay:YES]; + } +} + +- (void)lowerBaseline:(id)sender +{ + var range = [_textView selectedRange]; + if (range.length > 0) + { + var textStorage = [_textView textStorage]; + [textStorage beginEditing]; + + var currentOffset = [textStorage attribute:CPBaselineOffsetAttributeName atIndex:range.location effectiveRange:nil] || 0.0; + var newOffset = currentOffset - 2.0; + + [textStorage addAttribute:CPBaselineOffsetAttributeName value:newOffset range:range]; + [textStorage endEditing]; + [_textView setNeedsDisplay:YES]; + } +} + +- (void)resetBaseline:(id)sender +{ + var range = [_textView selectedRange]; + if (range.length > 0) + { + var textStorage = [_textView textStorage]; + [textStorage beginEditing]; + [textStorage removeAttribute:CPBaselineOffsetAttributeName range:range]; + [textStorage removeAttribute:CPSuperscriptAttributeName range:range]; + [textStorage endEditing]; + [_textView setNeedsDisplay:YES]; + } +} + - (void)insertAttachment:(id)sender { // Insert modern spinner image attachment @@ -90,6 +177,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 +239,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 +259,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:"]; @@ -178,6 +300,48 @@ [alignJustifyBtn setTarget:self]; [alignJustifyBtn setAction:@selector(alignJustified:)]; [toolbarView addSubview:alignJustifyBtn]; + currentX += 80; + + // Baseline & Script Testing Group + var labelBaseline = [[CPTextField alloc] initWithFrame:CGRectMake(currentX, 22, 85, 20)]; + [labelBaseline setStringValue:@"Baseline:"]; + [labelBaseline setFont:[CPFont systemFontOfSize:12]]; + [toolbarView addSubview:labelBaseline]; + currentX += 85; + + var superBtn = [[CPButton alloc] initWithFrame:CGRectMake(currentX, 15, 45, 30)]; + [superBtn setTitle:@"x²"]; + [superBtn setTarget:self]; + [superBtn setAction:@selector(makeSuperscript:)]; + [toolbarView addSubview:superBtn]; + currentX += 50; + + var subBtn = [[CPButton alloc] initWithFrame:CGRectMake(currentX, 15, 45, 30)]; + [subBtn setTitle:@"x₂"]; + [subBtn setTarget:self]; + [subBtn setAction:@selector(makeSubscript:)]; + [toolbarView addSubview:subBtn]; + currentX += 50; + + var raiseBtn = [[CPButton alloc] initWithFrame:CGRectMake(currentX, 15, 50, 30)]; + [raiseBtn setTitle:@"Base+"]; + [raiseBtn setTarget:self]; + [raiseBtn setAction:@selector(raiseBaseline:)]; + [toolbarView addSubview:raiseBtn]; + currentX += 55; + + var lowerBtn = [[CPButton alloc] initWithFrame:CGRectMake(currentX, 15, 50, 30)]; + [lowerBtn setTitle:@"Base-"]; + [lowerBtn setTarget:self]; + [lowerBtn setAction:@selector(lowerBaseline:)]; + [toolbarView addSubview:lowerBtn]; + currentX += 55; + + var normalBtn = [[CPButton alloc] initWithFrame:CGRectMake(currentX, 15, 60, 30)]; + [normalBtn setTitle:@"Normal"]; + [normalBtn setTarget:self]; + [normalBtn setAction:@selector(resetBaseline:)]; + [toolbarView addSubview:normalBtn]; // Default return key target test var returnButton = [[CPButton alloc] initWithFrame:CGRectMake(CGRectGetWidth([contentView bounds]) - 270, 15, 250, 30)]; @@ -210,9 +374,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 +390,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]; @@ -251,24 +416,34 @@ [editMenu addItemWithTitle:@"Cut" action:@selector(cut:) keyEquivalent:@"x"]; [editMenu addItemWithTitle:@"Copy" action:@selector(copy:) keyEquivalent:@"c"]; [editMenu addItemWithTitle:@"Paste" action:@selector(paste:) keyEquivalent:@"v"]; + + var pasteAsPlainItem = [editMenu addItemWithTitle:@"Paste as Plain Text" action:@selector(pasteAsPlainText:) keyEquivalent:@"v"]; + [pasteAsPlainItem setKeyEquivalentModifierMask:CPCommandKeyMask | CPAlternateKeyMask | CPShiftKeyMask]; + [editMenu addItemWithTitle:@"Delete" action:@selector(delete:) keyEquivalent:@""]; [editMenu addItemWithTitle:@"Select All" action:@selector(selectAll:) keyEquivalent:@"a"]; [editMenu addItemWithTitle:@"Undo" action:@selector(undo:) keyEquivalent:@"z"]; [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"]; [formatMenu addItemWithTitle:@"Font panel" action:@selector(orderFrontFontPanel:) keyEquivalent:@"t"]; + [formatMenu addItemWithTitle:@"Color panel" action:@selector(orderFrontColorPanel:) keyEquivalent:@"C"]; [formatMenu addItem:[CPMenuItem separatorItem]]; // Styles [formatMenu addItemWithTitle:@"Bold" action:@selector(bold:) keyEquivalent:@"b"]; [formatMenu addItemWithTitle:@"Italic" action:@selector(italic:) keyEquivalent:@"i"]; [formatMenu addItemWithTitle:@"Underline" action:@selector(underline:) keyEquivalent:@"u"]; [formatMenu addItem:[CPMenuItem separatorItem]]; + // Baseline & Scripts + [formatMenu addItemWithTitle:@"Superscript" action:@selector(makeSuperscript:) keyEquivalent:@"="]; + [formatMenu addItemWithTitle:@"Subscript" action:@selector(makeSubscript:) keyEquivalent:@"-"]; + [formatMenu addItemWithTitle:@"Raise Baseline" action:@selector(raiseBaseline:) keyEquivalent:@"+"]; + [formatMenu addItemWithTitle:@"Lower Baseline" action:@selector(lowerBaseline:) keyEquivalent:@"_"]; + [formatMenu addItemWithTitle:@"Reset Baseline" action:@selector(resetBaseline:) keyEquivalent:@"0"]; + [formatMenu addItem:[CPMenuItem separatorItem]]; // Alignment [formatMenu addItemWithTitle:@"Align Left" action:@selector(alignLeft:) keyEquivalent:@"{"]; [formatMenu addItemWithTitle:@"Center" action:@selector(alignCenter:) keyEquivalent:@"|"]; @@ -291,6 +466,51 @@ attributes:[CPDictionary dictionaryWithObjects:[centeredParagraph, [CPFont boldFontWithName:@"Arial" size:18], elegantForeground, elegantBackground] forKeys:[CPParagraphStyleAttributeName, CPFontAttributeName, CPForegroundColorAttributeName, CPBackgroundColorAttributeName]]]]; + // VISUAL TEST CASES: Baseline, Superscript, and Subscript Features + [_textView insertText:@"\n"]; + var sectionHeaderColor = [CPColor colorWithRed:0.5 green:0.25 blue:0.1 alpha:1.0]; + [_textView insertText:[[CPAttributedString alloc] initWithString:@"Baseline Shift & Superscript/Subscript Showcase\n" + attributes:[CPDictionary dictionaryWithObjects:[[CPFont boldFontWithName:@"Arial" size:16], sectionHeaderColor] + forKeys:[CPFontAttributeName, CPForegroundColorAttributeName]]]]; + + var normalFont = [CPFont systemFontOfSize:14.0]; + + // Test Case: E = mc² (Superscript) + var formulaEnergy = [[CPAttributedString alloc] initWithString:@" • Energy mass equivalence: E = mc" attributes:@{ CPFontAttributeName: normalFont }]; + var scriptTwo = [[CPAttributedString alloc] initWithString:@"2" attributes:@{ CPFontAttributeName: normalFont, CPSuperscriptAttributeName: 1 }]; + [_textView insertText:formulaEnergy]; + [_textView insertText:scriptTwo]; + [_textView insertText:@"\n"]; + + // Test Case: H₂O (Subscript) + var formulaWater = [[CPAttributedString alloc] initWithString:@" • Chemical formula: H" attributes:@{ CPFontAttributeName: normalFont }]; + var scriptSubTwo = [[CPAttributedString alloc] initWithString:@"2" attributes:@{ CPFontAttributeName: normalFont, CPSuperscriptAttributeName: -1 }]; + var formulaWaterEnd = [[CPAttributedString alloc] initWithString:@"O\n" attributes:@{ CPFontAttributeName: normalFont }]; + [_textView insertText:formulaWater]; + [_textView insertText:scriptSubTwo]; + [_textView insertText:formulaWaterEnd]; + + // Test Case: Ordinals + var ordText = [[CPAttributedString alloc] initWithString:@" • Ordinals: 1" attributes:@{ CPFontAttributeName: normalFont }]; + var st = [[CPAttributedString alloc] initWithString:@"st" attributes:@{ CPFontAttributeName: normalFont, CPSuperscriptAttributeName: 1 }]; + var rdText = [[CPAttributedString alloc] initWithString:@", 3" attributes:@{ CPFontAttributeName: normalFont }]; + var rd = [[CPAttributedString alloc] initWithString:@"rd" attributes:@{ CPFontAttributeName: normalFont, CPSuperscriptAttributeName: 1 }]; + [_textView insertText:ordText]; + [_textView insertText:st]; + [_textView insertText:rdText]; + [_textView insertText:rd]; + [_textView insertText:@"\n"]; + + // Test Case: Custom Baseline Offsets + var offsetLead = [[CPAttributedString alloc] initWithString:@" • Custom Offsets: " attributes:@{ CPFontAttributeName: normalFont }]; + var offsetUp = [[CPAttributedString alloc] initWithString:@"Raised " attributes:@{ CPFontAttributeName: normalFont, CPBaselineOffsetAttributeName: 4.0 }]; + var offsetDown = [[CPAttributedString alloc] initWithString:@"Lowered " attributes:@{ CPFontAttributeName: normalFont, CPBaselineOffsetAttributeName: -4.0 }]; + var offsetNormal = [[CPAttributedString alloc] initWithString:@"Standard\n" attributes:@{ CPFontAttributeName: normalFont }]; + [_textView insertText:offsetLead]; + [_textView insertText:offsetUp]; + [_textView insertText:offsetDown]; + [_textView insertText:offsetNormal]; + // Highlighted Heading - Pine & Sage Green tones [_textView insertText:@"\n"]; var showcaseForeground = [CPColor colorWithRed:0.15 green:0.25 blue:0.15 alpha:1.0]; // Forest Green @@ -321,6 +541,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 +569,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