UNPKG

@teaui/core

Version:

A high-level terminal UI library for Node

599 lines 24.2 kB
import * as unicode from '@teaui/term'; import { Container } from '../Container.js'; import { Style } from '../Style.js'; import { Point, Rect, Size } from '../geometry.js'; import { isMouseClicked, isMouseDragging, isMousePressEnd, isMousePressStart, } from '../events/index.js'; /** * A data table with sortable headers, selectable/scrollable rows, and column layout. * * ``` * Name │ Age │ Email │ Status * ───────────────────────────────────────────────── * Alice │ 30 │ alice@example.com │ Active * ▶Bob │ 25 │ bob@example.com │ Pending ◀ * Charlie │ 35 │ charlie@ex.com │ Active * ``` */ export class Table extends Container { #sourceData = []; #data = []; #columns = []; #format = () => ''; #selectedIndex = 0; #onSelect; #onSort; #sortKey; #sortDirection = 'asc'; #showRowNumbers = false; #isSelectable = false; #showSelected = undefined; #onSelectionChange; #selectedItems = new Set(); #childOffset = 0; get #isShowSelected() { return this.#showSelected ?? this.#isSelectable; } #dragSelectState = undefined; #dragScrollPinned = undefined; // scroll state #scrollOffset = 0; #bodyHeight = 0; #selectionDirty = true; constructor(props) { super(props); this.#update(props); } update(props) { this.#update(props); super.update(props); } #update({ data, columns, format, selectedIndex, onSelect, onSort, sortKey, sortDirection, showRowNumbers, isSelectable, showSelected, onSelectionChange, childOffset, }) { if (showRowNumbers !== undefined) { this.#showRowNumbers = showRowNumbers; } if (isSelectable !== undefined) { this.#isSelectable = isSelectable; } if (showSelected !== undefined) { this.#showSelected = showSelected; } if (onSelectionChange !== undefined) { this.#onSelectionChange = onSelectionChange; } if (childOffset !== undefined) { this.#childOffset = childOffset; } const dataChanged = data !== undefined && data !== this.#sourceData; const sortChanged = sortKey !== undefined && (sortKey !== this.#sortKey || sortDirection !== this.#sortDirection); this.#columns = columns ?? this.#columns; this.#format = format ?? this.#format; if (selectedIndex !== undefined) { this.#selectedIndex = selectedIndex; this.#selectionDirty = true; } if (onSelect !== undefined) { this.#onSelect = onSelect; } if (onSort !== undefined) { this.#onSort = onSort; } if (sortKey !== undefined) { this.#sortKey = sortKey; } if (sortDirection !== undefined) { this.#sortDirection = sortDirection; } if (data !== undefined) { this.#sourceData = data; } if (dataChanged || sortChanged) { this.#sortData(); } } #sortData() { if (!this.#sortKey) { this.#data = [...this.#sourceData]; if (this.#sortDirection === 'desc') { this.#data.reverse(); } return; } const key = this.#sortKey; const dir = this.#sortDirection; this.#data = [...this.#sourceData].sort((a, b) => { const aVal = this.#format(key, a); const bVal = this.#format(key, b); const cmp = aVal.localeCompare(bVal, undefined, { numeric: true }); return dir === 'asc' ? cmp : -cmp; }); } get selectedIndex() { return this.#selectedIndex; } set selectedIndex(value) { this.#selectedIndex = Math.max(0, Math.min(this.#data.length - 1, value)); this.#selectionDirty = true; this.invalidateRender(); } naturalSize(available) { return available; } receiveKey(event) { switch (event.name) { case 'up': this.selectedIndex = this.#selectedIndex - 1; break; case 'down': this.selectedIndex = this.#selectedIndex + 1; break; case 'pageup': this.selectedIndex = this.#selectedIndex - Math.max(1, this.#bodyHeight - 2); break; case 'pagedown': this.selectedIndex = this.#selectedIndex + Math.max(1, this.#bodyHeight - 2); break; case 'home': this.selectedIndex = 0; break; case 'end': this.selectedIndex = this.#data.length - 1; break; case 'return': if (this.#data.length > 0 && this.#onSelect) { this.#onSelect(this.#data[this.#selectedIndex], this.#selectedIndex); } break; case 'space': this.#toggleSelection(this.#selectedIndex); break; } } receiveMouse(event, system) { super.receiveMouse(event, system); // During drag-select, pin the viewport and suppress scroll if (this.#dragSelectState !== undefined) { if (isMouseDragging(event)) { this.#handleDragSelect(event.position.y); return; } if (isMousePressEnd(event)) { this.#dragSelectState = undefined; this.#dragScrollPinned = undefined; return; } } if (event.name === 'mouse.wheel.up') { this.#scrollOffset = Math.max(0, this.#scrollOffset - 1); this.invalidateRender(); } else if (event.name === 'mouse.wheel.down') { const maxScroll = Math.max(0, this.#data.length - this.#bodyHeight); this.#scrollOffset = Math.min(maxScroll, this.#scrollOffset + 1); this.invalidateRender(); } else if (isMousePressStart(event) && this.#isSelectable) { const y = event.position.y; if (y >= 2) { const rowIndex = this.#scrollOffset + (y - 2); if (rowIndex >= 0 && rowIndex < this.#data.length) { const item = this.#data[rowIndex]; const wasChecked = this.#selectedItems.has(item); this.#dragSelectState = wasChecked ? 'deselect' : 'select'; this.#dragScrollPinned = this.#scrollOffset; this.#applyDragSelect(rowIndex); this.selectedIndex = rowIndex; } } } else if (isMouseClicked(event)) { const y = event.position.y; // header row = 0, separator = 1, data rows start at 2 if (y === 0) { this.#handleHeaderClick(event.position.x); } else if (y >= 2) { const rowIndex = this.#scrollOffset + (y - 2); if (rowIndex < this.#data.length) { this.selectedIndex = rowIndex; if (!this.#isSelectable) { this.#onSelect?.(this.#data[rowIndex], rowIndex); } } } } } #handleDragSelect(y) { if (y < 2) { return; } const rowIndex = this.#scrollOffset + (y - 2); if (rowIndex >= 0 && rowIndex < this.#data.length) { this.#applyDragSelect(rowIndex); // Move cursor without triggering #ensureSelectedVisible this.#selectedIndex = rowIndex; this.invalidateRender(); } } #applyDragSelect(rowIndex) { const item = this.#data[rowIndex]; const changed = this.#dragSelectState === 'select' ? !this.#selectedItems.has(item) : this.#selectedItems.has(item); if (!changed) { return; } if (this.#dragSelectState === 'select') { this.#selectedItems.add(item); } else { this.#selectedItems.delete(item); } this.invalidateRender(); this.#onSelectionChange?.(new Set(this.#selectedItems)); } #handleHeaderClick(x) { const INDENT = 1; const checkboxWidth = this.#checkboxWidth(); const rowNumWidth = this.#rowNumberWidth(); // Click on checkbox column header → toggle all if (this.#isShowSelected && x >= INDENT && x < INDENT + checkboxWidth) { if (this.#selectedItems.size === this.#data.length) { this.#selectedItems.clear(); } else { for (const item of this.#data) { this.#selectedItems.add(item); } } this.invalidateRender(); this.#onSelectionChange?.(new Set(this.#selectedItems)); return; } // Click on row number column → sort by original array order if (this.#showRowNumbers && x >= INDENT + checkboxWidth && x < INDENT + checkboxWidth + rowNumWidth) { const direction = !this.#sortKey && this.#sortDirection === 'asc' ? 'desc' : 'asc'; this.#sortKey = undefined; this.#sortDirection = direction; this.#sortData(); this.invalidateRender(); this.#onSort?.('#', direction); return; } const TRAILING = 1; const widths = this.#calculateColumnWidths(this.contentSize.width - INDENT - TRAILING - checkboxWidth - rowNumWidth); let currentX = INDENT + checkboxWidth + rowNumWidth; for (let i = 0; i < this.#columns.length; i++) { const colWidth = widths[i]; // account for separator (3 chars: ' │ ') const nextX = currentX + colWidth + (i < this.#columns.length - 1 ? 3 : 0); if (x >= currentX && x < nextX) { const col = this.#columns[i]; if (!col.sortable) { return; } const direction = this.#sortKey === col.key && this.#sortDirection === 'asc' ? 'desc' : 'asc'; this.#sortKey = col.key; this.#sortDirection = direction; this.#sortData(); this.invalidateRender(); this.#onSort?.(col.key, direction); return; } currentX = nextX; } } #toggleSelection(rowIndex) { if (!this.#isSelectable || rowIndex < 0 || rowIndex >= this.#data.length) { return; } const item = this.#data[rowIndex]; if (this.#selectedItems.has(item)) { this.#selectedItems.delete(item); } else { this.#selectedItems.add(item); } this.invalidateRender(); this.#onSelectionChange?.(new Set(this.#selectedItems)); } /** Width of the checkbox column (including trailing separator). */ #checkboxWidth() { if (!this.#isShowSelected) { return 0; } return 3 + 3; // '[⨉]' (3) + ' │ ' (3) } /** Width of the row number column (including trailing separator space). */ #rowNumberWidth() { if (!this.#showRowNumbers) { return 0; } // Width of the largest row number, minimum width of '#' header const digitWidth = Math.max(1, String(this.#data.length).length); return digitWidth + 3; // digits + ' │ ' } #calculateColumnWidths(totalWidth) { const cols = this.#columns; const separatorWidth = (cols.length - 1) * 3; // ' │ ' between columns const available = totalWidth - separatorWidth; let fixedTotal = 0; let autoCount = 0; for (const col of cols) { if (typeof col.width === 'number') { fixedTotal += col.width; } else { autoCount += 1; } } const autoWidth = autoCount > 0 ? Math.floor((available - fixedTotal) / autoCount) : 0; return cols.map(col => { if (typeof col.width === 'number') { return col.width; } return Math.max(autoWidth, col.title.length); }); } #ensureSelectedVisible() { if (this.#bodyHeight <= 0) { return; } const halfHeight = Math.floor(this.#bodyHeight / 2); if (this.#selectedIndex < this.#scrollOffset) { this.#scrollOffset = this.#selectedIndex; } else if (this.#selectedIndex >= this.#scrollOffset + this.#bodyHeight) { this.#scrollOffset = this.#selectedIndex - this.#bodyHeight + 1; } else if (this.#selectedIndex >= halfHeight && this.#selectedIndex < this.#data.length - halfHeight) { // "moving window" for middle rows: selected stays at the center this.#scrollOffset = this.#selectedIndex - halfHeight; } else if (this.#selectedIndex >= this.#data.length - halfHeight) { // "bottom zone": pin scroll so last rows are visible, selected moves down this.#scrollOffset = Math.max(0, this.#data.length - this.#bodyHeight); } const maxScroll = Math.max(0, this.#data.length - this.#bodyHeight); this.#scrollOffset = Math.max(0, Math.min(maxScroll, this.#scrollOffset)); // Ensure the selected row doesn't land on an indicator row. // Top indicator occupies position 0 when scrollOffset > 0. // Bottom indicator occupies the last position when there are rows below. if (this.#data.length > this.#bodyHeight) { const rowsAbove = this.#scrollOffset; const rowsBelow = this.#data.length - this.#scrollOffset - this.#bodyHeight; if (rowsAbove > 0 && this.#selectedIndex === this.#scrollOffset) { // Selected would be on the top indicator row — scroll up to make room this.#scrollOffset = Math.max(0, this.#selectedIndex - 1); } if (rowsBelow > 0 && this.#selectedIndex === this.#scrollOffset + this.#bodyHeight - 1) { // Selected would be on the bottom indicator row — scroll down to make room this.#scrollOffset = Math.min(maxScroll, this.#selectedIndex - this.#bodyHeight + 2); } } } #alignText(text, width, align) { const textWidth = unicode.lineWidth(text); if (textWidth > width) { // truncate let w = 0; let result = ''; for (const char of unicode.printableChars(text)) { const cw = unicode.charWidth(char); if (cw === 0) { result += char; continue; } if (w + cw > width - 1) { result += ELLIPSIS; break; } result += char; w += cw; } return result; } const pad = width - textWidth; switch (align) { case 'right': return ' '.repeat(pad) + text; case 'center': { const left = Math.floor(pad / 2); const right = pad - left; return ' '.repeat(left) + text + ' '.repeat(right); } default: return text + ' '.repeat(pad); } } render(viewport) { if (viewport.isEmpty) { return super.render(viewport); } viewport.registerFocus({ isDefault: true }); viewport.registerMouse(['mouse.button.left', 'mouse.wheel']); const width = viewport.contentSize.width; const height = viewport.contentSize.height; // Reserve 1 character at the left and right for selection markers (▶ ◀) const INDENT = 1; const TRAILING = 1; const checkboxWidth = this.#checkboxWidth(); const rowNumWidth = this.#rowNumberWidth(); const contentWidth = width - INDENT - TRAILING - checkboxWidth - rowNumWidth; const widths = this.#calculateColumnWidths(contentWidth); const dimStyle = new Style({ dim: true }); const headerStyle = new Style({ dim: true, bold: true }); // Cursor row (not checked) const cursorStyle = new Style({ foreground: this.purpose.textColor, background: this.purpose.highlightColor, bold: true, }); const checkedRowStyle = new Style({ background: this.purpose.tableCheckedColor, }); const cursorCheckedStyle = new Style({ foreground: this.purpose.textColor, background: this.purpose.tableCheckedHighlightColor, bold: true, }); // Header row let headerX = INDENT; // Checkbox column header if (this.#isShowSelected) { const checkedCount = this.#selectedItems.size; const headerCheck = checkedCount === 0 ? CHECKBOX_UNCHECKED : checkedCount === this.#data.length ? CHECKBOX_CHECKED : CHECKBOX_PARTIAL; viewport.write(headerCheck, new Point(headerX, 0), headerStyle); headerX += 3; viewport.write(COLUMN_SEPARATOR, new Point(headerX, 0), dimStyle); headerX += 3; } // Row number column header if (this.#showRowNumbers) { const numColWidth = rowNumWidth - 3; // subtract separator const aligned = this.#alignText(ROW_NUMBER_HEADER, numColWidth, 'right'); viewport.write(aligned, new Point(headerX, 0), headerStyle); // Show sort arrow when sorting by original order (sortKey is undefined) if (!this.#sortKey) { const arrow = this.#sortDirection === 'asc' ? SORT_ASC : SORT_DESC; viewport.write(arrow, new Point(headerX, 0), headerStyle); } headerX += numColWidth; viewport.write(COLUMN_SEPARATOR, new Point(headerX, 0), dimStyle); headerX += 3; } for (let i = 0; i < this.#columns.length; i++) { const col = this.#columns[i]; const aligned = this.#alignText(col.title, widths[i], col.align ?? 'left'); viewport.write(aligned, new Point(headerX, 0), headerStyle); // Write sort arrow on top, positioned after the title text, clamped to column width if (this.#sortKey === col.key) { const arrow = this.#sortDirection === 'asc' ? SORT_ASC : SORT_DESC; const titleWidth = unicode.lineWidth(col.title); const align = col.align ?? 'left'; const titleStart = align === 'right' ? widths[i] - titleWidth : align === 'center' ? Math.floor((widths[i] - titleWidth) / 2) : 0; const arrowOffset = Math.min(titleStart + titleWidth + 1, widths[i] - 1); viewport.write(arrow, new Point(headerX + arrowOffset, 0), headerStyle); } headerX += widths[i]; if (i < this.#columns.length - 1) { viewport.write(COLUMN_SEPARATOR, new Point(headerX, 0), dimStyle); headerX += 3; } } // Separator if (height > 1) { const sep = HORIZONTAL_LINE.repeat(width); viewport.write(sep, new Point(0, 1), dimStyle); } // Body this.#bodyHeight = Math.max(0, height - 2); if (this.#selectionDirty) { this.#selectionDirty = false; this.#ensureSelectedVisible(); } // Pin viewport during drag-select if (this.#dragScrollPinned !== undefined) { this.#scrollOffset = this.#dragScrollPinned; } for (let i = 0; i < this.#bodyHeight; i++) { const rowIndex = this.#scrollOffset + i; const y = i + 2; if (rowIndex >= this.#data.length) { break; } const isSelected = rowIndex === this.#selectedIndex; const row = this.#data[rowIndex]; const isChecked = this.#selectedItems.has(row); const rowHighlight = isSelected && isChecked ? cursorCheckedStyle : isSelected ? cursorStyle : isChecked ? checkedRowStyle : Style.NONE; const effectiveSepStyle = isSelected && isChecked ? cursorCheckedStyle : isSelected ? cursorStyle : isChecked ? checkedRowStyle : dimStyle; if (isSelected || isChecked) { viewport.write(' '.repeat(width), new Point(0, y), rowHighlight); } if (isSelected) { const selectionStyle = isChecked ? cursorCheckedStyle : cursorStyle; viewport.write(SELECTION_MARKER, new Point(0, y), selectionStyle); viewport.write(SELECTION_MARKER_END, new Point(width - 1, y), selectionStyle); } let cellX = INDENT; // Checkbox column if (this.#isShowSelected) { const checkText = isChecked ? CHECKBOX_CHECKED : CHECKBOX_UNCHECKED; viewport.write(checkText, new Point(cellX, y), rowHighlight); cellX += 3; viewport.write(COLUMN_SEPARATOR, new Point(cellX, y), effectiveSepStyle); cellX += 3; } // Row number column if (this.#showRowNumbers) { const numColWidth = rowNumWidth - 3; // subtract separator const numText = String(rowIndex + 1); const aligned = this.#alignText(numText, numColWidth, 'right'); viewport.write(aligned, new Point(cellX, y), rowHighlight); cellX += numColWidth; viewport.write(COLUMN_SEPARATOR, new Point(cellX, y), effectiveSepStyle); cellX += 3; } const childIndex = rowIndex - this.#childOffset; const child = this.children[childIndex]; if (child) { const childWidth = Math.max(0, width - cellX - 1); if (childWidth > 0) { viewport.clipped(new Rect(new Point(cellX, y), new Size(childWidth, 1)), childViewport => child.render(childViewport)); } continue; } for (let j = 0; j < this.#columns.length; j++) { const col = this.#columns[j]; const text = this.#format(col.key, row); const aligned = this.#alignText(text, widths[j], col.align ?? 'left'); viewport.write(aligned, new Point(cellX, y), rowHighlight); cellX += widths[j]; if (j < this.#columns.length - 1) { viewport.write(COLUMN_SEPARATOR, new Point(cellX, y), effectiveSepStyle); cellX += 3; } } } } } const SELECTION_MARKER = '▶'; const SELECTION_MARKER_END = '◀'; const COLUMN_SEPARATOR = ' │ '; const HORIZONTAL_LINE = '─'; const SORT_ASC = '▲'; const SORT_DESC = '▼'; const ELLIPSIS = '…'; const CHECKBOX_CHECKED = '[✕]'; const CHECKBOX_UNCHECKED = '[ ]'; const CHECKBOX_PARTIAL = '[·]'; const ROW_NUMBER_HEADER = '#'; //# sourceMappingURL=Table.js.map