@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.
Files changed (3) hide show
  1. package/README.md +1 -0
  2. package/package.json +1 -1
  3. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timmy6942025/cli-timer",
3
- "version": "1.1.8",
3
+ "version": "1.1.10",
4
4
  "description": "Simple customizable terminal timer and stopwatch",
5
5
  "main": "src/index.js",
6
6
  "bin": {
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
- return hasVisibleGlyphs(renderWithFont(TIMER_SAMPLE_TEXT, fontName));
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 && isTimerCompatibleFont(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\x07\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
- if (result.reason === "incompatible") {
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
  }