diff --git a/packages/widgets/src/data/Table.ts b/packages/widgets/src/data/Table.ts index 223a06ed..77a38e70 100644 --- a/packages/widgets/src/data/Table.ts +++ b/packages/widgets/src/data/Table.ts @@ -2,7 +2,7 @@ // @termuijs/widgets — Table widget // ───────────────────────────────────────────────────── -import { type Screen, type Style, type Color, type KeyEvent, styleToCellAttrs, stringWidth, truncate, wordWrap, caps } from '@termuijs/core'; +import { type Screen, type Style, type Color, type KeyEvent, type MouseEvent, styleToCellAttrs, stringWidth, truncate, wordWrap, caps } from '@termuijs/core'; import { Widget } from '../base/Widget.js'; import { type TableState } from './TableState.js'; import { computeVariableRange } from '../input/virtual-scroll.js'; @@ -35,6 +35,8 @@ export interface TableOptions { separator?: string; /** Callback when a column header is activated to sort */ onSort?: (colIndex: number, direction: 'asc' | 'desc') => void; + /** Whether columns can be resized by dragging separators */ + resizable?: boolean; } export interface TableProps { @@ -71,6 +73,12 @@ export class Table extends Widget { protected _stripe: boolean; protected _stripeColor: Color; protected _separator: string; + protected _resizable: boolean; + protected _columnWidths: number[] = []; + private _lastWidth = -1; + private _isDragging = false; + private _dragColIndex = -1; + private _state?: TableState; private _onStateChange?: (state: TableState) => void; private _selectedRow = 0; @@ -110,9 +118,15 @@ export class Table extends Widget { this._stripe = options.stripe ?? true; this._stripeColor = options.stripeColor ?? { type: 'named', name: 'brightBlack' }; this._separator = options.separator ?? ' │ '; + this._resizable = options.resizable ?? false; this._state = state; this._onStateChange = onStateChange; this._tableOnSort = options.onSort; + + this.events.on('key', this.handleKey.bind(this)); + if (this._resizable) { + this.events.on('mouse', this.handleMouse.bind(this)); + } } // ── Public API ──────────────────────────────────── @@ -170,6 +184,54 @@ export class Table extends Widget { } } + handleMouse(event: MouseEvent): void { + if (!this._resizable) return; + + // Ensure we have computed widths at least once + const rect = this._getContentRect(); + const sepWidth = stringWidth(this._separator); + this._getColWidths(rect.width, sepWidth); + + if (event.type === 'mousedown') { + const colIndex = this._findColumnBoundaryAt(event.x); + if (colIndex !== -1) { + this._isDragging = true; + this._dragColIndex = colIndex; + } + } else if (event.type === 'mouseup' || event.type === 'dragend') { + this._isDragging = false; + this._dragColIndex = -1; + } else if (event.type === 'drag' && this._isDragging) { + let colStartX = rect.x; + for (let i = 0; i < this._dragColIndex; i++) { + colStartX += (this._columnWidths[i] ?? 0) + sepWidth; + } + + const newWidth = Math.max(1, event.x - colStartX); + this._columnWidths[this._dragColIndex] = newWidth; + + // To prevent table width from changing uncontrollably, we can subtract dx from the next column + // if there's enough space, but a simpler free-resizing UX works better for terminal tables + this.markDirty(); + } + } + + private _findColumnBoundaryAt(screenX: number): number { + const rect = this._getContentRect(); + let cx = rect.x; + const sepWidth = stringWidth(this._separator); + + for (let c = 0; c < this._columns.length - 1; c++) { + cx += this._columnWidths[c] ?? 0; + // The separator is from cx to cx + sepWidth + if (screenX >= cx && screenX < cx + sepWidth) { + return c; + } + cx += sepWidth; + } + return -1; + } + handleKey(event: KeyEvent): void { const minRow = this._showHeader ? -1 : 0; @@ -225,7 +287,7 @@ export class Table extends Widget { if (visibleHeight <= 0) { this._scrollOffset = 0; return; } const sepWidth = stringWidth(this._separator); - const colWidths = this._computeColumnWidths(Math.max(0, rect.width - (this._columns.length - 1) * sepWidth)); + const colWidths = this._getColWidths(rect.width, sepWidth); const sizes = this._computeRowHeights(colWidths); let selectedTop = 0; @@ -253,9 +315,7 @@ export class Table extends Widget { const sepWidth = stringWidth(this._separator); // Calculate column widths - const colWidths = this._computeColumnWidths( - width - (this._columns.length - 1) * sepWidth, - ); + const colWidths = this._getColWidths(width, sepWidth); let headerOffset = 0; @@ -367,6 +427,18 @@ export class Table extends Widget { } } + private _getColWidths(rectWidth: number, sepWidth: number): number[] { + if (!this._resizable) { + return this._computeColumnWidths(Math.max(0, rectWidth - (this._columns.length - 1) * sepWidth)); + } + + if (this._columnWidths.length !== this._columns.length || this._lastWidth !== rectWidth) { + this._columnWidths = this._computeColumnWidths(Math.max(0, rectWidth - (this._columns.length - 1) * sepWidth)); + this._lastWidth = rectWidth; + } + return this._columnWidths; + } + protected _computeColumnWidths(totalWidth: number): number[] { const fixedCols = this._columns.filter(c => c.width !== undefined); const flexCols = this._columns.filter(c => c.width === undefined);