@oakoliver/lipgloss 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/border.ts ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Border definitions and built-in border styles.
3
+ */
4
+
5
+ import { stringWidth } from './ansi.js';
6
+
7
+ /** Border character set */
8
+ export interface Border {
9
+ top: string;
10
+ bottom: string;
11
+ left: string;
12
+ right: string;
13
+ topLeft: string;
14
+ topRight: string;
15
+ bottomLeft: string;
16
+ bottomRight: string;
17
+ middleLeft: string;
18
+ middleRight: string;
19
+ middle: string;
20
+ middleTop: string;
21
+ middleBottom: string;
22
+ }
23
+
24
+ function border(b: Partial<Border>): Border {
25
+ return {
26
+ top: '', bottom: '', left: '', right: '',
27
+ topLeft: '', topRight: '', bottomLeft: '', bottomRight: '',
28
+ middleLeft: '', middleRight: '', middle: '', middleTop: '', middleBottom: '',
29
+ ...b,
30
+ };
31
+ }
32
+
33
+ /** No border */
34
+ export const noBorder: Border = border({});
35
+
36
+ /** Standard border with 90-degree corners */
37
+ export function normalBorder(): Border {
38
+ return border({
39
+ top: '─', bottom: '─', left: '│', right: '│',
40
+ topLeft: '┌', topRight: '┐', bottomLeft: '└', bottomRight: '┘',
41
+ middleLeft: '├', middleRight: '┤', middle: '┼', middleTop: '┬', middleBottom: '┴',
42
+ });
43
+ }
44
+
45
+ /** Rounded corners */
46
+ export function roundedBorder(): Border {
47
+ return border({
48
+ top: '─', bottom: '─', left: '│', right: '│',
49
+ topLeft: '╭', topRight: '╮', bottomLeft: '╰', bottomRight: '╯',
50
+ middleLeft: '├', middleRight: '┤', middle: '┼', middleTop: '┬', middleBottom: '┴',
51
+ });
52
+ }
53
+
54
+ /** Full block border */
55
+ export function blockBorder(): Border {
56
+ return border({
57
+ top: '█', bottom: '█', left: '█', right: '█',
58
+ topLeft: '█', topRight: '█', bottomLeft: '█', bottomRight: '█',
59
+ middleLeft: '█', middleRight: '█', middle: '█', middleTop: '█', middleBottom: '█',
60
+ });
61
+ }
62
+
63
+ /** Outer half-block border */
64
+ export function outerHalfBlockBorder(): Border {
65
+ return border({
66
+ top: '▀', bottom: '▄', left: '▌', right: '▐',
67
+ topLeft: '▛', topRight: '▜', bottomLeft: '▙', bottomRight: '▟',
68
+ });
69
+ }
70
+
71
+ /** Inner half-block border */
72
+ export function innerHalfBlockBorder(): Border {
73
+ return border({
74
+ top: '▄', bottom: '▀', left: '▐', right: '▌',
75
+ topLeft: '▗', topRight: '▖', bottomLeft: '▝', bottomRight: '▘',
76
+ });
77
+ }
78
+
79
+ /** Thick border */
80
+ export function thickBorder(): Border {
81
+ return border({
82
+ top: '━', bottom: '━', left: '┃', right: '┃',
83
+ topLeft: '┏', topRight: '┓', bottomLeft: '┗', bottomRight: '┛',
84
+ middleLeft: '┣', middleRight: '┫', middle: '╋', middleTop: '┳', middleBottom: '┻',
85
+ });
86
+ }
87
+
88
+ /** Double-line border */
89
+ export function doubleBorder(): Border {
90
+ return border({
91
+ top: '═', bottom: '═', left: '║', right: '║',
92
+ topLeft: '╔', topRight: '╗', bottomLeft: '╚', bottomRight: '╝',
93
+ middleLeft: '╠', middleRight: '╣', middle: '╬', middleTop: '╦', middleBottom: '╩',
94
+ });
95
+ }
96
+
97
+ /** Hidden border (spaces) — maintains layout without visible borders */
98
+ export function hiddenBorder(): Border {
99
+ return border({
100
+ top: ' ', bottom: ' ', left: ' ', right: ' ',
101
+ topLeft: ' ', topRight: ' ', bottomLeft: ' ', bottomRight: ' ',
102
+ middleLeft: ' ', middleRight: ' ', middle: ' ', middleTop: ' ', middleBottom: ' ',
103
+ });
104
+ }
105
+
106
+ /** Markdown-style table border */
107
+ export function markdownBorder(): Border {
108
+ return border({
109
+ top: '-', bottom: '-', left: '|', right: '|',
110
+ topLeft: '|', topRight: '|', bottomLeft: '|', bottomRight: '|',
111
+ middleLeft: '|', middleRight: '|', middle: '|', middleTop: '|', middleBottom: '|',
112
+ });
113
+ }
114
+
115
+ /** ASCII border using +, -, | characters */
116
+ export function asciiBorder(): Border {
117
+ return border({
118
+ top: '-', bottom: '-', left: '|', right: '|',
119
+ topLeft: '+', topRight: '+', bottomLeft: '+', bottomRight: '+',
120
+ middleLeft: '+', middleRight: '+', middle: '+', middleTop: '+', middleBottom: '+',
121
+ });
122
+ }
123
+
124
+ /** Get the max rune width of a border edge string */
125
+ export function maxRuneWidth(str: string): number {
126
+ if (!str) return 0;
127
+ if (str.length === 1) return stringWidth(str);
128
+ let width = 0;
129
+ for (const ch of str) {
130
+ width = Math.max(width, stringWidth(ch));
131
+ }
132
+ return width;
133
+ }
134
+
135
+ /** Get the width of a border edge (top, bottom, etc.) */
136
+ function getBorderEdgeWidth(...parts: string[]): number {
137
+ let max = 0;
138
+ for (const part of parts) {
139
+ max = Math.max(max, maxRuneWidth(part));
140
+ }
141
+ return max;
142
+ }
143
+
144
+ /** Get the size of the top edge of the border */
145
+ export function getTopSize(b: Border): number {
146
+ return getBorderEdgeWidth(b.topLeft, b.top, b.topRight);
147
+ }
148
+
149
+ /** Get the size of the right edge of the border */
150
+ export function getRightSize(b: Border): number {
151
+ return getBorderEdgeWidth(b.topRight, b.right, b.bottomRight);
152
+ }
153
+
154
+ /** Get the size of the bottom edge of the border */
155
+ export function getBottomSize(b: Border): number {
156
+ return getBorderEdgeWidth(b.bottomLeft, b.bottom, b.bottomRight);
157
+ }
158
+
159
+ /** Get the size of the left edge of the border */
160
+ export function getLeftSize(b: Border): number {
161
+ return getBorderEdgeWidth(b.topLeft, b.left, b.bottomLeft);
162
+ }
163
+
164
+ /** Check if a border is the "no border" (all empty strings) */
165
+ export function isNoBorder(b: Border): boolean {
166
+ return !b.top && !b.bottom && !b.left && !b.right &&
167
+ !b.topLeft && !b.topRight && !b.bottomLeft && !b.bottomRight;
168
+ }
package/src/color.ts ADDED
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Color types and utilities for terminal styling.
3
+ */
4
+
5
+ import type { ColorValue } from './ansi.js';
6
+
7
+ /**
8
+ * Represents no color — terminal default.
9
+ */
10
+ export const NO_COLOR: unique symbol = Symbol('NoColor');
11
+ export type NoColor = typeof NO_COLOR;
12
+
13
+ /**
14
+ * RGB color representation.
15
+ */
16
+ export interface RGBColor {
17
+ r: number;
18
+ g: number;
19
+ b: number;
20
+ }
21
+
22
+ /**
23
+ * A color that can be used in styling.
24
+ * - null/undefined/NO_COLOR = no color (terminal default)
25
+ * - number 0-15 = ANSI basic color
26
+ * - number 16-255 = ANSI 256 color
27
+ * - string "#RRGGBB" or "#RGB" = hex color
28
+ * - RGBColor = explicit RGB
29
+ */
30
+ export type Color = string | number | RGBColor | NoColor | null | undefined;
31
+
32
+ /** ANSI basic color constants (0-15) */
33
+ export const Black = 0;
34
+ export const Red = 1;
35
+ export const Green = 2;
36
+ export const Yellow = 3;
37
+ export const Blue = 4;
38
+ export const Magenta = 5;
39
+ export const Cyan = 6;
40
+ export const White = 7;
41
+ export const BrightBlack = 8;
42
+ export const BrightRed = 9;
43
+ export const BrightGreen = 10;
44
+ export const BrightYellow = 11;
45
+ export const BrightBlue = 12;
46
+ export const BrightMagenta = 13;
47
+ export const BrightCyan = 14;
48
+ export const BrightWhite = 15;
49
+
50
+ /**
51
+ * Parse a color specification into an internal ColorValue.
52
+ * Returns null for "no color".
53
+ */
54
+ export function parseColor(c: Color): ColorValue | null {
55
+ if (c === null || c === undefined || c === NO_COLOR) return null;
56
+
57
+ if (typeof c === 'number') {
58
+ if (c < 0) c = -c;
59
+ if (c < 16) return { type: 'basic', value: c };
60
+ if (c < 256) return { type: 'ansi256', value: c };
61
+ // Treat as packed RGB: 0xRRGGBB
62
+ return {
63
+ type: 'rgb',
64
+ value: 0,
65
+ r: (c >> 16) & 0xff,
66
+ g: (c >> 8) & 0xff,
67
+ b: c & 0xff,
68
+ };
69
+ }
70
+
71
+ if (typeof c === 'string') {
72
+ if (c.startsWith('#')) {
73
+ const rgb = parseHex(c);
74
+ if (!rgb) return null;
75
+ return { type: 'rgb', value: 0, ...rgb };
76
+ }
77
+ // Try parsing as number
78
+ const n = parseInt(c, 10);
79
+ if (!isNaN(n)) {
80
+ return parseColor(n);
81
+ }
82
+ return null;
83
+ }
84
+
85
+ // RGBColor object
86
+ if (typeof c === 'object' && 'r' in c && 'g' in c && 'b' in c) {
87
+ return { type: 'rgb', value: 0, r: c.r, g: c.g, b: c.b };
88
+ }
89
+
90
+ return null;
91
+ }
92
+
93
+ /**
94
+ * Parse a hex color string (#RGB or #RRGGBB) into RGB values.
95
+ */
96
+ export function parseHex(hex: string): RGBColor | null {
97
+ if (!hex.startsWith('#')) return null;
98
+
99
+ if (hex.length === 7) {
100
+ const r = parseInt(hex.slice(1, 3), 16);
101
+ const g = parseInt(hex.slice(3, 5), 16);
102
+ const b = parseInt(hex.slice(5, 7), 16);
103
+ if (isNaN(r) || isNaN(g) || isNaN(b)) return null;
104
+ return { r, g, b };
105
+ }
106
+
107
+ if (hex.length === 4) {
108
+ const r = parseInt(hex[1], 16) * 17;
109
+ const g = parseInt(hex[2], 16) * 17;
110
+ const b = parseInt(hex[3], 16) * 17;
111
+ if (isNaN(r) || isNaN(g) || isNaN(b)) return null;
112
+ return { r, g, b };
113
+ }
114
+
115
+ return null;
116
+ }
117
+
118
+ /**
119
+ * Convert any Color to RGB values (approximation for ANSI colors).
120
+ */
121
+ export function colorToRGB(c: Color): RGBColor | null {
122
+ const cv = parseColor(c);
123
+ if (!cv) return null;
124
+ if (cv.type === 'rgb') return { r: cv.r!, g: cv.g!, b: cv.b! };
125
+ if (cv.type === 'ansi256') return ansi256ToRGB(cv.value);
126
+ if (cv.type === 'basic') return ansi256ToRGB(cv.value);
127
+ return null;
128
+ }
129
+
130
+ /** Standard ANSI 16 color palette (approximate RGB values) */
131
+ const ANSI_16_COLORS: RGBColor[] = [
132
+ { r: 0, g: 0, b: 0 }, // Black
133
+ { r: 170, g: 0, b: 0 }, // Red
134
+ { r: 0, g: 170, b: 0 }, // Green
135
+ { r: 170, g: 85, b: 0 }, // Yellow
136
+ { r: 0, g: 0, b: 170 }, // Blue
137
+ { r: 170, g: 0, b: 170 }, // Magenta
138
+ { r: 0, g: 170, b: 170 }, // Cyan
139
+ { r: 170, g: 170, b: 170 }, // White
140
+ { r: 85, g: 85, b: 85 }, // Bright Black
141
+ { r: 255, g: 85, b: 85 }, // Bright Red
142
+ { r: 85, g: 255, b: 85 }, // Bright Green
143
+ { r: 255, g: 255, b: 85 }, // Bright Yellow
144
+ { r: 85, g: 85, b: 255 }, // Bright Blue
145
+ { r: 255, g: 85, b: 255 }, // Bright Magenta
146
+ { r: 85, g: 255, b: 255 }, // Bright Cyan
147
+ { r: 255, g: 255, b: 255 }, // Bright White
148
+ ];
149
+
150
+ /**
151
+ * Convert ANSI 256 color index to approximate RGB.
152
+ */
153
+ export function ansi256ToRGB(index: number): RGBColor {
154
+ if (index < 16) return ANSI_16_COLORS[index];
155
+
156
+ if (index < 232) {
157
+ // 6x6x6 color cube
158
+ const i = index - 16;
159
+ const r = Math.floor(i / 36);
160
+ const g = Math.floor((i % 36) / 6);
161
+ const b = i % 6;
162
+ return {
163
+ r: r ? r * 40 + 55 : 0,
164
+ g: g ? g * 40 + 55 : 0,
165
+ b: b ? b * 40 + 55 : 0,
166
+ };
167
+ }
168
+
169
+ // Grayscale (232-255)
170
+ const v = (index - 232) * 10 + 8;
171
+ return { r: v, g: v, b: v };
172
+ }
173
+
174
+ /**
175
+ * Determine if a color is "dark" based on luminance.
176
+ */
177
+ export function isDarkColor(c: Color): boolean {
178
+ const rgb = colorToRGB(c);
179
+ if (!rgb) return true;
180
+ // HSL lightness approximation
181
+ const max = Math.max(rgb.r, rgb.g, rgb.b) / 255;
182
+ const min = Math.min(rgb.r, rgb.g, rgb.b) / 255;
183
+ const l = (max + min) / 2;
184
+ return l < 0.5;
185
+ }
186
+
187
+ /**
188
+ * LightDark returns a function that picks a color based on background darkness.
189
+ */
190
+ export function lightDark(isDark: boolean): (light: Color, dark: Color) => Color {
191
+ return (light: Color, dark: Color) => isDark ? dark : light;
192
+ }
193
+
194
+ /**
195
+ * Returns the complementary color (180 degrees on the color wheel).
196
+ */
197
+ export function complementary(c: Color): Color {
198
+ const rgb = colorToRGB(c);
199
+ if (!rgb) return null;
200
+
201
+ // Convert to HSV, rotate hue 180 degrees, convert back
202
+ const { h, s, v } = rgbToHsv(rgb.r, rgb.g, rgb.b);
203
+ const newH = (h + 180) % 360;
204
+ const result = hsvToRgb(newH, s, v);
205
+ return result;
206
+ }
207
+
208
+ /**
209
+ * Darken a color by a percentage (0-1).
210
+ */
211
+ export function darken(c: Color, percent: number): Color {
212
+ const rgb = colorToRGB(c);
213
+ if (!rgb) return null;
214
+ const mult = 1 - clamp(percent, 0, 1);
215
+ return {
216
+ r: Math.round(rgb.r * mult),
217
+ g: Math.round(rgb.g * mult),
218
+ b: Math.round(rgb.b * mult),
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Lighten a color by a percentage (0-1).
224
+ */
225
+ export function lighten(c: Color, percent: number): Color {
226
+ const rgb = colorToRGB(c);
227
+ if (!rgb) return null;
228
+ const add = 255 * clamp(percent, 0, 1);
229
+ return {
230
+ r: Math.min(255, Math.round(rgb.r + add)),
231
+ g: Math.min(255, Math.round(rgb.g + add)),
232
+ b: Math.min(255, Math.round(rgb.b + add)),
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Adjust alpha (0 = transparent, 1 = opaque). Returns an RGBColor
238
+ * (terminal colors don't truly support alpha, but useful for blending).
239
+ */
240
+ export function alpha(c: Color, a: number): RGBColor | null {
241
+ const rgb = colorToRGB(c);
242
+ if (!rgb) return null;
243
+ const al = clamp(a, 0, 1);
244
+ return {
245
+ r: Math.round(rgb.r * al),
246
+ g: Math.round(rgb.g * al),
247
+ b: Math.round(rgb.b * al),
248
+ };
249
+ }
250
+
251
+ // ---- Helpers ----
252
+
253
+ function clamp(v: number, lo: number, hi: number): number {
254
+ return Math.min(hi, Math.max(lo, v));
255
+ }
256
+
257
+ function rgbToHsv(r: number, g: number, b: number): { h: number; s: number; v: number } {
258
+ r /= 255; g /= 255; b /= 255;
259
+ const max = Math.max(r, g, b);
260
+ const min = Math.min(r, g, b);
261
+ const d = max - min;
262
+ let h = 0;
263
+ const s = max === 0 ? 0 : d / max;
264
+ const v = max;
265
+
266
+ if (d !== 0) {
267
+ if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
268
+ else if (max === g) h = ((b - r) / d + 2) / 6;
269
+ else h = ((r - g) / d + 4) / 6;
270
+ }
271
+
272
+ return { h: h * 360, s, v };
273
+ }
274
+
275
+ function hsvToRgb(h: number, s: number, v: number): RGBColor {
276
+ h = ((h % 360) + 360) % 360;
277
+ const c = v * s;
278
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
279
+ const m = v - c;
280
+ let r = 0, g = 0, b = 0;
281
+
282
+ if (h < 60) { r = c; g = x; }
283
+ else if (h < 120) { r = x; g = c; }
284
+ else if (h < 180) { g = c; b = x; }
285
+ else if (h < 240) { g = x; b = c; }
286
+ else if (h < 300) { r = x; b = c; }
287
+ else { r = c; b = x; }
288
+
289
+ return {
290
+ r: Math.round((r + m) * 255),
291
+ g: Math.round((g + m) * 255),
292
+ b: Math.round((b + m) * 255),
293
+ };
294
+ }
package/src/index.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * lipgloss — CSS-like terminal styling for JavaScript.
3
+ * Zero dependencies, multi-runtime (Node.js, Bun, Deno).
4
+ *
5
+ * Ported from charmbracelet/lipgloss (Go) by Antonio Oliveira.
6
+ */
7
+
8
+ // Style
9
+ export { Style, newStyle, getLines, getFirstRune } from './style.js';
10
+ export type { Position } from './style.js';
11
+ export { Top, Bottom, Center, Left, Right } from './style.js';
12
+
13
+ // Colors
14
+ export {
15
+ NO_COLOR, Black, Red, Green, Yellow, Blue, Magenta, Cyan, White,
16
+ BrightBlack, BrightRed, BrightGreen, BrightYellow, BrightBlue,
17
+ BrightMagenta, BrightCyan, BrightWhite,
18
+ parseColor, parseHex, colorToRGB, ansi256ToRGB,
19
+ isDarkColor, lightDark, complementary, darken, lighten, alpha,
20
+ } from './color.js';
21
+ export type { Color, RGBColor, NoColor } from './color.js';
22
+
23
+ // Borders
24
+ export {
25
+ noBorder, normalBorder, roundedBorder, blockBorder,
26
+ outerHalfBlockBorder, innerHalfBlockBorder, thickBorder,
27
+ doubleBorder, hiddenBorder, markdownBorder, asciiBorder,
28
+ maxRuneWidth, getTopSize, getRightSize, getBottomSize, getLeftSize, isNoBorder,
29
+ } from './border.js';
30
+ export type { Border } from './border.js';
31
+
32
+ // Layout
33
+ export {
34
+ joinHorizontal, joinVertical,
35
+ place, placeHorizontal, placeVertical,
36
+ } from './layout.js';
37
+
38
+ // ANSI utilities
39
+ export {
40
+ SGR, stringWidth, stripAnsi, truncate, styled,
41
+ fgColor, fgAnsi256, fgBasic, bgColor, bgAnsi256, bgBasic,
42
+ ulColor, ulAnsi256, setHyperlink, resetHyperlink,
43
+ } from './ansi.js';
44
+ export type { ColorValue, UnderlineStyle, AnsiStyleOptions } from './ansi.js';
package/src/layout.ts ADDED
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Layout utilities: JoinHorizontal, JoinVertical, Place, PlaceHorizontal, PlaceVertical.
3
+ * Ported from charmbracelet/lipgloss join.go and position.go.
4
+ */
5
+
6
+ import { stringWidth } from './ansi.js';
7
+ import { getLines } from './style.js';
8
+ import type { Position } from './style.js';
9
+ import { Top, Bottom, Left, Right } from './style.js';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // JoinHorizontal
13
+ // ---------------------------------------------------------------------------
14
+
15
+ /**
16
+ * Horizontally join multi-line strings along a vertical axis.
17
+ * `pos` is the vertical alignment: 0 = top, 0.5 = center, 1 = bottom.
18
+ */
19
+ export function joinHorizontal(pos: Position, ...strs: string[]): string {
20
+ if (strs.length === 0) return '';
21
+ if (strs.length === 1) return strs[0];
22
+
23
+ const blocks: string[][] = [];
24
+ const maxWidths: number[] = [];
25
+ let maxHeight = 0;
26
+
27
+ for (let i = 0; i < strs.length; i++) {
28
+ const { lines, widest } = getLines(strs[i]);
29
+ blocks.push(lines);
30
+ maxWidths.push(widest);
31
+ if (lines.length > maxHeight) maxHeight = lines.length;
32
+ }
33
+
34
+ // Equalize heights
35
+ for (let i = 0; i < blocks.length; i++) {
36
+ if (blocks[i].length >= maxHeight) continue;
37
+ const extra = maxHeight - blocks[i].length;
38
+ const empties = new Array<string>(extra).fill('');
39
+
40
+ const p = Math.min(1, Math.max(0, pos));
41
+ if (p <= 0) {
42
+ // Top aligned
43
+ blocks[i] = [...blocks[i], ...empties];
44
+ } else if (p >= 1) {
45
+ // Bottom aligned
46
+ blocks[i] = [...empties, ...blocks[i]];
47
+ } else {
48
+ const split = Math.round(extra * p);
49
+ const top = extra - split;
50
+ const bottom = extra - top;
51
+ blocks[i] = [
52
+ ...new Array<string>(top).fill(''),
53
+ ...blocks[i],
54
+ ...new Array<string>(bottom).fill(''),
55
+ ];
56
+ }
57
+ }
58
+
59
+ // Merge
60
+ const result: string[] = [];
61
+ for (let row = 0; row < maxHeight; row++) {
62
+ let line = '';
63
+ for (let col = 0; col < blocks.length; col++) {
64
+ const cell = blocks[col][row] || '';
65
+ line += cell;
66
+ // Pad to max width
67
+ const pad = maxWidths[col] - stringWidth(cell);
68
+ if (pad > 0) line += ' '.repeat(pad);
69
+ }
70
+ result.push(line);
71
+ }
72
+
73
+ return result.join('\n');
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // JoinVertical
78
+ // ---------------------------------------------------------------------------
79
+
80
+ /**
81
+ * Vertically join multi-line strings along a horizontal axis.
82
+ * `pos` is the horizontal alignment: 0 = left, 0.5 = center, 1 = right.
83
+ */
84
+ export function joinVertical(pos: Position, ...strs: string[]): string {
85
+ if (strs.length === 0) return '';
86
+ if (strs.length === 1) return strs[0];
87
+
88
+ const blocks: string[][] = [];
89
+ let maxWidth = 0;
90
+
91
+ for (const str of strs) {
92
+ const { lines, widest } = getLines(str);
93
+ blocks.push(lines);
94
+ if (widest > maxWidth) maxWidth = widest;
95
+ }
96
+
97
+ const result: string[] = [];
98
+ for (let i = 0; i < blocks.length; i++) {
99
+ for (let j = 0; j < blocks[i].length; j++) {
100
+ const line = blocks[i][j];
101
+ const gap = maxWidth - stringWidth(line);
102
+
103
+ if (gap <= 0) {
104
+ result.push(line);
105
+ } else {
106
+ const p = Math.min(1, Math.max(0, pos));
107
+ if (p <= 0) {
108
+ // Left
109
+ result.push(line + ' '.repeat(gap));
110
+ } else if (p >= 1) {
111
+ // Right
112
+ result.push(' '.repeat(gap) + line);
113
+ } else {
114
+ const split = Math.round(gap * p);
115
+ const left = gap - split;
116
+ const right = gap - left;
117
+ result.push(' '.repeat(left) + line + ' '.repeat(right));
118
+ }
119
+ }
120
+ }
121
+ }
122
+
123
+ return result.join('\n');
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Place
128
+ // ---------------------------------------------------------------------------
129
+
130
+ /**
131
+ * Place a string in an unstyled box of given width and height.
132
+ */
133
+ export function place(width: number, height: number, hPos: Position, vPos: Position, str: string): string {
134
+ return placeVertical(height, vPos, placeHorizontal(width, hPos, str));
135
+ }
136
+
137
+ /**
138
+ * Place a string horizontally in an unstyled block of given width.
139
+ */
140
+ export function placeHorizontal(width: number, pos: Position, str: string): string {
141
+ const { lines, widest: contentWidth } = getLines(str);
142
+ const gap = width - contentWidth;
143
+ if (gap <= 0) return str;
144
+
145
+ const result: string[] = [];
146
+ for (const line of lines) {
147
+ const short = Math.max(0, contentWidth - stringWidth(line));
148
+ const totalGap = gap + short;
149
+ const p = Math.min(1, Math.max(0, pos));
150
+
151
+ if (p <= 0) {
152
+ // Left
153
+ result.push(line + ' '.repeat(totalGap));
154
+ } else if (p >= 1) {
155
+ // Right
156
+ result.push(' '.repeat(totalGap) + line);
157
+ } else {
158
+ const split = Math.round(totalGap * p);
159
+ const left = totalGap - split;
160
+ const right = totalGap - left;
161
+ result.push(' '.repeat(left) + line + ' '.repeat(right));
162
+ }
163
+ }
164
+
165
+ return result.join('\n');
166
+ }
167
+
168
+ /**
169
+ * Place a string vertically in an unstyled block of given height.
170
+ */
171
+ export function placeVertical(height: number, pos: Position, str: string): string {
172
+ const contentHeight = str.split('\n').length;
173
+ const gap = height - contentHeight;
174
+ if (gap <= 0) return str;
175
+
176
+ const { widest: width } = getLines(str);
177
+ const emptyLine = ' '.repeat(width);
178
+ const p = Math.min(1, Math.max(0, pos));
179
+
180
+ if (p <= 0) {
181
+ // Top
182
+ const bottom: string[] = [];
183
+ for (let i = 0; i < gap; i++) bottom.push(emptyLine);
184
+ return str + '\n' + bottom.join('\n');
185
+ } else if (p >= 1) {
186
+ // Bottom
187
+ const top: string[] = [];
188
+ for (let i = 0; i < gap; i++) top.push(emptyLine);
189
+ return top.join('\n') + '\n' + str;
190
+ } else {
191
+ const split = Math.round(gap * p);
192
+ const topCount = gap - split;
193
+ const bottomCount = gap - topCount;
194
+
195
+ const topLines: string[] = [];
196
+ for (let i = 0; i < topCount; i++) topLines.push(emptyLine);
197
+ const bottomLines: string[] = [];
198
+ for (let i = 0; i < bottomCount; i++) bottomLines.push(emptyLine);
199
+
200
+ let result = '';
201
+ if (topLines.length > 0) result += topLines.join('\n') + '\n';
202
+ result += str;
203
+ if (bottomLines.length > 0) result += '\n' + bottomLines.join('\n');
204
+ return result;
205
+ }
206
+ }