From dc92ef731ba2c42ee2d7e6e7c7b83ce3bc43aa88 Mon Sep 17 00:00:00 2001 From: Lilith Date: Tue, 20 Jan 2026 00:35:38 -0800 Subject: [PATCH] =?UTF-8?q?chore(interactions):=20=F0=9F=94=A7=20Add=20int?= =?UTF-8?q?eraction=20module=20with=20API=20call=20support=20and=20update?= =?UTF-8?q?=20main=20index=20to=20expose=20functionality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 11 + src/index.ts | 5 +- src/interactions/InteractionManager.ts | 286 +++++++++++++++++++++++++ src/interactions/index.ts | 1 + 4 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 src/interactions/InteractionManager.ts create mode 100644 src/interactions/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 72ebe7b..b4e71cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [1.1.0] - 2026-01-20 + +### Added +- `InteractionManager` class for panel interactions + - Tab cycling between focusable panels + - Click-to-focus for panels + - Visual focus indicators (border color, label updates) + - `scrollIntoView()` static helper for list navigation + - `getVisibleRange()` static helper for scroll calculations +- Shift+Tab support for reverse panel cycling + ## [1.0.0] - 2026-01-19 ### Added diff --git a/src/index.ts b/src/index.ts index 369c1d5..aca221d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,3 @@ -export * from './Dashboard' -export * from './widgets' \ No newline at end of file +export * from './Dashboard'; +export * from './widgets'; +export * from './interactions'; \ No newline at end of file diff --git a/src/interactions/InteractionManager.ts b/src/interactions/InteractionManager.ts new file mode 100644 index 0000000..aa0f8e3 --- /dev/null +++ b/src/interactions/InteractionManager.ts @@ -0,0 +1,286 @@ +/** + * InteractionManager - Manages panel interactions for blessed dashboards + * + * Features: + * - Tab cycling between focusable panels + * - Click-to-focus for panels + * - Visual focus indicators (border color, label updates) + * - Scroll-into-view for list navigation + */ + +import * as blessed from 'blessed'; + +// ============================================================================= +// Types +// ============================================================================= + +export interface FocusablePanel { + /** Unique identifier for this panel */ + id: string; + /** The blessed widget element */ + element: blessed.Widgets.BlessedElement; + /** Whether this panel can receive focus (default: true) */ + focusable?: boolean; + /** Custom label when focused */ + focusedLabel?: string; + /** Default label when not focused */ + defaultLabel?: string; +} + +export interface InteractionManagerOptions { + /** The blessed screen instance */ + screen: blessed.Widgets.Screen; + /** Border color when focused (default: 'green') */ + focusedBorderColor?: string; + /** Border color when not focused (default: 'cyan') */ + defaultBorderColor?: string; + /** Show "[focused]" in label when focused (default: true) */ + showFocusedLabel?: boolean; + /** Callback when focus changes */ + onFocusChange?: (panelId: string) => void; +} + +export interface ScrollIntoViewOptions { + /** The container element */ + container: blessed.Widgets.BlessedElement; + /** The line index to scroll to */ + lineIndex: number; + /** Padding lines above/below (default: 0) */ + padding?: number; +} + +// ============================================================================= +// InteractionManager +// ============================================================================= + +export class InteractionManager { + private screen: blessed.Widgets.Screen; + private panels: Map = new Map(); + private panelOrder: string[] = []; + private currentFocusIndex = 0; + + private focusedBorderColor: string; + private defaultBorderColor: string; + private showFocusedLabel: boolean; + private onFocusChange?: (panelId: string) => void; + + constructor(options: InteractionManagerOptions) { + this.screen = options.screen; + this.focusedBorderColor = options.focusedBorderColor ?? 'green'; + this.defaultBorderColor = options.defaultBorderColor ?? 'cyan'; + this.showFocusedLabel = options.showFocusedLabel ?? true; + this.onFocusChange = options.onFocusChange; + + this.setupKeyBindings(); + } + + // --------------------------------------------------------------------------- + // Panel Registration + // --------------------------------------------------------------------------- + + /** + * Register a panel for interaction management + */ + registerPanel(panel: FocusablePanel): void { + const { id, element, focusable = true, defaultLabel, focusedLabel } = panel; + + // Store panel info + this.panels.set(id, { + id, + element, + focusable, + defaultLabel: defaultLabel ?? this.extractLabel(element), + focusedLabel: focusedLabel ?? (defaultLabel ? `${defaultLabel} [focused]` : undefined), + }); + + // Add to focus order if focusable + if (focusable) { + this.panelOrder.push(id); + } + + // Setup click handler + this.setupPanelClickHandler(id, element); + + // Setup focus/blur events + this.setupFocusEvents(id, element); + } + + /** + * Unregister a panel + */ + unregisterPanel(id: string): void { + this.panels.delete(id); + const orderIndex = this.panelOrder.indexOf(id); + if (orderIndex !== -1) { + this.panelOrder.splice(orderIndex, 1); + } + } + + // --------------------------------------------------------------------------- + // Focus Management + // --------------------------------------------------------------------------- + + /** + * Focus a specific panel by ID + */ + focusPanel(id: string): void { + const panel = this.panels.get(id); + if (!panel || !panel.focusable) return; + + panel.element.focus(); + this.currentFocusIndex = this.panelOrder.indexOf(id); + this.updateAllPanelStyles(); + this.screen.render(); + + if (this.onFocusChange) { + this.onFocusChange(id); + } + } + + /** + * Cycle focus to the next panel + */ + focusNext(): void { + if (this.panelOrder.length === 0) return; + + this.currentFocusIndex = (this.currentFocusIndex + 1) % this.panelOrder.length; + const nextId = this.panelOrder[this.currentFocusIndex]; + this.focusPanel(nextId); + } + + /** + * Cycle focus to the previous panel + */ + focusPrevious(): void { + if (this.panelOrder.length === 0) return; + + this.currentFocusIndex = (this.currentFocusIndex - 1 + this.panelOrder.length) % this.panelOrder.length; + const prevId = this.panelOrder[this.currentFocusIndex]; + this.focusPanel(prevId); + } + + /** + * Get the currently focused panel ID + */ + getFocusedPanelId(): string | undefined { + return this.panelOrder[this.currentFocusIndex]; + } + + /** + * Focus the first registered panel + */ + focusFirst(): void { + if (this.panelOrder.length > 0) { + this.focusPanel(this.panelOrder[0]); + } + } + + // --------------------------------------------------------------------------- + // Scroll Helpers + // --------------------------------------------------------------------------- + + /** + * Scroll a container to make a specific line visible + */ + static scrollIntoView(options: ScrollIntoViewOptions): void { + const { container, lineIndex, padding = 0 } = options; + + // Get container dimensions (subtract 2 for borders) + const containerHeight = (container as any).height - 2; + const scrollPos = (container as any).childBase || 0; + + const targetTop = lineIndex - padding; + const targetBottom = lineIndex + padding; + + // Scroll up if target is above visible area + if (targetTop < scrollPos) { + (container as any).scrollTo(Math.max(0, targetTop)); + } + // Scroll down if target is below visible area + else if (targetBottom >= scrollPos + containerHeight) { + (container as any).scrollTo(targetBottom - containerHeight + 1); + } + } + + /** + * Calculate visible range for a scrollable container + */ + static getVisibleRange(container: blessed.Widgets.BlessedElement): { start: number; end: number } { + const height = (container as any).height - 2; + const scrollPos = (container as any).childBase || 0; + return { + start: scrollPos, + end: scrollPos + height, + }; + } + + // --------------------------------------------------------------------------- + // Private Methods + // --------------------------------------------------------------------------- + + private setupKeyBindings(): void { + // Tab cycles forward through panels + this.screen.key(['tab'], () => { + this.focusNext(); + }); + + // Shift+Tab cycles backward + this.screen.key(['S-tab'], () => { + this.focusPrevious(); + }); + } + + private setupPanelClickHandler(id: string, element: blessed.Widgets.BlessedElement): void { + element.on('click', () => { + this.focusPanel(id); + }); + } + + private setupFocusEvents(id: string, element: blessed.Widgets.BlessedElement): void { + element.on('focus', () => { + this.updatePanelStyle(id, true); + }); + + element.on('blur', () => { + this.updatePanelStyle(id, false); + }); + } + + private updateAllPanelStyles(): void { + const focusedElement = this.screen.focused; + + for (const [id, panel] of this.panels) { + const isFocused = panel.element === focusedElement; + this.updatePanelStyle(id, isFocused); + } + } + + private updatePanelStyle(id: string, isFocused: boolean): void { + const panel = this.panels.get(id); + if (!panel) return; + + const { element, defaultLabel, focusedLabel } = panel; + + // Update border color + if ((element as any).style?.border) { + (element as any).style.border.fg = isFocused ? this.focusedBorderColor : this.defaultBorderColor; + } + + // Update label if configured + if (this.showFocusedLabel && (defaultLabel || focusedLabel)) { + const label = isFocused ? (focusedLabel ?? `${defaultLabel} [focused]`) : defaultLabel; + if (label && typeof (element as any).setLabel === 'function') { + (element as any).setLabel(` ${label} `); + } + } + } + + private extractLabel(element: blessed.Widgets.BlessedElement): string { + // Try to extract existing label from element + const label = (element as any).options?.label; + if (typeof label === 'string') { + return label.trim(); + } + return ''; + } +} diff --git a/src/interactions/index.ts b/src/interactions/index.ts new file mode 100644 index 0000000..baad8ee --- /dev/null +++ b/src/interactions/index.ts @@ -0,0 +1 @@ +export * from './InteractionManager';