mirror of
https://github.com/cappuccino/cappuccino.git
synced 2026-08-25 13:07:02 +00:00
558 lines
19 KiB
Plaintext
558 lines
19 KiB
Plaintext
/* _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 <Foundation/CPAttributedString.j>
|
|
|
|
@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
|