@timmy6942025/cli-timer 1.1.8 → 1.1.10
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/README.md +1 -0
- package/package.json +1 -1
- package/src/index.js +160 -14
package/README.md
CHANGED
|
@@ -76,6 +76,7 @@ timer style <font>
|
|
|
76
76
|
```
|
|
77
77
|
|
|
78
78
|
Replace `<font>` with any font name from the list shown by `timer style`.
|
|
79
|
+
If a font is missing native timer digits, CLI Timer now substitutes close glyphs so the display stays stylized instead of falling back to plain text.
|
|
79
80
|
|
|
80
81
|
## Settings UI
|
|
81
82
|
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -12,6 +12,23 @@ const SETTINGS_STATE_PATH = path.join(CONFIG_DIR, "settings-state.json");
|
|
|
12
12
|
const DEFAULT_FONT = "Standard";
|
|
13
13
|
const TIMER_SAMPLE_TEXT = "01:23:45";
|
|
14
14
|
const MIN_FIGLET_WIDTH = 120;
|
|
15
|
+
const PRINTABLE_ASCII_CANDIDATES = Object.freeze(
|
|
16
|
+
Array.from({ length: 94 }, (_, index) => String.fromCharCode(33 + index))
|
|
17
|
+
);
|
|
18
|
+
const TIME_CHAR_FALLBACKS = Object.freeze({
|
|
19
|
+
"0": Object.freeze(["0", "O", "o", "Q", "D", "U", "X"]),
|
|
20
|
+
"1": Object.freeze(["1", "I", "l", "|", "!", "T", "X"]),
|
|
21
|
+
"2": Object.freeze(["2", "Z", "z", "S", "s", "X"]),
|
|
22
|
+
"3": Object.freeze(["3", "E", "e", "B", "b", "X"]),
|
|
23
|
+
"4": Object.freeze(["4", "A", "a", "H", "h", "X"]),
|
|
24
|
+
"5": Object.freeze(["5", "S", "s", "$", "X"]),
|
|
25
|
+
"6": Object.freeze(["6", "G", "g", "b", "X"]),
|
|
26
|
+
"7": Object.freeze(["7", "T", "t", "Y", "y", "X"]),
|
|
27
|
+
"8": Object.freeze(["8", "B", "b", "X"]),
|
|
28
|
+
"9": Object.freeze(["9", "g", "q", "P", "p", "X"]),
|
|
29
|
+
":": Object.freeze([":", "|", "!", "i", "I", ".", ";", "X"])
|
|
30
|
+
});
|
|
31
|
+
const SYNTHETIC_FILL_CHARS = Object.freeze(["#", "@", "%", "&", "*", "+", "=", "~", "^", "$", "?"]);
|
|
15
32
|
|
|
16
33
|
const MIN_TICK_RATE_MS = 50;
|
|
17
34
|
const MAX_TICK_RATE_MS = 1000;
|
|
@@ -48,6 +65,9 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
48
65
|
|
|
49
66
|
let allFontsCache = null;
|
|
50
67
|
let compatibleFontsSlowCache = null;
|
|
68
|
+
const glyphCache = new Map();
|
|
69
|
+
const tokenCandidatesCache = new Map();
|
|
70
|
+
const syntheticFillCache = new Map();
|
|
51
71
|
|
|
52
72
|
function clearScreen() {
|
|
53
73
|
process.stdout.write("\x1b[2J\x1b[H");
|
|
@@ -86,6 +106,21 @@ function hasVisibleGlyphs(text) {
|
|
|
86
106
|
return typeof text === "string" && /[^\s]/.test(text);
|
|
87
107
|
}
|
|
88
108
|
|
|
109
|
+
function significantLines(text) {
|
|
110
|
+
const lines = toDisplayLines(text).map((line) => line.trim());
|
|
111
|
+
return lines.filter((line) => line.length > 0);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isPlainTimerRender(rendered, timeText) {
|
|
115
|
+
const lines = significantLines(rendered);
|
|
116
|
+
return lines.length === 1 && lines[0] === String(timeText).trim();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isPlainGlyphRender(rendered, token) {
|
|
120
|
+
const lines = significantLines(rendered);
|
|
121
|
+
return lines.length === 1 && lines[0] === String(token).trim();
|
|
122
|
+
}
|
|
123
|
+
|
|
89
124
|
function renderWithFont(text, fontName) {
|
|
90
125
|
const width = Number.isFinite(process.stdout.columns)
|
|
91
126
|
? Math.max(MIN_FIGLET_WIDTH, Math.floor(process.stdout.columns))
|
|
@@ -104,8 +139,117 @@ function renderWithFont(text, fontName) {
|
|
|
104
139
|
}
|
|
105
140
|
}
|
|
106
141
|
|
|
142
|
+
function glyphCacheKey(fontName, token) {
|
|
143
|
+
return `${fontName}\u0000${token}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function tokenCandidates(token) {
|
|
147
|
+
if (tokenCandidatesCache.has(token)) {
|
|
148
|
+
return tokenCandidatesCache.get(token);
|
|
149
|
+
}
|
|
150
|
+
const seeded = TIME_CHAR_FALLBACKS[token] || [token];
|
|
151
|
+
const all = [...new Set([...seeded, ...PRINTABLE_ASCII_CANDIDATES])];
|
|
152
|
+
tokenCandidatesCache.set(token, all);
|
|
153
|
+
return all;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function glyphFromRendered(rendered) {
|
|
157
|
+
const lines = toDisplayLines(rendered);
|
|
158
|
+
const width = lines.reduce((max, line) => Math.max(max, line.length), 0);
|
|
159
|
+
return { lines, width };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function hashString(value) {
|
|
163
|
+
let hash = 2166136261;
|
|
164
|
+
for (const ch of String(value)) {
|
|
165
|
+
hash ^= ch.charCodeAt(0);
|
|
166
|
+
hash = Math.imul(hash, 16777619);
|
|
167
|
+
}
|
|
168
|
+
return hash >>> 0;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function syntheticFillCharForFont(fontName) {
|
|
172
|
+
if (syntheticFillCache.has(fontName)) {
|
|
173
|
+
return syntheticFillCache.get(fontName);
|
|
174
|
+
}
|
|
175
|
+
const fill = SYNTHETIC_FILL_CHARS[hashString(fontName) % SYNTHETIC_FILL_CHARS.length];
|
|
176
|
+
syntheticFillCache.set(fontName, fill);
|
|
177
|
+
return fill;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function synthesizeGlyph(fontName, token) {
|
|
181
|
+
const baseline = getRenderableGlyph(DEFAULT_FONT, token);
|
|
182
|
+
if (!baseline) {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
const fill = syntheticFillCharForFont(fontName);
|
|
186
|
+
const lines = baseline.lines.map((line) => line.replace(/[^\s]/g, fill));
|
|
187
|
+
const width = lines.reduce((max, line) => Math.max(max, line.length), 0);
|
|
188
|
+
return { lines, width };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function getRenderableGlyph(fontName, token) {
|
|
192
|
+
const cacheKey = glyphCacheKey(fontName, token);
|
|
193
|
+
if (glyphCache.has(cacheKey)) {
|
|
194
|
+
return glyphCache.get(cacheKey);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const options = tokenCandidates(token);
|
|
198
|
+
for (const candidate of options) {
|
|
199
|
+
const rendered = renderWithFont(candidate, fontName);
|
|
200
|
+
if (hasVisibleGlyphs(rendered) && !isPlainGlyphRender(rendered, candidate)) {
|
|
201
|
+
const glyph = glyphFromRendered(rendered);
|
|
202
|
+
glyphCache.set(cacheKey, glyph);
|
|
203
|
+
return glyph;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
glyphCache.set(cacheKey, null);
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function renderTimeByGlyphs(timeText, fontName) {
|
|
212
|
+
const glyphs = [];
|
|
213
|
+
for (const token of String(timeText)) {
|
|
214
|
+
const preferred = getRenderableGlyph(fontName, token);
|
|
215
|
+
if (preferred) {
|
|
216
|
+
glyphs.push(preferred);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (fontName !== DEFAULT_FONT) {
|
|
221
|
+
const synthesized = synthesizeGlyph(fontName, token);
|
|
222
|
+
if (synthesized) {
|
|
223
|
+
glyphs.push(synthesized);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const fallback = getRenderableGlyph(DEFAULT_FONT, token);
|
|
229
|
+
if (!fallback) {
|
|
230
|
+
return "";
|
|
231
|
+
}
|
|
232
|
+
glyphs.push(fallback);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const maxHeight = glyphs.reduce((max, glyph) => Math.max(max, glyph.lines.length), 0);
|
|
236
|
+
const outputLines = [];
|
|
237
|
+
for (let row = 0; row < maxHeight; row += 1) {
|
|
238
|
+
const parts = glyphs.map((glyph) => {
|
|
239
|
+
const padTop = maxHeight - glyph.lines.length;
|
|
240
|
+
const sourceIndex = row - padTop;
|
|
241
|
+
const line = sourceIndex >= 0 ? glyph.lines[sourceIndex] || "" : "";
|
|
242
|
+
return line.padEnd(glyph.width, " ");
|
|
243
|
+
});
|
|
244
|
+
outputLines.push(parts.join(" ").replace(/\s+$/g, ""));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return `${outputLines.join("\n")}\n`;
|
|
248
|
+
}
|
|
249
|
+
|
|
107
250
|
function isTimerCompatibleFont(fontName) {
|
|
108
|
-
|
|
251
|
+
const rendered = renderWithFont(TIMER_SAMPLE_TEXT, fontName);
|
|
252
|
+
return hasVisibleGlyphs(rendered) && !isPlainTimerRender(rendered, TIMER_SAMPLE_TEXT);
|
|
109
253
|
}
|
|
110
254
|
|
|
111
255
|
function getTimerCompatibleFontsSlow() {
|
|
@@ -240,7 +384,7 @@ function normalizeConfig(raw) {
|
|
|
240
384
|
next.keybindings = normalizeKeybindings(raw.keybindings);
|
|
241
385
|
if (typeof raw.font === "string") {
|
|
242
386
|
const normalizedFont = normalizeFontName(raw.font);
|
|
243
|
-
if (normalizedFont
|
|
387
|
+
if (normalizedFont) {
|
|
244
388
|
next.font = normalizedFont;
|
|
245
389
|
}
|
|
246
390
|
}
|
|
@@ -284,9 +428,6 @@ function setFontInConfig(requestedFont) {
|
|
|
284
428
|
if (!normalized) {
|
|
285
429
|
return { ok: false, reason: "unknown", font: null };
|
|
286
430
|
}
|
|
287
|
-
if (!isTimerCompatibleFont(normalized)) {
|
|
288
|
-
return { ok: false, reason: "incompatible", font: null };
|
|
289
|
-
}
|
|
290
431
|
const updated = updateConfig({ font: normalized });
|
|
291
432
|
return { ok: true, reason: null, font: updated.font };
|
|
292
433
|
}
|
|
@@ -346,15 +487,25 @@ function parseDurationArgs(args) {
|
|
|
346
487
|
|
|
347
488
|
function renderTimeAscii(timeText, fontName) {
|
|
348
489
|
const preferred = renderWithFont(timeText, fontName);
|
|
349
|
-
if (hasVisibleGlyphs(preferred)) {
|
|
490
|
+
if (hasVisibleGlyphs(preferred) && !isPlainTimerRender(preferred, timeText)) {
|
|
350
491
|
return preferred;
|
|
351
492
|
}
|
|
352
493
|
|
|
494
|
+
const preferredByGlyph = renderTimeByGlyphs(timeText, fontName);
|
|
495
|
+
if (hasVisibleGlyphs(preferredByGlyph) && !isPlainTimerRender(preferredByGlyph, timeText)) {
|
|
496
|
+
return preferredByGlyph;
|
|
497
|
+
}
|
|
498
|
+
|
|
353
499
|
const fallback = renderWithFont(timeText, DEFAULT_FONT);
|
|
354
|
-
if (hasVisibleGlyphs(fallback)) {
|
|
500
|
+
if (hasVisibleGlyphs(fallback) && !isPlainTimerRender(fallback, timeText)) {
|
|
355
501
|
return fallback;
|
|
356
502
|
}
|
|
357
503
|
|
|
504
|
+
const fallbackByGlyph = renderTimeByGlyphs(timeText, DEFAULT_FONT);
|
|
505
|
+
if (hasVisibleGlyphs(fallbackByGlyph)) {
|
|
506
|
+
return fallbackByGlyph;
|
|
507
|
+
}
|
|
508
|
+
|
|
358
509
|
return `${timeText}\n`;
|
|
359
510
|
}
|
|
360
511
|
|
|
@@ -704,7 +855,7 @@ function playCompletionAlarm(config) {
|
|
|
704
855
|
return;
|
|
705
856
|
}
|
|
706
857
|
try {
|
|
707
|
-
process.stderr.write("\x07
|
|
858
|
+
process.stderr.write("\x07".repeat(5));
|
|
708
859
|
} catch (_error) {
|
|
709
860
|
}
|
|
710
861
|
}
|
|
@@ -1096,13 +1247,8 @@ function runTimer(args) {
|
|
|
1096
1247
|
const requestedFont = args.slice(1).join(" ");
|
|
1097
1248
|
const result = setFontInConfig(requestedFont);
|
|
1098
1249
|
if (!result.ok) {
|
|
1099
|
-
|
|
1100
|
-
process.stderr.write(`Font is incompatible with timer digits: ${requestedFont}\n`);
|
|
1101
|
-
} else {
|
|
1102
|
-
process.stderr.write(`Unknown font: ${requestedFont}\n`);
|
|
1103
|
-
}
|
|
1250
|
+
process.stderr.write(`Unknown font: ${requestedFont}\n`);
|
|
1104
1251
|
process.stderr.write("Run `timer style` to list fonts.\n");
|
|
1105
|
-
process.stderr.write("Run `timer style --compatible` to list only compatible fonts.\n");
|
|
1106
1252
|
process.exitCode = 1;
|
|
1107
1253
|
return;
|
|
1108
1254
|
}
|