UNPKG

asciitorium

Version:
263 lines (262 loc) 10.5 kB
import { Component } from '../core/Component.js'; import { ScrollableViewport } from '../core/ScrollableViewport.js'; import { OptionGroup } from './OptionGroup.js'; export class Select extends Component { flattenChildren(children) { const items = []; for (const child of children) { if (child instanceof OptionGroup) { // Add group header items.push({ label: child.label, value: null, // Group headers have no value isGroupHeader: true, isLastInGroup: false, groupDepth: 0, }); // Add group children const groupChildren = child.children; groupChildren.forEach((option, index) => { const isLast = index === groupChildren.length - 1; items.push({ label: option.label, value: option.value, isGroupHeader: false, isLastInGroup: isLast, groupDepth: 1, }); }); } else { // Regular Option (not in a group) items.push({ label: child.label, value: child.value, isGroupHeader: false, isLastInGroup: false, groupDepth: 0, }); } } return items; } constructor(options) { super({ ...options, height: options.height ?? options.style?.height ?? 3, border: options.border ?? options.style?.border ?? true, }); this.scrollableViewport = new ScrollableViewport(); this.focusedIndex = 0; this.selectedIndex = 0; this.focusable = true; this.hasFocus = false; this.showSelectionBox = options.showSelectionBox ?? true; // Support both old API (items/selectedItem) and new API (children/selected) if (options.children && options.children.length > 0) { // New JSX-based API with Option/OptionGroup children this.items = this.flattenChildren(options.children); this.selectedState = options.selected; } else { // Old API with string array this.items = (options.items || []).map((item) => ({ label: item, value: item, isGroupHeader: false, isLastInGroup: false, groupDepth: 0, })); this.selectedState = options.selectedItem; } const initialSelectedIndex = Math.max(0, this.items.findIndex((item) => item.value === this.selectedState.value)); this.selectedIndex = initialSelectedIndex; this.focusedIndex = initialSelectedIndex; this.bind(this.selectedState, (value) => { const index = this.items.findIndex((item) => item.value === value); if (index !== -1 && index !== this.selectedIndex) { this.selectedIndex = index; this.focusedIndex = index; } }); } /** * Move focus to the next item (skipping group headers) */ moveNext() { let newIndex = this.focusedIndex + 1; // Skip group headers while (newIndex < this.items.length && this.items[newIndex].isGroupHeader) { newIndex++; } // Clamp to valid range if (newIndex < this.items.length) { this.focusedIndex = newIndex; } } /** * Move focus to the previous item (skipping group headers) */ movePrevious() { let newIndex = this.focusedIndex - 1; // Skip group headers while (newIndex >= 0 && this.items[newIndex].isGroupHeader) { newIndex--; } // Clamp to valid range if (newIndex >= 0) { this.focusedIndex = newIndex; } } /** * Select the currently focused item (if not a group header) */ select() { if (this.focusedIndex >= 0 && this.focusedIndex < this.items.length && !this.items[this.focusedIndex].isGroupHeader) { this.selectedIndex = this.focusedIndex; this.selectedState.value = this.items[this.selectedIndex].value; } } /** * Jump to the first item */ moveToStart() { this.focusedIndex = 0; // Skip if first item is a group header if (this.items[0]?.isGroupHeader) { this.moveNext(); } } /** * Jump to the last item */ moveToEnd() { this.focusedIndex = this.items.length - 1; // Skip if last item is a group header if (this.items[this.focusedIndex]?.isGroupHeader) { this.movePrevious(); } } /** * Page down (move down by roughly a page of items) */ pageDown() { const innerHeight = this.height - (this.border ? 2 : 0) - (this.height < 5 ? 0 : 1); const lineHeight = 2; const maxVisible = Math.max(1, Math.floor(innerHeight / lineHeight)); let newIndex = Math.min(this.items.length - 1, this.focusedIndex + maxVisible); // Skip group headers while (newIndex < this.items.length && this.items[newIndex].isGroupHeader) { newIndex++; } if (newIndex < this.items.length) { this.focusedIndex = newIndex; } } /** * Page up (move up by roughly a page of items) */ pageUp() { const innerHeight = this.height - (this.border ? 2 : 0) - (this.height < 5 ? 0 : 1); const lineHeight = 2; const maxVisible = Math.max(1, Math.floor(innerHeight / lineHeight)); let newIndex = Math.max(0, this.focusedIndex - maxVisible); // Skip group headers while (newIndex >= 0 && this.items[newIndex].isGroupHeader) { newIndex--; } if (newIndex >= 0) { this.focusedIndex = newIndex; } } draw() { const buffer = super.draw(); const borderPad = this.border ? 1 : 0; const paddingTop = this.height < 5 ? 0 : 1; const lineHeight = 2; const innerHeight = this.height - 2 * borderPad - paddingTop; const maxVisible = Math.max(1, Math.floor(innerHeight / lineHeight)); // Use ScrollableViewport to calculate scroll window const itemLabels = this.items.map((item) => item.label); const scrollWindow = this.scrollableViewport.calculateScrollWindow({ items: itemLabels, totalCount: this.items.length, maxVisible, focusedIndex: this.focusedIndex, }); const { startIdx: focusedStartIdx, visibleItems: focusedVisibleItems } = scrollWindow; // Draw items focusedVisibleItems.forEach((itemLabel, i) => { const y = borderPad + paddingTop + i * lineHeight; const x = borderPad; if (y >= buffer.length) { console.error(`❌ BUFFER OVERFLOW: Trying to draw at y=${y} but buffer.length=${buffer.length}!`); return; } const itemIndex = focusedStartIdx + i; const item = this.items[itemIndex]; const isFocused = itemIndex === this.focusedIndex; const isSelected = itemIndex === this.selectedIndex; // Calculate tree prefix and text offset let treePrefix = ''; let textOffset = x + 2; // Default offset after focus indicator if (item.isGroupHeader) { // Group headers have no tree prefix treePrefix = ''; } else if (item.groupDepth > 0) { // Items in a group get tree connectors treePrefix = item.isLastInGroup ? '└─ ' : '├─ '; textOffset = x + 2; // Tree prefix starts after focus indicator } // Draw vertical tree lines in gap rows FIRST (before selection box) if (i > 0 && item.groupDepth > 0 && !item.isGroupHeader) { const prevItemIndex = focusedStartIdx + i - 1; if (prevItemIndex >= 0 && prevItemIndex < this.items.length) { const prevItem = this.items[prevItemIndex]; // Draw vertical line if previous item is in a group and not the last if (prevItem.groupDepth > 0 && !prevItem.isLastInGroup) { const gapY = y - 1; buffer[gapY][textOffset] = '│'; } } } // Draw focus prefix if (isFocused && this.hasFocus) buffer[y][x] = '>'; // Draw tree prefix for (let n = 0; n < treePrefix.length; n++) { buffer[y][textOffset + n] = treePrefix[n]; } // Draw item label (after tree prefix) const labelStartX = textOffset + treePrefix.length; // itemLabel can be either a string (for group headers) or an array (for options) const labelText = Array.isArray(itemLabel) ? itemLabel[0] : itemLabel; for (let n = 0; n < labelText.length; n++) { buffer[y][labelStartX + n] = labelText[n]; } // Draw box around selected item last (over tree characters) if (isSelected && !item.isGroupHeader && this.showSelectionBox) { // Draw box border buffer[y - 1][x + 1] = '╭'; buffer[y][x + 1] = '│'; buffer[y + 1][x + 1] = '╰'; for (let j = 2; j < this.width - 4; j++) { buffer[y - 1][x + j] = '─'; buffer[y + 1][x + j] = '─'; } buffer[y - 1][this.width - 3] = '╮'; buffer[y][this.width - 3] = '│'; buffer[y + 1][this.width - 3] = '╯'; } }); // Draw scroll indicators using ScrollableViewport this.scrollableViewport.drawScrollIndicators(buffer, this.width, this.height, borderPad, scrollWindow.showUpArrow, scrollWindow.showDownArrow); this.buffer = buffer; return buffer; } }