@oakoliver/bubblezone 0.0.0-stage → 1.0.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 ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Antonio Oliveira <antonio@oakoliver.com>
4
+ Copyright (c) 2022 Liam Stanley <liam@liam.sh>
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,186 @@
1
- # Temporary Holding Version
1
+ # @oakoliver/bubblezone
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Mouse hit-zones for Bubble Tea in TypeScript. Wrap a part of your view in `mark()`, run the finished view through `scan()` once, and ask `get(id).inBounds(msg)` when a mouse event arrives. A TypeScript port of [lrstanley/bubblezone](https://github.com/lrstanley/bubblezone) for [@oakoliver/bubbletea](https://github.com/oakoliver/bubbletea).
4
+
5
+ **Version 1.0.0** ports upstream at commit [`d3a8f5f`](https://github.com/lrstanley/bubblezone/commit/d3a8f5f72d19043b3fa10006bbdf6d19e29b9cc4) (`master`, eight commits after the `v2.0.0` tag; only CI, docs and lint config changed since the tag), the version built for Bubble Tea v2. It is meant for `@oakoliver/bubbletea` 1.2.x, which has parity with Bubble Tea v2.0.10. See [Parity with upstream](#parity-with-upstream) for what is ported and what differs.
6
+
7
+ <img alt="examples/buttons.ts --demo: a Delete 3 files? dialog with Yes and No buttons. A scripted pointer (the yellow cell) moves onto Yes, which turns green; a click prints where inside the zone it landed; the pointer then clicks the left edge of No, which turns red" src="https://raw.githubusercontent.com/oakoliver/bubblezone/main/assets/buttons.gif" width="600">
8
+
9
+ The pointer in this recording is scripted: a terminal recorder cannot click, so the examples have a `--demo` flag that sends real mouse messages to the running program, aimed at zones by ID. See [Recordings](#recordings).
10
+
11
+ **Read the article:** [Getting Started with Bubblezone in TypeScript](https://engineering.oakoliver.com/articles/getting-started-bubblezone-typescript-clickable-terminal-ui) · [Porting Bubblezone from Go to TypeScript](https://engineering.oakoliver.com/articles/porting-go-bubblezone-to-typescript-mouse-hit-testing-for-bubble-tea)
12
+
13
+ ## Larger Examples
14
+
15
+ Two full-screen programs in [`examples/`](examples/) use zones for everything you can click, in layouts of bordered panes, wrapped text and dialogs.
16
+
17
+ ### files.ts: a mouse-driven file manager
18
+
19
+ <img alt="examples/files.ts --demo: clicking Projects in the sidebar opens it; a double-click opens bubblezone; clicking the Size header sorts twice; a breadcrumb jumps back up; the Recent tab lists changed files; a right-click opens a context menu, Rename opens a dialog and a suffix is typed; Delete asks first; the Trash tab restores the file; the hidden-files toggle shows dotfiles" src="https://raw.githubusercontent.com/oakoliver/bubblezone/main/assets/files.gif" width="800">
20
+
21
+ An invented, in-memory file system (nothing on disk is read or changed). Tabs, toolbar buttons, a clickable path, sidebar places, sortable column headers, list rows, preview buttons, a context menu, dialogs and a scroll bar are all zones. Double-click opens, right-click opens a menu, the wheel scrolls.
22
+
23
+ ### console.ts: a service dashboard
24
+
25
+ <img alt="examples/console.ts --demo: the inventory card is selected and its replica count raised; the log is filtered, scrolled up with the wheel and released; Restart asks first and the card shows restarting; billing traffic is switched on; the environment chip changes to staging; the Deploys tab sorts by service, expands a row and opens a roll-back dialog that is cancelled" src="https://raw.githubusercontent.com/oakoliver/bubblezone/main/assets/console.gif" width="800">
26
+
27
+ Six service cards with a live sparkline, replica steppers, Restart, Logs and a traffic toggle, a scrolling log with filter chips, and a Deploys table. The buttons sit inside the card zone, so one click lands in several zones at once; the program receives them through `anyInBoundsAndUpdate()` and sorts them out by ID.
28
+
29
+ ### Small examples
30
+
31
+ | Program | What it shows |
32
+ |---|---|
33
+ | [`buttons.ts`](examples/buttons.ts) | `mark`, `scan`, `get().inBounds()` and `pos()` in one screen, with hover. |
34
+ | [`list.ts`](examples/list.ts) | A Bubbles list whose rows are clickable (port of upstream's `list-default`), and padding a multi-line zone so its box covers the row. |
35
+ | [`nested.ts`](examples/nested.ts) | `newPrefix()` for child components and `anyInBoundsAndUpdate()` with `MsgZoneInBounds`. |
36
+
37
+ <img alt="examples/list.ts --demo: clicking a row in a Bubbles list selects it, the wheel pages through the list" src="https://raw.githubusercontent.com/oakoliver/bubblezone/main/assets/list.gif" width="500"> <img alt="examples/nested.ts --demo: three counters; clicking minus, plus and reset inside each panel changes that panel, and the line under the panels names the zones the click landed in" src="https://raw.githubusercontent.com/oakoliver/bubblezone/main/assets/nested.gif" width="500">
38
+
39
+ ### Running the examples
40
+
41
+ The examples import this repository's source. Bubble Tea, Lip Gloss and Bubbles are dev dependencies here:
42
+
43
+ ```bash
44
+ git clone https://github.com/oakoliver/bubblezone && cd bubblezone
45
+ bun install
46
+ bun examples/files.ts # also: console.ts, buttons.ts, list.ts, nested.ts
47
+ bun examples/files.ts --demo # replay a scripted mouse
48
+ ```
49
+
50
+ Keys, flags and details are in [`examples/README.md`](examples/README.md).
51
+
52
+ ## Install
53
+
54
+ ```bash
55
+ npm install @oakoliver/bubblezone @oakoliver/bubbletea
56
+ ```
57
+
58
+ `@oakoliver/bubbletea` (^1.2.1) is a peer dependency so your program and bubblezone share one copy of Bubble Tea. `@oakoliver/lipgloss` (^1.1.3) is an optional peer dependency: bubblezone does not import it, but zones are only useful with views built the way Lip Gloss builds them.
59
+
60
+ ## Quick Start
61
+
62
+ ```typescript
63
+ import {
64
+ type Cmd, type Model, type Msg,
65
+ KeyPressMsg, MouseButton, MouseMode, MouseReleaseMsg, Program, Quit, View,
66
+ } from '@oakoliver/bubbletea';
67
+ import { joinHorizontal, newStyle, Top } from '@oakoliver/lipgloss';
68
+ import * as zone from '@oakoliver/bubblezone';
69
+
70
+ zone.NewGlobal(); // one app-wide manager; zone.New() makes one you pass around
71
+
72
+ class App implements Model {
73
+ active = 'none';
74
+
75
+ init(): Cmd { return null; }
76
+
77
+ update(msg: Msg): [Model, Cmd] {
78
+ if (msg instanceof MouseReleaseMsg && msg.button === MouseButton.Left) {
79
+ if (zone.get('confirm').inBounds(msg)) this.active = 'confirm';
80
+ else if (zone.get('cancel').inBounds(msg)) this.active = 'cancel';
81
+ }
82
+ if (msg instanceof KeyPressMsg && msg.toString() === 'q') return [this, Quit];
83
+ return [this, null];
84
+ }
85
+
86
+ view(): View {
87
+ const button = (label: string) => newStyle().padding(0, 3).background('#7D56F4').render(label);
88
+ const buttons = joinHorizontal(Top, zone.mark('confirm', button('OK')), ' ', zone.mark('cancel', button('Cancel')));
89
+ // scan() strips the markers and records where each zone ended up
90
+ const v = new View(zone.scan(newStyle().margin(1, 2).render(buttons + '\n\nlast click: ' + this.active)));
91
+ v.altScreen = true;
92
+ v.mouseMode = MouseMode.CellMotion; // AllMotion if you want hover
93
+ return v;
94
+ }
95
+ }
96
+
97
+ await new Program(new App()).run();
98
+ ```
99
+
100
+ - `mark(id, text)` wraps `text` in two invisible markers. They are private ANSI sequences with no width, so Lip Gloss measures, pads, joins and wraps the text as before.
101
+ - `scan(view)` goes over the finished view once, removes the markers and stores each zone's box. Call it on the outermost view only.
102
+ - `get(id)` returns the zone for `id`. It is never `null`: for an unknown ID you get a zero zone whose `inBounds()` is `false`.
103
+ - `zone.inBounds(msg)` is true when the event's cell is inside the zone's box; `zone.pos(msg)` is the cell relative to the zone's top-left, or `[-1, -1]`.
104
+
105
+ ## API
106
+
107
+ Everything is available as a `Manager` (`zone.New()`) and as functions on the global manager (`zone.NewGlobal()`). The global functions come in two spellings, `Mark` as in Go and `mark`; they are the same functions.
108
+
109
+ | Upstream (Go) | TypeScript | Notes |
110
+ |---|---|---|
111
+ | `zone.New() *Manager` | `New(): Manager` | |
112
+ | `zone.NewGlobal()`, `zone.DefaultManager` | `NewGlobal()`, `DefaultManager` | `DefaultManager` is `undefined` until `NewGlobal()`; the global functions throw `manager not initialized` before that. |
113
+ | `Manager.Mark(id, v)` | `manager.mark(id, v)` / `Mark`, `mark` | Returns `v` unchanged when disabled or when `id` or `v` is empty. |
114
+ | `Manager.Scan(v)` | `manager.scan(v)` / `Scan`, `scan` | Zones are stored before it returns. |
115
+ | `Manager.Get(id) *ZoneInfo` | `manager.get(id): ZoneInfo` | A zero zone, not `nil`. |
116
+ | `Manager.Clear(id)` | `manager.clear(id)` | |
117
+ | `Manager.NewPrefix()` | `manager.newPrefix()` | `zone_N__`; the counter is shared by all managers, as upstream. |
118
+ | `Manager.SetEnabled(bool)`, `Enabled()` | `setEnabled(bool)`, `enabled()` | |
119
+ | `Manager.Close()` | `manager.close()` | Stops storing zones; `scan()` still strips markers. |
120
+ | `ZoneInfo.StartX/StartY/EndX/EndY` | `startX/startY/endX/endY` | Zero-based, inclusive. Also `id`, `width`, `height`. |
121
+ | `ZoneInfo.IsZero()`, `InBounds(msg)`, `Pos(msg)` | `isZero()`, `inBounds(msg)`, `pos(msg)` | `pos()` returns `[x, y]`. They also accept a plain `{ x, y }`. |
122
+ | `MsgZoneInBounds{Zone, Event}` | `MsgZoneInBounds` with `zone`, `event`, `_tag` | |
123
+ | `Manager.AnyInBounds(model, msg)` | `manager.anyInBounds(model, msg)` | |
124
+ | `Manager.AnyInBoundsAndUpdate(model, msg)` | `manager.anyInBoundsAndUpdate(model, msg): [Model, Cmd]` | Commands are combined with Bubble Tea's `Batch`. |
125
+
126
+ Also exported: `printableWidth(text)` and `runeWidth(codePoint)`, the width functions the scanner uses.
127
+
128
+ ## Tips
129
+
130
+ These follow upstream's README, with what was measured against this port.
131
+
132
+ - **Overlapping IDs.** Use `newPrefix()` in components that can appear more than once and put the prefix in front of their zone IDs. [`nested.ts`](examples/nested.ts) does.
133
+ - **Use Lip Gloss's `width()`, not `.length`.** The markers have no width for `width()`/`stringWidth()`. They do count for `.length`.
134
+ - **Cutting keeps the zone.** Upstream warns that `MaxWidth()`/`MaxHeight()` hard-trim text and break zones. With Lip Gloss 1.1.3 `maxWidth()`, `truncate()` and `sliceAnsi()` keep the markers where they were, so a zone that is cut shrinks to the part still visible, and a zone cut off entirely becomes empty (its end lies before its start, so nothing is in bounds). Tests cover this; I found no cut that leaves a marker unpaired. Cutting *lines* (a height limit) was not tested.
135
+ - **Scan only at the root.** A scan inside a child removes markers the root needs, and each scan forgets zones it did not see.
136
+ - **A zone is a box**, from its first cell to its last. A zone that wraps onto a second line ends to the left of where it starts, and `inBounds()` is then `false` everywhere. Keep clickable text to one line, or do your own check.
137
+ - **A multi-line zone is only as wide as its last line.** If the lines differ in width, pad them to a common width before `mark()`. [`list.ts`](examples/list.ts) does.
138
+ - **Lip Gloss layers and the compositor drop the markers.** `newCompositor(...).render()` removes private escape sequences, so zones marked inside a layer, or in the text under one, are not found (a test shows this, with Lip Gloss 1.1.3). Upstream's README warns about the v2 compositor too. [`examples/_kit.ts`](examples/_kit.ts) has `splice()`, which puts a block over another and keeps the markers; render the page under a dialog without zones so the dialog is the only thing that takes clicks.
139
+ - **Alt screen and mouse mode.** Set `view.altScreen = true`, since zones are screen cells; use `MouseMode.CellMotion` for clicks and wheel, `AllMotion` for hover.
140
+
141
+ ## Parity with upstream
142
+
143
+ Ported from `lrstanley/bubblezone` at `d3a8f5f`:
144
+
145
+ | Upstream file | Here |
146
+ |---|---|
147
+ | `manager.go`, `manager_global.go` | `src/manager.ts`, `src/global.ts` |
148
+ | `scanner.go` | `src/scanner.ts` (a single pass instead of a state machine), `src/width.ts` |
149
+ | `zoneinfo.go` | `src/zoneinfo.ts` |
150
+ | `messages.go` | `src/messages.ts`, and `Manager.anyInBounds*` |
151
+ | `manager_test.go`, `zoneinfo_test.go`, `messages_test.go` | `tests/manager.test.ts`, `tests/zoneinfo.test.ts`, `tests/messages.test.ts`: every case of the scan table, plus the cases for mark, clear, close, disabled mode, position, bounds, `AnyInBounds` and `AnyInBoundsAndUpdate` |
152
+
153
+ What is the same: the marker format (`ESC [ <number> z`, numbers from 1001), pairing by marker, positions measured on the cleaned text (width since the last newline, count of newlines), the last zone wins when an ID is marked twice, stale zones dropped on every scan, `MsgZoneInBounds` sent in alphabetical order of ID.
154
+
155
+ What differs:
156
+
157
+ 1. **`scan()` is synchronous.** Upstream hands zones to a goroutine over a channel, so `Get` right after `Scan` may see old data, and its tests sleep. Here the zones are stored before `scan()` returns, so no test sleeps and `get()` right after `scan()` is correct. `Close()` has no worker to stop; it only turns storing off.
158
+ 2. **`get()` never returns `nil`.** An unknown ID gives a zero `ZoneInfo`.
159
+ 3. **Global `SetEnabled(false)` clears the zones**, like the manager's method does. Upstream's global function only flips the flag.
160
+ 4. **Markers from another manager are stripped and ignored.** Upstream stores them under an empty ID.
161
+ 5. **Width measurement is self-contained.** Upstream uses `go-runewidth` per code point and ends an escape sequence at the first letter, which also ends a hyperlink (`ESC ] 8 ; ; url BEL`) in the middle of its URL. `printableWidth()` skips OSC, DCS, APC, PM and SOS strings up to BEL or ST. It still counts per code point, as upstream does, so emoji built with a zero-width joiner count each part. It agrees with Lip Gloss's `stringWidth` on every sample in the tests that has no joiner sequences.
162
+ 6. **`inBounds()` and `pos()` also take a plain `{ x, y }`**, and `ZoneInfo` has `id`, `width` and `height`.
163
+ 7. **IDs are sorted by UTF-16 code unit**, upstream sorts bytes. The two differ only for IDs that mix characters above U+FFFF with some characters in U+E000–U+FFFF.
164
+
165
+ Not ported: upstream's Go benchmarks and `FuzzScan` (replaced by `bench/scan.ts` and a seeded random-input test), its `_examples/` programs (replaced by the examples here) and its repository tooling.
166
+
167
+ New tests that upstream does not have: measuring (wide characters, combining marks, hyperlinks), Lip Gloss layout (`joinHorizontal`, borders, padding, `place`, wrapping, `maxWidth`, compositor), two managers side by side, and the Quick Start above.
168
+
169
+ ## Development
170
+
171
+ ```bash
172
+ bun install
173
+ bun test # 122 tests
174
+ bun run typecheck # the library
175
+ bun run typecheck:examples
176
+ bun bench/scan.ts # timings
177
+ npm run build # dist/index.js, dist/index.cjs, dist/*.d.ts
178
+ ```
179
+
180
+ ## Recordings
181
+
182
+ The GIFs were recorded with [VHS](https://github.com/charmbracelet/vhs) from the tapes in `assets/tapes/`, run from the repository root: `vhs assets/tapes/files.tape`. VHS has no mouse commands (and its scroll commands do not arrive as wheel events), so each example takes `--demo`: a script in the example sends real `MouseMotionMsg`, `MouseClickMsg`, `MouseReleaseMsg` and `MouseWheelMsg` values to the running program with `program.send()`. The targets are zone IDs; for each step the script asks the zone manager where the zone is and aims at its middle, so a scripted click goes through the same `inBounds()` checks as a real one. A yellow cell shows the scripted pointer and a badge in the last row says `demo: scripted mouse`. What this does not test is the terminal's own mouse reporting; the Quick Start and the guide's programs were also run with real SGR mouse sequences written to a pseudo-terminal.
183
+
184
+ ## License
185
+
186
+ MIT. Copyright (c) 2025 Antonio Oliveira and (c) 2022 Liam Stanley, who wrote the original bubblezone. See [LICENSE](LICENSE).
@@ -0,0 +1,33 @@
1
+ import type { Cmd, Model, MouseMsg } from '@oakoliver/bubbletea';
2
+ import { Manager } from './manager.js';
3
+ import type { ZoneInfo } from './zoneinfo.js';
4
+ /** The app-wide manager. It is undefined until `NewGlobal()` is called. */
5
+ export declare let DefaultManager: Manager | undefined;
6
+ /**
7
+ * Creates the app-wide manager so you do not have to hand a manager to every
8
+ * component. Calling it again does nothing. If you write a library, accept a
9
+ * `Manager` from the user instead of relying on the global one.
10
+ */
11
+ export declare function NewGlobal(): void;
12
+ /** Stops the global manager. */
13
+ export declare function Close(): void;
14
+ /** Enables or disables the global manager; see `Manager.setEnabled`. */
15
+ export declare function SetEnabled(v: boolean): void;
16
+ /** Reports whether the global manager is enabled. */
17
+ export declare function Enabled(): boolean;
18
+ /** Returns a unique ID prefix; see `Manager.newPrefix`. */
19
+ export declare function NewPrefix(): string;
20
+ /** Wraps `v` in zone markers; see `Manager.mark`. */
21
+ export declare function Mark(id: string, v: string): string;
22
+ /** Forgets the stored zone for `id`. */
23
+ export declare function Clear(id: string): void;
24
+ /** Returns the zone for `id`, or a zero zone if it is not known; see `Manager.get`. */
25
+ export declare function Get(id: string): ZoneInfo;
26
+ /** Strips markers from a view and records the zones; see `Manager.scan`. */
27
+ export declare function Scan(v: string): string;
28
+ /** Sends a `MsgZoneInBounds` for every zone under the mouse; see `Manager.anyInBounds`. */
29
+ export declare function AnyInBounds(model: Model, mouse: MouseMsg): void;
30
+ /** Like `AnyInBounds`, carrying the updated model and batched commands through. */
31
+ export declare function AnyInBoundsAndUpdate(model: Model, mouse: MouseMsg): [Model, Cmd];
32
+ export { NewGlobal as newGlobal, Close as close, SetEnabled as setEnabled, Enabled as enabled, NewPrefix as newPrefix, Mark as mark, Clear as clear, Get as get, Scan as scan, AnyInBounds as anyInBounds, AnyInBoundsAndUpdate as anyInBoundsAndUpdate, };
33
+ //# sourceMappingURL=global.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"global.d.ts","sourceRoot":"","sources":["../src/global.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AACjE,OAAO,EAAE,OAAO,EAAO,MAAM,cAAc,CAAC;AAC5C,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAE9C,2EAA2E;AAC3E,eAAO,IAAI,cAAc,EAAE,OAAO,GAAG,SAAS,CAAC;AAO/C;;;;GAIG;AACH,wBAAgB,SAAS,IAAI,IAAI,CAGhC;AAED,gCAAgC;AAChC,wBAAgB,KAAK,IAAI,IAAI,CAE5B;AAED,wEAAwE;AACxE,wBAAgB,UAAU,CAAC,CAAC,EAAE,OAAO,GAAG,IAAI,CAE3C;AAED,qDAAqD;AACrD,wBAAgB,OAAO,IAAI,OAAO,CAEjC;AAED,2DAA2D;AAC3D,wBAAgB,SAAS,IAAI,MAAM,CAElC;AAED,qDAAqD;AACrD,wBAAgB,IAAI,CAAC,EAAE,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAElD;AAED,wCAAwC;AACxC,wBAAgB,KAAK,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAEtC;AAED,uFAAuF;AACvF,wBAAgB,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,QAAQ,CAExC;AAED,4EAA4E;AAC5E,wBAAgB,IAAI,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAEtC;AAED,2FAA2F;AAC3F,wBAAgB,WAAW,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,GAAG,IAAI,CAE/D;AAED,mFAAmF;AACnF,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,GAAG,CAAC,KAAK,EAAE,GAAG,CAAC,CAEhF;AAGD,OAAO,EACL,SAAS,IAAI,SAAS,EACtB,KAAK,IAAI,KAAK,EACd,UAAU,IAAI,UAAU,EACxB,OAAO,IAAI,OAAO,EAClB,SAAS,IAAI,SAAS,EACtB,IAAI,IAAI,IAAI,EACZ,KAAK,IAAI,KAAK,EACd,GAAG,IAAI,GAAG,EACV,IAAI,IAAI,IAAI,EACZ,WAAW,IAAI,WAAW,EAC1B,oBAAoB,IAAI,oBAAoB,GAC7C,CAAC"}