UNPKG

@teaui/core

Version:

A high-level terminal UI library for Node

242 lines 9.07 kB
import { View } from '../View.js'; import { Point, Size, interpolate } from '../geometry.js'; import { Style } from '../Style.js'; export class Progress extends View { #direction = 'horizontal'; #range = [0, 100]; #value = 0; #showPercent = false; #location = 'center'; constructor(props) { super(props); this.#update(props); } update(props) { this.#update(props); super.update(props); } #update({ direction, min, max, value, showPercent, location }) { this.#direction = direction ?? 'horizontal'; this.#range = [min ?? 0, max ?? 100]; this.#showPercent = showPercent ?? false; this.#location = location ?? 'center'; this.#value = value ?? this.#range[0]; } get value() { return this.#value; } set value(value) { this.#value = value; if (value !== this.#value) { this.#value = value; this.invalidateRender(); } } naturalSize(available) { return new Size(available.width, 1); } /** * The label reserve is the total chars consumed by the percent label and its * space separator when placed outside the bar (left or right). The label is * always padded to the width of "100%" (4 chars) plus 1 space = 5 chars. */ static LABEL_WIDTH = 4; // width of "100%" static LABEL_RESERVE = 5; // space + LABEL_WIDTH render(viewport) { if (viewport.isEmpty) { return; } let percent = ''; if (this.#showPercent) { const percentNum = interpolate(this.#value, this.#range, [0, 100], true); percent = `${Math.round(percentNum)}%`; } // For left/right the bar shrinks to make room for the external label const isExternal = this.#showPercent && (this.#location === 'left' || this.#location === 'right'); const barWidth = isExternal ? Math.max(1, viewport.contentSize.width - Progress.LABEL_RESERVE) : viewport.contentSize.width; // Pad the percent to LABEL_WIDTH so numbers are right-justified const paddedPercent = isExternal ? percent.padStart(Progress.LABEL_WIDTH) : percent; let percentStartX; let barStartX; switch (this.#location) { case 'left': // " 50% ████..." — label then space then bar percentStartX = 0; barStartX = Progress.LABEL_RESERVE; break; case 'right': // "████...╴ 50%" — bar then space then label percentStartX = barWidth + 1; barStartX = 0; break; case 'center': default: percentStartX = ~~((barWidth - percent.length) / 2); barStartX = 0; break; } const percentStartPoint = new Point(percentStartX, viewport.contentSize.height <= 1 ? 0 : 1); const textStyle = this.purpose.text(); const controlStyle = this.purpose.ui({ isHover: true }).invert().merge({ background: textStyle.background, }); const altTextStyle = new Style({ foreground: textStyle.foreground, background: controlStyle.foreground, }); if (this.#direction === 'horizontal') { this.#renderHorizontal(viewport, barWidth, barStartX, paddedPercent, percentStartPoint, textStyle, controlStyle, altTextStyle); } else { this.#renderVertical(viewport, percent, percentStartPoint, textStyle, controlStyle, altTextStyle); } } #renderHorizontal(viewport, barWidth, barStartX, percent, percentStartPoint, textStyle, controlStyle, altTextStyle) { const barEndX = barStartX + barWidth; const progressX = barStartX + Math.round(interpolate(this.#value, this.#range, [0, barWidth - 1], true)); viewport.contentRect.forEachPoint(pt => { let char, style = textStyle; // Percent text (rendered at percentStartPoint for all locations) if (this.#showPercent && pt.x >= percentStartPoint.x && pt.x - percentStartPoint.x < percent.length && pt.y === percentStartPoint.y) { char = percent[pt.x - percentStartPoint.x]; if (this.#location === 'center' && pt.x <= progressX) { style = altTextStyle; } else { style = textStyle; } } else if (pt.x < barStartX || pt.x >= barEndX) { // Outside the bar area (left/right label region, space separator) char = ' '; style = textStyle; } else { const barX = pt.x - barStartX; // position within the bar (0-based) const min = Math.min(...this.#range); if (pt.x <= progressX && this.#value > min) { if (pt.y === 0 && viewport.contentSize.height > 1) { char = '▄'; } else if (pt.y === viewport.contentSize.height - 1 && viewport.contentSize.height > 1) { char = '▀'; } else { char = '█'; } style = controlStyle; } else if (viewport.contentSize.height === 1) { if (barX === 0) { char = '╶'; } else if (barX === barWidth - 1) { char = '╴'; } else { char = '─'; } } else if (pt.y === 0) { if (barX === 0) { char = '╭'; } else if (barX === barWidth - 1) { char = '╮'; } else { char = '─'; } } else if (pt.y === viewport.contentSize.height - 1) { if (barX === 0) { char = '╰'; } else if (barX === barWidth - 1) { char = '╯'; } else { char = '─'; } } else if (barX === 0 || barX === barWidth - 1) { char = '│'; } else { char = ' '; } } viewport.write(char, pt, style); }); } #renderVertical(viewport, _percent, _percentStartPoint, textStyle, controlStyle, _altTextStyle) { const progressY = Math.round(interpolate(this.#value, this.#range, [viewport.contentSize.height - 1, 0], true)); viewport.contentRect.forEachPoint(pt => { let char, style = textStyle; if (pt.y >= progressY) { if (pt.x === 0 && viewport.contentSize.width > 1) { char = '▐'; } else if (pt.x === viewport.contentSize.width - 1 && viewport.contentSize.width > 1) { char = '▌'; } else { char = '█'; } style = controlStyle; } else if (viewport.contentSize.width === 1) { if (pt.y === 0) { char = '╷'; } else if (pt.y === viewport.contentSize.height - 1) { char = '╵'; } else { char = '│'; } } else if (pt.x === 0) { if (pt.y === 0) { char = '╭'; } else if (pt.y === viewport.contentSize.height - 1) { char = '╰'; } else { char = '│'; } } else if (pt.x === viewport.contentSize.width - 1) { if (pt.y === 0) { char = '╮'; } else if (pt.y === viewport.contentSize.height - 1) { char = '╯'; } else { char = '│'; } } else if (pt.y === 0 || pt.y === viewport.contentSize.height - 1) { char = '─'; } else { char = ' '; } viewport.write(char, pt, style); }); } } //# sourceMappingURL=Progress.js.map