-
Notifications
You must be signed in to change notification settings - Fork 199
feat: add slide-in/fade-out animation for Toast notifications #1740
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,74 +1,98 @@ | ||||||||||||||||||||||||||||||||||||||||
| // Toast — auto-dismiss notification | ||||||||||||||||||||||||||||||||||||||||
| // Toast - auto-dismiss notification | ||||||||||||||||||||||||||||||||||||||||
| import { Widget } from '@termuijs/widgets'; | ||||||||||||||||||||||||||||||||||||||||
| import { type Screen, mergeStyles, defaultStyle, styleToCellAttrs, caps } from '@termuijs/core'; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| export type ToastType = 'info' | 'success' | 'warning' | 'error'; | ||||||||||||||||||||||||||||||||||||||||
| export interface ToastMessage { text: string; type: ToastType; expireAt: number; } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| export interface ToastMessage { | ||||||||||||||||||||||||||||||||||||||||
| text: string; | ||||||||||||||||||||||||||||||||||||||||
| type: ToastType; | ||||||||||||||||||||||||||||||||||||||||
| expireAt: number; | ||||||||||||||||||||||||||||||||||||||||
| createdAt: number; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| export interface ToastOptions { | ||||||||||||||||||||||||||||||||||||||||
| position?: 'top-right' | 'bottom-right' | 'top-left' | 'bottom-left'; | ||||||||||||||||||||||||||||||||||||||||
| durationMs?: number; | ||||||||||||||||||||||||||||||||||||||||
| maxVisible?: number; | ||||||||||||||||||||||||||||||||||||||||
| /** Enable screen reader announcements (default: true) */ | ||||||||||||||||||||||||||||||||||||||||
| announce?: boolean; | ||||||||||||||||||||||||||||||||||||||||
| position?: 'top-right' | 'bottom-right' | 'top-left' | 'bottom-left'; | ||||||||||||||||||||||||||||||||||||||||
| durationMs?: number; | ||||||||||||||||||||||||||||||||||||||||
| maxVisible?: number; | ||||||||||||||||||||||||||||||||||||||||
| announce?: boolean; | ||||||||||||||||||||||||||||||||||||||||
| animationMs?: number; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| const ICONS_UNICODE: Record<ToastType, string> = { info: 'ℹ', success: '✓', warning: '⚠', error: '✗' }; | ||||||||||||||||||||||||||||||||||||||||
| const ICONS_UNICODE: Record<ToastType, string> = { info: 'i', success: '+', warning: '!', error: 'x' }; | ||||||||||||||||||||||||||||||||||||||||
| const ICONS_ASCII: Record<ToastType, string> = { info: 'i', success: '+', warning: '!', error: 'x' }; | ||||||||||||||||||||||||||||||||||||||||
| const COLORS: Record<ToastType, string> = { info: 'cyan', success: 'green', warning: 'yellow', error: 'red' }; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| export class Toast extends Widget { | ||||||||||||||||||||||||||||||||||||||||
| private _messages: ToastMessage[] = []; | ||||||||||||||||||||||||||||||||||||||||
| private _position: string; | ||||||||||||||||||||||||||||||||||||||||
| private _durationMs: number; | ||||||||||||||||||||||||||||||||||||||||
| private _maxVisible: number; | ||||||||||||||||||||||||||||||||||||||||
| private _announce: boolean; | ||||||||||||||||||||||||||||||||||||||||
| private _messages: ToastMessage[] = []; | ||||||||||||||||||||||||||||||||||||||||
| private _position: string; | ||||||||||||||||||||||||||||||||||||||||
| private _durationMs: number; | ||||||||||||||||||||||||||||||||||||||||
| private _maxVisible: number; | ||||||||||||||||||||||||||||||||||||||||
| private _announce: boolean; | ||||||||||||||||||||||||||||||||||||||||
| private _animationMs: number; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| constructor(options: ToastOptions = {}) { | ||||||||||||||||||||||||||||||||||||||||
| super(mergeStyles(defaultStyle(), {})); | ||||||||||||||||||||||||||||||||||||||||
| this._position = options.position ?? 'top-right'; | ||||||||||||||||||||||||||||||||||||||||
| this._durationMs = options.durationMs ?? 3000; | ||||||||||||||||||||||||||||||||||||||||
| this._maxVisible = options.maxVisible ?? 5; | ||||||||||||||||||||||||||||||||||||||||
| this._announce = options.announce ?? true; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| constructor(options: ToastOptions = {}) { | ||||||||||||||||||||||||||||||||||||||||
| super(mergeStyles(defaultStyle(), {})); | ||||||||||||||||||||||||||||||||||||||||
| this._position = options.position ?? 'top-right'; | ||||||||||||||||||||||||||||||||||||||||
| this._durationMs = options.durationMs ?? 3000; | ||||||||||||||||||||||||||||||||||||||||
| this._maxVisible = options.maxVisible ?? 5; | ||||||||||||||||||||||||||||||||||||||||
| this._announce = options.announce ?? true; | ||||||||||||||||||||||||||||||||||||||||
| this._animationMs = options.animationMs ?? 300; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| push(text: string, type: ToastType = 'info'): void { | ||||||||||||||||||||||||||||||||||||||||
| this._messages.push({ text, type, expireAt: Date.now() + this._durationMs }); | ||||||||||||||||||||||||||||||||||||||||
| this.markDirty(); | ||||||||||||||||||||||||||||||||||||||||
| if (this._announce) { | ||||||||||||||||||||||||||||||||||||||||
| this._announceToScreenReader(text, type); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| info(text: string): void { this.push(text, 'info'); } | ||||||||||||||||||||||||||||||||||||||||
| success(text: string): void { this.push(text, 'success'); } | ||||||||||||||||||||||||||||||||||||||||
| warning(text: string): void { this.push(text, 'warning'); } | ||||||||||||||||||||||||||||||||||||||||
| error(text: string): void { this.push(text, 'error'); } | ||||||||||||||||||||||||||||||||||||||||
| push(text: string, type: ToastType = 'info'): void { | ||||||||||||||||||||||||||||||||||||||||
| const now = Date.now(); | ||||||||||||||||||||||||||||||||||||||||
| this._messages.push({ text, type, expireAt: now + this._durationMs, createdAt: now }); | ||||||||||||||||||||||||||||||||||||||||
| this.markDirty(); | ||||||||||||||||||||||||||||||||||||||||
| if (this._announce) this._announceToScreenReader(text, type); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| private _announceToScreenReader(text: string, _type: ToastType): void { | ||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||
| const announcement = `[${text}]`; | ||||||||||||||||||||||||||||||||||||||||
| const oscSequence = `\x1b]777;notify;TermUI;${announcement}\x07`; | ||||||||||||||||||||||||||||||||||||||||
| process.stderr.write(oscSequence); | ||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||
| // Silently fail if stderr is not writable | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| protected _renderSelf(screen: Screen): void { | ||||||||||||||||||||||||||||||||||||||||
| const now = Date.now(); | ||||||||||||||||||||||||||||||||||||||||
| this._messages = this._messages.filter(m => m.expireAt > now); | ||||||||||||||||||||||||||||||||||||||||
| if (this._messages.length === 0) return; | ||||||||||||||||||||||||||||||||||||||||
| const { x, y, width, height } = this._rect; | ||||||||||||||||||||||||||||||||||||||||
| const visible = this._messages.slice(-this._maxVisible); | ||||||||||||||||||||||||||||||||||||||||
| const tw = Math.min(40, width - 2); | ||||||||||||||||||||||||||||||||||||||||
| const isRight = this._position.includes('right'); | ||||||||||||||||||||||||||||||||||||||||
| const isBottom = this._position.includes('bottom'); | ||||||||||||||||||||||||||||||||||||||||
| const sx = isRight ? x + width - tw - 1 : x + 1; | ||||||||||||||||||||||||||||||||||||||||
| const sy = isBottom ? y + height - visible.length - 1 : y + 1; | ||||||||||||||||||||||||||||||||||||||||
| const icons = caps.unicode ? ICONS_UNICODE : ICONS_ASCII; | ||||||||||||||||||||||||||||||||||||||||
| const attrs = styleToCellAttrs(this.style); | ||||||||||||||||||||||||||||||||||||||||
| for (let i = 0; i < visible.length; i++) { | ||||||||||||||||||||||||||||||||||||||||
| const m = visible[i]; | ||||||||||||||||||||||||||||||||||||||||
| const label = ` ${icons[m.type]} ${m.text} `.slice(0, tw).padEnd(tw); | ||||||||||||||||||||||||||||||||||||||||
| screen.writeString(sx, sy + i, label, { ...attrs, fg: { type: 'named', name: COLORS[m.type] as any }, bold: true }); // as any: COLORS values are string but Style.name expects NamedColor; types need aligning | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| info(text: string): void { this.push(text, 'info'); } | ||||||||||||||||||||||||||||||||||||||||
| success(text: string): void { this.push(text, 'success'); } | ||||||||||||||||||||||||||||||||||||||||
| warning(text: string): void { this.push(text, 'warning'); } | ||||||||||||||||||||||||||||||||||||||||
| error(text: string): void { this.push(text, 'error'); } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| private _announceToScreenReader(text: string, _type: ToastType): void { | ||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||
| process.stderr.write('\x1b]777;notify;TermUI;[' + text + ']\x07'); | ||||||||||||||||||||||||||||||||||||||||
| } catch { } | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| private _getAnimationProgress(createdAt: number, expireAt: number): number { | ||||||||||||||||||||||||||||||||||||||||
| const now = Date.now(); | ||||||||||||||||||||||||||||||||||||||||
| const elapsed = now - createdAt; | ||||||||||||||||||||||||||||||||||||||||
| const remaining = expireAt - now; | ||||||||||||||||||||||||||||||||||||||||
| if (remaining < this._animationMs) return Math.max(0, remaining / this._animationMs); | ||||||||||||||||||||||||||||||||||||||||
| if (elapsed < this._animationMs) return elapsed / this._animationMs; | ||||||||||||||||||||||||||||||||||||||||
| return 1; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| protected _renderSelf(screen: Screen): void { | ||||||||||||||||||||||||||||||||||||||||
| const now = Date.now(); | ||||||||||||||||||||||||||||||||||||||||
| this._messages = this._messages.filter(m => m.expireAt > now); | ||||||||||||||||||||||||||||||||||||||||
| if (this._messages.length === 0) return; | ||||||||||||||||||||||||||||||||||||||||
| const { x, y, width, height } = this._rect; | ||||||||||||||||||||||||||||||||||||||||
| const visible = this._messages.slice(-this._maxVisible); | ||||||||||||||||||||||||||||||||||||||||
| const tw = Math.min(40, width - 2); | ||||||||||||||||||||||||||||||||||||||||
| const isRight = this._position.includes('right'); | ||||||||||||||||||||||||||||||||||||||||
| const isBottom = this._position.includes('bottom'); | ||||||||||||||||||||||||||||||||||||||||
| const sx = isRight ? x + width - tw - 1 : x + 1; | ||||||||||||||||||||||||||||||||||||||||
| const sy = isBottom ? y + height - visible.length - 1 : y + 1; | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+76
to
+80
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Guard narrow widget widths before computing toast text width Line 76 can produce Suggested fix protected _renderSelf(screen: Screen): void {
const now = Date.now();
this._messages = this._messages.filter(m => m.expireAt > now);
if (this._messages.length === 0) return;
const { x, y, width, height } = this._rect;
+ if (width < 3 || height < 2) return;
const visible = this._messages.slice(-this._maxVisible);
- const tw = Math.min(40, width - 2);
+ const tw = Math.max(1, Math.min(40, width - 2));🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||
| const icons = caps.unicode ? ICONS_UNICODE : ICONS_ASCII; | ||||||||||||||||||||||||||||||||||||||||
| const attrs = styleToCellAttrs(this.style); | ||||||||||||||||||||||||||||||||||||||||
| for (let i = 0; i < visible.length; i++) { | ||||||||||||||||||||||||||||||||||||||||
| const m = visible[i]; | ||||||||||||||||||||||||||||||||||||||||
| const progress = this._getAnimationProgress(m.createdAt, m.expireAt); | ||||||||||||||||||||||||||||||||||||||||
| const fullLabel = (' ' + icons[m.type] + ' ' + m.text + ' ').slice(0, tw).padEnd(tw); | ||||||||||||||||||||||||||||||||||||||||
| const visibleChars = Math.floor(progress * tw); | ||||||||||||||||||||||||||||||||||||||||
| const label = fullLabel.slice(0, visibleChars).padEnd(tw); | ||||||||||||||||||||||||||||||||||||||||
| screen.writeString(sx, sy + i, label, { ...attrs, fg: { type: 'named', name: COLORS[m.type] as any }, bold: true }); | ||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Verify assertion/any usage in the changed file and strict-mode declaration in repo tsconfig(s)
rg -n --type=ts '\bas any\b|@\s*ts-ignore|as\s+[A-Za-z0-9_<>{}\[\]\|]+' packages/ui/src/Toast.ts
fd -i '^tsconfig(\..+)?\.json$' | xargs -r rg -n '"strict"\s*:\s*true'Repository: Karanjot786/TermUI Length of output: 635 🏁 Script executed: # Get context around line 89 in Toast.ts
sed -n '80,100p' packages/ui/src/Toast.ts
# Find where COLORS is defined
rg -n 'COLORS\s*[:=]' packages/ui/src/Toast.ts
# Check the entire file structure
wc -l packages/ui/src/Toast.tsRepository: Karanjot786/TermUI Length of output: 1151 🏁 Script executed: # Get the top of Toast.ts to see imports and type definitions
head -30 packages/ui/src/Toast.ts
# Find ToastType definition
rg -n 'type ToastType|enum ToastType' packages/ui/src/Toast.tsRepository: Karanjot786/TermUI Length of output: 1174 🏁 Script executed: # Look for Color type definition in the `@termuijs/core` package
find packages -name "*.ts" -o -name "*.tsx" | xargs rg -l "type Color|interface Color" | head -5
# Search for the Color type more broadly
rg -A 5 "type Color\s*=|interface Color\s*\{" packages/core/src/Repository: Karanjot786/TermUI Length of output: 650 🏁 Script executed: # Find NamedColor type definition
rg -A 5 "type NamedColor" packages/core/src/style/Color.tsRepository: Karanjot786/TermUI Length of output: 311 Type Line 89 uses Suggested change-const COLORS: Record<ToastType, string> = { info: 'cyan', success: 'green', warning: 'yellow', error: 'red' };
+const COLORS: Record<ToastType, NamedColor> = { info: 'cyan', success: 'green', warning: 'yellow', error: 'red' };Then remove the - screen.writeString(sx, sy + i, label, { ...attrs, fg: { type: 'named', name: COLORS[m.type] as any }, bold: true });
+ screen.writeString(sx, sy + i, label, { ...attrs, fg: { type: 'named', name: COLORS[m.type] }, bold: true });Also add the import for -import { type Screen, mergeStyles, defaultStyle, styleToCellAttrs, caps } from '`@termuijs/core`';
+import { type Screen, type NamedColor, mergeStyles, defaultStyle, styleToCellAttrs, caps } from '`@termuijs/core`';📝 Committable suggestion
Suggested change
Suggested change
Suggested change
🤖 Prompt for AI AgentsSource: Coding guidelines |
||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| const anyAnimating = visible.some(m => { | ||||||||||||||||||||||||||||||||||||||||
| const elapsed = now - m.createdAt; | ||||||||||||||||||||||||||||||||||||||||
| const remaining = m.expireAt - now; | ||||||||||||||||||||||||||||||||||||||||
| return elapsed < this._animationMs || remaining < this._animationMs; | ||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||
| if (anyAnimating) setTimeout(() => this.markDirty(), 16); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+91
to
+97
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid scheduling overlapping animation timers every render Lines 91-97 schedule a new timeout whenever Suggested fix export class Toast extends Widget {
private _messages: ToastMessage[] = [];
+ private _animationTimer: ReturnType<typeof setTimeout> | null = null;
@@
- if (anyAnimating) setTimeout(() => this.markDirty(), 16);
+ if (anyAnimating && this._animationTimer === null) {
+ this._animationTimer = setTimeout(() => {
+ this._animationTimer = null;
+ this.markDirty();
+ }, 16);
+ }
}
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Escape toast text before writing OSC notification sequence
Line 57 embeds raw
textinto an OSC control sequence. Iftextcontains ESC/BEL/ST characters, it can terminate/inject terminal control codes. Sanitize control bytes before concatenation.Suggested fix
private _announceToScreenReader(text: string, _type: ToastType): void { try { - process.stderr.write('\x1b]777;notify;TermUI;[' + text + ']\x07'); + const safeText = text.replace(/[\x07\x1b\x9c]/g, ''); + process.stderr.write('\x1b]777;notify;TermUI;[' + safeText + ']\x07'); } catch { } }🤖 Prompt for AI Agents