@ahmd-sh/hntui 0.3.0 → 0.4.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 +14 -9
- package/package.json +1 -1
- package/src/App.tsx +5 -1
- package/src/utils/themeWipe.ts +109 -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
|
|
@@ -59,13 +63,13 @@ Press `q` (or `Ctrl-C`) to quit.
|
|
|
59
63
|
| Key | Action |
|
|
60
64
|
|---|---|
|
|
61
65
|
| `j` / `↓`, `k` / `↑` | Move cursor |
|
|
62
|
-
| `gg`, `
|
|
66
|
+
| `gg`, `G` | Jump to first or last |
|
|
63
67
|
| `Ctrl-D`, `Ctrl-U`, `PgDown`, `PgUp` | Scroll a half page |
|
|
64
68
|
| `c`, `Enter` | Open the story and read its comments |
|
|
65
69
|
| `h` / `←`, `l` / `→` | Previous or next tab |
|
|
66
70
|
| `Tab`, `Shift-Tab` | Cycle through tabs |
|
|
67
71
|
| `1` through `6` | Jump to a specific category |
|
|
68
|
-
| `
|
|
72
|
+
| `S` | Jump to the Saved list |
|
|
69
73
|
| `s` | Save or unsave the highlighted post |
|
|
70
74
|
| `o` | Open the story's URL in your browser |
|
|
71
75
|
| `r` | Refresh the current feed |
|
|
@@ -77,7 +81,7 @@ Press `q` (or `Ctrl-C`) to quit.
|
|
|
77
81
|
| Key | Action |
|
|
78
82
|
|---|---|
|
|
79
83
|
| `j` / `↓`, `k` / `↑` | Move the comment cursor |
|
|
80
|
-
| `gg`, `
|
|
84
|
+
| `gg`, `G` | Jump to first or last comment |
|
|
81
85
|
| `Ctrl-D`, `Ctrl-U`, `PgDown`, `PgUp` | Scroll a half page |
|
|
82
86
|
| `Space` | Collapse or expand the current subtree |
|
|
83
87
|
| `Enter` | Open the links popup for the current comment |
|
|
@@ -92,7 +96,7 @@ Press `q` (or `Ctrl-C`) to quit.
|
|
|
92
96
|
| Key | Action |
|
|
93
97
|
|---|---|
|
|
94
98
|
| `j`, `k`, `↑`, `↓` | Move |
|
|
95
|
-
| `gg`, `
|
|
99
|
+
| `gg`, `G` | First or last link |
|
|
96
100
|
| `o`, `Enter` | Open the highlighted link |
|
|
97
101
|
| `Esc`, `Backspace` | Close the popup |
|
|
98
102
|
|
|
@@ -133,7 +137,7 @@ Press `t` to toggle. The dark theme is mostly black with orange accents. The lig
|
|
|
133
137
|
|
|
134
138
|
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
139
|
|
|
136
|
-
`
|
|
140
|
+
`S` jumps straight to the Saved list from anywhere.
|
|
137
141
|
|
|
138
142
|
## Development
|
|
139
143
|
|
|
@@ -164,6 +168,7 @@ git push --follow-tags
|
|
|
164
168
|
|
|
165
169
|
- [OpenTUI](https://github.com/anomalyco/opentui) by Anomaly. The native TUI core that makes all of this possible.
|
|
166
170
|
- [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).
|
|
171
|
+
- [Effect](https://effect.website) for empowering the data layer under the hood.
|
|
167
172
|
- [Hacker News](https://news.ycombinator.com) for the content and the open API.
|
|
168
173
|
|
|
169
174
|
## License
|
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -20,6 +20,7 @@ import { resolveStory } from "./api/hn"
|
|
|
20
20
|
import type { HnError, HnItemGone } from "./api/hn"
|
|
21
21
|
import { AppRuntime } from "./runtime"
|
|
22
22
|
import { hnErrorMessage } from "./utils/errors"
|
|
23
|
+
import { startThemeWipe } from "./utils/themeWipe"
|
|
23
24
|
import { LinksPopup } from "./components/LinksPopup"
|
|
24
25
|
import { HelpOverlay } from "./components/HelpOverlay"
|
|
25
26
|
import { ContextMenu, type MenuItem } from "./components/ContextMenu"
|
|
@@ -360,7 +361,10 @@ export function App() {
|
|
|
360
361
|
}
|
|
361
362
|
|
|
362
363
|
if (name === "t") {
|
|
363
|
-
|
|
364
|
+
const next = theme.name === "dark" ? lightTheme : darkTheme
|
|
365
|
+
// snapshot the old-theme frame BEFORE React re-renders in the new theme
|
|
366
|
+
if (renderer) startThemeWipe(renderer, String(next.accent))
|
|
367
|
+
setTheme(next)
|
|
364
368
|
return
|
|
365
369
|
}
|
|
366
370
|
|
|
@@ -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
|
+
}
|