@flyingrobots/bijou-tui 3.0.0 → 3.1.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/dist/app-frame-actions.d.ts +1 -5
- package/dist/app-frame-actions.d.ts.map +1 -1
- package/dist/app-frame-actions.js +19 -15
- package/dist/app-frame-actions.js.map +1 -1
- package/dist/app-frame-render.d.ts.map +1 -1
- package/dist/app-frame-render.js +6 -1
- package/dist/app-frame-render.js.map +1 -1
- package/dist/app-frame-types.d.ts +9 -0
- package/dist/app-frame-types.d.ts.map +1 -1
- package/dist/app-frame-types.js.map +1 -1
- package/dist/app-frame.d.ts +36 -1
- package/dist/app-frame.d.ts.map +1 -1
- package/dist/app-frame.js +136 -20
- package/dist/app-frame.js.map +1 -1
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +11 -3
- package/dist/commands.js.map +1 -1
- package/dist/driver.d.ts +4 -5
- package/dist/driver.d.ts.map +1 -1
- package/dist/driver.js +15 -17
- package/dist/driver.js.map +1 -1
- package/dist/eventbus.d.ts +5 -0
- package/dist/eventbus.d.ts.map +1 -1
- package/dist/eventbus.js +44 -7
- package/dist/eventbus.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/notification.d.ts +73 -0
- package/dist/notification.d.ts.map +1 -0
- package/dist/notification.js +693 -0
- package/dist/notification.js.map +1 -0
- package/dist/overlay.d.ts +3 -1
- package/dist/overlay.d.ts.map +1 -1
- package/dist/overlay.js.map +1 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +76 -59
- package/dist/runtime.js.map +1 -1
- package/dist/types.d.ts +30 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
import { createSurface, segmentGraphemes, surfaceToString, } from '@flyingrobots/bijou';
|
|
2
|
+
import { visibleLength } from './viewport.js';
|
|
3
|
+
const ENTER_DURATION_MS = 180;
|
|
4
|
+
const EXIT_DURATION_MS = 320;
|
|
5
|
+
const DEFAULT_MARGIN = 1;
|
|
6
|
+
const DEFAULT_GAP = 1;
|
|
7
|
+
const HISTORY_LIMIT = 250;
|
|
8
|
+
const TONE_ICONS = {
|
|
9
|
+
INFO: '\u2139',
|
|
10
|
+
SUCCESS: '\u2714',
|
|
11
|
+
WARNING: '\u26a0',
|
|
12
|
+
ERROR: '\u2718',
|
|
13
|
+
};
|
|
14
|
+
const TONE_BORDER_KEYS = {
|
|
15
|
+
INFO: 'primary',
|
|
16
|
+
SUCCESS: 'success',
|
|
17
|
+
WARNING: 'warning',
|
|
18
|
+
ERROR: 'error',
|
|
19
|
+
};
|
|
20
|
+
export function createNotificationState() {
|
|
21
|
+
return {
|
|
22
|
+
items: [],
|
|
23
|
+
overflowExits: [],
|
|
24
|
+
history: [],
|
|
25
|
+
nextId: 1,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function defaultDurationMs(variant) {
|
|
29
|
+
switch (variant) {
|
|
30
|
+
case 'ACTIONABLE':
|
|
31
|
+
return null;
|
|
32
|
+
case 'INLINE':
|
|
33
|
+
return 5_000;
|
|
34
|
+
case 'TOAST':
|
|
35
|
+
return 4_000;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function focusableIds(items) {
|
|
39
|
+
return items
|
|
40
|
+
.filter((item) => item.action != null)
|
|
41
|
+
.map((item) => item.id);
|
|
42
|
+
}
|
|
43
|
+
function normalizeFocusedId(items, focusedId) {
|
|
44
|
+
const focusable = focusableIds(items);
|
|
45
|
+
if (focusable.length === 0)
|
|
46
|
+
return undefined;
|
|
47
|
+
if (focusedId != null && focusable.includes(focusedId))
|
|
48
|
+
return focusedId;
|
|
49
|
+
return focusable[focusable.length - 1];
|
|
50
|
+
}
|
|
51
|
+
function archiveNotifications(history, items) {
|
|
52
|
+
if (items.length === 0)
|
|
53
|
+
return history;
|
|
54
|
+
const archived = [...items].sort((left, right) => right.updatedAtMs - left.updatedAtMs || right.id - left.id);
|
|
55
|
+
return [...archived, ...history].slice(0, HISTORY_LIMIT);
|
|
56
|
+
}
|
|
57
|
+
function advanceExitRecord(item, nowMs) {
|
|
58
|
+
const deltaMs = Math.max(0, nowMs - item.updatedAtMs);
|
|
59
|
+
const progress = Math.max(0, item.progress - (deltaMs / EXIT_DURATION_MS));
|
|
60
|
+
if (progress > 0) {
|
|
61
|
+
return {
|
|
62
|
+
active: {
|
|
63
|
+
...item,
|
|
64
|
+
progress,
|
|
65
|
+
updatedAtMs: nowMs,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
archived: {
|
|
71
|
+
...item,
|
|
72
|
+
progress: 0,
|
|
73
|
+
updatedAtMs: nowMs,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export function pushNotification(state, spec, nowMs) {
|
|
78
|
+
const variant = spec.variant ?? 'TOAST';
|
|
79
|
+
const next = {
|
|
80
|
+
id: state.nextId,
|
|
81
|
+
title: spec.title,
|
|
82
|
+
message: spec.message ?? '',
|
|
83
|
+
variant,
|
|
84
|
+
tone: spec.tone ?? 'INFO',
|
|
85
|
+
durationMs: spec.durationMs === undefined ? defaultDurationMs(variant) : spec.durationMs,
|
|
86
|
+
placement: spec.placement ?? 'LOWER_RIGHT',
|
|
87
|
+
action: spec.action,
|
|
88
|
+
bgToken: spec.bgToken,
|
|
89
|
+
accentToken: spec.accentToken,
|
|
90
|
+
overflow: spec.overflow ?? 'wrap',
|
|
91
|
+
createdAtMs: nowMs,
|
|
92
|
+
updatedAtMs: nowMs,
|
|
93
|
+
phase: 'entering',
|
|
94
|
+
progress: 0,
|
|
95
|
+
};
|
|
96
|
+
const items = [...state.items, next];
|
|
97
|
+
return {
|
|
98
|
+
...state,
|
|
99
|
+
items,
|
|
100
|
+
nextId: state.nextId + 1,
|
|
101
|
+
focusedId: normalizeFocusedId(items, next.action != null ? next.id : state.focusedId),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
export function dismissNotification(state, id, nowMs) {
|
|
105
|
+
const items = state.items.map((item) => {
|
|
106
|
+
if (item.id !== id || item.phase === 'exiting')
|
|
107
|
+
return item;
|
|
108
|
+
return {
|
|
109
|
+
...item,
|
|
110
|
+
phase: 'exiting',
|
|
111
|
+
updatedAtMs: nowMs,
|
|
112
|
+
exitStartedAtMs: nowMs,
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
return {
|
|
116
|
+
...state,
|
|
117
|
+
items,
|
|
118
|
+
focusedId: normalizeFocusedId(items, state.focusedId),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
export function dismissFocusedNotification(state, nowMs) {
|
|
122
|
+
if (state.focusedId == null)
|
|
123
|
+
return state;
|
|
124
|
+
return dismissNotification(state, state.focusedId, nowMs);
|
|
125
|
+
}
|
|
126
|
+
export function relocateNotifications(state, placement, nowMs) {
|
|
127
|
+
if (state.items.every((item) => item.placement === placement))
|
|
128
|
+
return state;
|
|
129
|
+
return {
|
|
130
|
+
...state,
|
|
131
|
+
items: state.items.map((item) => {
|
|
132
|
+
if (nowMs == null || item.phase === 'exiting') {
|
|
133
|
+
return { ...item, placement };
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
...item,
|
|
137
|
+
placement,
|
|
138
|
+
phase: 'entering',
|
|
139
|
+
progress: 0,
|
|
140
|
+
updatedAtMs: nowMs,
|
|
141
|
+
enteredAtMs: undefined,
|
|
142
|
+
exitStartedAtMs: undefined,
|
|
143
|
+
};
|
|
144
|
+
}),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
export function cycleNotificationFocus(state, delta) {
|
|
148
|
+
const focusable = focusableIds(state.items);
|
|
149
|
+
if (focusable.length === 0)
|
|
150
|
+
return state;
|
|
151
|
+
const index = state.focusedId == null ? -1 : focusable.indexOf(state.focusedId);
|
|
152
|
+
const nextIndex = index < 0
|
|
153
|
+
? (delta >= 0 ? 0 : focusable.length - 1)
|
|
154
|
+
: (index + delta + focusable.length) % focusable.length;
|
|
155
|
+
return {
|
|
156
|
+
...state,
|
|
157
|
+
focusedId: focusable[nextIndex],
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
export function activateFocusedNotification(state, nowMs) {
|
|
161
|
+
if (state.focusedId == null)
|
|
162
|
+
return { state };
|
|
163
|
+
const target = state.items.find((item) => item.id === state.focusedId);
|
|
164
|
+
if (target?.action == null)
|
|
165
|
+
return { state };
|
|
166
|
+
return {
|
|
167
|
+
state: dismissNotification(state, target.id, nowMs),
|
|
168
|
+
payload: target.action.payload,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
export function tickNotifications(state, nowMs) {
|
|
172
|
+
const nextItems = [];
|
|
173
|
+
const archived = [];
|
|
174
|
+
const nextOverflowExits = [];
|
|
175
|
+
const archivedOverflowExits = [];
|
|
176
|
+
for (const item of state.items) {
|
|
177
|
+
const deltaMs = Math.max(0, nowMs - item.updatedAtMs);
|
|
178
|
+
if (item.phase === 'entering') {
|
|
179
|
+
const progress = Math.min(1, item.progress + (deltaMs / ENTER_DURATION_MS));
|
|
180
|
+
nextItems.push(progress >= 1
|
|
181
|
+
? {
|
|
182
|
+
...item,
|
|
183
|
+
phase: 'visible',
|
|
184
|
+
progress: 1,
|
|
185
|
+
enteredAtMs: nowMs,
|
|
186
|
+
updatedAtMs: nowMs,
|
|
187
|
+
}
|
|
188
|
+
: {
|
|
189
|
+
...item,
|
|
190
|
+
progress,
|
|
191
|
+
updatedAtMs: nowMs,
|
|
192
|
+
});
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (item.phase === 'visible') {
|
|
196
|
+
const visibleSince = item.enteredAtMs ?? item.createdAtMs;
|
|
197
|
+
if (item.durationMs != null && nowMs - visibleSince >= item.durationMs) {
|
|
198
|
+
nextItems.push({
|
|
199
|
+
...item,
|
|
200
|
+
phase: 'exiting',
|
|
201
|
+
exitStartedAtMs: nowMs,
|
|
202
|
+
updatedAtMs: nowMs,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
nextItems.push({
|
|
207
|
+
...item,
|
|
208
|
+
updatedAtMs: nowMs,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const result = advanceExitRecord(item, nowMs);
|
|
214
|
+
if (result.active != null) {
|
|
215
|
+
nextItems.push(result.active);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (result.archived != null) {
|
|
219
|
+
archived.push(result.archived);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
for (const item of state.overflowExits) {
|
|
223
|
+
const result = advanceExitRecord(item, nowMs);
|
|
224
|
+
if (result.active != null) {
|
|
225
|
+
nextOverflowExits.push(result.active);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (result.archived != null) {
|
|
229
|
+
archivedOverflowExits.push(result.archived);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
...state,
|
|
234
|
+
items: nextItems,
|
|
235
|
+
overflowExits: nextOverflowExits,
|
|
236
|
+
history: archiveNotifications(state.history, [...archived, ...archivedOverflowExits]),
|
|
237
|
+
focusedId: normalizeFocusedId(nextItems, state.focusedId),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
export function hasNotifications(state) {
|
|
241
|
+
return state.items.length > 0;
|
|
242
|
+
}
|
|
243
|
+
export function notificationsNeedTick(state) {
|
|
244
|
+
return state.overflowExits.length > 0
|
|
245
|
+
|| state.items.some((item) => item.phase !== 'visible' || item.durationMs != null);
|
|
246
|
+
}
|
|
247
|
+
function toneSemanticKey(tone) {
|
|
248
|
+
switch (tone) {
|
|
249
|
+
case 'INFO':
|
|
250
|
+
return 'info';
|
|
251
|
+
case 'SUCCESS':
|
|
252
|
+
return 'success';
|
|
253
|
+
case 'WARNING':
|
|
254
|
+
return 'warning';
|
|
255
|
+
case 'ERROR':
|
|
256
|
+
return 'error';
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function defaultBgToken(ctx) {
|
|
260
|
+
return ctx?.theme.theme.surface.overlay;
|
|
261
|
+
}
|
|
262
|
+
function formatTimeLabel(ms) {
|
|
263
|
+
return new Date(ms).toLocaleTimeString('en-US', {
|
|
264
|
+
hour: '2-digit',
|
|
265
|
+
minute: '2-digit',
|
|
266
|
+
second: '2-digit',
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
function tokenToCellStyle(token) {
|
|
270
|
+
if (token == null)
|
|
271
|
+
return {};
|
|
272
|
+
return {
|
|
273
|
+
fg: token.hex,
|
|
274
|
+
bg: token.bg,
|
|
275
|
+
modifiers: token.modifiers,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
function withModifiers(style, modifiers) {
|
|
279
|
+
const next = new Set(style.modifiers ?? []);
|
|
280
|
+
for (const modifier of modifiers) {
|
|
281
|
+
next.add(modifier);
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
...style,
|
|
285
|
+
modifiers: next.size === 0 ? undefined : Array.from(next),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
function createSegmentSurface(segments) {
|
|
289
|
+
const graphemeSegments = segments.map((segment) => ({
|
|
290
|
+
graphemes: segmentGraphemes(segment.text ?? ''),
|
|
291
|
+
style: segment.style,
|
|
292
|
+
}));
|
|
293
|
+
const width = graphemeSegments.reduce((sum, segment) => sum + segment.graphemes.length, 0);
|
|
294
|
+
const surface = createSurface(width, 1);
|
|
295
|
+
let x = 0;
|
|
296
|
+
for (const segment of graphemeSegments) {
|
|
297
|
+
for (const char of segment.graphemes) {
|
|
298
|
+
surface.set(x, 0, {
|
|
299
|
+
char,
|
|
300
|
+
fg: segment.style?.fg,
|
|
301
|
+
bg: segment.style?.bg,
|
|
302
|
+
modifiers: segment.style?.modifiers ? [...segment.style.modifiers] : undefined,
|
|
303
|
+
empty: false,
|
|
304
|
+
});
|
|
305
|
+
x++;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return surface;
|
|
309
|
+
}
|
|
310
|
+
function createBlankLineSurface(width) {
|
|
311
|
+
return createSurface(Math.max(0, width), 1);
|
|
312
|
+
}
|
|
313
|
+
function fitLineSurface(surface, width) {
|
|
314
|
+
const safeWidth = Math.max(0, width);
|
|
315
|
+
const line = createSurface(safeWidth, 1);
|
|
316
|
+
if (safeWidth > 0) {
|
|
317
|
+
line.blit(surface, 0, 0, 0, 0, safeWidth, 1);
|
|
318
|
+
}
|
|
319
|
+
return line;
|
|
320
|
+
}
|
|
321
|
+
function wrapLineSurface(surface, width) {
|
|
322
|
+
const safeWidth = Math.max(1, width);
|
|
323
|
+
if (surface.width === 0)
|
|
324
|
+
return [createBlankLineSurface(safeWidth)];
|
|
325
|
+
const rows = [];
|
|
326
|
+
for (let offset = 0; offset < surface.width; offset += safeWidth) {
|
|
327
|
+
const row = createSurface(safeWidth, 1);
|
|
328
|
+
row.blit(surface, 0, 0, offset, 0, safeWidth, 1);
|
|
329
|
+
rows.push(row);
|
|
330
|
+
}
|
|
331
|
+
return rows;
|
|
332
|
+
}
|
|
333
|
+
function renderPlainSurface(surface) {
|
|
334
|
+
const lines = [];
|
|
335
|
+
for (let y = 0; y < surface.height; y++) {
|
|
336
|
+
let line = '';
|
|
337
|
+
for (let x = 0; x < surface.width; x++) {
|
|
338
|
+
line += surface.get(x, y).char;
|
|
339
|
+
}
|
|
340
|
+
lines.push(line);
|
|
341
|
+
}
|
|
342
|
+
return lines.join('\n');
|
|
343
|
+
}
|
|
344
|
+
function standaloneRows(lineSurface, width, overflow) {
|
|
345
|
+
if (overflow === 'truncate')
|
|
346
|
+
return [fitLineSurface(lineSurface, width)];
|
|
347
|
+
return wrapLineSurface(lineSurface, width);
|
|
348
|
+
}
|
|
349
|
+
function composeColumnRows(left, right, width, overflow) {
|
|
350
|
+
const safeWidth = Math.max(1, width);
|
|
351
|
+
const rightWidth = Math.min(right.width, safeWidth);
|
|
352
|
+
if (overflow === 'truncate') {
|
|
353
|
+
const row = createSurface(safeWidth, 1);
|
|
354
|
+
const leftWidth = Math.max(0, safeWidth - rightWidth);
|
|
355
|
+
if (leftWidth > 0) {
|
|
356
|
+
row.blit(left, 0, 0, 0, 0, leftWidth, 1);
|
|
357
|
+
}
|
|
358
|
+
if (rightWidth > 0) {
|
|
359
|
+
row.blit(right, safeWidth - rightWidth, 0, Math.max(0, right.width - rightWidth), 0, rightWidth, 1);
|
|
360
|
+
}
|
|
361
|
+
return [row];
|
|
362
|
+
}
|
|
363
|
+
const gap = rightWidth > 0 ? 1 : 0;
|
|
364
|
+
const leftWidth = Math.max(1, safeWidth - rightWidth - gap);
|
|
365
|
+
const wrappedLeft = wrapLineSurface(left, leftWidth);
|
|
366
|
+
return wrappedLeft.map((rowSurface, index) => {
|
|
367
|
+
const row = createSurface(safeWidth, 1);
|
|
368
|
+
row.blit(rowSurface, 0, 0);
|
|
369
|
+
if (index === 0 && rightWidth > 0) {
|
|
370
|
+
row.blit(right, safeWidth - rightWidth, 0, Math.max(0, right.width - rightWidth), 0, rightWidth, 1);
|
|
371
|
+
}
|
|
372
|
+
return row;
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
function resolveRegion(options) {
|
|
376
|
+
const screenWidth = Math.max(0, options.screenWidth);
|
|
377
|
+
const screenHeight = Math.max(0, options.screenHeight);
|
|
378
|
+
if (options.region == null) {
|
|
379
|
+
return { row: 0, col: 0, width: screenWidth, height: screenHeight };
|
|
380
|
+
}
|
|
381
|
+
return {
|
|
382
|
+
row: Math.max(0, options.region.row),
|
|
383
|
+
col: Math.max(0, options.region.col),
|
|
384
|
+
width: Math.max(0, options.region.width),
|
|
385
|
+
height: Math.max(0, options.region.height),
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
function measureTextWidth(item, screenWidth) {
|
|
389
|
+
const available = Math.max(18, screenWidth - 7);
|
|
390
|
+
const titleWidth = visibleLength(item.title);
|
|
391
|
+
const messageWidth = visibleLength(item.message);
|
|
392
|
+
const buttonWidth = item.action == null ? 0 : visibleLength(item.action.label) + 6;
|
|
393
|
+
const base = Math.max(titleWidth + 8, messageWidth + 2, buttonWidth + 2);
|
|
394
|
+
if (item.variant === 'INLINE') {
|
|
395
|
+
const target = Math.max(base + 8, Math.floor(screenWidth * 0.66));
|
|
396
|
+
return Math.min(available, Math.max(28, target));
|
|
397
|
+
}
|
|
398
|
+
return Math.min(available, Math.max(26, Math.min(52, base + 6)));
|
|
399
|
+
}
|
|
400
|
+
function renderNotificationSurface(item, options, focused) {
|
|
401
|
+
const ctx = options.ctx;
|
|
402
|
+
const textWidth = measureTextWidth(item, resolveRegion(options).width);
|
|
403
|
+
const mutedStyle = tokenToCellStyle(ctx?.semantic('muted'));
|
|
404
|
+
const titleStyle = withModifiers({}, ['bold']);
|
|
405
|
+
const iconStyle = tokenToCellStyle(ctx?.semantic(toneSemanticKey(item.tone)));
|
|
406
|
+
const accentStyle = tokenToCellStyle(item.accentToken ?? ctx?.border(TONE_BORDER_KEYS[item.tone]));
|
|
407
|
+
const backgroundStyle = tokenToCellStyle(item.bgToken ?? defaultBgToken(ctx));
|
|
408
|
+
const closeSurface = createSegmentSurface([{ text: '\u2715', style: mutedStyle }]);
|
|
409
|
+
const icon = TONE_ICONS[item.tone];
|
|
410
|
+
const overflow = item.overflow;
|
|
411
|
+
const rows = [];
|
|
412
|
+
if (item.variant === 'INLINE') {
|
|
413
|
+
const left = createSegmentSurface([
|
|
414
|
+
{ text: icon, style: iconStyle },
|
|
415
|
+
{ text: ' ' },
|
|
416
|
+
{ text: item.title, style: titleStyle },
|
|
417
|
+
...(item.message.length > 0
|
|
418
|
+
? [
|
|
419
|
+
{ text: ' ' },
|
|
420
|
+
{ text: item.message, style: mutedStyle },
|
|
421
|
+
]
|
|
422
|
+
: []),
|
|
423
|
+
]);
|
|
424
|
+
rows.push(...composeColumnRows(left, closeSurface, textWidth, overflow));
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
const titleLeft = createSegmentSurface([
|
|
428
|
+
{ text: icon, style: iconStyle },
|
|
429
|
+
{ text: ' ' },
|
|
430
|
+
{ text: item.title, style: titleStyle },
|
|
431
|
+
]);
|
|
432
|
+
rows.push(...composeColumnRows(titleLeft, closeSurface, textWidth, overflow));
|
|
433
|
+
if (item.message.length > 0) {
|
|
434
|
+
const messageSurface = createSegmentSurface([{ text: item.message, style: mutedStyle }]);
|
|
435
|
+
rows.push(...standaloneRows(messageSurface, textWidth, overflow));
|
|
436
|
+
}
|
|
437
|
+
if (item.variant === 'ACTIONABLE') {
|
|
438
|
+
rows.push(createBlankLineSurface(textWidth));
|
|
439
|
+
const actionLabel = item.action == null
|
|
440
|
+
? 'Dismiss'
|
|
441
|
+
: (focused ? `[ ${item.action.label} ]` : ` ${item.action.label} `);
|
|
442
|
+
const actionStyle = focused ? withModifiers({}, ['bold']) : {};
|
|
443
|
+
rows.push(...standaloneRows(createSegmentSurface([{ text: actionLabel, style: actionStyle }]), textWidth, overflow));
|
|
444
|
+
}
|
|
445
|
+
if (item.variant === 'TOAST') {
|
|
446
|
+
rows.push(createBlankLineSurface(textWidth));
|
|
447
|
+
const timestampSurface = createSegmentSurface([{ text: formatTimeLabel(item.createdAtMs), style: mutedStyle }]);
|
|
448
|
+
rows.push(...standaloneRows(timestampSurface, textWidth, overflow));
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
const contentRows = rows.length === 0 ? [createBlankLineSurface(textWidth)] : rows;
|
|
452
|
+
const cardWidth = textWidth + 3;
|
|
453
|
+
const cardHeight = contentRows.length;
|
|
454
|
+
const card = createSurface(cardWidth, cardHeight, {
|
|
455
|
+
char: ' ',
|
|
456
|
+
fg: backgroundStyle.fg,
|
|
457
|
+
bg: backgroundStyle.bg,
|
|
458
|
+
modifiers: backgroundStyle.modifiers ? [...backgroundStyle.modifiers] : undefined,
|
|
459
|
+
empty: false,
|
|
460
|
+
});
|
|
461
|
+
for (let y = 0; y < contentRows.length; y++) {
|
|
462
|
+
card.set(0, y, {
|
|
463
|
+
char: '\u258e',
|
|
464
|
+
fg: accentStyle.fg,
|
|
465
|
+
bg: backgroundStyle.bg,
|
|
466
|
+
modifiers: accentStyle.modifiers ? [...accentStyle.modifiers] : undefined,
|
|
467
|
+
empty: false,
|
|
468
|
+
});
|
|
469
|
+
card.blit(contentRows[y], 2, y, 0, 0, contentRows[y].width, 1, {
|
|
470
|
+
char: true,
|
|
471
|
+
fg: true,
|
|
472
|
+
bg: false,
|
|
473
|
+
modifiers: true,
|
|
474
|
+
alpha: true,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
return card;
|
|
478
|
+
}
|
|
479
|
+
function sortForPlacement(items, placement) {
|
|
480
|
+
const ordered = [...items].sort((left, right) => right.createdAtMs - left.createdAtMs || right.id - left.id);
|
|
481
|
+
return placementSortSign(placement) === 'bottom' ? ordered.reverse() : ordered;
|
|
482
|
+
}
|
|
483
|
+
function placementSortSign(placement) {
|
|
484
|
+
switch (placement) {
|
|
485
|
+
case 'UPPER_LEFT':
|
|
486
|
+
case 'UPPER_RIGHT':
|
|
487
|
+
case 'TOP_CENTER':
|
|
488
|
+
return 'top';
|
|
489
|
+
case 'LOWER_LEFT':
|
|
490
|
+
case 'LOWER_RIGHT':
|
|
491
|
+
case 'BOTTOM_CENTER':
|
|
492
|
+
return 'bottom';
|
|
493
|
+
case 'CENTER':
|
|
494
|
+
return 'center';
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
function anchoredCol(placement, width, screenWidth, margin) {
|
|
498
|
+
switch (placement) {
|
|
499
|
+
case 'UPPER_LEFT':
|
|
500
|
+
case 'LOWER_LEFT':
|
|
501
|
+
return margin;
|
|
502
|
+
case 'UPPER_RIGHT':
|
|
503
|
+
case 'LOWER_RIGHT':
|
|
504
|
+
return Math.max(margin, screenWidth - width - margin);
|
|
505
|
+
case 'TOP_CENTER':
|
|
506
|
+
case 'BOTTOM_CENTER':
|
|
507
|
+
case 'CENTER':
|
|
508
|
+
return Math.max(0, Math.floor((screenWidth - width) / 2));
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
function applyAnimationOffset(placement, width, height, margin, progress) {
|
|
512
|
+
const remaining = 1 - progress;
|
|
513
|
+
const slideX = Math.round(remaining * (width + margin));
|
|
514
|
+
const slideY = Math.round(remaining * (height + margin));
|
|
515
|
+
switch (placement) {
|
|
516
|
+
case 'UPPER_LEFT':
|
|
517
|
+
case 'LOWER_LEFT':
|
|
518
|
+
return { rowDelta: 0, colDelta: -slideX };
|
|
519
|
+
case 'UPPER_RIGHT':
|
|
520
|
+
case 'LOWER_RIGHT':
|
|
521
|
+
return { rowDelta: 0, colDelta: slideX };
|
|
522
|
+
case 'TOP_CENTER':
|
|
523
|
+
return { rowDelta: -slideY, colDelta: 0 };
|
|
524
|
+
case 'BOTTOM_CENTER':
|
|
525
|
+
return { rowDelta: slideY, colDelta: 0 };
|
|
526
|
+
case 'CENTER':
|
|
527
|
+
return { rowDelta: -slideY, colDelta: 0 };
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
function createRenderEntry(item, options, focusedId) {
|
|
531
|
+
return {
|
|
532
|
+
item,
|
|
533
|
+
surface: renderNotificationSurface(item, options, focusedId === item.id),
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
function selectVisibleNotificationIds(state, options) {
|
|
537
|
+
const region = resolveRegion(options);
|
|
538
|
+
const margin = options.margin ?? DEFAULT_MARGIN;
|
|
539
|
+
const gap = options.gap ?? DEFAULT_GAP;
|
|
540
|
+
const availableHeight = Math.max(1, region.height - (margin * 2));
|
|
541
|
+
const grouped = new Map();
|
|
542
|
+
for (const item of state.items) {
|
|
543
|
+
const placementItems = grouped.get(item.placement) ?? [];
|
|
544
|
+
placementItems.push(item);
|
|
545
|
+
grouped.set(item.placement, placementItems);
|
|
546
|
+
}
|
|
547
|
+
const visibleIds = new Set();
|
|
548
|
+
for (const items of grouped.values()) {
|
|
549
|
+
const newestFirst = [...items].sort((left, right) => right.createdAtMs - left.createdAtMs || right.id - left.id);
|
|
550
|
+
let usedHeight = 0;
|
|
551
|
+
let keptCount = 0;
|
|
552
|
+
for (const item of newestFirst) {
|
|
553
|
+
const entry = createRenderEntry(item, options, state.focusedId);
|
|
554
|
+
const required = entry.surface.height + (keptCount > 0 ? gap : 0);
|
|
555
|
+
if (keptCount === 0 || usedHeight + required <= availableHeight) {
|
|
556
|
+
visibleIds.add(item.id);
|
|
557
|
+
usedHeight += required;
|
|
558
|
+
keptCount++;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return visibleIds;
|
|
563
|
+
}
|
|
564
|
+
export function trimNotificationsToViewport(state, options, nowMs) {
|
|
565
|
+
const visibleIds = selectVisibleNotificationIds(state, options);
|
|
566
|
+
const keptItems = state.items.filter((item) => visibleIds.has(item.id));
|
|
567
|
+
if (keptItems.length === state.items.length) {
|
|
568
|
+
const focusedId = normalizeFocusedId(keptItems, state.focusedId);
|
|
569
|
+
return focusedId === state.focusedId ? state : { ...state, focusedId };
|
|
570
|
+
}
|
|
571
|
+
const evictedItems = state.items.filter((item) => !visibleIds.has(item.id));
|
|
572
|
+
const exitStartedAtMs = nowMs ?? evictedItems.reduce((max, item) => Math.max(max, item.updatedAtMs, item.createdAtMs), 0);
|
|
573
|
+
const overflowExits = [
|
|
574
|
+
...state.overflowExits,
|
|
575
|
+
...evictedItems.map((item) => ({
|
|
576
|
+
...item,
|
|
577
|
+
phase: 'exiting',
|
|
578
|
+
progress: 1,
|
|
579
|
+
updatedAtMs: exitStartedAtMs,
|
|
580
|
+
exitStartedAtMs,
|
|
581
|
+
})),
|
|
582
|
+
].sort((left, right) => right.updatedAtMs - left.updatedAtMs || right.id - left.id);
|
|
583
|
+
return {
|
|
584
|
+
...state,
|
|
585
|
+
items: keptItems,
|
|
586
|
+
overflowExits,
|
|
587
|
+
focusedId: normalizeFocusedId(keptItems, state.focusedId),
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
function renderOverflowExits(exits, placement, activeTotalHeight, region, margin, gap, options, focusedId) {
|
|
591
|
+
if (exits.length === 0)
|
|
592
|
+
return [];
|
|
593
|
+
const rendered = [...exits]
|
|
594
|
+
.sort((left, right) => right.updatedAtMs - left.updatedAtMs || right.id - left.id)
|
|
595
|
+
.map((item) => createRenderEntry(item, options, focusedId));
|
|
596
|
+
const overlays = [];
|
|
597
|
+
const mode = placementSortSign(placement);
|
|
598
|
+
if (mode === 'bottom') {
|
|
599
|
+
let cursor = Math.max(margin, region.height - activeTotalHeight - margin) - gap;
|
|
600
|
+
for (const entry of rendered) {
|
|
601
|
+
cursor -= entry.surface.height;
|
|
602
|
+
const baseCol = anchoredCol(placement, entry.surface.width, region.width, margin);
|
|
603
|
+
const offset = applyAnimationOffset(placement, entry.surface.width, entry.surface.height, margin, entry.item.progress);
|
|
604
|
+
overlays.push({
|
|
605
|
+
row: region.row + cursor + offset.rowDelta,
|
|
606
|
+
col: region.col + baseCol + offset.colDelta,
|
|
607
|
+
surface: entry.surface,
|
|
608
|
+
content: options.ctx != null
|
|
609
|
+
? surfaceToString(entry.surface, options.ctx.style)
|
|
610
|
+
: renderPlainSurface(entry.surface),
|
|
611
|
+
});
|
|
612
|
+
cursor -= gap;
|
|
613
|
+
}
|
|
614
|
+
return overlays;
|
|
615
|
+
}
|
|
616
|
+
let cursor = mode === 'top'
|
|
617
|
+
? margin + activeTotalHeight + (activeTotalHeight > 0 ? gap : 0)
|
|
618
|
+
: Math.max(0, Math.floor((region.height + activeTotalHeight) / 2) + gap);
|
|
619
|
+
for (const entry of rendered) {
|
|
620
|
+
const baseCol = anchoredCol(placement, entry.surface.width, region.width, margin);
|
|
621
|
+
const offset = applyAnimationOffset(placement, entry.surface.width, entry.surface.height, margin, entry.item.progress);
|
|
622
|
+
overlays.push({
|
|
623
|
+
row: region.row + cursor + offset.rowDelta,
|
|
624
|
+
col: region.col + baseCol + offset.colDelta,
|
|
625
|
+
surface: entry.surface,
|
|
626
|
+
content: options.ctx != null
|
|
627
|
+
? surfaceToString(entry.surface, options.ctx.style)
|
|
628
|
+
: renderPlainSurface(entry.surface),
|
|
629
|
+
});
|
|
630
|
+
cursor += entry.surface.height + gap;
|
|
631
|
+
}
|
|
632
|
+
return overlays;
|
|
633
|
+
}
|
|
634
|
+
export function renderNotificationStack(state, options) {
|
|
635
|
+
const screenWidth = Math.max(0, options.screenWidth);
|
|
636
|
+
const screenHeight = Math.max(0, options.screenHeight);
|
|
637
|
+
if (screenWidth <= 0 || screenHeight <= 0)
|
|
638
|
+
return [];
|
|
639
|
+
const region = resolveRegion(options);
|
|
640
|
+
if (region.width <= 0 || region.height <= 0)
|
|
641
|
+
return [];
|
|
642
|
+
const margin = options.margin ?? DEFAULT_MARGIN;
|
|
643
|
+
const gap = options.gap ?? DEFAULT_GAP;
|
|
644
|
+
const visibleIds = selectVisibleNotificationIds(state, options);
|
|
645
|
+
const grouped = new Map();
|
|
646
|
+
const overflowGrouped = new Map();
|
|
647
|
+
for (const item of state.items) {
|
|
648
|
+
if (!visibleIds.has(item.id))
|
|
649
|
+
continue;
|
|
650
|
+
const placementItems = grouped.get(item.placement) ?? [];
|
|
651
|
+
placementItems.push(item);
|
|
652
|
+
grouped.set(item.placement, placementItems);
|
|
653
|
+
}
|
|
654
|
+
for (const item of state.overflowExits) {
|
|
655
|
+
const placementItems = overflowGrouped.get(item.placement) ?? [];
|
|
656
|
+
placementItems.push(item);
|
|
657
|
+
overflowGrouped.set(item.placement, placementItems);
|
|
658
|
+
}
|
|
659
|
+
const overlays = [];
|
|
660
|
+
const placements = new Set([
|
|
661
|
+
...grouped.keys(),
|
|
662
|
+
...overflowGrouped.keys(),
|
|
663
|
+
]);
|
|
664
|
+
for (const placement of placements) {
|
|
665
|
+
const items = grouped.get(placement) ?? [];
|
|
666
|
+
const rendered = sortForPlacement(items, placement).map((item) => createRenderEntry(item, options, state.focusedId));
|
|
667
|
+
const totalHeight = rendered.reduce((sum, entry) => sum + entry.surface.height, 0)
|
|
668
|
+
+ Math.max(0, rendered.length - 1) * gap;
|
|
669
|
+
const mode = placementSortSign(placement);
|
|
670
|
+
let cursor = mode === 'top'
|
|
671
|
+
? margin
|
|
672
|
+
: (mode === 'bottom'
|
|
673
|
+
? Math.max(margin, region.height - totalHeight - margin)
|
|
674
|
+
: Math.max(0, Math.floor((region.height - totalHeight) / 2)));
|
|
675
|
+
for (const entry of rendered) {
|
|
676
|
+
const baseRow = cursor;
|
|
677
|
+
const baseCol = anchoredCol(placement, entry.surface.width, region.width, margin);
|
|
678
|
+
const offset = applyAnimationOffset(placement, entry.surface.width, entry.surface.height, margin, entry.item.progress);
|
|
679
|
+
overlays.push({
|
|
680
|
+
row: region.row + baseRow + offset.rowDelta,
|
|
681
|
+
col: region.col + baseCol + offset.colDelta,
|
|
682
|
+
surface: entry.surface,
|
|
683
|
+
content: options.ctx != null
|
|
684
|
+
? surfaceToString(entry.surface, options.ctx.style)
|
|
685
|
+
: renderPlainSurface(entry.surface),
|
|
686
|
+
});
|
|
687
|
+
cursor += entry.surface.height + gap;
|
|
688
|
+
}
|
|
689
|
+
overlays.push(...renderOverflowExits(overflowGrouped.get(placement) ?? [], placement, totalHeight, region, margin, gap, options, state.focusedId));
|
|
690
|
+
}
|
|
691
|
+
return overlays;
|
|
692
|
+
}
|
|
693
|
+
//# sourceMappingURL=notification.js.map
|