dbn-cli 0.4.0 → 0.5.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dbn-cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.3",
4
4
  "description": "A lightweight terminal-based database browser",
5
5
  "repository": "https://github.com/amio/dbn-cli",
6
6
  "type": "module",
@@ -9,10 +9,10 @@
9
9
  "dbn": "bin/dbn.ts"
10
10
  },
11
11
  "scripts": {
12
- "test": "node --test --experimental-strip-types test/*.test.ts",
13
- "test:watch": "node --test --watch --experimental-strip-types test/*.test.ts",
14
- "test:coverage": "node --test --experimental-test-coverage --experimental-strip-types test/*.test.ts",
15
- "dev": "node bin/dbp.ts"
12
+ "test": "node --test --experimental-strip-types src/**/*.test.ts src/*.test.ts",
13
+ "test:watch": "node --test --watch --experimental-strip-types src/**/*.test.ts src/*.test.ts",
14
+ "test:coverage": "node --test --experimental-test-coverage --experimental-strip-types src/**/*.test.ts src/*.test.ts",
15
+ "dev": "node bin/dbn.ts"
16
16
  },
17
17
  "keywords": [
18
18
  "database",
@@ -0,0 +1,207 @@
1
+ import { describe, it, before, after } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { existsSync, unlinkSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { SQLiteAdapter } from '../adapter/sqlite.ts';
8
+
9
+ const TEST_DB = join(tmpdir(), 'dbn-cli-test.db');
10
+
11
+ /**
12
+ * Set up test database
13
+ */
14
+ function setupTestDB(): void {
15
+ // Remove old test db if exists
16
+ if (existsSync(TEST_DB)) {
17
+ unlinkSync(TEST_DB);
18
+ }
19
+
20
+ // Create test database
21
+ const db = new DatabaseSync(TEST_DB);
22
+
23
+ // Create test tables
24
+ db.exec(`
25
+ CREATE TABLE users (
26
+ id INTEGER PRIMARY KEY,
27
+ name TEXT NOT NULL,
28
+ email TEXT,
29
+ age INTEGER
30
+ );
31
+ `);
32
+
33
+ db.exec(`
34
+ CREATE TABLE posts (
35
+ id INTEGER PRIMARY KEY,
36
+ user_id INTEGER,
37
+ title TEXT NOT NULL,
38
+ content TEXT,
39
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
40
+ );
41
+ `);
42
+
43
+ // Insert test data
44
+ const insertUser = db.prepare('INSERT INTO users (name, email, age) VALUES (?, ?, ?)');
45
+ insertUser.run('Alice', 'alice@example.com', 30);
46
+ insertUser.run('Bob', 'bob@example.com', 25);
47
+ insertUser.run('Charlie', 'charlie@example.com', 35);
48
+
49
+ const insertPost = db.prepare('INSERT INTO posts (user_id, title, content) VALUES (?, ?, ?)');
50
+ insertPost.run(1, 'Hello World', 'This is my first post');
51
+ insertPost.run(1, 'Second Post', 'Another post by Alice');
52
+ insertPost.run(2, 'Bobs Post', 'Post by Bob');
53
+
54
+ db.close();
55
+ }
56
+
57
+ /**
58
+ * Clean up test database
59
+ */
60
+ function cleanupTestDB(): void {
61
+ if (existsSync(TEST_DB)) {
62
+ unlinkSync(TEST_DB);
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Test suite for SQLiteAdapter
68
+ */
69
+ describe('SQLiteAdapter', () => {
70
+ before(() => {
71
+ setupTestDB();
72
+ });
73
+
74
+ after(() => {
75
+ cleanupTestDB();
76
+ });
77
+
78
+ it('should connect to database', () => {
79
+ const adapter = new SQLiteAdapter();
80
+ adapter.connect(TEST_DB);
81
+ assert.ok((adapter as any).db !== null, 'Database should be connected');
82
+ assert.strictEqual((adapter as any).path, TEST_DB, 'Path should be set correctly');
83
+ adapter.close();
84
+ });
85
+
86
+ it('should throw error when connecting to invalid path', () => {
87
+ const adapter = new SQLiteAdapter();
88
+ // Try to connect to an invalid path (directory that doesn't exist)
89
+ assert.throws(
90
+ () => adapter.connect('/nonexistent/path/to/database.db'),
91
+ /Failed to open database/,
92
+ 'Should throw error for invalid path'
93
+ );
94
+ });
95
+
96
+ it('should get all tables', () => {
97
+ const adapter = new SQLiteAdapter();
98
+ adapter.connect(TEST_DB);
99
+
100
+ const tables = adapter.getTables();
101
+ assert.strictEqual(tables.length, 2, 'Should have 2 tables');
102
+ assert.strictEqual(tables[0].name, 'posts', 'First table should be posts');
103
+ assert.strictEqual(tables[1].name, 'users', 'Second table should be users');
104
+ assert.ok(tables[0].row_count !== undefined, 'Should have row_count property');
105
+
106
+ adapter.close();
107
+ });
108
+
109
+ it('should get table schema', () => {
110
+ const adapter = new SQLiteAdapter();
111
+ adapter.connect(TEST_DB);
112
+
113
+ const schema = adapter.getTableSchema('users');
114
+ assert.strictEqual(schema.length, 4, 'Users table should have 4 columns');
115
+ assert.strictEqual(schema[0].name, 'id', 'First column should be id');
116
+ assert.strictEqual(schema[0].type, 'INTEGER', 'ID should be INTEGER');
117
+ assert.strictEqual(schema[0].pk, 1, 'ID should be primary key');
118
+ assert.strictEqual(schema[1].name, 'name', 'Second column should be name');
119
+ assert.strictEqual(schema[1].type, 'TEXT', 'Name should be TEXT');
120
+ assert.strictEqual(schema[1].notnull, 1, 'Name should be NOT NULL');
121
+
122
+ adapter.close();
123
+ });
124
+
125
+ it('should get table data', () => {
126
+ const adapter = new SQLiteAdapter();
127
+ adapter.connect(TEST_DB);
128
+
129
+ const data = adapter.getTableData('users');
130
+ assert.strictEqual(data.length, 3, 'Should have 3 users');
131
+ assert.strictEqual(data[0].name, 'Alice', 'First user should be Alice');
132
+ assert.strictEqual(data[0].email, 'alice@example.com', 'Alice email should match');
133
+ assert.strictEqual(data[0].age, 30, 'Alice age should be 30');
134
+
135
+ adapter.close();
136
+ });
137
+
138
+ it('should get table data with pagination', () => {
139
+ const adapter = new SQLiteAdapter();
140
+ adapter.connect(TEST_DB);
141
+
142
+ // Get 2 rows starting from offset 1
143
+ const data = adapter.getTableData('users', { limit: 2, offset: 1 });
144
+ assert.strictEqual(data.length, 2, 'Should return 2 users');
145
+ assert.strictEqual(data[0].name, 'Bob', 'First result should be Bob');
146
+ assert.strictEqual(data[1].name, 'Charlie', 'Second result should be Charlie');
147
+
148
+ adapter.close();
149
+ });
150
+
151
+ it('should get table data with custom limit', () => {
152
+ const adapter = new SQLiteAdapter();
153
+ adapter.connect(TEST_DB);
154
+
155
+ const data = adapter.getTableData('users', { limit: 1 });
156
+ assert.strictEqual(data.length, 1, 'Should return 1 user');
157
+ assert.strictEqual(data[0].name, 'Alice', 'Should return Alice');
158
+
159
+ adapter.close();
160
+ });
161
+
162
+ it('should get row count', () => {
163
+ const adapter = new SQLiteAdapter();
164
+ adapter.connect(TEST_DB);
165
+
166
+ const userCount = adapter.getRowCount('users');
167
+ assert.strictEqual(userCount, 3, 'Should have 3 users');
168
+
169
+ const postCount = adapter.getRowCount('posts');
170
+ assert.strictEqual(postCount, 3, 'Should have 3 posts');
171
+
172
+ adapter.close();
173
+ });
174
+
175
+ it('should close database connection', () => {
176
+ const adapter = new SQLiteAdapter();
177
+ adapter.connect(TEST_DB);
178
+ assert.ok((adapter as any).db !== null, 'Database should be connected');
179
+
180
+ adapter.close();
181
+ assert.strictEqual((adapter as any).db, null, 'Database should be null after close');
182
+ });
183
+
184
+ it('should handle closing when not connected', () => {
185
+ const adapter = new SQLiteAdapter();
186
+ assert.doesNotThrow(() => adapter.close(), 'Should not throw when closing unconnected adapter');
187
+ });
188
+
189
+ it('should delete a row', () => {
190
+ const adapter = new SQLiteAdapter();
191
+ adapter.connect(TEST_DB);
192
+
193
+ const initialCount = adapter.getRowCount('users');
194
+ const user = adapter.getTableData('users', { limit: 1, offset: 0 })[0];
195
+ assert.ok(user.id, 'User should have an id');
196
+
197
+ adapter.deleteRow('users', { id: user.id });
198
+
199
+ const newCount = adapter.getRowCount('users');
200
+ assert.strictEqual(newCount, initialCount - 1, 'Row count should decrease by 1');
201
+
202
+ const remainingUsers = adapter.getTableData('users');
203
+ assert.ok(!remainingUsers.find(u => u.id === user.id), 'Deleted user should not be in results');
204
+
205
+ adapter.close();
206
+ });
207
+ });
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ import { SQLiteAdapter } from './adapter/sqlite.ts';
5
5
  import { Screen } from './ui/screen.ts';
6
6
  import { Renderer } from './ui/renderer.ts';
7
7
  import { Navigator } from './ui/navigator.ts';
8
+ import { debounce } from './utils/debounce.ts';
8
9
  import type { KeyPress } from './types.ts';
9
10
 
10
11
  /**
@@ -72,10 +73,9 @@ export class DBPeek {
72
73
  // Set up keyboard input
73
74
  this.setupInput();
74
75
 
75
- // Handle screen resize
76
- this.screen.on('resize', () => {
77
- this.render();
78
- });
76
+ // Handle screen resize with debounce to avoid flickering
77
+ const debouncedRender = debounce(() => this.render(), 50);
78
+ this.screen.on('resize', debouncedRender);
79
79
 
80
80
  // Initial render
81
81
  this.render();
@@ -86,7 +86,9 @@ export class DBPeek {
86
86
  */
87
87
  private setupInput(): void {
88
88
  // Enable raw mode for key-by-key input
89
- stdin.setRawMode(true);
89
+ if (stdin.isTTY) {
90
+ stdin.setRawMode(true);
91
+ }
90
92
  stdin.resume();
91
93
  stdin.setEncoding('utf8');
92
94
 
@@ -138,12 +140,28 @@ export class DBPeek {
138
140
  break;
139
141
 
140
142
  case 'j':
143
+ if (this.navigator.getState().type === 'row-detail') {
144
+ (this.navigator as any).nextRecord();
145
+ } else {
146
+ this.navigator.moveDown();
147
+ }
148
+ this.render();
149
+ break;
150
+
151
+ case 'k':
152
+ if (this.navigator.getState().type === 'row-detail') {
153
+ (this.navigator as any).prevRecord();
154
+ } else {
155
+ this.navigator.moveUp();
156
+ }
157
+ this.render();
158
+ break;
159
+
141
160
  case 'down':
142
161
  this.navigator.moveDown();
143
162
  this.render();
144
163
  break;
145
164
 
146
- case 'k':
147
165
  case 'up':
148
166
  this.navigator.moveUp();
149
167
  this.render();
@@ -0,0 +1,101 @@
1
+ import { describe, it, before, after } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { unlinkSync, existsSync } from 'node:fs';
5
+ import { SQLiteAdapter } from './adapter/sqlite.ts';
6
+ import { Navigator } from './ui/navigator.ts';
7
+
8
+ const REPRO_DB = './repro-bug.db';
9
+
10
+ describe('Navigator Bug Reproduction & Edge Cases', () => {
11
+ let adapter: SQLiteAdapter;
12
+ let navigator: Navigator;
13
+
14
+ before(() => {
15
+ const db = new DatabaseSync(REPRO_DB);
16
+ db.exec(`
17
+ CREATE TABLE items (
18
+ id INTEGER PRIMARY KEY,
19
+ name TEXT
20
+ );
21
+ `);
22
+
23
+ const insert = db.prepare('INSERT INTO items (name) VALUES (?)');
24
+ for (let i = 1; i <= 100; i++) {
25
+ insert.run(`Item ${i}`);
26
+ }
27
+ db.close();
28
+
29
+ adapter = new SQLiteAdapter();
30
+ adapter.connect(REPRO_DB);
31
+ navigator = new Navigator(adapter);
32
+ });
33
+
34
+ after(() => {
35
+ adapter.close();
36
+ if (existsSync(REPRO_DB)) {
37
+ unlinkSync(REPRO_DB);
38
+ }
39
+ });
40
+
41
+ it('should enter the correct row when scrolled down', () => {
42
+ navigator.init();
43
+ // Enter table detail for 'items'
44
+ navigator.enter();
45
+
46
+ let state = navigator.getState() as any;
47
+ assert.strictEqual(state.type, 'table-detail');
48
+ assert.strictEqual(state.dataCursor, 0);
49
+ assert.strictEqual(state.dataOffset, 0);
50
+
51
+ // Move down 30 times.
52
+ // Default visibleRows is 20, so this should push dataOffset to 11 (if cursor hits bottom at 19)
53
+ for (let i = 0; i < 30; i++) {
54
+ navigator.moveDown();
55
+ }
56
+
57
+ state = navigator.getState() as any;
58
+ const expectedRowIndex = state.dataOffset + state.dataCursor;
59
+ const expectedName = `Item ${expectedRowIndex + 1}`;
60
+
61
+ navigator.enter(); // Enter row detail
62
+ const rowState = navigator.getState() as any;
63
+
64
+ assert.strictEqual(rowState.type, 'row-detail');
65
+ assert.strictEqual(rowState.rowIndex, expectedRowIndex, `Row index should be ${expectedRowIndex}`);
66
+ assert.strictEqual(rowState.row.name, expectedName, `Row name should be ${expectedName}`);
67
+ });
68
+
69
+ it('should handle jumping to bottom and moving up', () => {
70
+ navigator.init();
71
+ navigator.enter();
72
+
73
+ navigator.jumpToBottom();
74
+ let state = navigator.getState() as any;
75
+ assert.strictEqual(state.dataOffset + state.dataCursor, 99);
76
+
77
+ navigator.moveUp();
78
+ state = navigator.getState() as any;
79
+ assert.strictEqual(state.dataOffset + state.dataCursor, 98);
80
+
81
+ navigator.enter();
82
+ const rowState = navigator.getState() as any;
83
+ assert.strictEqual(rowState.row.name, 'Item 99');
84
+ assert.strictEqual(rowState.rowIndex, 98);
85
+ });
86
+
87
+ it('should handle jumping to top after scrolling', () => {
88
+ navigator.init();
89
+ navigator.enter();
90
+
91
+ for (let i = 0; i < 50; i++) navigator.moveDown();
92
+ navigator.jumpToTop();
93
+
94
+ let state = navigator.getState() as any;
95
+ assert.strictEqual(state.dataOffset, 0);
96
+ assert.strictEqual(state.dataCursor, 0);
97
+
98
+ const selectedRow = (navigator as any).getSelectedRow(state);
99
+ assert.strictEqual(selectedRow.name, 'Item 1');
100
+ });
101
+ });
package/src/types.ts CHANGED
@@ -91,10 +91,14 @@ export interface TableDetailViewState extends BaseViewState {
91
91
  totalRows: number;
92
92
  dataOffset: number;
93
93
  dataCursor: number;
94
+ bufferOffset: number;
94
95
  visibleRows: number;
95
96
  showSchema?: boolean;
96
97
  deleteConfirm?: DeleteConfirmationState;
97
98
  notice?: string;
99
+ columnWeights?: number[];
100
+ cachedColWidths?: number[];
101
+ cachedScreenWidth?: number;
98
102
  }
99
103
 
100
104
  /**
@@ -116,7 +120,11 @@ export interface RowDetailViewState extends BaseViewState {
116
120
  tableName: string;
117
121
  row: Record<string, any>;
118
122
  rowIndex: number;
123
+ totalRows: number;
119
124
  schema: ColumnSchema[];
125
+ scrollOffset: number;
126
+ totalLines: number;
127
+ visibleHeight: number;
120
128
  deleteConfirm?: DeleteConfirmationState;
121
129
  notice?: string;
122
130
  }
@@ -0,0 +1,58 @@
1
+ # GRIT (Grid-TUI)
2
+
3
+ GRIT is a lightweight TUI layout library built for Node.js. It focuses on a modern "OpenCode" aesthetic using solid background blocks and Unicode half-height characters for smooth transitions, rather than traditional box-drawing characters.
4
+
5
+ ## Design Philosophy
6
+
7
+ - **Grid-based**: Everything is a grid or a box.
8
+ - **No Lines**: Uses background colors and Unicode blocks (`▀`, `▄`) for visual separation.
9
+ - **TrueColor**: Native support for 24-bit ANSI colors.
10
+ - **Width-Aware**: Correctly handles CJK characters and emojis.
11
+
12
+ ## API
13
+
14
+ ### `Box`
15
+
16
+ A container that handles background color, padding, and alignment.
17
+
18
+ ```typescript
19
+ import { Box } from './grit/index.ts';
20
+
21
+ const box = new Box({
22
+ width: 80,
23
+ background: '#1A1A1A',
24
+ padding: 1
25
+ });
26
+
27
+ const line = box.render("Hello World", { align: 'center' });
28
+ ```
29
+
30
+ ### `Transition`
31
+
32
+ Creates a half-height vertical transition between two background colors.
33
+
34
+ ```typescript
35
+ import { Transition } from './grit/index.ts';
36
+
37
+ // Top half is #1A1A1A, bottom half is #0D0D0D
38
+ const line = Transition.draw(80, '#1A1A1A', '#0D0D0D');
39
+ ```
40
+
41
+ ### `Grid`
42
+
43
+ Calculates weighted column widths for tabular layouts.
44
+
45
+ ```typescript
46
+ import { Grid } from './grit/index.ts';
47
+
48
+ const columns = Grid.calculateWidths(100, [
49
+ { weight: 1 }, // Flexible
50
+ { weight: 2 }, // Twice as wide
51
+ { minWidth: 10 } // Fixed minimum
52
+ ]);
53
+ ```
54
+
55
+ ## Internal Utilities
56
+
57
+ - `ANSI`: Low-level ANSI escape sequence generators.
58
+ - `wrapAnsiBg`: Helper to persist background colors across ANSI resets.
@@ -0,0 +1,67 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { Box, Transition, Grid, ANSI } from './index.ts';
4
+
5
+ test('Grid.calculateWidths distributes widths correctly', () => {
6
+ const widths = Grid.calculateWidths(100, [
7
+ { weight: 1 },
8
+ { weight: 1 }
9
+ ]);
10
+ assert.deepEqual(widths, [50, 50]);
11
+
12
+ const weightedWidths = Grid.calculateWidths(100, [
13
+ { weight: 1 },
14
+ { weight: 3 }
15
+ ]);
16
+ assert.deepEqual(weightedWidths, [25, 75]);
17
+
18
+ const minWidths = Grid.calculateWidths(100, [
19
+ { weight: 1, minWidth: 40 },
20
+ { weight: 1 }
21
+ ]);
22
+ // Total weight 2, available 100 - 40 = 60.
23
+ // Col 1: 40 + (1/2 * 60) = 70
24
+ // Col 2: 0 + (1/2 * 60) = 30
25
+ assert.deepEqual(minWidths, [70, 30]);
26
+ });
27
+
28
+ test('Grid.calculateWidths caps extreme weights', () => {
29
+ // Avg weight = (1 + 10) / 2 = 5.5. Max weight = 22.
30
+ // Wait, if I have 1 and 100, avg is 50.5, max is 202. That won't cap.
31
+ // If I have [1, 1, 1, 10], avg is 13/4 = 3.25. Max is 13. Capped 10 is fine.
32
+ // If I have [1, 1, 1, 20], avg is 23/4 = 5.75. Max is 23.
33
+ // Let's use [1, 1, 1, 1, 1, 100]. Avg = 105/6 = 17.5. Max = 70. 100 capped to 70.
34
+ const configs = [
35
+ { weight: 1 }, { weight: 1 }, { weight: 1 }, { weight: 1 }, { weight: 1 }, { weight: 100 }
36
+ ];
37
+ const widths = Grid.calculateWidths(100, configs);
38
+ // Total capped weight = 1+1+1+1+1+70 = 75.
39
+ // Each 1 weight gets 1/75 * 100 = 1.33 -> 1
40
+ // 70 weight gets 70/75 * 100 = 93.33 -> 93
41
+ // Sum = 1*5 + 93 = 98. Last col gets remainder: 93 + 2 = 95.
42
+ assert.deepEqual(widths, [1, 1, 1, 1, 1, 95]);
43
+ });
44
+
45
+ test('Box renders with background and padding', () => {
46
+ const box = new Box({ width: 10, background: '#000000', padding: 1 });
47
+ const result = box.render('HI');
48
+
49
+ // ANSI.bg('#000000') + ' ' + 'HI' + ' '.repeat(10 - 1 - 2 - 1) + ' ' + ANSI.reset
50
+ // width 10, padding 1. Inner width 8. 'HI' is 2. Fill 6.
51
+ // BG + ' ' (pad) + 'HI' + ' ' (fill) + ' ' (pad) + RESET
52
+ const expected = `${ANSI.bg('#000000')} HI ${ANSI.reset}`;
53
+ assert.strictEqual(result, expected);
54
+ });
55
+
56
+ test('Box handles alignment', () => {
57
+ const box = new Box({ width: 10, padding: 0 });
58
+
59
+ assert.strictEqual(box.render('HI', { align: 'right' }), ' HI');
60
+ assert.strictEqual(box.render('HI', { align: 'center' }), ' HI ');
61
+ });
62
+
63
+ test('Transition.draw generates correct ANSI', () => {
64
+ const result = Transition.draw(5, '#FF0000', '#0000FF');
65
+ const expected = `${ANSI.fg('#FF0000')}${ANSI.bg('#0000FF')}▀▀▀▀▀${ANSI.reset}`;
66
+ assert.strictEqual(result, expected);
67
+ });
@@ -0,0 +1,107 @@
1
+ import { ANSI, wrapAnsiBg } from './utils.ts';
2
+ import { getVisibleWidth } from '../../utils/format.ts';
3
+ import type { Color, Alignment, LayoutOptions, ColumnConfig } from './types.ts';
4
+
5
+ export * from './types.ts';
6
+ export * from './utils.ts';
7
+
8
+ export class Box {
9
+ private options: LayoutOptions;
10
+
11
+ constructor(options: LayoutOptions) {
12
+ this.options = {
13
+ padding: 0,
14
+ ...options
15
+ };
16
+ }
17
+
18
+ render(content: string, options: { align?: Alignment; background?: Color } = {}): string {
19
+ const { width, background: defaultBg, padding = 0 } = this.options;
20
+ const bg = options.background || defaultBg || '';
21
+
22
+ const innerWidth = width - (padding * 2);
23
+ const contentWidth = getVisibleWidth(content);
24
+
25
+ let line = '';
26
+ if (bg) line += ANSI.bg(bg);
27
+
28
+ // Left padding
29
+ line += ' '.repeat(padding);
30
+
31
+ const fill = Math.max(0, innerWidth - contentWidth);
32
+ const safeContent = wrapAnsiBg(content, bg);
33
+
34
+ if (options.align === 'right') {
35
+ line += ' '.repeat(fill) + safeContent;
36
+ } else if (options.align === 'center') {
37
+ const leftFill = Math.floor(fill / 2);
38
+ const rightFill = fill - leftFill;
39
+ line += ' '.repeat(leftFill) + safeContent + ' '.repeat(rightFill);
40
+ } else {
41
+ line += safeContent + ' '.repeat(fill);
42
+ }
43
+
44
+ // Right padding
45
+ line += ' '.repeat(padding);
46
+
47
+ if (bg) line += ANSI.reset;
48
+
49
+ return line;
50
+ }
51
+ }
52
+
53
+ export class Transition {
54
+ static draw(width: number, topBg: Color, bottomBg: Color): string {
55
+ if (topBg === bottomBg) {
56
+ return `${ANSI.bg(bottomBg)}${' '.repeat(width)}${ANSI.reset}`;
57
+ }
58
+ return `${ANSI.fg(topBg)}${ANSI.bg(bottomBg)}${ANSI.blockUpper.repeat(width)}${ANSI.reset}`;
59
+ }
60
+
61
+ static drawInverted(width: number, topBg: Color, bottomBg: Color): string {
62
+ if (topBg === bottomBg) {
63
+ return `${ANSI.bg(topBg)}${' '.repeat(width)}${ANSI.reset}`;
64
+ }
65
+ return `${ANSI.fg(bottomBg)}${ANSI.bg(topBg)}${ANSI.blockLower.repeat(width)}${ANSI.reset}`;
66
+ }
67
+ }
68
+
69
+ export class Grid {
70
+ static calculateWidths(totalWidth: number, configs: ColumnConfig[]): number[] {
71
+ const numCols = configs.length;
72
+ if (numCols === 0) return [];
73
+
74
+ const minWidths = configs.map(c => c.minWidth ?? 0);
75
+ const weights = configs.map(c => c.weight ?? 1);
76
+
77
+ const totalMinWidth = minWidths.reduce((a, b) => a + b, 0);
78
+ const availableWidth = totalWidth - totalMinWidth;
79
+
80
+ if (availableWidth <= 0) {
81
+ // If not enough space, distribute equally based on minWidths or just evenly
82
+ const equalWidth = Math.floor(totalWidth / numCols);
83
+ return new Array(numCols).fill(equalWidth);
84
+ }
85
+
86
+ // Cap weights to prevent extreme ratios (max 4x average)
87
+ const avgWeight = weights.reduce((a, b) => a + b, 0) / numCols;
88
+ const maxWeight = avgWeight * 4;
89
+ const cappedWeights = weights.map(w => Math.min(w, maxWeight));
90
+ const totalWeight = cappedWeights.reduce((a, b) => a + b, 0);
91
+
92
+ if (totalWeight === 0) {
93
+ const equalWidth = Math.floor(totalWidth / numCols);
94
+ return new Array(numCols).fill(equalWidth);
95
+ }
96
+
97
+ const widths = cappedWeights.map((w, i) => minWidths[i] + Math.floor((w / totalWeight) * availableWidth));
98
+
99
+ // Distribute rounding remainder to the last column
100
+ const usedWidth = widths.reduce((a, b) => a + b, 0);
101
+ if (usedWidth < totalWidth) {
102
+ widths[widths.length - 1] += (totalWidth - usedWidth);
103
+ }
104
+
105
+ return widths;
106
+ }
107
+ }
@@ -0,0 +1,16 @@
1
+ export type Color = string; // Hex color string like '#FFFFFF'
2
+
3
+ export type Alignment = 'left' | 'right' | 'center';
4
+
5
+ export interface LayoutOptions {
6
+ width: number;
7
+ height?: number;
8
+ padding?: number;
9
+ background?: Color;
10
+ }
11
+
12
+ export interface ColumnConfig {
13
+ weight?: number;
14
+ minWidth?: number;
15
+ maxWidth?: number;
16
+ }