@ahmd-sh/hntui 0.3.0 → 0.5.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/README.md +22 -9
- package/package.json +1 -1
- package/src/App.tsx +8 -1
- package/src/components/StatusBar.tsx +5 -1
- package/src/hooks/useUpdateCheck.ts +29 -0
- package/src/index.tsx +15 -2
- package/src/utils/selfUpdate.test.ts +33 -0
- package/src/utils/selfUpdate.ts +120 -0
- package/src/utils/themeWipe.ts +109 -0
- package/src/version.ts +4 -0
package/README.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
<h1 align="center">hntui: Hacker News in your terminal!</h1>
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<p align="center">A sleek and elegant tui for browsing one of the best tech news sources.</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<img src=".github/assets/hntui-showcase.webp" alt="hntui — Hacker News in your terminal" width="800">
|
|
7
|
+
</p>
|
|
4
8
|
|
|
5
9
|
```
|
|
6
10
|
curl -fsSL https://raw.githubusercontent.com/ahmd-sh/hntui/main/install.sh | sh
|
|
@@ -30,7 +34,7 @@ The prebuilt binaries have no dependencies. Installing through npm (or hacking o
|
|
|
30
34
|
curl -fsSL https://raw.githubusercontent.com/ahmd-sh/hntui/main/install.sh | sh
|
|
31
35
|
```
|
|
32
36
|
|
|
33
|
-
Installs a self-contained binary
|
|
37
|
+
Installs a self-contained binary to `~/.local/bin`. You can also grab a binary for your platform directly from the [releases page](https://github.com/ahmd-sh/hntui/releases). Binaries cover macOS (Apple Silicon) and Linux (x64, arm64) — on an Intel Mac, use the Bun install below.
|
|
34
38
|
|
|
35
39
|
### With Bun
|
|
36
40
|
|
|
@@ -38,7 +42,7 @@ Installs a self-contained binary (runtime included, nothing else needed) to `~/.
|
|
|
38
42
|
bun add -g @ahmd-sh/hntui
|
|
39
43
|
```
|
|
40
44
|
|
|
41
|
-
|
|
45
|
+
Or run it once without installing:
|
|
42
46
|
|
|
43
47
|
```bash
|
|
44
48
|
bunx @ahmd-sh/hntui
|
|
@@ -52,6 +56,14 @@ hntui
|
|
|
52
56
|
|
|
53
57
|
Press `q` (or `Ctrl-C`) to quit.
|
|
54
58
|
|
|
59
|
+
## Updating
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
hntui update
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Checks the latest release and, for binary installs, replaces itself in place. Bun installs update with `bun add -g @ahmd-sh/hntui` instead (`hntui update` will tell you so). When a newer release exists, the status bar shows a quiet hint. `hntui --version` prints the installed version.
|
|
66
|
+
|
|
55
67
|
## Keybindings
|
|
56
68
|
|
|
57
69
|
### Story list
|
|
@@ -59,13 +71,13 @@ Press `q` (or `Ctrl-C`) to quit.
|
|
|
59
71
|
| Key | Action |
|
|
60
72
|
|---|---|
|
|
61
73
|
| `j` / `↓`, `k` / `↑` | Move cursor |
|
|
62
|
-
| `gg`, `
|
|
74
|
+
| `gg`, `G` | Jump to first or last |
|
|
63
75
|
| `Ctrl-D`, `Ctrl-U`, `PgDown`, `PgUp` | Scroll a half page |
|
|
64
76
|
| `c`, `Enter` | Open the story and read its comments |
|
|
65
77
|
| `h` / `←`, `l` / `→` | Previous or next tab |
|
|
66
78
|
| `Tab`, `Shift-Tab` | Cycle through tabs |
|
|
67
79
|
| `1` through `6` | Jump to a specific category |
|
|
68
|
-
| `
|
|
80
|
+
| `S` | Jump to the Saved list |
|
|
69
81
|
| `s` | Save or unsave the highlighted post |
|
|
70
82
|
| `o` | Open the story's URL in your browser |
|
|
71
83
|
| `r` | Refresh the current feed |
|
|
@@ -77,7 +89,7 @@ Press `q` (or `Ctrl-C`) to quit.
|
|
|
77
89
|
| Key | Action |
|
|
78
90
|
|---|---|
|
|
79
91
|
| `j` / `↓`, `k` / `↑` | Move the comment cursor |
|
|
80
|
-
| `gg`, `
|
|
92
|
+
| `gg`, `G` | Jump to first or last comment |
|
|
81
93
|
| `Ctrl-D`, `Ctrl-U`, `PgDown`, `PgUp` | Scroll a half page |
|
|
82
94
|
| `Space` | Collapse or expand the current subtree |
|
|
83
95
|
| `Enter` | Open the links popup for the current comment |
|
|
@@ -92,7 +104,7 @@ Press `q` (or `Ctrl-C`) to quit.
|
|
|
92
104
|
| Key | Action |
|
|
93
105
|
|---|---|
|
|
94
106
|
| `j`, `k`, `↑`, `↓` | Move |
|
|
95
|
-
| `gg`, `
|
|
107
|
+
| `gg`, `G` | First or last link |
|
|
96
108
|
| `o`, `Enter` | Open the highlighted link |
|
|
97
109
|
| `Esc`, `Backspace` | Close the popup |
|
|
98
110
|
|
|
@@ -133,7 +145,7 @@ Press `t` to toggle. The dark theme is mostly black with orange accents. The lig
|
|
|
133
145
|
|
|
134
146
|
Press `s` on any story to save it. Saved posts get a small star next to the title and show up in the Saved tab on the right side of the tab strip. The list persists across sessions in `~/.config/hntui/saved.json` as a small JSON file. (Config from the app's `hackernuis` days is migrated automatically on first run.) Press `s` again to remove a post from the list.
|
|
135
147
|
|
|
136
|
-
`
|
|
148
|
+
`S` jumps straight to the Saved list from anywhere.
|
|
137
149
|
|
|
138
150
|
## Development
|
|
139
151
|
|
|
@@ -164,6 +176,7 @@ git push --follow-tags
|
|
|
164
176
|
|
|
165
177
|
- [OpenTUI](https://github.com/anomalyco/opentui) by Anomaly. The native TUI core that makes all of this possible.
|
|
166
178
|
- [opentui-spinner](https://github.com/msmps/opentui-spinner) by Matt Simpson. The Knight Rider loading scanner is adapted from `examples/knight-rider/utils.ts` (MIT).
|
|
179
|
+
- [Effect](https://effect.website) for empowering the data layer under the hood.
|
|
167
180
|
- [Hacker News](https://news.ycombinator.com) for the content and the open API.
|
|
168
181
|
|
|
169
182
|
## License
|
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -12,6 +12,7 @@ import { useItems } from "./hooks/useItems"
|
|
|
12
12
|
import { flattenTree, useCommentTree } from "./hooks/useCommentTree"
|
|
13
13
|
import { useSaved } from "./hooks/useSaved"
|
|
14
14
|
import { useHistory } from "./hooks/useHistory"
|
|
15
|
+
import { useUpdateCheck } from "./hooks/useUpdateCheck"
|
|
15
16
|
import { ALL_CATEGORIES, FEED_CATEGORIES } from "./api/types"
|
|
16
17
|
import type { Category, FeedCategory, Item } from "./api/types"
|
|
17
18
|
import { openUrl } from "./utils/openUrl"
|
|
@@ -20,6 +21,7 @@ import { resolveStory } from "./api/hn"
|
|
|
20
21
|
import type { HnError, HnItemGone } from "./api/hn"
|
|
21
22
|
import { AppRuntime } from "./runtime"
|
|
22
23
|
import { hnErrorMessage } from "./utils/errors"
|
|
24
|
+
import { startThemeWipe } from "./utils/themeWipe"
|
|
23
25
|
import { LinksPopup } from "./components/LinksPopup"
|
|
24
26
|
import { HelpOverlay } from "./components/HelpOverlay"
|
|
25
27
|
import { ContextMenu, type MenuItem } from "./components/ContextMenu"
|
|
@@ -49,6 +51,7 @@ export function App() {
|
|
|
49
51
|
const { items: feedItems, loading: feedItemsLoading } = useItems(visibleIds)
|
|
50
52
|
const { entries: savedEntries, idSet: savedIds, isSaved, toggle: toggleSave } = useSaved()
|
|
51
53
|
const { entries: historyEntries, idSet: viewedIds, markViewed, clear: clearHistory } = useHistory()
|
|
54
|
+
const updateAvailable = useUpdateCheck()
|
|
52
55
|
|
|
53
56
|
const savedIdList = useMemo(() => savedEntries.map((e) => e.id), [savedEntries])
|
|
54
57
|
const { items: savedItemsRaw, loading: savedLoading } = useItems(
|
|
@@ -360,7 +363,10 @@ export function App() {
|
|
|
360
363
|
}
|
|
361
364
|
|
|
362
365
|
if (name === "t") {
|
|
363
|
-
|
|
366
|
+
const next = theme.name === "dark" ? lightTheme : darkTheme
|
|
367
|
+
// snapshot the old-theme frame BEFORE React re-renders in the new theme
|
|
368
|
+
if (renderer) startThemeWipe(renderer, String(next.accent))
|
|
369
|
+
setTheme(next)
|
|
364
370
|
return
|
|
365
371
|
}
|
|
366
372
|
|
|
@@ -580,6 +586,7 @@ export function App() {
|
|
|
580
586
|
view={view.kind === "detail" ? "detail" : "list"}
|
|
581
587
|
category={category}
|
|
582
588
|
loading={statusLoading}
|
|
589
|
+
updateAvailable={updateAvailable}
|
|
583
590
|
message={
|
|
584
591
|
view.kind === "resolveError"
|
|
585
592
|
? view.error._tag === "HnItemGone"
|
|
@@ -7,9 +7,10 @@ interface Props {
|
|
|
7
7
|
category?: Category
|
|
8
8
|
loading?: boolean
|
|
9
9
|
message?: string
|
|
10
|
+
updateAvailable?: string | null
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
export function StatusBar({ view, category, loading, message }: Props) {
|
|
13
|
+
export function StatusBar({ view, category, loading, message, updateAvailable }: Props) {
|
|
13
14
|
const t = useTheme()
|
|
14
15
|
const hints =
|
|
15
16
|
view === "list"
|
|
@@ -34,6 +35,9 @@ export function StatusBar({ view, category, loading, message }: Props) {
|
|
|
34
35
|
{loading ? <Loader /> : null}
|
|
35
36
|
<text fg={t.statusHint} {...selectionColors(t)}>{message ?? hints}</text>
|
|
36
37
|
<box flexGrow={1} />
|
|
38
|
+
{updateAvailable ? (
|
|
39
|
+
<text fg={t.textDim} {...selectionColors(t)}>{`v${updateAvailable} · hntui update`}</text>
|
|
40
|
+
) : null}
|
|
37
41
|
<text fg={t.statusHint} {...selectionColors(t)}>? help</text>
|
|
38
42
|
</box>
|
|
39
43
|
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useEffect, useState } from "react"
|
|
2
|
+
import { Effect, Fiber } from "effect"
|
|
3
|
+
import { AppRuntime } from "../runtime"
|
|
4
|
+
import { VERSION } from "../version"
|
|
5
|
+
import { compareVersions, fetchLatestVersion } from "../utils/selfUpdate"
|
|
6
|
+
|
|
7
|
+
// One check per app launch, silently skipped when offline: returns the newer
|
|
8
|
+
// version string when a release is ahead of this build, otherwise null.
|
|
9
|
+
export function useUpdateCheck(): string | null {
|
|
10
|
+
const [latest, setLatest] = useState<string | null>(null)
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const fiber = AppRuntime.runFork(
|
|
14
|
+
// fetchLatestVersion never throws (5s timeout, null on any failure)
|
|
15
|
+
Effect.promise(() => fetchLatestVersion()).pipe(
|
|
16
|
+
Effect.andThen((v) =>
|
|
17
|
+
Effect.sync(() => {
|
|
18
|
+
if (v && compareVersions(v, VERSION) > 0) setLatest(v)
|
|
19
|
+
}),
|
|
20
|
+
),
|
|
21
|
+
),
|
|
22
|
+
)
|
|
23
|
+
return () => {
|
|
24
|
+
AppRuntime.runFork(Fiber.interrupt(fiber))
|
|
25
|
+
}
|
|
26
|
+
}, [])
|
|
27
|
+
|
|
28
|
+
return latest
|
|
29
|
+
}
|
package/src/index.tsx
CHANGED
|
@@ -3,6 +3,19 @@ import { createCliRenderer } from "@opentui/core"
|
|
|
3
3
|
import { createRoot } from "@opentui/react"
|
|
4
4
|
import "opentui-spinner/react"
|
|
5
5
|
import { App } from "./App"
|
|
6
|
+
import { VERSION } from "./version"
|
|
7
|
+
import { selfUpdate } from "./utils/selfUpdate"
|
|
6
8
|
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
+
const arg = process.argv[2]
|
|
10
|
+
if (arg === "--version" || arg === "-v" || arg === "version") {
|
|
11
|
+
console.log(`hntui ${VERSION}`)
|
|
12
|
+
process.exit(0)
|
|
13
|
+
} else if (arg === "update") {
|
|
14
|
+
process.exit(await selfUpdate())
|
|
15
|
+
} else if (arg) {
|
|
16
|
+
console.error(`hntui: unknown command "${arg}"\nusage: hntui [update | --version]`)
|
|
17
|
+
process.exit(1)
|
|
18
|
+
} else {
|
|
19
|
+
const renderer = await createCliRenderer({ useMouse: true })
|
|
20
|
+
createRoot(renderer).render(<App />)
|
|
21
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { compareVersions, releaseAsset } from "./selfUpdate"
|
|
3
|
+
|
|
4
|
+
describe("compareVersions", () => {
|
|
5
|
+
test("orders plain versions", () => {
|
|
6
|
+
expect(compareVersions("0.4.0", "0.3.0")).toBeGreaterThan(0)
|
|
7
|
+
expect(compareVersions("0.3.0", "0.4.0")).toBeLessThan(0)
|
|
8
|
+
expect(compareVersions("0.4.0", "0.4.0")).toBe(0)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test("compares numerically, not lexically", () => {
|
|
12
|
+
expect(compareVersions("0.10.0", "0.9.0")).toBeGreaterThan(0)
|
|
13
|
+
expect(compareVersions("1.0.0", "0.99.99")).toBeGreaterThan(0)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test("tolerates missing segments", () => {
|
|
17
|
+
expect(compareVersions("1.0", "1.0.0")).toBe(0)
|
|
18
|
+
expect(compareVersions("1", "1.0.1")).toBeLessThan(0)
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe("releaseAsset", () => {
|
|
23
|
+
test("maps supported platforms", () => {
|
|
24
|
+
expect(releaseAsset("darwin", "arm64")).toBe("hntui-darwin-arm64.tar.gz")
|
|
25
|
+
expect(releaseAsset("linux", "x64")).toBe("hntui-linux-x64.tar.gz")
|
|
26
|
+
expect(releaseAsset("linux", "arm64")).toBe("hntui-linux-arm64.tar.gz")
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test("unsupported platforms get null (Intel macOS, windows)", () => {
|
|
30
|
+
expect(releaseAsset("darwin", "x64")).toBeNull()
|
|
31
|
+
expect(releaseAsset("win32", "x64")).toBeNull()
|
|
32
|
+
})
|
|
33
|
+
})
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { chmodSync, mkdtempSync, renameSync, rmSync } from "fs"
|
|
2
|
+
import { dirname, join } from "path"
|
|
3
|
+
import { VERSION } from "../version"
|
|
4
|
+
|
|
5
|
+
const REPO = "ahmd-sh/hntui"
|
|
6
|
+
const LATEST_URL = `https://github.com/${REPO}/releases/latest`
|
|
7
|
+
|
|
8
|
+
// "1.2.3" vs "1.10.0" → negative if a < b, 0 if equal, positive if a > b
|
|
9
|
+
export function compareVersions(a: string, b: string): number {
|
|
10
|
+
const pa = a.split(".").map(Number)
|
|
11
|
+
const pb = b.split(".").map(Number)
|
|
12
|
+
for (let i = 0; i < 3; i++) {
|
|
13
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0)
|
|
14
|
+
if (d !== 0) return d
|
|
15
|
+
}
|
|
16
|
+
return 0
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// which release tarball serves this machine, or null if none exists
|
|
20
|
+
export function releaseAsset(platform: string, arch: string): string | null {
|
|
21
|
+
if (platform === "darwin" && arch === "arm64") return "hntui-darwin-arm64.tar.gz"
|
|
22
|
+
if (platform === "linux" && (arch === "x64" || arch === "arm64"))
|
|
23
|
+
return `hntui-linux-${arch}.tar.gz`
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The releases/latest page redirects to .../tag/vX.Y.Z — the version is in a
|
|
28
|
+
// header of a tiny response: no API, no auth, no rate-limit concerns.
|
|
29
|
+
export async function fetchLatestVersion(timeoutMs = 5000): Promise<string | null> {
|
|
30
|
+
try {
|
|
31
|
+
const res = await fetch(LATEST_URL, {
|
|
32
|
+
redirect: "manual",
|
|
33
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
34
|
+
})
|
|
35
|
+
const location = res.headers.get("location") ?? ""
|
|
36
|
+
const m = location.match(/\/tag\/v(\d+\.\d+\.\d+)$/)
|
|
37
|
+
return m ? m[1]! : null
|
|
38
|
+
} catch {
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type Channel = "binary" | "bun" | "dev"
|
|
44
|
+
|
|
45
|
+
// A compiled executable runs from bun's virtual filesystem; under the bun
|
|
46
|
+
// runtime, a global install lives in node_modules, a source checkout doesn't.
|
|
47
|
+
function installChannel(): Channel {
|
|
48
|
+
if (import.meta.url.includes("$bunfs")) return "binary"
|
|
49
|
+
return import.meta.path.includes("node_modules") ? "bun" : "dev"
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// `hntui update` — returns a process exit code.
|
|
53
|
+
export async function selfUpdate(): Promise<number> {
|
|
54
|
+
const latest = await fetchLatestVersion()
|
|
55
|
+
if (!latest) {
|
|
56
|
+
console.error("hntui: couldn't reach GitHub to check the latest release.")
|
|
57
|
+
return 1
|
|
58
|
+
}
|
|
59
|
+
if (compareVersions(latest, VERSION) <= 0) {
|
|
60
|
+
console.log(`hntui ${VERSION} is up to date (latest release is v${latest}).`)
|
|
61
|
+
return 0
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
console.log(`hntui ${VERSION} → v${latest} available.`)
|
|
65
|
+
const channel = installChannel()
|
|
66
|
+
if (channel === "bun") {
|
|
67
|
+
console.log("This copy was installed with Bun — update it with:\n bun add -g @ahmd-sh/hntui")
|
|
68
|
+
return 0
|
|
69
|
+
}
|
|
70
|
+
if (channel === "dev") {
|
|
71
|
+
console.log("Running from a source checkout — update it with git pull.")
|
|
72
|
+
return 0
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const asset = releaseAsset(process.platform, process.arch)
|
|
76
|
+
if (!asset) {
|
|
77
|
+
console.error(
|
|
78
|
+
`hntui: no prebuilt binary for ${process.platform}-${process.arch}.\n` +
|
|
79
|
+
"Install with Bun instead: bun add -g @ahmd-sh/hntui",
|
|
80
|
+
)
|
|
81
|
+
return 1
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const target = process.execPath
|
|
85
|
+
const targetDir = dirname(target)
|
|
86
|
+
console.log(`downloading ${asset} ...`)
|
|
87
|
+
const res = await fetch(`https://github.com/${REPO}/releases/latest/download/${asset}`)
|
|
88
|
+
if (!res.ok) {
|
|
89
|
+
console.error(`hntui: download failed (HTTP ${res.status}).`)
|
|
90
|
+
return 1
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// extract in the target's own directory: the final rename must not cross
|
|
94
|
+
// filesystems, and renaming over a running executable is safe (the running
|
|
95
|
+
// process keeps its inode; the path points at the new file)
|
|
96
|
+
let tmpDir: string | null = null
|
|
97
|
+
try {
|
|
98
|
+
tmpDir = mkdtempSync(join(targetDir, ".hntui-update-"))
|
|
99
|
+
const tarPath = join(tmpDir, asset)
|
|
100
|
+
await Bun.write(tarPath, res)
|
|
101
|
+
const tar = Bun.spawn(["tar", "-xzf", tarPath, "-C", tmpDir], {
|
|
102
|
+
stdout: "ignore",
|
|
103
|
+
stderr: "pipe",
|
|
104
|
+
})
|
|
105
|
+
if ((await tar.exited) !== 0) {
|
|
106
|
+
console.error(`hntui: extract failed: ${await new Response(tar.stderr).text()}`)
|
|
107
|
+
return 1
|
|
108
|
+
}
|
|
109
|
+
const fresh = join(tmpDir, "hntui")
|
|
110
|
+
chmodSync(fresh, 0o755)
|
|
111
|
+
renameSync(fresh, target)
|
|
112
|
+
console.log(`updated to v${latest} (${target})`)
|
|
113
|
+
return 0
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.error(`hntui: update failed: ${err}\nIs ${targetDir} writable?`)
|
|
116
|
+
return 1
|
|
117
|
+
} finally {
|
|
118
|
+
if (tmpDir) rmSync(tmpDir, { recursive: true, force: true })
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { RGBA } from "@opentui/core"
|
|
2
|
+
import type { CliRenderer, OptimizedBuffer } from "@opentui/core"
|
|
3
|
+
|
|
4
|
+
// Animated theme switch: a wave sweeps out of a random point on a random
|
|
5
|
+
// screen edge and every cell flips to the new theme as the front passes it.
|
|
6
|
+
//
|
|
7
|
+
// It works as a renderer post-process, below React entirely: at trigger time
|
|
8
|
+
// we snapshot the last OLD-theme frame (char/fg/bg/attributes per cell), let
|
|
9
|
+
// React re-render the whole tree in the NEW theme underneath, and then — for
|
|
10
|
+
// the duration — repaint the snapshot onto every cell the wave hasn't reached
|
|
11
|
+
// yet. Cells just behind the front glow in the accent color for a beat.
|
|
12
|
+
|
|
13
|
+
const DURATION_MS = 500
|
|
14
|
+
const EDGE_WIDTH = 1.5
|
|
15
|
+
const X_SCALE = 0.55
|
|
16
|
+
|
|
17
|
+
let cancelActive: (() => void) | null = null
|
|
18
|
+
|
|
19
|
+
export function startThemeWipe(renderer: CliRenderer, accentHex: string): void {
|
|
20
|
+
// re-triggering mid-wipe restarts from whatever is on screen right now
|
|
21
|
+
cancelActive?.()
|
|
22
|
+
|
|
23
|
+
const src = renderer.currentRenderBuffer
|
|
24
|
+
const width = src.width
|
|
25
|
+
const height = src.height
|
|
26
|
+
const cells = width * height
|
|
27
|
+
const snap = src.buffers
|
|
28
|
+
const snapChar = snap.char.slice(0, cells)
|
|
29
|
+
const snapAttr = snap.attributes.slice(0, cells)
|
|
30
|
+
const snapFg = snap.fg.slice(0, cells * 4)
|
|
31
|
+
const snapBg = snap.bg.slice(0, cells * 4)
|
|
32
|
+
|
|
33
|
+
// a fresh origin per wipe: anywhere along any edge (0 top, 1 right, 2 bottom, 3 left)
|
|
34
|
+
const edge = (Math.random() * 4) | 0
|
|
35
|
+
const ox = edge === 1 ? width - 1 : edge === 3 ? 0 : Math.random() * (width - 1)
|
|
36
|
+
const oy = edge === 0 ? 0 : edge === 2 ? height - 1 : Math.random() * (height - 1)
|
|
37
|
+
|
|
38
|
+
// distance of every cell from the origin
|
|
39
|
+
const dist = new Float32Array(cells)
|
|
40
|
+
let maxDist = 0
|
|
41
|
+
for (let y = 0; y < height; y++) {
|
|
42
|
+
for (let x = 0; x < width; x++) {
|
|
43
|
+
const dx = (x - ox) * X_SCALE
|
|
44
|
+
const dy = y - oy
|
|
45
|
+
const d = Math.sqrt(dx * dx + dy * dy)
|
|
46
|
+
dist[y * width + x] = d
|
|
47
|
+
if (d > maxDist) maxDist = d
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const accent = RGBA.fromHex(accentHex)
|
|
52
|
+
const fgScratch = RGBA.fromValues(0, 0, 0, 1)
|
|
53
|
+
const bgScratch = RGBA.fromValues(0, 0, 0, 1)
|
|
54
|
+
const start = performance.now()
|
|
55
|
+
|
|
56
|
+
let fn: (buffer: OptimizedBuffer, deltaTime: number) => void
|
|
57
|
+
|
|
58
|
+
const cancel = () => {
|
|
59
|
+
renderer.removePostProcessFn(fn)
|
|
60
|
+
renderer.dropLive()
|
|
61
|
+
if (cancelActive === cancel) cancelActive = null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
fn = (buffer) => {
|
|
65
|
+
// terminal resized mid-wipe: the snapshot grid no longer applies
|
|
66
|
+
if (buffer.width !== width || buffer.height !== height) return cancel()
|
|
67
|
+
|
|
68
|
+
const t = Math.min(1, (performance.now() - start) / DURATION_MS)
|
|
69
|
+
const eased = 1 - (1 - t) * (1 - t) // ease-out: fast ignition, soft landing
|
|
70
|
+
const r = eased * (maxDist + EDGE_WIDTH)
|
|
71
|
+
const live = buffer.buffers
|
|
72
|
+
|
|
73
|
+
for (let i = 0; i < cells; i++) {
|
|
74
|
+
const d = dist[i]!
|
|
75
|
+
if (d <= r - EDGE_WIDTH) continue // wave passed: new theme shows through
|
|
76
|
+
|
|
77
|
+
const x = i % width
|
|
78
|
+
const y = (i / width) | 0
|
|
79
|
+
const o = i * 4
|
|
80
|
+
|
|
81
|
+
if (d <= r) {
|
|
82
|
+
// wavefront band: the just-flipped cell glows on an accent background
|
|
83
|
+
fgScratch.buffer[0] = live.fg[o]!
|
|
84
|
+
fgScratch.buffer[1] = live.fg[o + 1]!
|
|
85
|
+
fgScratch.buffer[2] = live.fg[o + 2]!
|
|
86
|
+
fgScratch.buffer[3] = live.fg[o + 3]!
|
|
87
|
+
buffer.drawChar(live.char[i]!, x, y, fgScratch, accent, live.attributes[i]!)
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// not reached yet: hold the old-theme snapshot in place
|
|
92
|
+
fgScratch.buffer[0] = snapFg[o]!
|
|
93
|
+
fgScratch.buffer[1] = snapFg[o + 1]!
|
|
94
|
+
fgScratch.buffer[2] = snapFg[o + 2]!
|
|
95
|
+
fgScratch.buffer[3] = snapFg[o + 3]!
|
|
96
|
+
bgScratch.buffer[0] = snapBg[o]!
|
|
97
|
+
bgScratch.buffer[1] = snapBg[o + 1]!
|
|
98
|
+
bgScratch.buffer[2] = snapBg[o + 2]!
|
|
99
|
+
bgScratch.buffer[3] = snapBg[o + 3]!
|
|
100
|
+
buffer.drawChar(snapChar[i]!, x, y, fgScratch, bgScratch, snapAttr[i]!)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (t >= 1) cancel()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
cancelActive = cancel
|
|
107
|
+
renderer.addPostProcessFn(fn)
|
|
108
|
+
renderer.requestLive()
|
|
109
|
+
}
|
package/src/version.ts
ADDED