apple-notes-tui 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/LICENSE +21 -0
- package/README.md +124 -0
- package/bin/notes.js +4 -0
- package/package.json +40 -0
- package/src/app.js +775 -0
- package/src/config.js +27 -0
- package/src/keys.js +172 -0
- package/src/store.js +174 -0
- package/src/term.js +103 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// User config lives at ~/.config/notes-cli/config.json (or $XDG_CONFIG_HOME).
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
|
|
6
|
+
const configDir = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
7
|
+
export const configPath = path.join(configDir, 'notes-cli', 'config.json');
|
|
8
|
+
|
|
9
|
+
export const DEFAULTS = {
|
|
10
|
+
keymap: 'hybrid', // hybrid | vim | emacs
|
|
11
|
+
sort: 'modified', // modified | created | title
|
|
12
|
+
keys: {}, // extra bindings per action, e.g. { "quit": ["x"] }
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function loadConfig() {
|
|
16
|
+
try {
|
|
17
|
+
const raw = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
18
|
+
return { ...DEFAULTS, ...raw, keys: raw.keys || {} };
|
|
19
|
+
} catch {
|
|
20
|
+
return { ...DEFAULTS };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function saveConfig(cfg) {
|
|
25
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
26
|
+
fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2) + '\n');
|
|
27
|
+
}
|
package/src/keys.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Raw stdin parsing and keymap resolution (hybrid / vim / emacs + user overrides).
|
|
2
|
+
|
|
3
|
+
// Turns a raw stdin string into a list of key names like:
|
|
4
|
+
// 'a', 'G', 'ctrl+d', 'meta+v', 'up', 'pagedown', 'enter', 'escape', 'space'.
|
|
5
|
+
// Mouse input (SGR mode) yields 'wheelup'/'wheeldown' for the scroll wheel
|
|
6
|
+
// and a { x, y } object (1-based screen cells) for a left-button press.
|
|
7
|
+
export function parseInput(str) {
|
|
8
|
+
const keys = [];
|
|
9
|
+
let i = 0;
|
|
10
|
+
while (i < str.length) {
|
|
11
|
+
const ch = str[i];
|
|
12
|
+
if (ch === '\x1b') {
|
|
13
|
+
const next = str[i + 1];
|
|
14
|
+
if (next === '[' || next === 'O') {
|
|
15
|
+
let j = i + 2;
|
|
16
|
+
let seq = '';
|
|
17
|
+
while (j < str.length && !/[A-Za-z~]/.test(str[j])) {
|
|
18
|
+
seq += str[j];
|
|
19
|
+
j++;
|
|
20
|
+
}
|
|
21
|
+
const full = seq + (str[j] || '');
|
|
22
|
+
const map = {
|
|
23
|
+
A: 'up', B: 'down', C: 'right', D: 'left',
|
|
24
|
+
H: 'home', F: 'end', Z: 'shift+tab',
|
|
25
|
+
'1~': 'home', '3~': 'delete', '4~': 'end',
|
|
26
|
+
'5~': 'pageup', '6~': 'pagedown',
|
|
27
|
+
};
|
|
28
|
+
if (full[0] === '<' && /[Mm]$/.test(full)) {
|
|
29
|
+
// SGR mouse report: \x1b[<button;x;y then M (press) or m (release)
|
|
30
|
+
const [b, x, y] = full.slice(1, -1).split(';').map(Number);
|
|
31
|
+
if (full.endsWith('M')) {
|
|
32
|
+
if (b === 64) keys.push('wheelup');
|
|
33
|
+
else if (b === 65) keys.push('wheeldown');
|
|
34
|
+
else if (b === 0) keys.push({ x, y });
|
|
35
|
+
}
|
|
36
|
+
} else if (map[full]) keys.push(map[full]);
|
|
37
|
+
i = j + 1;
|
|
38
|
+
} else if (next !== undefined) {
|
|
39
|
+
keys.push('meta+' + next);
|
|
40
|
+
i += 2;
|
|
41
|
+
} else {
|
|
42
|
+
keys.push('escape');
|
|
43
|
+
i += 1;
|
|
44
|
+
}
|
|
45
|
+
} else if (ch === '\r' || ch === '\n') {
|
|
46
|
+
keys.push('enter');
|
|
47
|
+
i++;
|
|
48
|
+
} else if (ch === '\t') {
|
|
49
|
+
keys.push('tab');
|
|
50
|
+
i++;
|
|
51
|
+
} else if (ch === '\x7f' || ch === '\b') {
|
|
52
|
+
keys.push('backspace');
|
|
53
|
+
i++;
|
|
54
|
+
} else if (ch === ' ') {
|
|
55
|
+
keys.push('space');
|
|
56
|
+
i++;
|
|
57
|
+
} else {
|
|
58
|
+
const code = ch.charCodeAt(0);
|
|
59
|
+
if (code < 32) {
|
|
60
|
+
keys.push('ctrl+' + String.fromCharCode(code + 96));
|
|
61
|
+
i++;
|
|
62
|
+
} else {
|
|
63
|
+
const c = String.fromCodePoint(str.codePointAt(i));
|
|
64
|
+
keys.push(c);
|
|
65
|
+
i += c.length;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return keys;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Bindings active in every keymap. Arrow keys always work.
|
|
73
|
+
const COMMON = {
|
|
74
|
+
up: ['up'],
|
|
75
|
+
down: ['down'],
|
|
76
|
+
pageUp: ['pageup'],
|
|
77
|
+
pageDown: ['pagedown', 'space'],
|
|
78
|
+
top: ['home'],
|
|
79
|
+
bottom: ['end'],
|
|
80
|
+
open: ['enter'],
|
|
81
|
+
back: ['escape'],
|
|
82
|
+
prev: ['left'],
|
|
83
|
+
next: ['right'],
|
|
84
|
+
openExternal: ['o'],
|
|
85
|
+
openFile: ['a'],
|
|
86
|
+
edit: ['e'],
|
|
87
|
+
new: ['n'],
|
|
88
|
+
quit: ['q', 'ctrl+c'],
|
|
89
|
+
help: ['?'],
|
|
90
|
+
refresh: ['r'],
|
|
91
|
+
search: ['/'],
|
|
92
|
+
settings: ['s'],
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const VIM = {
|
|
96
|
+
down: ['j'],
|
|
97
|
+
up: ['k'],
|
|
98
|
+
pageDown: ['ctrl+d', 'ctrl+f'],
|
|
99
|
+
pageUp: ['ctrl+u', 'ctrl+b'],
|
|
100
|
+
top: ['g g'], // chord: g then g
|
|
101
|
+
bottom: ['G'],
|
|
102
|
+
prev: ['h'],
|
|
103
|
+
next: ['l'],
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const EMACS = {
|
|
107
|
+
down: ['ctrl+n'],
|
|
108
|
+
up: ['ctrl+p'],
|
|
109
|
+
pageDown: ['ctrl+v'],
|
|
110
|
+
pageUp: ['meta+v'],
|
|
111
|
+
top: ['meta+<'],
|
|
112
|
+
bottom: ['meta+>'],
|
|
113
|
+
back: ['ctrl+g'],
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Merges COMMON + preset(s) + user overrides into { action: [keys...] }.
|
|
117
|
+
export function effectiveBindings(config) {
|
|
118
|
+
const sets = [COMMON];
|
|
119
|
+
if (config.keymap === 'vim' || config.keymap === 'hybrid') sets.push(VIM);
|
|
120
|
+
if (config.keymap === 'emacs' || config.keymap === 'hybrid') sets.push(EMACS);
|
|
121
|
+
const map = {};
|
|
122
|
+
for (const set of sets) {
|
|
123
|
+
for (const [action, keys] of Object.entries(set)) {
|
|
124
|
+
map[action] = [...(map[action] || []), ...keys];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
for (const [action, keys] of Object.entries(config.keys || {})) {
|
|
128
|
+
const extra = Array.isArray(keys) ? keys : [keys];
|
|
129
|
+
map[action] = [...extra, ...(map[action] || [])];
|
|
130
|
+
}
|
|
131
|
+
return map;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Resolves a key press to an action, tracking two-key chords like 'g g'.
|
|
135
|
+
// Returns { action, pending } — pending is the chord prefix awaiting its
|
|
136
|
+
// second key, if any.
|
|
137
|
+
export function actionFor(bindings, key, pending) {
|
|
138
|
+
if (pending) {
|
|
139
|
+
const combo = pending + ' ' + key;
|
|
140
|
+
for (const [action, keys] of Object.entries(bindings)) {
|
|
141
|
+
if (keys.includes(combo)) return { action, pending: null };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
for (const keys of Object.values(bindings)) {
|
|
145
|
+
for (const k of keys) {
|
|
146
|
+
if (k.startsWith(key + ' ')) return { action: null, pending: key };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
for (const [action, keys] of Object.entries(bindings)) {
|
|
150
|
+
if (keys.includes(key)) return { action, pending: null };
|
|
151
|
+
}
|
|
152
|
+
return { action: null, pending: null };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const KEY_LABELS = {
|
|
156
|
+
up: '↑', down: '↓', left: '←', right: '→',
|
|
157
|
+
enter: '↵', escape: 'esc', space: 'space',
|
|
158
|
+
pageup: 'PgUp', pagedown: 'PgDn', home: 'Home', end: 'End',
|
|
159
|
+
backspace: '⌫',
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export function prettyKey(key) {
|
|
163
|
+
return key
|
|
164
|
+
.split(' ')
|
|
165
|
+
.map((part) => {
|
|
166
|
+
if (KEY_LABELS[part]) return KEY_LABELS[part];
|
|
167
|
+
if (part.startsWith('ctrl+')) return 'Ctrl-' + part.slice(5);
|
|
168
|
+
if (part.startsWith('meta+')) return 'Alt-' + part.slice(5);
|
|
169
|
+
return part;
|
|
170
|
+
})
|
|
171
|
+
.join(' ');
|
|
172
|
+
}
|
package/src/store.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Data layer: talks to Notes.app over Apple Events via osascript (JXA).
|
|
2
|
+
// Kept behind this interface so a faster backend (e.g. direct SQLite reads)
|
|
3
|
+
// could be swapped in later without touching the UI.
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
function runJXA(script) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
execFile(
|
|
12
|
+
'osascript',
|
|
13
|
+
['-l', 'JavaScript', '-e', script],
|
|
14
|
+
{ maxBuffer: 64 * 1024 * 1024, timeout: 60_000 },
|
|
15
|
+
(err, stdout, stderr) => {
|
|
16
|
+
if (err) {
|
|
17
|
+
const msg = (stderr || err.message || '').trim();
|
|
18
|
+
if (/-1743|not authori[sz]ed/i.test(msg)) {
|
|
19
|
+
reject(new Error(
|
|
20
|
+
'macOS blocked access to Notes.\n' +
|
|
21
|
+
'Open System Settings → Privacy & Security → Automation and\n' +
|
|
22
|
+
'allow your terminal app to control Notes, then try again.'
|
|
23
|
+
));
|
|
24
|
+
} else {
|
|
25
|
+
reject(new Error(msg || 'osascript failed'));
|
|
26
|
+
}
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
resolve(stdout.trim());
|
|
30
|
+
}
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const LIST_SCRIPT = `
|
|
36
|
+
(() => {
|
|
37
|
+
const app = Application('Notes');
|
|
38
|
+
const out = [];
|
|
39
|
+
for (const folder of app.folders()) {
|
|
40
|
+
const folderName = folder.name();
|
|
41
|
+
if (folderName === 'Recently Deleted') continue;
|
|
42
|
+
let ids, titles, modified, created;
|
|
43
|
+
try {
|
|
44
|
+
const notes = folder.notes;
|
|
45
|
+
ids = notes.id();
|
|
46
|
+
titles = notes.name();
|
|
47
|
+
modified = notes.modificationDate();
|
|
48
|
+
created = notes.creationDate();
|
|
49
|
+
} catch (e) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
let locked = null;
|
|
53
|
+
try { locked = folder.notes.passwordProtected(); } catch (e) {}
|
|
54
|
+
for (let i = 0; i < ids.length; i++) {
|
|
55
|
+
out.push({
|
|
56
|
+
id: ids[i],
|
|
57
|
+
title: titles[i] || 'Untitled',
|
|
58
|
+
folder: folderName,
|
|
59
|
+
modified: modified[i] ? modified[i].toISOString() : null,
|
|
60
|
+
created: created[i] ? created[i].toISOString() : null,
|
|
61
|
+
locked: locked ? !!locked[i] : false,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return JSON.stringify(out);
|
|
66
|
+
})()`;
|
|
67
|
+
|
|
68
|
+
export async function fetchNoteList() {
|
|
69
|
+
const raw = await runJXA(LIST_SCRIPT);
|
|
70
|
+
const notes = JSON.parse(raw);
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
return notes.filter((n) => {
|
|
73
|
+
if (seen.has(n.id)) return false;
|
|
74
|
+
seen.add(n.id);
|
|
75
|
+
return true;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function fetchNoteText(id) {
|
|
80
|
+
const script = `
|
|
81
|
+
(() => {
|
|
82
|
+
const app = Application('Notes');
|
|
83
|
+
const note = app.notes.byId(${JSON.stringify(id)});
|
|
84
|
+
let ids = [], names = [];
|
|
85
|
+
try {
|
|
86
|
+
ids = note.attachments.id();
|
|
87
|
+
names = note.attachments.name();
|
|
88
|
+
} catch (e) {}
|
|
89
|
+
return JSON.stringify({ text: note.plaintext(), ids, names });
|
|
90
|
+
})()`;
|
|
91
|
+
const { text, ids, names } = JSON.parse(await runJXA(script));
|
|
92
|
+
const attachments = ids.map((attId, i) => ({ id: attId, name: names[i] || 'attachment' }));
|
|
93
|
+
// U+FFFC marks inline attachments (images, tables) that plaintext can't
|
|
94
|
+
// carry. They appear in the same order as the note's attachment list, so
|
|
95
|
+
// substitute each marker with the matching filename.
|
|
96
|
+
let i = 0;
|
|
97
|
+
const rendered = (text || '').replace(//g, () => {
|
|
98
|
+
const name = attachments[i++]?.name;
|
|
99
|
+
return name ? `[📎 ${name}]` : '[attachment]';
|
|
100
|
+
});
|
|
101
|
+
return { text: rendered, attachments };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Exports an attachment through Notes.app (which can read its own container —
|
|
105
|
+
// this process can't without Full Disk Access) and opens it with the default
|
|
106
|
+
// app. Exports land in one temp dir per run and are reused on reopen.
|
|
107
|
+
let exportDir = null;
|
|
108
|
+
|
|
109
|
+
export async function openAttachment(noteId, attachment) {
|
|
110
|
+
if (!exportDir) exportDir = fs.mkdtempSync(path.join(os.tmpdir(), 'notes-cli-'));
|
|
111
|
+
const safe = (attachment.name || 'attachment').replace(/[/:]/g, '_');
|
|
112
|
+
const dest = path.join(exportDir, safe);
|
|
113
|
+
if (!fs.existsSync(dest)) {
|
|
114
|
+
const script = `
|
|
115
|
+
(() => {
|
|
116
|
+
const app = Application('Notes');
|
|
117
|
+
const att = app.notes.byId(${JSON.stringify(noteId)}).attachments.byId(${JSON.stringify(attachment.id)});
|
|
118
|
+
app.save(att, { in: Path(${JSON.stringify(dest)}) });
|
|
119
|
+
})()`;
|
|
120
|
+
await runJXA(script);
|
|
121
|
+
}
|
|
122
|
+
await new Promise((resolve, reject) => {
|
|
123
|
+
execFile('open', [dest], (err) => (err ? reject(err) : resolve()));
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Notes bodies are HTML: each line becomes a <div>, and Notes derives the
|
|
128
|
+
// note title from the first line.
|
|
129
|
+
function textToHtml(text) {
|
|
130
|
+
const esc = (s) =>
|
|
131
|
+
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
132
|
+
return text
|
|
133
|
+
.split('\n')
|
|
134
|
+
.map((line) => (line.trim() ? `<div>${esc(line)}</div>` : '<div><br></div>'))
|
|
135
|
+
.join('');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Replaces the note's body with plain text. Rich formatting and inline
|
|
139
|
+
// attachments in the old body are lost, which the user accepted for
|
|
140
|
+
// in-terminal editing.
|
|
141
|
+
export async function saveNoteText(id, text) {
|
|
142
|
+
const html = textToHtml(text);
|
|
143
|
+
const script = `
|
|
144
|
+
(() => {
|
|
145
|
+
const app = Application('Notes');
|
|
146
|
+
app.notes.byId(${JSON.stringify(id)}).body = ${JSON.stringify(html)};
|
|
147
|
+
})()`;
|
|
148
|
+
await runJXA(script);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Creates a note in the default account's default folder; returns its id.
|
|
152
|
+
export async function createNote(text) {
|
|
153
|
+
const script = `
|
|
154
|
+
(() => {
|
|
155
|
+
const app = Application('Notes');
|
|
156
|
+
const note = app.Note({ body: ${JSON.stringify(textToHtml(text))} });
|
|
157
|
+
app.defaultAccount.notes.push(note);
|
|
158
|
+
return note.id();
|
|
159
|
+
})()`;
|
|
160
|
+
return runJXA(script);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Brings the note up in Notes.app itself. For password-protected notes this
|
|
164
|
+
// is the unlock path: Notes prompts for Touch ID / password, and once the
|
|
165
|
+
// session is unlocked its text becomes readable over Apple Events too.
|
|
166
|
+
export async function openInNotes(id) {
|
|
167
|
+
const script = `
|
|
168
|
+
(() => {
|
|
169
|
+
const app = Application('Notes');
|
|
170
|
+
app.activate();
|
|
171
|
+
app.notes.byId(${JSON.stringify(id)}).show();
|
|
172
|
+
})()`;
|
|
173
|
+
await runJXA(script);
|
|
174
|
+
}
|
package/src/term.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// ANSI escape codes and text-measurement helpers for the TUI.
|
|
2
|
+
|
|
3
|
+
export const altOn = '\x1b[?1049h\x1b[?25l'; // alt screen + hide cursor
|
|
4
|
+
export const altOff = '\x1b[?1049l\x1b[?25h'; // restore screen + show cursor
|
|
5
|
+
export const mouseOn = '\x1b[?1000h\x1b[?1006h'; // click + wheel reporting (SGR)
|
|
6
|
+
export const mouseOff = '\x1b[?1006l\x1b[?1000l';
|
|
7
|
+
export const home = '\x1b[H';
|
|
8
|
+
export const clearBelow = '\x1b[0J';
|
|
9
|
+
export const clearLine = '\x1b[K';
|
|
10
|
+
|
|
11
|
+
export const bold = (s) => `\x1b[1m${s}\x1b[22m`;
|
|
12
|
+
export const dim = (s) => `\x1b[2m${s}\x1b[22m`;
|
|
13
|
+
|
|
14
|
+
const rgb = (r, g, b) => (s) => `\x1b[38;2;${r};${g};${b}m${s}\x1b[39m`;
|
|
15
|
+
export const accent = rgb(215, 119, 87); // coral, à la Claude Code
|
|
16
|
+
export const grey = rgb(150, 148, 145);
|
|
17
|
+
|
|
18
|
+
// Approximate display width of a code point (wide CJK/emoji = 2, marks = 0).
|
|
19
|
+
function charWidth(cp) {
|
|
20
|
+
if (cp === 0x200d || cp === 0xfe0f || (cp >= 0x300 && cp <= 0x36f)) return 0;
|
|
21
|
+
if (
|
|
22
|
+
(cp >= 0x1100 && cp <= 0x115f) ||
|
|
23
|
+
(cp >= 0x2e80 && cp <= 0xa4cf) ||
|
|
24
|
+
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
25
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
26
|
+
(cp >= 0xfe30 && cp <= 0xfe4f) ||
|
|
27
|
+
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
28
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
29
|
+
(cp >= 0x1f000 && cp <= 0x1faff) ||
|
|
30
|
+
(cp >= 0x2600 && cp <= 0x27bf)
|
|
31
|
+
) {
|
|
32
|
+
return 2;
|
|
33
|
+
}
|
|
34
|
+
return 1;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function strWidth(s) {
|
|
38
|
+
let w = 0;
|
|
39
|
+
for (const ch of s) w += charWidth(ch.codePointAt(0));
|
|
40
|
+
return w;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function truncate(s, width) {
|
|
44
|
+
if (width <= 0) return '';
|
|
45
|
+
if (strWidth(s) <= width) return s;
|
|
46
|
+
let out = '';
|
|
47
|
+
let w = 0;
|
|
48
|
+
for (const ch of s) {
|
|
49
|
+
const cw = charWidth(ch.codePointAt(0));
|
|
50
|
+
if (w + cw > width - 1) break;
|
|
51
|
+
out += ch;
|
|
52
|
+
w += cw;
|
|
53
|
+
}
|
|
54
|
+
return out + '…';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function padEnd(s, width) {
|
|
58
|
+
const pad = width - strWidth(s);
|
|
59
|
+
return pad > 0 ? s + ' '.repeat(pad) : s;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Truncates and pads plain text to an exact width, then optionally styles it.
|
|
63
|
+
export function padRowPlain(s, width, styleFn) {
|
|
64
|
+
const text = padEnd(truncate(s, width), width);
|
|
65
|
+
return styleFn ? styleFn(text) : text;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Word-wraps plain text to a given display width, hard-breaking long words.
|
|
69
|
+
export function wrap(text, width) {
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const rawLine of text.split('\n')) {
|
|
72
|
+
const line = rawLine.replace(/\t/g, ' ');
|
|
73
|
+
if (strWidth(line) <= width) {
|
|
74
|
+
out.push(line);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
let cur = '';
|
|
78
|
+
for (const word of line.split(' ')) {
|
|
79
|
+
const candidate = cur ? cur + ' ' + word : word;
|
|
80
|
+
if (strWidth(candidate) <= width) {
|
|
81
|
+
cur = candidate;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (cur) out.push(cur);
|
|
85
|
+
let rest = word;
|
|
86
|
+
while (strWidth(rest) > width) {
|
|
87
|
+
let piece = '';
|
|
88
|
+
let pw = 0;
|
|
89
|
+
for (const ch of rest) {
|
|
90
|
+
const cw = charWidth(ch.codePointAt(0));
|
|
91
|
+
if (pw + cw > width) break;
|
|
92
|
+
piece += ch;
|
|
93
|
+
pw += cw;
|
|
94
|
+
}
|
|
95
|
+
out.push(piece);
|
|
96
|
+
rest = rest.slice(piece.length);
|
|
97
|
+
}
|
|
98
|
+
cur = rest;
|
|
99
|
+
}
|
|
100
|
+
out.push(cur);
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|