UNPKG

@teaui/core

Version:

A high-level terminal UI library for Node

287 lines 11.3 kB
import * as unicode from '@teaui/term'; import { Container } from '../Container.js'; import { Point, Size } from '../geometry.js'; import { isMouseClicked, isMouseEnter, isMouseExit, isMouseMove, } from '../events/index.js'; import { Style } from '../Style.js'; const MAX_TITLE_WIDTH = 25; export class Breadcrumb extends Container { #items = []; #isActive = true; #palette = DEFAULT_PALETTE; #segments = []; #hoverIndex = null; constructor(props) { super(props); this.#update(props); } update(props) { this.#update(props); super.update(props); } #update({ items, isActive, palette }) { this.#items = items ?? []; this.#isActive = isActive ?? true; this.#palette = palette ?? DEFAULT_PALETTE; } /** * Given a Color, return a brighter version suitable for hover highlighting. * Maps standard terminal colours → their bright variants. */ static brightenColor(color) { if (typeof color !== 'string') { return color; } const map = { black: 'gray', red: 'brightRed', green: 'brightGreen', yellow: 'brightYellow', blue: 'brightBlue', magenta: 'brightMagenta', cyan: 'brightCyan', white: 'brightWhite', gray: 'brightWhite', grey: 'brightWhite', }; return map[color] ?? color; } /** * Compute the styles for a breadcrumb segment, accounting for hover state. * * Returns { segmentStyle, arrowStyle, finalArrowStyle } where arrowStyle is * the style for the leading arrow (left separator) and finalArrowStyle is * only set for the last item (the trailing arrow). * * @param colors - fg/bg for this item * @param prevColors - fg/bg for the previous item (null if first) * @param nextColors - fg/bg for the next item (null if last — used for trailing arrow) * @param isHovered - whether this item is being hovered * @param isFirst - whether this is the first item * @param isLast - whether this is the last item * @param prevHovered - whether the previous item is hovered (affects this item's left arrow) */ static highlightStyles(colors, prevColors, isHovered, isActive, isFirst, isLast, prevHovered) { const bg = isHovered ? Breadcrumb.hoverBg(colors) : colors.bg; const fg = isHovered ? Breadcrumb.hoverFg(colors) : colors.fg; const segmentStyle = new Style({ foreground: fg, background: bg, underline: isHovered && !isActive, }); let arrowStyle = null; if (!isFirst && prevColors) { // The left arrow's fg = previous item's bg, bg = this item's bg const prevBg = prevHovered ? Breadcrumb.hoverBg(prevColors) : prevColors.bg; arrowStyle = new Style({ foreground: prevBg, background: bg, }); } let finalArrowStyle = null; if (isLast) { finalArrowStyle = new Style({ foreground: bg, background: 'default', }); } return { segmentStyle, arrowStyle, finalArrowStyle }; } /** * Resolve the hover foreground for a palette entry. * Uses the explicit `fgHover` colour if provided, otherwise falls back to `fg`. */ static hoverFg(entry) { return entry.fgHover ?? entry.fg; } /** * Resolve the hover background for a palette entry. * Uses the explicit `bgHover` colour if provided, otherwise falls back to * `brightenColor(bg)`. */ static hoverBg(entry) { return entry.bgHover ?? Breadcrumb.brightenColor(entry.bg); } /** * Build a clipped title string: truncates to MAX_TITLE_WIDTH and adds "…" if needed. */ static clippedTitle(title) { const width = unicode.lineWidth(title); if (width <= MAX_TITLE_WIDTH) { return title; } // Truncate by characters until we fit (accounting for wide chars) let result = ''; let w = 0; for (const ch of title) { const cw = unicode.lineWidth(ch); if (w + cw > MAX_TITLE_WIDTH - 1) { break; } result += ch; w += cw; } return result + '…'; } /** * Measure the segments for the current items. This builds the SegmentRegion * array so that mouse hit-testing works correctly. */ static measureSegments(items) { const segments = []; let x = 0; for (let i = 0; i < items.length; i++) { const title = Breadcrumb.clippedTitle(items[i].title); const isFirst = i === 0; if (isFirst) { // " 🏠 {title} " — no leading arrow const text = ` 🏠 ${title} `; const textWidth = unicode.lineWidth(text); segments.push({ index: i, arrowX: x, arrowWidth: 0, textX: x, textWidth, }); x += textWidth; } else { // Leading arrow (1 cell) then " {title} " const arrowX = x; const arrowWidth = 1; const textX = x + arrowWidth; const text = ` ${title} `; const textWidth = unicode.lineWidth(text); segments.push({ index: i, arrowX, arrowWidth, textX, textWidth, }); x += arrowWidth + textWidth; } } return segments; } naturalSize(_available) { if (this.#items.length === 0) { return new Size(0, 1); } const segments = Breadcrumb.measureSegments(this.#items); const last = segments[segments.length - 1]; // Total width = end of last segment + 1 for the trailing arrow const width = last.textX + last.textWidth + 1; return new Size(width, 1); } receiveMouse(event, system) { super.receiveMouse(event, system); if (isMouseExit(event)) { this.#hoverIndex = null; } else if (isMouseEnter(event) || isMouseMove(event)) { this.#hoverIndex = this.#indexAtX(event.position.x); } if (isMouseClicked(event)) { const index = this.#indexAtX(event.position.x); if (index !== null) { this.#items[index]?.onPress?.(); } } } #indexAtX(x) { for (const seg of this.#segments) { // Hit test against the full segment area (arrow + text) const segStart = seg.arrowX; const segEnd = seg.textX + seg.textWidth; if (x >= segStart && x < segEnd) { return seg.index; } } return null; } render(viewport) { if (viewport.isEmpty || this.#items.length === 0) { return super.render(viewport); } viewport.registerMouse(['mouse.button.left', 'mouse.move']); this.#segments = Breadcrumb.measureSegments(this.#items); for (const seg of this.#segments) { const i = seg.index; const item = this.#items[i]; const isFirst = i === 0; const isLast = i === this.#items.length - 1; const title = Breadcrumb.clippedTitle(item.title); const isHovered = this.#hoverIndex === i; const prevHovered = this.#hoverIndex === i - 1; if (this.#isActive) { const colorIndex = i % this.#palette.length; const colors = this.#palette[colorIndex]; const prevColors = !isFirst ? this.#palette[(i - 1) % this.#palette.length] : null; const { segmentStyle, arrowStyle, finalArrowStyle } = Breadcrumb.highlightStyles(colors, prevColors, isHovered, this.#isActive, isFirst, isLast, prevHovered); // Draw leading arrow if (!isFirst && arrowStyle) { viewport.write(ACTIVE_ARROW, new Point(seg.arrowX, 0), arrowStyle); } // Draw padded text const text = isFirst ? ` 🏠 ${title} ` : ` ${title} `; let x = seg.textX; for (const ch of text) { if (x < viewport.contentSize.width) { viewport.write(ch, new Point(x, 0), segmentStyle); } x += unicode.lineWidth(ch); } // Draw trailing arrow for last item if (isLast && finalArrowStyle) { const trailX = seg.textX + seg.textWidth; if (trailX < viewport.contentSize.width) { viewport.write(ACTIVE_ARROW, new Point(trailX, 0), finalArrowStyle); } } } else { // Inactive rendering — plain text with muted separators if (!isFirst) { const mutedStyle = new Style({ foreground: 'gray' }); viewport.write(INACTIVE_ARROW, new Point(seg.arrowX, 0), mutedStyle); } const plainStyle = isHovered ? new Style({ underline: true }) : this.purpose.ui({}); const text = isFirst ? ` 🏠 ${title} ` : ` ${title} `; let x = seg.textX; for (const ch of text) { if (x < viewport.contentSize.width) { viewport.write(ch, new Point(x, 0), plainStyle); } x += unicode.lineWidth(ch); } } } super.render(viewport); } } // Default colour palette — 12-bit colours with pre-computed hover variants const DEFAULT_PALETTE = [ { fg: '#333', fgHover: '#333', bg: '#817', bgHover: '#c256ad' }, { fg: '#333', fgHover: '#333', bg: '#a35', bgHover: '#e76d87' }, { fg: '#333', fgHover: '#333', bg: '#c66', bgHover: '#ff9d9a' }, { fg: '#333', fgHover: '#333', bg: '#e94', bgHover: '#ffd281' }, { fg: '#333', fgHover: '#333', bg: '#ed0', bgHover: '#ffff77' }, { fg: '#333', fgHover: '#333', bg: '#9d5', bgHover: '#d2ff93' }, { fg: '#333', fgHover: '#333', bg: '#4d8', bgHover: '#8bffc0' }, { fg: '#333', fgHover: '#333', bg: '#2cb', bgHover: '#79fff4' }, { fg: '#333', fgHover: '#333', bg: '#0bc', bgHover: '#71f5ff' }, { fg: '#333', fgHover: '#333', bg: '#09c', bgHover: '#66d2ff' }, { fg: '#333', fgHover: '#333', bg: '#36b', bgHover: '#679df7' }, { fg: '#333', fgHover: '#333', bg: '#639', bgHover: '#996ad3' }, ]; // Arrow constants for breadcrumb separators const INACTIVE_ARROW = ''; // For inactive/muted breadcrumbs const ACTIVE_ARROW = ''; // For active breadcrumbs (Powerline right triangle) //# sourceMappingURL=Breadcrumb.js.map