dbn-cli 0.4.0 → 0.5.2
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 +5 -5
- package/src/adapter/adapter.test.ts +207 -0
- package/src/index.ts +20 -2
- package/src/repro_bug.test.ts +101 -0
- package/src/types.ts +8 -0
- package/src/ui/grit/README.md +58 -0
- package/src/ui/grit/index.test.ts +67 -0
- package/src/ui/grit/index.ts +101 -0
- package/src/ui/grit/types.ts +16 -0
- package/src/ui/grit/utils.ts +35 -0
- package/src/ui/navigator.test.ts +434 -0
- package/src/ui/navigator.ts +199 -36
- package/src/ui/navigator_sync.test.ts +95 -0
- package/src/ui/renderer.ts +242 -490
- package/src/ui/screen.ts +6 -6
- package/src/ui/theme.test.ts +30 -0
- package/src/ui/theme.ts +20 -46
- package/src/utils/format.test.ts +209 -0
- package/src/utils/format.ts +94 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dbn-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.2",
|
|
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/
|
|
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
|
@@ -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.
|
|
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,101 @@
|
|
|
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
|
+
return `${ANSI.fg(topBg)}${ANSI.bg(bottomBg)}${ANSI.blockUpper.repeat(width)}${ANSI.reset}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
static drawInverted(width: number, topBg: Color, bottomBg: Color): string {
|
|
59
|
+
return `${ANSI.fg(bottomBg)}${ANSI.bg(topBg)}${ANSI.blockLower.repeat(width)}${ANSI.reset}`;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class Grid {
|
|
64
|
+
static calculateWidths(totalWidth: number, configs: ColumnConfig[]): number[] {
|
|
65
|
+
const numCols = configs.length;
|
|
66
|
+
if (numCols === 0) return [];
|
|
67
|
+
|
|
68
|
+
const minWidths = configs.map(c => c.minWidth ?? 0);
|
|
69
|
+
const weights = configs.map(c => c.weight ?? 1);
|
|
70
|
+
|
|
71
|
+
const totalMinWidth = minWidths.reduce((a, b) => a + b, 0);
|
|
72
|
+
const availableWidth = totalWidth - totalMinWidth;
|
|
73
|
+
|
|
74
|
+
if (availableWidth <= 0) {
|
|
75
|
+
// If not enough space, distribute equally based on minWidths or just evenly
|
|
76
|
+
const equalWidth = Math.floor(totalWidth / numCols);
|
|
77
|
+
return new Array(numCols).fill(equalWidth);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Cap weights to prevent extreme ratios (max 4x average)
|
|
81
|
+
const avgWeight = weights.reduce((a, b) => a + b, 0) / numCols;
|
|
82
|
+
const maxWeight = avgWeight * 4;
|
|
83
|
+
const cappedWeights = weights.map(w => Math.min(w, maxWeight));
|
|
84
|
+
const totalWeight = cappedWeights.reduce((a, b) => a + b, 0);
|
|
85
|
+
|
|
86
|
+
if (totalWeight === 0) {
|
|
87
|
+
const equalWidth = Math.floor(totalWidth / numCols);
|
|
88
|
+
return new Array(numCols).fill(equalWidth);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const widths = cappedWeights.map((w, i) => minWidths[i] + Math.floor((w / totalWeight) * availableWidth));
|
|
92
|
+
|
|
93
|
+
// Distribute rounding remainder to the last column
|
|
94
|
+
const usedWidth = widths.reduce((a, b) => a + b, 0);
|
|
95
|
+
if (usedWidth < totalWidth) {
|
|
96
|
+
widths[widths.length - 1] += (totalWidth - usedWidth);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return widths;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const ANSI = {
|
|
2
|
+
reset: '\x1b[0m',
|
|
3
|
+
bold: '\x1b[1m',
|
|
4
|
+
dim: '\x1b[2m',
|
|
5
|
+
inverse: '\x1b[7m',
|
|
6
|
+
// TrueColor (24-bit) foreground
|
|
7
|
+
fg: (hex: string) => {
|
|
8
|
+
const { r, g, b } = hexToRgb(hex);
|
|
9
|
+
return `\x1b[38;2;${r};${g};${b}m`;
|
|
10
|
+
},
|
|
11
|
+
// TrueColor (24-bit) background
|
|
12
|
+
bg: (hex: string) => {
|
|
13
|
+
const { r, g, b } = hexToRgb(hex);
|
|
14
|
+
return `\x1b[48;2;${r};${g};${b}m`;
|
|
15
|
+
},
|
|
16
|
+
blockUpper: '▀',
|
|
17
|
+
blockLower: '▄',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function hexToRgb(hex: string) {
|
|
21
|
+
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|
22
|
+
return result ? {
|
|
23
|
+
r: parseInt(result[1], 16),
|
|
24
|
+
g: parseInt(result[2], 16),
|
|
25
|
+
b: parseInt(result[3], 16)
|
|
26
|
+
} : { r: 0, g: 0, b: 0 };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Ensures background color is maintained if content contains ANSI resets.
|
|
31
|
+
*/
|
|
32
|
+
export function wrapAnsiBg(content: string, bg: string): string {
|
|
33
|
+
if (!bg) return content;
|
|
34
|
+
return content.replaceAll(ANSI.reset, ANSI.reset + ANSI.bg(bg));
|
|
35
|
+
}
|