@svelterm/core 0.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/LICENSE +21 -0
- package/README.md +174 -0
- package/dist/src/components/spinner.d.ts +11 -0
- package/dist/src/components/spinner.js +19 -0
- package/dist/src/components/text-buffer.d.ts +21 -0
- package/dist/src/components/text-buffer.js +87 -0
- package/dist/src/css/animation-runner.d.ts +17 -0
- package/dist/src/css/animation-runner.js +72 -0
- package/dist/src/css/animation.d.ts +5 -0
- package/dist/src/css/animation.js +6 -0
- package/dist/src/css/calc.d.ts +5 -0
- package/dist/src/css/calc.js +130 -0
- package/dist/src/css/color.d.ts +1 -0
- package/dist/src/css/color.js +157 -0
- package/dist/src/css/compute.d.ts +63 -0
- package/dist/src/css/compute.js +606 -0
- package/dist/src/css/defaults.d.ts +8 -0
- package/dist/src/css/defaults.js +44 -0
- package/dist/src/css/incremental.d.ts +9 -0
- package/dist/src/css/incremental.js +46 -0
- package/dist/src/css/index.d.ts +5 -0
- package/dist/src/css/index.js +3 -0
- package/dist/src/css/media.d.ts +11 -0
- package/dist/src/css/media.js +59 -0
- package/dist/src/css/parser.d.ts +20 -0
- package/dist/src/css/parser.js +241 -0
- package/dist/src/css/selector.d.ts +17 -0
- package/dist/src/css/selector.js +272 -0
- package/dist/src/css/specificity.d.ts +7 -0
- package/dist/src/css/specificity.js +89 -0
- package/dist/src/css/values.d.ts +17 -0
- package/dist/src/css/values.js +58 -0
- package/dist/src/css/variables.d.ts +6 -0
- package/dist/src/css/variables.js +42 -0
- package/dist/src/debug/console.d.ts +16 -0
- package/dist/src/debug/console.js +65 -0
- package/dist/src/debug/server.d.ts +22 -0
- package/dist/src/debug/server.js +90 -0
- package/dist/src/headless.d.ts +21 -0
- package/dist/src/headless.js +26 -0
- package/dist/src/index.d.ts +18 -0
- package/dist/src/index.js +485 -0
- package/dist/src/input/dispatch.d.ts +18 -0
- package/dist/src/input/dispatch.js +70 -0
- package/dist/src/input/focus.d.ts +18 -0
- package/dist/src/input/focus.js +81 -0
- package/dist/src/input/hit.d.ts +3 -0
- package/dist/src/input/hit.js +29 -0
- package/dist/src/input/keyboard.d.ts +9 -0
- package/dist/src/input/keyboard.js +100 -0
- package/dist/src/input/mouse.d.ts +7 -0
- package/dist/src/input/mouse.js +35 -0
- package/dist/src/input/scroll.d.ts +2 -0
- package/dist/src/input/scroll.js +24 -0
- package/dist/src/layout/cache.d.ts +4 -0
- package/dist/src/layout/cache.js +8 -0
- package/dist/src/layout/engine.d.ts +9 -0
- package/dist/src/layout/engine.js +455 -0
- package/dist/src/layout/flex.d.ts +4 -0
- package/dist/src/layout/flex.js +30 -0
- package/dist/src/layout/incremental.d.ts +8 -0
- package/dist/src/layout/incremental.js +58 -0
- package/dist/src/layout/size.d.ts +2 -0
- package/dist/src/layout/size.js +25 -0
- package/dist/src/layout/text.d.ts +7 -0
- package/dist/src/layout/text.js +52 -0
- package/dist/src/render/ansi.d.ts +23 -0
- package/dist/src/render/ansi.js +108 -0
- package/dist/src/render/border.d.ts +4 -0
- package/dist/src/render/border.js +60 -0
- package/dist/src/render/buffer.d.ts +23 -0
- package/dist/src/render/buffer.js +70 -0
- package/dist/src/render/context.d.ts +19 -0
- package/dist/src/render/context.js +98 -0
- package/dist/src/render/diff.d.ts +2 -0
- package/dist/src/render/diff.js +53 -0
- package/dist/src/render/incremental-paint.d.ts +10 -0
- package/dist/src/render/incremental-paint.js +94 -0
- package/dist/src/render/paint-text.d.ts +29 -0
- package/dist/src/render/paint-text.js +120 -0
- package/dist/src/render/paint.d.ts +5 -0
- package/dist/src/render/paint.js +220 -0
- package/dist/src/render/queue.d.ts +24 -0
- package/dist/src/render/queue.js +54 -0
- package/dist/src/render/scrollbar.d.ts +3 -0
- package/dist/src/render/scrollbar.js +19 -0
- package/dist/src/render/snapshot.d.ts +18 -0
- package/dist/src/render/snapshot.js +126 -0
- package/dist/src/renderer/default.d.ts +3 -0
- package/dist/src/renderer/default.js +3 -0
- package/dist/src/renderer/index.d.ts +11 -0
- package/dist/src/renderer/index.js +116 -0
- package/dist/src/renderer/node.d.ts +44 -0
- package/dist/src/renderer/node.js +153 -0
- package/dist/src/terminal/screen.d.ts +10 -0
- package/dist/src/terminal/screen.js +31 -0
- package/dist/src/terminal/stdin-router.d.ts +31 -0
- package/dist/src/terminal/stdin-router.js +133 -0
- package/package.json +64 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tom Yandell
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# svelterm
|
|
2
|
+
|
|
3
|
+
Svelte 5 components rendered to the terminal with real CSS.
|
|
4
|
+
|
|
5
|
+
Write standard Svelte components with `<style>` blocks. They render in the terminal with ANSI escape sequences — flexbox layout, scoped styles, CSS variables, pseudo-classes, all on a cell grid.
|
|
6
|
+
|
|
7
|
+
> **Early release.** Svelterm requires an unmerged Svelte branch (`svelte-custom-renderer` by [@paoloricciuti](https://github.com/paoloricciuti)) that adds the custom renderer API. It is not usable with mainline Svelte yet.
|
|
8
|
+
|
|
9
|
+
## Example
|
|
10
|
+
|
|
11
|
+
```svelte
|
|
12
|
+
<script>
|
|
13
|
+
let count = $state(0)
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<style>
|
|
17
|
+
.counter {
|
|
18
|
+
display: flex;
|
|
19
|
+
flex-direction: column;
|
|
20
|
+
border: rounded;
|
|
21
|
+
border-color: cyan;
|
|
22
|
+
padding: 1cell;
|
|
23
|
+
gap: 1cell;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.value {
|
|
27
|
+
color: yellow;
|
|
28
|
+
font-weight: bold;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
button:focus {
|
|
32
|
+
color: cyan;
|
|
33
|
+
font-weight: bold;
|
|
34
|
+
}
|
|
35
|
+
</style>
|
|
36
|
+
|
|
37
|
+
<div class="counter">
|
|
38
|
+
<span>Count: <span class="value">{count}</span></span>
|
|
39
|
+
<button onclick={() => count++}>Increment</button>
|
|
40
|
+
<button onclick={() => count--}>Decrement</button>
|
|
41
|
+
</div>
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
import { run } from '@svelterm/core/app'
|
|
46
|
+
import { readFileSync } from 'fs'
|
|
47
|
+
import App from './App.svelte'
|
|
48
|
+
|
|
49
|
+
const css = readFileSync('./main.css', 'utf-8')
|
|
50
|
+
run(App, { css })
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Dual-target components
|
|
54
|
+
|
|
55
|
+
The same Svelte component can render in both terminal and browser. Terminal-specific CSS values (`border: rounded`, `1cell`, `opacity: dim`) are naturally ignored by browsers — they're invalid CSS. Browser-specific rules go in `@media (display-mode: screen)`.
|
|
56
|
+
|
|
57
|
+
```svelte
|
|
58
|
+
<style>
|
|
59
|
+
.greeting {
|
|
60
|
+
border: rounded;
|
|
61
|
+
border-color: cyan;
|
|
62
|
+
padding: 1cell;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@media (display-mode: screen) {
|
|
66
|
+
.greeting {
|
|
67
|
+
border: 2px solid #00b4d8;
|
|
68
|
+
border-radius: 8px;
|
|
69
|
+
padding: 1rem;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
</style>
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
To build for each target, use separate Vite configs — one with `customRenderer: '@svelterm/core'` for terminal, one without for browser. The component source is the same.
|
|
76
|
+
|
|
77
|
+
## What's different in terminal CSS
|
|
78
|
+
|
|
79
|
+
Standard CSS works as expected. These are the terminal-specific additions:
|
|
80
|
+
|
|
81
|
+
| Feature | Terminal | Browser |
|
|
82
|
+
|---------|----------|---------|
|
|
83
|
+
| **Borders** | `single`, `double`, `rounded`, `heavy` (box-drawing characters) | Ignored (invalid values) |
|
|
84
|
+
| **Units** | `cell` — one monospace character position | Ignored (unknown unit) |
|
|
85
|
+
| **Opacity** | `dim` — terminal dim attribute | Ignored (invalid value) |
|
|
86
|
+
| **Colors** | ANSI names, 256-color, truecolor hex, CSS named colors | Standard CSS colors |
|
|
87
|
+
| **Media** | `@media (display-mode: terminal)` | `@media (display-mode: screen)` |
|
|
88
|
+
|
|
89
|
+
## Features
|
|
90
|
+
|
|
91
|
+
- **CSS engine** — selectors, specificity, cascade, inheritance, scoped styles, `var()`, `calc()`, `@media`, `@keyframes`
|
|
92
|
+
- **Flexbox layout** — `flex-direction`, `justify-content`, `align-items`, `flex-grow`, `flex-shrink`, `gap`, `flex-wrap`
|
|
93
|
+
- **Terminal rendering** — ANSI colors (16, 256, truecolor), borders, text styles, differential output
|
|
94
|
+
- **Input** — keyboard events, mouse click and scroll, focus management with Tab/Shift+Tab, `:focus` and `:hover` pseudo-classes
|
|
95
|
+
- **Text input** — `<input>` and `<textarea>` with readline-like editing
|
|
96
|
+
- **Incremental updates** — mutation tracking classifies changes as paint-only, style-resolve, or layout to avoid full recomputation
|
|
97
|
+
- **Color scheme** — automatic `prefers-color-scheme` detection via terminal queries
|
|
98
|
+
|
|
99
|
+
## Prerequisites
|
|
100
|
+
|
|
101
|
+
Svelterm requires the experimental custom renderer API, available on the [`svelte-custom-renderer`](https://github.com/paoloricciuti/svelte/tree/svelte-custom-renderer) branch:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
# Clone the branch
|
|
105
|
+
git clone -b svelte-custom-renderer https://github.com/paoloricciuti/svelte.git svelte-fork
|
|
106
|
+
cd svelte-fork
|
|
107
|
+
pnpm install
|
|
108
|
+
pnpm -C packages/svelte build
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Then reference it in your project's `package.json`:
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
{
|
|
115
|
+
"peerDependencies": {
|
|
116
|
+
"svelte": "file:../svelte-fork/packages/svelte"
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Setup
|
|
122
|
+
|
|
123
|
+
Configure the Svelte compiler to use svelterm as the custom renderer:
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
// vite.config.ts
|
|
127
|
+
import { defineConfig } from 'vite'
|
|
128
|
+
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
|
129
|
+
|
|
130
|
+
export default defineConfig({
|
|
131
|
+
plugins: [
|
|
132
|
+
svelte({
|
|
133
|
+
compilerOptions: {
|
|
134
|
+
experimental: {
|
|
135
|
+
customRenderer: '@svelterm/core',
|
|
136
|
+
},
|
|
137
|
+
css: 'external',
|
|
138
|
+
},
|
|
139
|
+
}),
|
|
140
|
+
],
|
|
141
|
+
build: {
|
|
142
|
+
target: 'node22',
|
|
143
|
+
rollupOptions: {
|
|
144
|
+
external: ['svelte', 'svelte/renderer', 'svelte/internal',
|
|
145
|
+
'svelte/internal/client', 'ws', 'http', 'crypto'],
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
})
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## API
|
|
152
|
+
|
|
153
|
+
### `run(component, options?)`
|
|
154
|
+
|
|
155
|
+
Start an interactive terminal application.
|
|
156
|
+
|
|
157
|
+
```typescript
|
|
158
|
+
import { run } from '@svelterm/core/app'
|
|
159
|
+
|
|
160
|
+
const stop = run(App, {
|
|
161
|
+
css, // Extracted CSS string
|
|
162
|
+
fullscreen: true, // Use alternate screen buffer (default: true)
|
|
163
|
+
mouse: true, // Enable mouse input (default: true)
|
|
164
|
+
props: { name: 'world' },
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
// Call stop() to shut down and restore terminal
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Returns a function that stops the application, unmounts the component, and restores the terminal.
|
|
171
|
+
|
|
172
|
+
## License
|
|
173
|
+
|
|
174
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const SPINNER_DOTS: string[];
|
|
2
|
+
export declare const SPINNER_LINE: string[];
|
|
3
|
+
export declare const SPINNER_BRAILLE: string[];
|
|
4
|
+
export declare class Spinner {
|
|
5
|
+
private frames;
|
|
6
|
+
private index;
|
|
7
|
+
constructor(frames?: string[]);
|
|
8
|
+
get frame(): string;
|
|
9
|
+
tick(): void;
|
|
10
|
+
reset(): void;
|
|
11
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const SPINNER_DOTS = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
2
|
+
export const SPINNER_LINE = ['|', '/', '-', '\\'];
|
|
3
|
+
export const SPINNER_BRAILLE = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'];
|
|
4
|
+
export class Spinner {
|
|
5
|
+
frames;
|
|
6
|
+
index = 0;
|
|
7
|
+
constructor(frames = SPINNER_DOTS) {
|
|
8
|
+
this.frames = frames;
|
|
9
|
+
}
|
|
10
|
+
get frame() {
|
|
11
|
+
return this.frames[this.index];
|
|
12
|
+
}
|
|
13
|
+
tick() {
|
|
14
|
+
this.index = (this.index + 1) % this.frames.length;
|
|
15
|
+
}
|
|
16
|
+
reset() {
|
|
17
|
+
this.index = 0;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { KeyEvent } from '../input/keyboard.js';
|
|
2
|
+
export declare class TextBuffer {
|
|
3
|
+
private _text;
|
|
4
|
+
private _cursor;
|
|
5
|
+
constructor(initial?: string);
|
|
6
|
+
get text(): string;
|
|
7
|
+
set text(value: string);
|
|
8
|
+
get cursor(): number;
|
|
9
|
+
set cursor(value: number);
|
|
10
|
+
insert(chars: string): void;
|
|
11
|
+
delete(): void;
|
|
12
|
+
backspace(): void;
|
|
13
|
+
moveLeft(): void;
|
|
14
|
+
moveRight(): void;
|
|
15
|
+
home(): void;
|
|
16
|
+
end(): void;
|
|
17
|
+
clearToStart(): void;
|
|
18
|
+
clearToEnd(): void;
|
|
19
|
+
handleKey(key: KeyEvent): boolean;
|
|
20
|
+
private handleCtrl;
|
|
21
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export class TextBuffer {
|
|
2
|
+
_text;
|
|
3
|
+
_cursor;
|
|
4
|
+
constructor(initial = '') {
|
|
5
|
+
this._text = initial;
|
|
6
|
+
this._cursor = initial.length;
|
|
7
|
+
}
|
|
8
|
+
get text() { return this._text; }
|
|
9
|
+
set text(value) { this._text = value; }
|
|
10
|
+
get cursor() { return this._cursor; }
|
|
11
|
+
set cursor(value) {
|
|
12
|
+
this._cursor = Math.max(0, Math.min(value, this._text.length));
|
|
13
|
+
}
|
|
14
|
+
insert(chars) {
|
|
15
|
+
this._text = this._text.substring(0, this._cursor) + chars + this._text.substring(this._cursor);
|
|
16
|
+
this._cursor += chars.length;
|
|
17
|
+
}
|
|
18
|
+
delete() {
|
|
19
|
+
if (this._cursor >= this._text.length)
|
|
20
|
+
return;
|
|
21
|
+
this._text = this._text.substring(0, this._cursor) + this._text.substring(this._cursor + 1);
|
|
22
|
+
}
|
|
23
|
+
backspace() {
|
|
24
|
+
if (this._cursor <= 0)
|
|
25
|
+
return;
|
|
26
|
+
this._text = this._text.substring(0, this._cursor - 1) + this._text.substring(this._cursor);
|
|
27
|
+
this._cursor--;
|
|
28
|
+
}
|
|
29
|
+
moveLeft() { this.cursor--; }
|
|
30
|
+
moveRight() { this.cursor++; }
|
|
31
|
+
home() { this._cursor = 0; }
|
|
32
|
+
end() { this._cursor = this._text.length; }
|
|
33
|
+
clearToStart() {
|
|
34
|
+
this._text = this._text.substring(this._cursor);
|
|
35
|
+
this._cursor = 0;
|
|
36
|
+
}
|
|
37
|
+
clearToEnd() {
|
|
38
|
+
this._text = this._text.substring(0, this._cursor);
|
|
39
|
+
}
|
|
40
|
+
handleKey(key) {
|
|
41
|
+
if (key.ctrl)
|
|
42
|
+
return this.handleCtrl(key.key);
|
|
43
|
+
switch (key.key) {
|
|
44
|
+
case 'Backspace':
|
|
45
|
+
this.backspace();
|
|
46
|
+
return true;
|
|
47
|
+
case 'Delete':
|
|
48
|
+
this.delete();
|
|
49
|
+
return true;
|
|
50
|
+
case 'ArrowLeft':
|
|
51
|
+
this.moveLeft();
|
|
52
|
+
return true;
|
|
53
|
+
case 'ArrowRight':
|
|
54
|
+
this.moveRight();
|
|
55
|
+
return true;
|
|
56
|
+
case 'Home':
|
|
57
|
+
this.home();
|
|
58
|
+
return true;
|
|
59
|
+
case 'End':
|
|
60
|
+
this.end();
|
|
61
|
+
return true;
|
|
62
|
+
default:
|
|
63
|
+
if (key.key.length === 1) {
|
|
64
|
+
this.insert(key.key);
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
handleCtrl(key) {
|
|
71
|
+
switch (key) {
|
|
72
|
+
case 'a':
|
|
73
|
+
this.home();
|
|
74
|
+
return true;
|
|
75
|
+
case 'e':
|
|
76
|
+
this.end();
|
|
77
|
+
return true;
|
|
78
|
+
case 'u':
|
|
79
|
+
this.clearToStart();
|
|
80
|
+
return true;
|
|
81
|
+
case 'k':
|
|
82
|
+
this.clearToEnd();
|
|
83
|
+
return true;
|
|
84
|
+
default: return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { KeyframeStop } from './parser.js';
|
|
2
|
+
import type { ResolvedStyle } from './compute.js';
|
|
3
|
+
/**
|
|
4
|
+
* Runs a CSS animation by applying keyframe properties at the current time.
|
|
5
|
+
* Terminal animations are discrete (no interpolation between color values).
|
|
6
|
+
*/
|
|
7
|
+
export declare class AnimationRunner {
|
|
8
|
+
private keyframes;
|
|
9
|
+
private duration;
|
|
10
|
+
private iterations;
|
|
11
|
+
constructor(keyframes: KeyframeStop[], durationMs: number, iterations: number);
|
|
12
|
+
/** Apply the appropriate keyframe declarations to a style at the given elapsed time */
|
|
13
|
+
apply(style: ResolvedStyle, elapsedMs: number): void;
|
|
14
|
+
isFinished(elapsedMs: number): boolean;
|
|
15
|
+
private getProgress;
|
|
16
|
+
private getKeyframeAt;
|
|
17
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { resolveColor } from './color.js';
|
|
2
|
+
/**
|
|
3
|
+
* Runs a CSS animation by applying keyframe properties at the current time.
|
|
4
|
+
* Terminal animations are discrete (no interpolation between color values).
|
|
5
|
+
*/
|
|
6
|
+
export class AnimationRunner {
|
|
7
|
+
keyframes;
|
|
8
|
+
duration;
|
|
9
|
+
iterations;
|
|
10
|
+
constructor(keyframes, durationMs, iterations) {
|
|
11
|
+
this.keyframes = keyframes.sort((a, b) => a.offset - b.offset);
|
|
12
|
+
this.duration = durationMs;
|
|
13
|
+
this.iterations = iterations;
|
|
14
|
+
}
|
|
15
|
+
/** Apply the appropriate keyframe declarations to a style at the given elapsed time */
|
|
16
|
+
apply(style, elapsedMs) {
|
|
17
|
+
if (this.keyframes.length === 0 || this.duration <= 0)
|
|
18
|
+
return;
|
|
19
|
+
const progress = this.getProgress(elapsedMs);
|
|
20
|
+
const kf = this.getKeyframeAt(progress);
|
|
21
|
+
if (!kf)
|
|
22
|
+
return;
|
|
23
|
+
for (const decl of kf.declarations) {
|
|
24
|
+
applyAnimatedProperty(style, decl);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
isFinished(elapsedMs) {
|
|
28
|
+
if (this.iterations === Infinity)
|
|
29
|
+
return false;
|
|
30
|
+
return elapsedMs >= this.duration * this.iterations;
|
|
31
|
+
}
|
|
32
|
+
getProgress(elapsedMs) {
|
|
33
|
+
if (this.iterations === Infinity) {
|
|
34
|
+
return (elapsedMs % this.duration) / this.duration;
|
|
35
|
+
}
|
|
36
|
+
const totalDuration = this.duration * this.iterations;
|
|
37
|
+
if (elapsedMs >= totalDuration)
|
|
38
|
+
return 1; // finished — hold at end
|
|
39
|
+
return (elapsedMs % this.duration) / this.duration;
|
|
40
|
+
}
|
|
41
|
+
getKeyframeAt(progress) {
|
|
42
|
+
// Discrete: find the last keyframe whose offset <= progress
|
|
43
|
+
let result = null;
|
|
44
|
+
for (const kf of this.keyframes) {
|
|
45
|
+
if (kf.offset <= progress)
|
|
46
|
+
result = kf;
|
|
47
|
+
else
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
return result ?? this.keyframes[0];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function applyAnimatedProperty(style, decl) {
|
|
54
|
+
switch (decl.property) {
|
|
55
|
+
case 'color':
|
|
56
|
+
style.fg = resolveColor(decl.value);
|
|
57
|
+
break;
|
|
58
|
+
case 'background-color':
|
|
59
|
+
case 'background':
|
|
60
|
+
style.bg = resolveColor(decl.value);
|
|
61
|
+
break;
|
|
62
|
+
case 'font-weight':
|
|
63
|
+
style.bold = decl.value === 'bold' || parseInt(decl.value) >= 700;
|
|
64
|
+
break;
|
|
65
|
+
case 'font-style':
|
|
66
|
+
style.italic = decl.value === 'italic';
|
|
67
|
+
break;
|
|
68
|
+
case 'opacity':
|
|
69
|
+
style.dim = decl.value === 'dim' || (parseFloat(decl.value) < 1);
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evaluate CSS math functions: calc(), min(), max(), clamp().
|
|
3
|
+
* Returns the computed cell value, or null if the input is not a math function.
|
|
4
|
+
*/
|
|
5
|
+
export function evaluateCalc(value, available) {
|
|
6
|
+
const trimmed = value.trim();
|
|
7
|
+
if (trimmed.startsWith('calc(')) {
|
|
8
|
+
const expr = trimmed.slice(5, -1).trim();
|
|
9
|
+
return Math.round(evaluateExpression(expr, available));
|
|
10
|
+
}
|
|
11
|
+
if (trimmed.startsWith('min(')) {
|
|
12
|
+
const args = splitArgs(trimmed.slice(4, -1));
|
|
13
|
+
const values = args.map(a => resolveValue(a.trim(), available));
|
|
14
|
+
return Math.round(Math.min(...values));
|
|
15
|
+
}
|
|
16
|
+
if (trimmed.startsWith('max(')) {
|
|
17
|
+
const args = splitArgs(trimmed.slice(4, -1));
|
|
18
|
+
const values = args.map(a => resolveValue(a.trim(), available));
|
|
19
|
+
return Math.round(Math.max(...values));
|
|
20
|
+
}
|
|
21
|
+
if (trimmed.startsWith('clamp(')) {
|
|
22
|
+
const args = splitArgs(trimmed.slice(6, -1));
|
|
23
|
+
if (args.length !== 3)
|
|
24
|
+
return null;
|
|
25
|
+
const min = resolveValue(args[0].trim(), available);
|
|
26
|
+
const preferred = resolveValue(args[1].trim(), available);
|
|
27
|
+
const max = resolveValue(args[2].trim(), available);
|
|
28
|
+
return Math.round(Math.min(Math.max(preferred, min), max));
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
function evaluateExpression(expr, available) {
|
|
33
|
+
// Tokenize: split on +, -, *, / while preserving the operators
|
|
34
|
+
const tokens = tokenize(expr);
|
|
35
|
+
if (tokens.length === 0)
|
|
36
|
+
return 0;
|
|
37
|
+
// First pass: resolve values
|
|
38
|
+
const values = [];
|
|
39
|
+
const ops = [];
|
|
40
|
+
for (const token of tokens) {
|
|
41
|
+
if (token === '+' || token === '-' || token === '*' || token === '/') {
|
|
42
|
+
ops.push(token);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
values.push(resolveValue(token.trim(), available));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// Second pass: * and / first
|
|
49
|
+
let i = 0;
|
|
50
|
+
while (i < ops.length) {
|
|
51
|
+
if (ops[i] === '*') {
|
|
52
|
+
values[i] = values[i] * values[i + 1];
|
|
53
|
+
values.splice(i + 1, 1);
|
|
54
|
+
ops.splice(i, 1);
|
|
55
|
+
}
|
|
56
|
+
else if (ops[i] === '/') {
|
|
57
|
+
values[i] = values[i] / values[i + 1];
|
|
58
|
+
values.splice(i + 1, 1);
|
|
59
|
+
ops.splice(i, 1);
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
i++;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Third pass: + and -
|
|
66
|
+
let result = values[0];
|
|
67
|
+
for (let j = 0; j < ops.length; j++) {
|
|
68
|
+
if (ops[j] === '+')
|
|
69
|
+
result += values[j + 1];
|
|
70
|
+
else if (ops[j] === '-')
|
|
71
|
+
result -= values[j + 1];
|
|
72
|
+
}
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
function tokenize(expr) {
|
|
76
|
+
const tokens = [];
|
|
77
|
+
let current = '';
|
|
78
|
+
for (let i = 0; i < expr.length; i++) {
|
|
79
|
+
const ch = expr[i];
|
|
80
|
+
if ((ch === '+' || ch === '-') && current.trim() && i > 0 && expr[i - 1] !== '(') {
|
|
81
|
+
tokens.push(current.trim());
|
|
82
|
+
tokens.push(ch);
|
|
83
|
+
current = '';
|
|
84
|
+
}
|
|
85
|
+
else if (ch === '*' || ch === '/') {
|
|
86
|
+
tokens.push(current.trim());
|
|
87
|
+
tokens.push(ch);
|
|
88
|
+
current = '';
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
current += ch;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (current.trim())
|
|
95
|
+
tokens.push(current.trim());
|
|
96
|
+
return tokens.filter(Boolean);
|
|
97
|
+
}
|
|
98
|
+
function resolveValue(value, available) {
|
|
99
|
+
const trimmed = value.trim();
|
|
100
|
+
if (trimmed.endsWith('%')) {
|
|
101
|
+
return available * parseFloat(trimmed) / 100;
|
|
102
|
+
}
|
|
103
|
+
if (trimmed.endsWith('cell')) {
|
|
104
|
+
return parseFloat(trimmed);
|
|
105
|
+
}
|
|
106
|
+
// Bare number (for multipliers like * 2)
|
|
107
|
+
const num = parseFloat(trimmed);
|
|
108
|
+
return isNaN(num) ? 0 : num;
|
|
109
|
+
}
|
|
110
|
+
function splitArgs(argsStr) {
|
|
111
|
+
const args = [];
|
|
112
|
+
let current = '';
|
|
113
|
+
let depth = 0;
|
|
114
|
+
for (const ch of argsStr) {
|
|
115
|
+
if (ch === '(')
|
|
116
|
+
depth++;
|
|
117
|
+
if (ch === ')')
|
|
118
|
+
depth--;
|
|
119
|
+
if (ch === ',' && depth === 0) {
|
|
120
|
+
args.push(current);
|
|
121
|
+
current = '';
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
current += ch;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (current.trim())
|
|
128
|
+
args.push(current);
|
|
129
|
+
return args;
|
|
130
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function resolveColor(value: string): string;
|