@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 CHANGED
@@ -1,6 +1,10 @@
1
- # hntui
1
+ <h1 align="center">hntui: Hacker News in your terminal!</h1>
2
2
 
3
- Hacker News in your terminal! (formerly published as `@ahmd-sh/hackernuis`)
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 (runtime included, nothing else needed) to `~/.local/bin`. You can also grab a binary for your platform directly from the [releases page](https://github.com/ahmd-sh/hntui/releases).
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
- Either way the command is `hntui`. Or run it once without installing:
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`, `Shift-G` | Jump to first or last |
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
- | `Shift-S` | Jump to the Saved list |
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`, `Shift-G` | Jump to first or last comment |
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`, `Shift-G` | First or last link |
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
- `Shift-S` jumps straight to the Saved list from anywhere.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ahmd-sh/hntui",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Beautiful Hacker News browser for the terminal, built on OpenTUI + React + Bun",
5
5
  "type": "module",
6
6
  "module": "src/index.tsx",
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
- setTheme((cur) => (cur.name === "dark" ? lightTheme : darkTheme))
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
+ }