UNPKG

@teaui/core

Version:

A high-level terminal UI library for Node

363 lines 13.3 kB
import { Container } from '../Container.js'; import { Point, Rect, Size } from '../geometry.js'; import { isMouseClicked, isMouseExit, isMouseWheel, } from '../events/index.js'; import { Style } from '../Style.js'; import { define } from '../util.js'; export class Page extends Container { static Section; #activeIndex = 0; #onChange; // Animation state #animating = false; #animationElapsed = 0; // elapsed time in ms #animationDirection = 0; // -1 = sliding left (going forward), +1 = sliding right (going backward) #outgoingIndex = -1; // section index of the outgoing page #incomingIndex = -1; // section index of the incoming page // Mouse scroll state #scrollDx = 0; #scrollTimeout = 0; #disableScrollTimeout = 0; // Dot layout (computed during render, used for mouse hit-testing) #dotRects = []; #hoveredDot = -1; static create(sections, extraProps = {}) { const page = new Page(extraProps); for (const section of sections) { if (section instanceof Section) { page.addSection(section); } else { const [title, view] = section; page.addSection(title, view); } } return page; } constructor(props = {}) { super(props); this.#update(props); } update(props) { this.#update(props); super.update(props); } #update({ activeIndex, onChange }) { if (activeIndex !== undefined && activeIndex !== this.#activeIndex) { this.#navigateTo(activeIndex); } this.#onChange = onChange; } get sections() { return this.children.map(view => view instanceof Section ? view : new ImplicitSection(view)); } get activeIndex() { return this.#activeIndex; } set activeIndex(value) { if (value === this.#activeIndex) return; this.#navigateTo(value); } addSection(titleOrSection, view) { let sectionView; if (titleOrSection instanceof Section) { sectionView = titleOrSection; } else { sectionView = Section.create(titleOrSection, view); } this.add(sectionView); } #navigateTo(index) { const sections = this.sections; if (sections.length === 0) return; index = Math.max(0, Math.min(sections.length - 1, index)); if (index === this.#activeIndex && !this.#animating) return; const prevIndex = this.#animating ? this.#incomingIndex : this.#activeIndex; if (index === prevIndex) return; this.#outgoingIndex = prevIndex; this.#incomingIndex = index; this.#animationDirection = index > prevIndex ? -1 : 1; this.#animationElapsed = 0; this.#animating = true; this.#activeIndex = index; this.#onChange?.(index); this.invalidateSize(); } #hasTitles() { return this.sections.some(s => s.title.length > 0); } #indicatorHeight() { return 1 + (this.#hasTitles() ? 1 : 0); } naturalSize(available) { const indicatorHeight = this.#indicatorHeight(); const childAvailable = available.shrink(0, indicatorHeight); let maxWidth = 0, maxHeight = 0; for (const section of this.sections) { const size = section.naturalSize(childAvailable); maxWidth = Math.max(maxWidth, size.width); maxHeight = Math.max(maxHeight, size.height); } return new Size(maxWidth, maxHeight + indicatorHeight); } receiveKey(event) { const sections = this.sections; if (sections.length === 0) return; switch (event.name) { case 'pagedown': this.#navigateTo(this.#activeIndex + 1); break; case 'pageup': this.#navigateTo(this.#activeIndex - 1); break; case 'home': this.#navigateTo(0); break; case 'end': this.#navigateTo(sections.length - 1); break; } } receiveMouse(event, system) { super.receiveMouse(event, system); if (isMouseWheel(event)) { if (this.#disableScrollTimeout > 0) return; if (event.name === 'mouse.wheel.up' || event.name === 'mouse.wheel.left') { this.#scrollDx -= 1; } else if (event.name === 'mouse.wheel.down' || event.name === 'mouse.wheel.right') { this.#scrollDx += 1; } if (this.#scrollDx <= -SCROLL_THRESHOLD) { this.#scrollDx = 0; this.#scrollTimeout = 0; this.#disableScrollTimeout = DISABLE_TIMEOUT; this.#navigateTo(this.#activeIndex - 1); } else if (this.#scrollDx >= SCROLL_THRESHOLD) { this.#scrollDx = 0; this.#scrollTimeout = 0; this.#disableScrollTimeout = DISABLE_TIMEOUT; this.#navigateTo(this.#activeIndex + 1); } else { this.#scrollTimeout = SCROLL_TIMEOUT; this.invalidateSize(); } return; } // Update dot hover based on mouse position if (isMouseExit(event)) { this.#hoveredDot = -1; } else { this.#hoveredDot = this.#dotIndexAt(event.position); } if (isMouseClicked(event)) { const dotIndex = this.#dotIndexAt(event.position); if (dotIndex >= 0) { this.#navigateTo(dotIndex); } } } #dotIndexAt(position) { for (let i = 0; i < this.#dotRects.length; i++) { if (this.#dotRects[i].includes(position)) { return i; } } return -1; } receiveTick(dt) { if (this.#disableScrollTimeout > 0) { this.#disableScrollTimeout -= dt; if (this.#disableScrollTimeout <= 0) { this.#disableScrollTimeout = 0; } } if (this.#scrollDx !== 0) { this.#scrollTimeout -= dt; if (this.#scrollTimeout <= 0) { this.#scrollDx = 0; this.#scrollTimeout = 0; } } this.#animationElapsed += dt; if (this.#animationElapsed >= ANIMATION_DURATION) { this.#animating = false; this.#animationElapsed = 0; this.#outgoingIndex = -1; this.#incomingIndex = -1; this.invalidateSize(); return true; } this.invalidateSize(); return true; } render(viewport) { const sections = this.sections; if (sections.length === 0) return; viewport.registerFocus(); viewport.registerMouse(['mouse.button.left', 'mouse.move', 'mouse.wheel']); if (this.#animating || this.#scrollDx !== 0 || this.#disableScrollTimeout > 0) { viewport.registerTick(); } const indicatorHeight = this.#indicatorHeight(); const contentHeight = viewport.contentSize.height - indicatorHeight; const contentWidth = viewport.contentSize.width; if (contentHeight > 0) { const contentRect = new Rect([0, 0], [contentWidth, contentHeight]); if (this.#animating && this.#outgoingIndex >= 0) { const t = Math.min(1, this.#animationElapsed / ANIMATION_DURATION); const eased = easeInOut(t); const offset = Math.round(eased * contentWidth); // Both pages slide in the same direction: // forward (direction = -1): both slide left // backward (direction = +1): both slide right const outX = this.#animationDirection * offset; const inX = this.#animationDirection * (offset - contentWidth); const outgoing = sections[this.#outgoingIndex]; const incoming = sections[this.#incomingIndex]; viewport.clipped(contentRect, inner => { if (outgoing) { inner.clipped(new Rect([outX, 0], [contentWidth, contentHeight]), inner2 => outgoing.render(inner2)); } if (incoming) { inner.clipped(new Rect([inX, 0], [contentWidth, contentHeight]), inner2 => incoming.render(inner2)); } }); } else { const active = sections[this.#activeIndex]; if (active) { viewport.clipped(contentRect, inner => { active.render(inner); }); } } } // Render indicator area this.#renderIndicator(viewport, sections, contentHeight); } #renderIndicator(viewport, sections, contentY) { const textStyle = this.purpose.text(); const totalDotsWidth = sections.length * DOT_WIDTH; const startX = Math.max(0, Math.floor((viewport.contentSize.width - totalDotsWidth) / 2)); const hasTitles = this.#hasTitles(); const indicatorHeight = this.#indicatorHeight(); const dotsY = contentY + (hasTitles ? 1 : 0); // Paint subtle background across the indicator area const bgStyle = this.#controlsBackground(); viewport.paint(bgStyle, new Rect([0, contentY], [viewport.contentSize.width, indicatorHeight])); // Render title if any section has a title if (hasTitles) { const titleY = contentY; // Show hovered dot's title, or active section's title const titleIndex = this.#hoveredDot >= 0 ? this.#hoveredDot : this.#activeIndex; const title = sections[titleIndex]?.title ?? ''; if (title.length > 0) { const titleX = Math.max(0, Math.floor((viewport.contentSize.width - title.length) / 2)); viewport.write(title, new Point(titleX, titleY), textStyle.merge(bgStyle)); } } // Render dots and store rects for hit-testing this.#dotRects = []; for (let i = 0; i < sections.length; i++) { const dotX = startX + i * DOT_WIDTH; const dotChar = i === this.#activeIndex ? DOT_ACTIVE : DOT_INACTIVE; // DOT_WIDTH = 3: [pad] [dot] [pad] const dotRect = new Rect([dotX, dotsY], [DOT_WIDTH, 1]); this.#dotRects.push(dotRect); const isHover = this.#hoveredDot === i; const style = this.#dotStyle(i === this.#activeIndex, isHover); if (isHover) { viewport.paint(style, dotRect); } viewport.write(dotChar, new Point(dotX + 1, dotsY), style); } } #controlsBackground() { return new Style({ background: this.purpose.ui().background }); } #dotStyle(isActive, isHover) { if (isHover) { const { foreground, background } = this.purpose.ui({ isHover: true }); return new Style({ bold: true, foreground, background }); } const background = this.purpose.ui().background; if (isActive) { return new Style({ bold: true, background }); } return new Style({ dim: true, background }); } } class Section extends Container { #title; static create(title, child, extraProps = {}) { return new Section({ title, child, ...extraProps }); } constructor({ title, ...props }) { super(props); this.#title = title; define(this, 'title', { enumerable: true }); } /** * Returns the explicit title if set, otherwise falls back to the first * child's `heading` property. */ get title() { return this.#title ?? this.children[0]?.heading ?? ''; } set title(value) { this.#title = value; this.invalidateSize(); } update({ title, ...props }) { if (title !== undefined) { this.title = title; } super.update(props); } } /** * Lightweight wrapper so non-Section children can be treated uniformly. * Delegates rendering and sizing to the underlying view. */ class ImplicitSection { #view; constructor(view) { this.#view = view; } get title() { return this.#view.heading ?? ''; } naturalSize(available) { return this.#view.naturalSize(available); } render(viewport) { this.#view.render(viewport); } } Page.Section = Section; const DOT_ACTIVE = '●'; const DOT_INACTIVE = '○'; const DOT_WIDTH = 3; const SCROLL_THRESHOLD = 3; const SCROLL_TIMEOUT = 3000; // ms to accrue scroll events before resetting const DISABLE_TIMEOUT = 300; // ms to ignore scroll events after a page change const ANIMATION_DURATION = 400; // ms function easeInOut(t) { return t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2; } //# sourceMappingURL=Page.js.map