raintron 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.
@@ -0,0 +1,323 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>raindown</title>
7
+ <style>
8
+ body { margin: 0; height: 100vh; overflow: hidden; background: #0b0e14; }
9
+ canvas { display: block; }
10
+ </style>
11
+ </head>
12
+ <body>
13
+ <canvas></canvas>
14
+ <script>
15
+ {
16
+ // A cell is the unit of the interface: one monospace character drawn over
17
+ // its own small bitmap. Transparent pixels (0) let the background show through.
18
+ const CELL_W = 8
19
+ const CELL_H = 16
20
+
21
+ const packedColors = new Map()
22
+
23
+ // Packs '#rrggbb' into the 32-bit ABGR layout used by ImageData on little-endian machines.
24
+ function packColor(hex) {
25
+ if (!packedColors.has(hex)) {
26
+ const r = parseInt(hex.slice(1, 3), 16)
27
+ const g = parseInt(hex.slice(3, 5), 16)
28
+ const b = parseInt(hex.slice(5, 7), 16)
29
+ packedColors.set(hex, ((255 << 24) | (b << 16) | (g << 8) | r) >>> 0)
30
+ }
31
+ return packedColors.get(hex)
32
+ }
33
+
34
+ const DEFAULT_FG = '#d8dee9'
35
+ const DEFAULT_BG = packColor('#0b0e14')
36
+
37
+ class Cell {
38
+ constructor(col, row) {
39
+ this.col = col
40
+ this.row = row
41
+ this.pixels = new Uint32Array(CELL_W * CELL_H)
42
+ this.reset()
43
+ }
44
+
45
+ reset() {
46
+ this.char = ' '
47
+ this.fg = DEFAULT_FG
48
+ this.bg = DEFAULT_BG
49
+ this.span = 1
50
+ this.pixels.fill(0)
51
+ this.dirty = true
52
+ }
53
+
54
+ // A wide character, like an emoji, spans two cells, and the cell after it is left empty.
55
+ writeChar(char, fg = this.fg, span = 1) {
56
+ this.char = char
57
+ this.fg = fg
58
+ this.span = span
59
+ this.dirty = true
60
+ }
61
+
62
+ setBackground(packed) {
63
+ this.bg = packed
64
+ this.dirty = true
65
+ }
66
+
67
+ clearPixels() {
68
+ this.pixels.fill(0)
69
+ this.dirty = true
70
+ }
71
+
72
+ setPixel(x, y, packed) {
73
+ this.pixels[y * CELL_W + x] = packed
74
+ this.dirty = true
75
+ }
76
+
77
+ // Writes this cell's pixels, with background filled in, into a shared framebuffer.
78
+ compositeInto(buffer, bufferWidth) {
79
+ const originX = this.col * CELL_W
80
+ const originY = this.row * CELL_H
81
+ for (let y = 0; y < CELL_H; y++) {
82
+ const rowStart = (originY + y) * bufferWidth + originX
83
+ for (let x = 0; x < CELL_W; x++) {
84
+ buffer[rowStart + x] = this.pixels[y * CELL_W + x] || this.bg
85
+ }
86
+ }
87
+ this.dirty = false
88
+ }
89
+ }
90
+
91
+
92
+ // The grid addresses the same surface two ways: by cell (col, row) for text,
93
+ // and by global pixel (px, py) for graphics. Every pixel belongs to exactly one cell.
94
+ class Grid {
95
+ constructor(cols, rows) {
96
+ this.cols = cols
97
+ this.rows = rows
98
+ this.cells = Array.from({ length: cols * rows }, (_, i) => new Cell(i % cols, Math.floor(i / cols)))
99
+ }
100
+
101
+ get pixelWidth() { return this.cols * CELL_W }
102
+ get pixelHeight() { return this.rows * CELL_H }
103
+
104
+ cellAt(col, row) {
105
+ if (col < 0 || row < 0 || col >= this.cols || row >= this.rows) return undefined
106
+ return this.cells[row * this.cols + col]
107
+ }
108
+
109
+ writeText(col, row, text, fg) {
110
+ ;[...text].forEach((char, i) => this.cellAt(col + i, row)?.writeChar(char, fg))
111
+ }
112
+
113
+ setPackedPixel(px, py, packed) {
114
+ this.cellAt(Math.floor(px / CELL_W), Math.floor(py / CELL_H))?.setPixel(px % CELL_W, py % CELL_H, packed)
115
+ }
116
+
117
+ clear() {
118
+ this.cells.forEach(cell => cell.reset())
119
+ }
120
+ }
121
+
122
+
123
+ // Draws a grid in two passes: all cell bitmaps into one low-res framebuffer that is
124
+ // scaled up crisply, then each cell's character centered on top, but for block glyphs, which are drawn as pixels.
125
+ class Renderer {
126
+ constructor(canvas, grid, scale) {
127
+ this.canvas = canvas
128
+ this.grid = grid
129
+ this.scale = scale
130
+ canvas.width = grid.pixelWidth * scale
131
+ canvas.height = grid.pixelHeight * scale
132
+ this.context = canvas.getContext('2d')
133
+ this.context.imageSmoothingEnabled = false
134
+
135
+ this.framebuffer = new OffscreenCanvas(grid.pixelWidth, grid.pixelHeight)
136
+ this.imageData = new ImageData(grid.pixelWidth, grid.pixelHeight)
137
+ this.pixels = new Uint32Array(this.imageData.data.buffer)
138
+ }
139
+
140
+ renderDirtyGrid() {
141
+ const dirtyCells = this.grid.cells.filter(cell => cell.dirty)
142
+ if (dirtyCells.length === 0) return
143
+ dirtyCells.forEach(cell => cell.compositeInto(this.pixels, this.grid.pixelWidth))
144
+ this.framebuffer.getContext('2d').putImageData(this.imageData, 0, 0)
145
+ this.context.drawImage(this.framebuffer, 0, 0, this.canvas.width, this.canvas.height)
146
+ this.drawChars()
147
+ }
148
+
149
+ drawChars() {
150
+ const cellWidth = CELL_W * this.scale
151
+ const cellHeight = CELL_H * this.scale
152
+ this.context.font = `${Math.floor(cellHeight * 0.85)}px ui-monospace, Menlo, monospace`
153
+ this.context.textAlign = 'center'
154
+ this.context.textBaseline = 'middle'
155
+ for (const cell of this.grid.cells) {
156
+ if (cell.char === ' ' || cell.char === '' || isBlockGlyph(cell.char)) continue
157
+ this.context.fillStyle = cell.fg
158
+ this.context.fillText(cell.char, (cell.col + cell.span / 2) * cellWidth, (cell.row + 0.5) * cellHeight)
159
+ }
160
+ }
161
+
162
+ cellAtClientPoint(clientX, clientY) {
163
+ const bounds = this.canvas.getBoundingClientRect()
164
+ const col = Math.floor((clientX - bounds.left) / bounds.width * this.grid.cols)
165
+ const row = Math.floor((clientY - bounds.top) / bounds.height * this.grid.rows)
166
+ return this.grid.cellAt(col, row)
167
+ }
168
+ }
169
+
170
+ // Raindown's rain cloud, written by bin/export-cloud from the terminal UI's Cloud. Edit that, rather than this.
171
+ const CLOUD_DRAWING = {"width":50,"rows":19,"cells":[[2,21,"█","#7eaeec"],[2,22,"▓","#a7b7f6"],[2,23,"▓","#bad7fc"],[2,24,"▓","#a1b2f7"],[2,25,"▓","#86d1f4"],[2,26,"▐","#689ee3"],[2,27,"█","#64bef2"],[2,28,"▓","#7481fb"],[2,29,"▓","#59c0f8"],[3,19,"█","#b5dcf7"],[3,20,"▚","#86b1ea"],[3,21,"▓","#869cfd"],[3,22,"▓","#98abf5"],[3,23,"█","#a2d9f1"],[3,24,"█","#83caf6"],[3,25,"▓","#bfc8fd"],[3,26,"█","#aad9fe"],[3,27,"▓","#99bef5"],[3,28,"█","#859ff4"],[3,29,"▓","#5995fc"],[3,30,"▓","#bcdef5"],[3,31,"▓","#b0dff3"],[4,17,"█","#83aaec"],[4,18,"▓","#5292e0"],[4,19,"█","#66bce1"],[4,20,"▓","#8da9e8"],[4,21,"█","#b3c5ef"],[4,22,"█","#537fdf"],[4,23,"█","#96a4f8"],[4,24,"█","#5371ea"],[4,25,"▓","#478bf0"],[4,26,"▓","#8b9fee"],[4,27,"█","#9ec1fa"],[4,28,"▓","#5198fb"],[4,29,"█","#7dd6fc"],[4,30,"▓","#a1b4f2"],[4,31,"█","#6089e1"],[4,32,"█","#6a9ce7"],[4,33,"█","#51c5fb"],[5,16,"▓","#5f70ec"],[5,17,"█","#5f79ec"],[5,18,"█","#4f69ee"],[5,19,"▓","#8fadf0"],[5,20,"█","#98b9f0"],[5,21,"▓","#a0b0f8"],[5,22,"█","#78b4fc"],[5,23,"▓","#75a8fa"],[5,24,"▓","#8094f9"],[5,25,"▓","#71b3fe"],[5,26,"█","#71b9ea"],[5,27,"█","#8bb3f9"],[5,28,"█","#73aef7"],[5,29,"▓","#87c1f8"],[5,30,"▓","#9ab5ef"],[5,31,"█","#4267fa"],[5,32,"▓","#77b5f3"],[5,33,"▓","#4e5cda"],[5,34,"█","#5274ef"],[6,15,"█","#7ba0e5"],[6,16,"▓","#9fd3f4"],[6,17,"█","#94a6f5"],[6,18,"█","#7fcbeb"],[6,19,"█","#5294e0"],[6,20,"█","#3a98fd"],[6,21,"█","#66c2ff"],[6,22,"▓","#4179e1"],[6,23,"▓","#7fa8e6"],[6,24,"▓","#96a5fd"],[6,25,"█","#779bfd"],[6,26,"▓","#519eec"],[6,27,"█","#4456f8"],[6,28,"▓","#85c8fe"],[6,29,"█","#a1c1f2"],[6,30,"█","#2a54f8"],[6,31,"▓","#5194ec"],[6,32,"█","#6ca4e0"],[6,33,"█","#a1c2ed"],[6,34,"█","#58c9fd"],[6,35,"█","#88d7fc"],[6,37,"█","#6076f0"],[7,15,"█","#5892e9"],[7,16,"▓","#7ea1f1"],[7,17,"█","#63bcf8"],[7,18,"█","#3448fe"],[7,19,"█","#6dcdfd"],[7,20,"▓","#1eb8fa"],[7,21,"▓","#1b87f3"],[7,22,"█","#45afed"],[7,23,"▓","#8dabe8"],[7,24,"▌","#626ffd"],[7,25,"█","#5d88e5"],[7,26,"█","#7eacec"],[7,27,"█","#3e89f9"],[7,28,"█","#8ac6f9"],[7,29,"█","#70b2f0"],[7,30,"█","#6ec5ed"],[7,31,"█","#7696ef"],[7,32,"█","#70c3fa"],[7,33,"█","#8ec6eb"],[7,34,"█","#6383ee"],[7,35,"█","#297bff"],[7,36,"▓","#6894ee"],[7,37,"█","#78a7ed"],[7,38,"█","#8ac0ff"],[7,39,"█","#97acf2"],[7,40,"█","#8ea0fa"],[7,41,"▓","#879ced"],[7,42,"█","#2e49e0"],[8,10,"█","#7aa9e6"],[8,11,"█","#7e88e7"],[8,12,"█","#54b4fc"],[8,13,"▓","#5785e0"],[8,14,"█","#143af5"],[8,15,"█","#89c9eb"],[8,16,"█","#829bf2"],[8,17,"█","#4db8ff"],[8,18,"█","#8997ec"],[8,19,"▀","#4f5ff3"],[8,20,"█","#5a85e2"],[8,21,"█","#428eff"],[8,22,"█","#66a9fa"],[8,23,"█","#2151e4"],[8,24,"█","#134af1"],[8,25,"▓","#7faef0"],[8,26,"█","#86a3f9"],[8,27,"█","#4aa0de"],[8,28,"█","#2a77f4"],[8,29,"▓","#3b62ed"],[8,30,"█","#537fee"],[8,31,"█","#4799e1"],[8,32,"█","#1d38ed"],[8,33,"█","#38b6fa"],[8,34,"█","#43a8db"],[8,35,"█","#70b9e6"],[8,36,"█","#567ef5"],[8,37,"█","#6874fd"],[8,38,"█","#6fa3e7"],[8,39,"█","#798fe6"],[8,40,"█","#7683f4"],[8,41,"█","#3aa8e4"],[8,42,"█","#6f87f1"],[8,43,"▄","#3647fc"],[9,8,"█","#3756e1"],[9,9,"█","#489cea"],[9,10,"█","#208cdf"],[9,11,"█","#485ae5"],[9,12,"█","#2243d3"],[9,13,"▓","#4674d8"],[9,14,"█","#62a1ef"],[9,15,"█","#4473f3"],[9,16,"█","#6abef6"],[9,17,"█","#5e91e8"],[9,18,"█","#4165f6"],[9,19,"█","#2d9efb"],[9,20,"█","#2a5dea"],[9,21,"█","#3376eb"],[9,22,"█","#106df9"],[9,23,"█","#76c9f9"],[9,24,"█","#3b57e3"],[9,25,"█","#768af9"],[9,26,"█","#0e9ef1"],[9,27,"█","#248cd6"],[9,28,"█","#6574ec"],[9,29,"█","#3654fc"],[9,30,"█","#66b2f0"],[9,31,"█","#2e5cdc"],[9,32,"█","#0ea6ec"],[9,33,"█","#5e85e8"],[9,34,"█","#04adf6"],[9,35,"█","#0330fc"],[9,36,"█","#889ae7"],[9,37,"█","#287ff0"],[9,38,"█","#09aefb"],[9,39,"█","#80c7ea"],[9,40,"█","#74c3fb"],[9,41,"█","#196ee6"],[9,42,"█","#528de5"],[9,43,"█","#70a0ff"],[9,44,"█","#768fea"],[10,7,"█","#349edf"],[10,8,"█","#2c7fdd"],[10,9,"█","#6f94f1"],[10,10,"█","#44a3da"],[10,11,"█","#4ec2f4"],[10,12,"█","#5493f2"],[10,13,"█","#62a5df"],[10,14,"█","#58c9fd"],[10,15,"█","#4d5bff"],[10,16,"█","#5fa2dd"],[10,17,"█","#1125df"],[10,18,"█","#172ad9"],[10,19,"█","#3058df"],[10,20,"█","#429eff"],[10,21,"█","#448bfd"],[10,22,"█","#3647fc"],[10,23,"█","#5a66ed"],[10,24,"█","#2335fb"],[10,25,"█","#1e64dc"],[10,26,"█","#4b59f1"],[10,27,"█","#4c99e6"],[10,28,"█","#234cd1"],[10,29,"█","#3153ed"],[10,30,"█","#2b62ee"],[10,31,"█","#697be2"],[10,32,"█","#6abbf0"],[10,33,"█","#0824fd"],[10,34,"█","#4a69d9"],[10,35,"█","#4eaef4"],[10,36,"█","#3c59d7"],[10,37,"█","#3972d5"],[10,38,"█","#4e9ce9"],[10,39,"█","#2438f0"],[10,40,"█","#2333e7"],[10,41,"█","#475ef0"],[10,42,"█","#1f6ee5"],[10,43,"█","#3195d8"],[10,44,"█","#71a1ef"],[10,45,"█","#50b1f1"],[11,6,"█","#1daeed"],[11,7,"█","#1ea3dc"],[11,8,"█","#5777f4"],[11,9,"█","#0f2bff"],[11,10,"█","#1c85e9"],[11,11,"█","#1b8dde"],[11,12,"█","#42aee0"],[11,13,"█","#224afc"],[11,14,"█","#2b95f7"],[11,15,"█","#495efd"],[11,16,"█","#2c90d8"],[11,17,"█","#6081eb"],[11,18,"█","#497bee"],[11,19,"█","#2942d1"],[11,20,"█","#1e7df1"],[11,21,"█","#68ace3"],[11,22,"█","#2270dd"],[11,23,"█","#1c47ca"],[11,24,"█","#4772ff"],[11,25,"█","#0474dc"],[11,26,"█","#2753c4"],[11,27,"█","#0120e9"],[11,28,"█","#619ff5"],[11,29,"█","#6794f4"],[11,30,"█","#1c87ca"],[11,31,"█","#2b70de"],[11,32,"█","#447ee4"],[11,33,"█","#5fb7e3"],[11,34,"█","#0d95de"],[11,35,"█","#5b97e1"],[11,36,"█","#5cb4eb"],[11,37,"█","#366be7"],[11,38,"█","#445ce4"],[11,39,"█","#2c9fd8"],[11,40,"█","#4b63e7"],[11,41,"█","#2a66df"],[11,42,"█","#214ed4"],[11,43,"█","#4891e5"],[11,44,"█","#289deb"],[11,45,"█","#32b8f5"],[12,6,"█","#3853dc"],[12,7,"█","#137fd8"],[12,8,"█","#103bc6"],[12,9,"█","#3887d6"],[12,10,"█","#46a4f6"],[12,11,"█","#1981c2"],[12,12,"█","#34b7ef"],[12,13,"█","#2683b5"],[12,14,"█","#2a6dfe"],[12,15,"█","#37a3eb"],[12,16,"█","#1867c9"],[12,17,"█","#0294e3"],[12,18,"█","#0076d6"],[12,19,"█","#2275ce"],[12,20,"█","#0d59de"],[12,21,"█","#0754da"],[12,22,"█","#45baed"],[12,23,"█","#0394e2"],[12,24,"█","#4e94da"],[12,25,"█","#58b5ee"],[12,26,"█","#5d90ea"],[12,27,"█","#4182d8"],[12,28,"█","#1144ee"],[12,29,"█","#1a51d1"],[12,30,"█","#2aa0e5"],[12,31,"█","#5d8ae5"],[12,32,"█","#43c0fe"],[12,33,"█","#2c60e2"],[12,34,"█","#1978eb"],[12,35,"█","#1d86c3"],[12,36,"█","#2635ba"],[12,37,"█","#5a77ed"],[12,38,"█","#0477fb"],[12,39,"█","#2379af"],[12,40,"█","#143bbd"],[12,41,"█","#3e8ce5"],[12,42,"█","#3f53ee"],[12,43,"█","#44bcf3"],[12,44,"█","#2133d4"],[12,45,"█","#2378c7"],[13,6,"█","#2a43e5"],[13,7,"█","#0440f6"],[13,8,"█","#0494d2"],[13,9,"█","#4fa3e3"],[13,10,"█","#0f7db8"],[13,11,"█","#1d52b4"],[13,12,"█","#5399df"],[13,13,"█","#256bc1"],[13,14,"█","#1b7ff8"],[13,15,"█","#0142da"],[13,16,"█","#116edf"],[13,17,"█","#0379d3"],[13,18,"█","#24a4f9"],[13,19,"█","#3fb1ee"],[13,20,"█","#184ee2"],[13,21,"█","#216dca"],[13,22,"█","#4a59e3"],[13,23,"█","#0f53c7"],[13,24,"█","#6198e0"],[13,25,"█","#4169d8"],[13,26,"█","#1532ef"],[13,27,"█","#074bc0"],[13,28,"█","#3cb7ec"],[13,29,"█","#335cff"],[13,30,"█","#0279e8"],[13,31,"█","#1959b3"],[13,32,"█","#2d90d2"],[13,33,"█","#1288bf"],[13,34,"█","#377ee1"],[13,35,"█","#0475f6"],[13,36,"█","#0b38cb"],[13,37,"█","#1c65e3"],[13,38,"█","#1a79bc"],[13,39,"█","#1a81c7"],[13,40,"█","#1663df"],[13,41,"█","#4155ec"],[13,42,"█","#2030d9"],[13,43,"▌","#094ac3"],[13,44,"█","#4f67f3"],[14,7,"█","#017cef"],[14,8,"█","#0f79eb"],[14,9,"█","#0659f4"],[14,10,"█","#1a99f9"],[14,11,"█","#2541d0"],[14,12,"█","#2e73ea"],[14,13,"█","#0531c2"],[14,14,"█","#0c32ed"],[14,15,"█","#0b64ea"],[14,16,"█","#174bc4"],[14,17,"█","#0a8ae6"],[14,18,"█","#048ad2"],[14,19,"█","#2952f5"],[14,20,"█","#0d59de"],[14,21,"█","#456ad9"],[14,22,"█","#2349a9"],[14,23,"█","#2339c7"],[14,24,"█","#2a65ef"],[14,25,"█","#3142f6"],[14,26,"█","#236da9"],[14,27,"█","#0873bf"],[14,28,"█","#1e9ff6"],[14,29,"█","#3668dd"],[14,30,"█","#014be9"],[14,31,"█","#1e4afa"],[14,32,"█","#0931b3"],[14,33,"█","#0d89bf"],[14,34,"█","#284de2"],[14,35,"█","#2230c9"],[14,36,"█","#128cce"],[14,37,"█","#45a4ed"],[14,38,"█","#0074c2"],[14,39,"█","#376cf1"],[14,40,"█","#1f5ce0"],[14,41,"█","#2a7ef4"],[14,42,"█","#1338f1"],[14,43,"█","#213eb0"],[15,7,"█","#0da9f8"],[15,8,"█","#1477c2"],[15,9,"█","#2b96de"],[15,10,"█","#0826d4"],[15,11,"█","#4aa8de"],[15,12,"█","#3046e8"],[15,13,"█","#0871d4"],[15,14,"█","#1c5eb5"],[15,15,"█","#233caf"],[15,16,"█","#005fdb"],[15,17,"█","#1441f5"],[15,18,"█","#0e43c8"],[15,19,"█","#1847c9"],[15,20,"▐","#174a96"],[15,21,"█","#3565de"],[15,22,"█","#4059e7"],[15,23,"█","#0566e6"],[15,24,"█","#1678b1"],[15,25,"█","#3f5be9"],[15,26,"█","#0d75de"],[15,27,"█","#1154e4"],[15,28,"█","#1f2eb2"],[15,29,"█","#154ef9"],[15,30,"█","#0158e4"],[15,31,"█","#1790ee"],[15,32,"█","#1d7ab4"],[15,33,"█","#26bcfd"],[15,34,"█","#387ad6"],[15,35,"█","#125bb5"],[15,36,"█","#062ff9"],[15,37,"█","#29a0db"],[15,38,"█","#2a68cb"],[15,39,"█","#2575e4"],[15,40,"█","#053acc"],[15,41,"█","#1736cf"],[15,42,"█","#076cdf"],[15,43,"█","#0f4cbd"],[16,8,"█","#1a2bc7"],[16,9,"█","#112092"],[16,10,"█","#0c489d"],[16,11,"█","#2c6fed"],[16,12,"█","#1f60d1"],[16,13,"█","#1ea6f6"],[16,14,"█","#1b7cac"],[16,15,"█","#0876a6"],[16,16,"█","#0c69ac"],[16,17,"█","#015fb7"],[16,18,"█","#3a5ee4"],[16,19,"█","#0c6f9d"],[16,20,"█","#205e9d"],[16,21,"█","#1644b1"],[16,22,"█","#0c49e4"],[16,23,"█","#2ba2de"],[16,24,"█","#172dd3"],[16,25,"█","#1d7fb4"],[16,26,"█","#0b92d5"],[16,27,"█","#0b8bd5"],[16,28,"█","#1a4899"],[16,29,"█","#0936c8"],[16,30,"█","#0335aa"],[16,31,"█","#10739e"],[16,32,"█","#113e92"],[16,33,"█","#3852e5"],[16,34,"█","#1f86c7"],[16,35,"█","#065ff9"],[16,36,"█","#0738ca"],[16,37,"█","#0b43da"],[16,38,"█","#0920c8"],[16,39,"█","#194c94"],[16,40,"█","#1682c0"],[16,41,"█","#368add"],[16,42,"█","#0c77b0"],[17,9,"█","#1c44ca"],[17,10,"█","#0459f6"],[17,11,"█","#061cc6"],[17,12,"█","#162f88"],[17,13,"█","#035fd8"],[17,14,"█","#0351a5"],[17,15,"█","#182795"],[17,16,"█","#0c60df"],[17,17,"█","#015cb7"],[17,18,"█","#1d46d7"],[17,19,"█","#1b86c0"],[17,20,"█","#101fc6"],[17,21,"█","#057de6"],[17,22,"█","#192d86"],[17,23,"█","#1531d1"],[17,24,"█","#032691"],[17,25,"█","#3170dd"],[17,26,"█","#2464b2"],[17,27,"█","#1c6e97"],[17,28,"█","#1348aa"],[17,29,"█","#3047d9"],[17,30,"█","#1a33c1"],[17,31,"█","#031fab"],[17,32,"█","#102689"],[17,33,"█","#1775b0"],[17,34,"█","#1d5a87"],[17,35,"█","#216eb5"],[17,36,"█","#1068fe"],[17,37,"█","#0f3285"],[17,38,"█","#0c558d"],[17,39,"█","#1636b6"],[17,40,"█","#0a1fd6"],[17,41,"▐","#1190d4"],[18,12,"█","#1e47c2"],[18,13,"█","#0b71a8"],[18,14,"█","#1847bf"],[18,15,"█","#173a9b"],[18,16,"█","#0d59af"],[18,17,"█","#1e358f"],[18,18,"█","#055699"],[18,19,"█","#1c3c9b"],[18,20,"█","#2386d1"],[18,21,"█","#2146ab"],[18,22,"█","#2b8aca"],[18,23,"█","#058ac7"],[18,24,"█","#0e6da0"],[18,25,"█","#0230bb"],[18,26,"█","#0734a6"],[18,27,"█","#0988d7"],[18,28,"█","#0f79d7"],[18,29,"█","#055e99"],[18,30,"█","#192b7b"],[18,31,"█","#196b9f"],[18,32,"█","#0b188e"],[18,33,"█","#1f70c7"],[18,34,"█","#1c429c"],[18,35,"█","#1d87dd"],[18,36,"█","#2145b0"],[18,37,"█","#2651b5"],[18,38,"█","#10238e"]],"rainy":[14,15,16,20,21,25,29,30,32,34,35,38,40,42],"rain":["│","╎","┊","'",","],"rainColors":["#66adff","#5b9ae3","#5087c7","#4474ab","#39618f","#2e4e73","#233b57","#17283b"]}
172
+
173
+
174
+ // Unicode block elements as which of a cell's pixels they cover, so they tile seamlessly, where a font's glyphs leave
175
+ // gaps between rows.
176
+ const HALF_W = CELL_W / 2
177
+ const HALF_H = CELL_H / 2
178
+ const COVERAGE = {
179
+ '█': () => true,
180
+ '▓': (x, y) => x % 2 === 1 || y % 2 === 1,
181
+ '▀': (x, y) => y < HALF_H,
182
+ '▄': (x, y) => y >= HALF_H,
183
+ '▌': (x) => x < HALF_W,
184
+ // Left eighths, like the ends of chart bars, an eighth of the cell each.
185
+ '▏': (x) => x < 1,
186
+ '▎': (x) => x < 2,
187
+ '▍': (x) => x < 3,
188
+ '▋': (x) => x < 5,
189
+ '▊': (x) => x < 6,
190
+ '▉': (x) => x < 7,
191
+ '▐': (x) => x >= HALF_W,
192
+ '▚': (x, y) => (x < HALF_W) === (y < HALF_H),
193
+ '▞': (x, y) => (x < HALF_W) !== (y < HALF_H)
194
+ }
195
+
196
+ function isBlockGlyph(char) {
197
+ return Object.hasOwn(COVERAGE, char)
198
+ }
199
+
200
+ function paintBlockGlyph(cell, char, packed) {
201
+ const covers = COVERAGE[char]
202
+ for (let y = 0; y < CELL_H; y++) {
203
+ for (let x = 0; x < CELL_W; x++) {
204
+ if (covers(x, y)) cell.setPixel(x, y, packed)
205
+ }
206
+ }
207
+ }
208
+
209
+
210
+ // The terminal UI's rain cloud, in the middle of the grid: its blocks painted as pixels, in their own blues, and its
211
+ // rain falling from the same columns, by the same checksums, but as pixel streaks moving down a pixel at a time.
212
+ const TICK_MS = 50
213
+ const RAIN_STEPS = CLOUD_DRAWING.rainColors.length
214
+ const RAIN_COLORS = CLOUD_DRAWING.rainColors.map(packColor)
215
+ const DROP_X = CELL_W / 2
216
+ // Each rain character as the pixel rows of its streak, like │ a long line and , a short one.
217
+ const STREAKS = {
218
+ '│': [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13],
219
+ '╎': [2, 3, 4, 5, 9, 10, 11, 12],
220
+ '┊': [2, 3, 6, 7, 10, 11],
221
+ "'": [2, 3, 4],
222
+ ',': [11, 12]
223
+ }
224
+
225
+ const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
226
+ let c = n
227
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
228
+ return c >>> 0
229
+ })
230
+
231
+ // Zlib's crc32, as the terminal UI checksums where each drop is.
232
+ function crc32(text) {
233
+ let crc = 0xffffffff
234
+ for (const byte of new TextEncoder().encode(text)) crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8)
235
+ return (crc ^ 0xffffffff) >>> 0
236
+ }
237
+
238
+ class RainCloud {
239
+ constructor(grid) {
240
+ this.grid = grid
241
+ this.left = Math.floor((grid.cols - CLOUD_DRAWING.width) / 2)
242
+ this.top = Math.max(Math.floor((grid.rows - CLOUD_DRAWING.rows) / 3), 0)
243
+ this.rainTop = this.top + CLOUD_DRAWING.rows
244
+ this.rainRows = grid.rows - this.rainTop
245
+ }
246
+
247
+ paintCloud() {
248
+ for (const [row, column, block, color] of CLOUD_DRAWING.cells) {
249
+ const cell = this.grid.cellAt(this.left + column, this.top + row)
250
+ if (cell) paintBlockGlyph(cell, block, packColor(color))
251
+ }
252
+ }
253
+
254
+ // The rain at a time in ticks, its fraction moving each drop part of the way to the row below.
255
+ paintRain(ticks) {
256
+ const tick = Math.floor(ticks)
257
+ const offset = Math.floor((ticks - tick) * CELL_H)
258
+ for (const column of CLOUD_DRAWING.rainy) {
259
+ for (let row = this.rainTop; row < this.grid.rows; row++) this.grid.cellAt(this.left + column, row)?.clearPixels()
260
+ for (let below = 0; below < this.rainRows; below++) this.paintDrop(column, below, tick, offset)
261
+ }
262
+ }
263
+
264
+ // The drop, if there's one, in the column at the row below the cloud at the tick, as the terminal UI's drop is.
265
+ paintDrop(column, below, tick, offset) {
266
+ const step = Math.floor(below * RAIN_STEPS / this.rainRows)
267
+ const drop = crc32(`${column} ${below - tick}`)
268
+ if (drop % 100 >= 90 - step * 9) return
269
+
270
+ const streak = STREAKS[CLOUD_DRAWING.rain[drop % CLOUD_DRAWING.rain.length]]
271
+ const x = (this.left + column) * CELL_W + DROP_X
272
+ const y = (this.rainTop + below) * CELL_H + offset
273
+ for (const row of streak) {
274
+ if (y + row < this.grid.pixelHeight) this.grid.setPackedPixel(x, y + row, RAIN_COLORS[step])
275
+ }
276
+ }
277
+ }
278
+
279
+
280
+ // The loading page: the rain cloud filling the window until the app's server answers, then the app.
281
+
282
+ // The editor's scale, so the cloud's cells are the grid's: a grid pixel is a CSS pixel, rounded to a whole number
283
+ // of the screen's.
284
+ const DEVICE_SCALE = Math.max(1, Math.round(devicePixelRatio))
285
+ const CSS_SCALE = DEVICE_SCALE / devicePixelRatio
286
+ const POLL_MS = 200
287
+
288
+ const grid = new Grid(
289
+ Math.max(Math.floor(innerWidth / (CELL_W * CSS_SCALE)), 50),
290
+ Math.max(Math.floor(innerHeight / (CELL_H * CSS_SCALE)), 24)
291
+ )
292
+ const canvas = document.querySelector('canvas')
293
+ const renderer = new Renderer(canvas, grid, DEVICE_SCALE)
294
+ canvas.style.width = `${grid.pixelWidth * CSS_SCALE}px`
295
+ canvas.style.height = `${grid.pixelHeight * CSS_SCALE}px`
296
+ const cloud = new RainCloud(grid)
297
+ cloud.paintCloud()
298
+
299
+ function animateRain(time) {
300
+ cloud.paintRain(time / TICK_MS)
301
+ renderer.renderDirtyGrid()
302
+ requestAnimationFrame(animateRain)
303
+ }
304
+ requestAnimationFrame(animateRain)
305
+
306
+ async function openAppWhenServing(url) {
307
+ for (;;) {
308
+ try {
309
+ await fetch(url, { mode: 'no-cors', cache: 'no-store' })
310
+ return location.replace(url)
311
+ } catch {
312
+ await new Promise(resolve => setTimeout(resolve, POLL_MS))
313
+ }
314
+ }
315
+ }
316
+
317
+ // The app's URL is the page's fragment, like loading.html#http://127.0.0.1:4133/, as a data: URL has no query.
318
+ const next = location.hash.slice(1)
319
+ if (next) openAppWhenServing(next)
320
+ }
321
+ </script>
322
+ </body>
323
+ </html>
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raintron
4
+ # webview_ruby compiles a native C++ extension against the platform's GUI toolkit
5
+ # (Cocoa+WebKit / GTK3+WebKit2GTK / MSHTML). Missing headers otherwise surface as a
6
+ # cryptic mkmf/rake compile failure deep in `bundle install` output, so check for the
7
+ # actual prerequisite upfront and say plainly what's missing. `raintron install`
8
+ # inserts a two-line `require 'preflight/preflight'; Raintron::Preflight.check!` at
9
+ # the top of the consuming app's Gemfile (not a copy-pasted version of this logic),
10
+ # so improvements here reach every consumer via `bundle update` rather than needing
11
+ # every app's Gemfile hand-edited again.
12
+ module Preflight
13
+ def self.check!
14
+ case RbConfig::CONFIG['host_os']
15
+ when /darwin/ then check_macos
16
+ when /linux/ then check_linux
17
+ end
18
+ end
19
+
20
+ def self.check_macos
21
+ return if system('xcrun --find clang++', out: File::NULL, err: File::NULL)
22
+
23
+ warn <<~MSG
24
+ warning: webview_ruby needs the Xcode Command Line Tools to compile its native
25
+ extension (Cocoa/WebKit bindings). Install them with:
26
+ xcode-select --install
27
+ MSG
28
+ end
29
+
30
+ def self.check_linux
31
+ return if system('pkg-config --exists gtk+-3.0 webkit2gtk-4.0', out: File::NULL, err: File::NULL)
32
+
33
+ warn <<~MSG
34
+ warning: webview_ruby needs GTK3 + WebKit2GTK development headers to compile its
35
+ native extension. Install them first, e.g.:
36
+ Debian/Ubuntu: sudo apt-get install -y build-essential libgtk-3-dev libwebkit2gtk-4.0-dev
37
+ Fedora: sudo dnf install -y gcc-c++ gtk3-devel webkit2gtk3-devel
38
+ MSG
39
+ end
40
+
41
+ private_class_method :check_macos, :check_linux
42
+ end
43
+ end
data/lib/raintron.rb ADDED
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'version'
4
+ require_relative 'launcher/launcher'
5
+ require_relative 'shims/shims'
6
+ require_relative 'icons/icon_generator'
7
+ require_relative 'app_config/app_config'
8
+
9
+ # Merely requiring 'raintron' (i.e. having it in a Gemfile) applies zero shims and
10
+ # never requires 'webview_ruby' -- both only happen inside Launcher#run, and the
11
+ # shims only inside the forked server child. A plain `rain server` dev workflow with
12
+ # raintron in the Gemfile is unaffected by its presence; see lib/shims/shims.rb.
13
+ module Raintron
14
+ class Error < StandardError; end
15
+ class UnsupportedSourceError < Error; end
16
+
17
+ def self.launch(**kwargs, &server_block)
18
+ Launcher.new(**kwargs, &server_block).run
19
+ end
20
+
21
+ def self.packed?
22
+ Shims.packed?
23
+ end
24
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'io/console'
4
+
5
+ # raindeer's LowFrame calls IO.console.winsize unconditionally to size its terminal
6
+ # animation. IO.console is nil with no controlling tty (e.g. launched from Finder/Dock
7
+ # as a .app, or spawned by webview's bin/desktop), which crashes boot. Fall back to a
8
+ # fixed size in that case; a real terminal's console is used untouched otherwise.
9
+ module Raintron
10
+ module ConsoleShim
11
+ FakeConsole = Struct.new(:winsize)
12
+
13
+ def console(*args)
14
+ super || FakeConsole.new([40, 120])
15
+ end
16
+ end
17
+ end
18
+
19
+ IO.singleton_class.prepend(Raintron::ConsoleShim)
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Tebako's packed virtual filesystem interposes regular file I/O (open/read/stat) but
4
+ # not chdir(2) -- gems that Dir.chdir into their own lib directory just to Dir.glob
5
+ # relative to it (e.g. low_type's adapter discovery) get ENOENT for a path that
6
+ # plainly exists. Fall back to running the block without actually changing directory:
7
+ # every current caller of this pattern only globs for optional plugin files that don't
8
+ # ship anyway, so the glob coming up empty either way is a no-op, not a behavior change.
9
+ module Raintron
10
+ module ChdirFallback
11
+ def chdir(dir = nil, &block)
12
+ return super unless block
13
+
14
+ super
15
+ rescue Errno::ENOENT
16
+ block.call(dir)
17
+ end
18
+ end
19
+ end
20
+
21
+ Dir.singleton_class.prepend(Raintron::ChdirFallback)
22
+
23
+ # Prism.parse_file reads the target file natively (C extension), bypassing whatever
24
+ # path Tebako interposes for Ruby-level File/require reads -- it ENOENTs on packed
25
+ # paths that `require`/`require_relative` load from just fine elsewhere in the same
26
+ # process. low_type's `lowkey` uses it to introspect a gem's own source at load time.
27
+ # Fall back to reading through Ruby's File (which the packed image does support) and
28
+ # parsing that string instead; Prism.parse and Prism.parse_file return the same
29
+ # Prism::ParseResult shape for identical source, so this is a transparent substitution.
30
+ require 'prism'
31
+
32
+ module Raintron
33
+ module PrismParseFileFallback
34
+ def parse_file(filepath, **options)
35
+ super
36
+ rescue Errno::ENOENT
37
+ parse(File.binread(filepath), **options)
38
+ end
39
+ end
40
+ end
41
+
42
+ Prism.singleton_class.prepend(Raintron::PrismParseFileFallback)
43
+
44
+ # Tebako doesn't support chdir(2) into any packed path, including the app's own root
45
+ # (real chdir there ENOENTs same as anywhere else) -- but raindeer's boot.rb builds
46
+ # config/config.yaml's path from Dir.pwd, assuming cwd == app root. Rather than actually
47
+ # changing directory, report the app root as Dir.pwd's answer whenever the entry point
48
+ # has told us (via RAINTRON_APP_ROOT) what it is; regular file reads against that path
49
+ # already work fine (require/require_relative use them throughout), so this makes
50
+ # Dir.pwd-relative path math resolve correctly without needing real chdir at all.
51
+ #
52
+ # Launcher sets RAINTRON_APP_ROOT unconditionally, in both packed and normal dev runs
53
+ # (it's simply "what is the app's own root", not a packed-only concept -- it also makes
54
+ # bin/desktop robust to being launched from a cwd other than the project root even
55
+ # without Tebako involved). Because of that, this override can't double as the signal
56
+ # for Shims.packed? the way an earlier draft assumed -- see shims.rb.
57
+ module Raintron
58
+ module PwdOverride
59
+ def pwd
60
+ ENV['RAINTRON_APP_ROOT'] || super
61
+ end
62
+ end
63
+ end
64
+
65
+ Dir.singleton_class.prepend(Raintron::PwdOverride)
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raintron
4
+ # Applies the environment-compatibility patches a desktop-launched server needs.
5
+ # Never applied by a plain `require 'raintron'` -- only Launcher#start_server calls
6
+ # this, and only inside the forked server child, so a normal `rain server` dev
7
+ # workflow with raintron in the Gemfile is completely unaffected by its presence.
8
+ #
9
+ # Even the packed shims are individually inert on an ordinary filesystem (the chdir
10
+ # and Prism fallbacks only fire on ENOENT; PwdOverride only overrides Dir.pwd when
11
+ # TEBAKO_APP_ROOT is set), so the `packed:` gate here is belt-and-suspenders on top
12
+ # of that, not the only thing preventing interference with normal development.
13
+ module Shims
14
+ def self.apply!(packed: packed?)
15
+ require_relative 'console_shim'
16
+ require_relative 'packed_fs_shim' if packed
17
+ end
18
+
19
+ # Tebako doesn't document any Ruby-level "am I packed" marker (checked directly
20
+ # against its README/spec docs) -- but every file loaded from inside a packed image,
21
+ # gem code included, resolves to a path containing "/__tfs__/" (confirmed directly
22
+ # against this session's own crash logs from a real pressed build, e.g.
23
+ # ".../__tfs__/lib/ruby/gems/.../raindeer-0.9.9/lib/raindeer/boot.rb"). Checking
24
+ # THIS file's own __dir__ is self-contained -- it doesn't depend on the consuming
25
+ # app's entry point cooperating -- but it is pinned to a specific Tebako version's
26
+ # internal mount name, not a documented stable API, so RAINTRON_FORCE_PACKED is an
27
+ # escape hatch if a future Tebako release ever changes it.
28
+ def self.packed?
29
+ return true if ENV['RAINTRON_FORCE_PACKED']
30
+
31
+ __dir__.include?('/__tfs__/')
32
+ end
33
+ end
34
+ end
data/lib/version.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raintron
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ffi'
4
+ require 'json'
5
+
6
+ module Raintron
7
+ # A window without a title bar, like Obsidian's, on macOS: the page runs under a transparent title bar with its
8
+ # title hidden, leaving only the traffic lights over it. The page takes the title bar's place, so it's told with
9
+ # window.raintron = { titlebar: 'hidden' }, and calls window.raintronDragWindow() on a mouse down where the window
10
+ # should be dragged from, as WebKit takes the mouse events the title bar had.
11
+ #
12
+ # Required only by Launcher#open_window, after webview_ruby, in the window's process. Elsewhere the title bar stays.
13
+ module WindowChrome
14
+ FULL_SIZE_CONTENT_VIEW = 1 << 15
15
+ TITLE_HIDDEN = 1
16
+
17
+ # The Objective-C runtime, with objc_msgSend typed for each shape of message sent here.
18
+ module ObjC
19
+ extend FFI::Library
20
+
21
+ ffi_lib '/usr/lib/libobjc.A.dylib'
22
+ attach_function :get_class, :objc_getClass, [:string], :pointer
23
+ attach_function :selector, :sel_registerName, [:string], :pointer
24
+ attach_function :send_for_pointer, :objc_msgSend, %i[pointer pointer], :pointer
25
+ attach_function :send_for_ulong, :objc_msgSend, %i[pointer pointer], :ulong
26
+ attach_function :send_pointer, :objc_msgSend, %i[pointer pointer pointer], :void
27
+ attach_function :send_ulong, :objc_msgSend, %i[pointer pointer ulong], :void
28
+ attach_function :send_bool, :objc_msgSend, %i[pointer pointer bool], :void
29
+ attach_function :send_long, :objc_msgSend, %i[pointer pointer long], :void
30
+ end
31
+
32
+ module_function
33
+
34
+ def hide_titlebar(webview)
35
+ return unless RbConfig::CONFIG['host_os'].include?('darwin')
36
+
37
+ window = native_window(webview)
38
+ style = ObjC.send_for_ulong(window, ObjC.selector('styleMask'))
39
+ ObjC.send_ulong(window, ObjC.selector('setStyleMask:'), style | FULL_SIZE_CONTENT_VIEW)
40
+ ObjC.send_bool(window, ObjC.selector('setTitlebarAppearsTransparent:'), true)
41
+ ObjC.send_long(window, ObjC.selector('setTitleVisibility:'), TITLE_HIDDEN)
42
+ webview.bind('raintronDragWindow') { |*| drag(window) }
43
+ webview.init("window.raintron = #{JSON.generate(titlebar: 'hidden')}")
44
+ end
45
+
46
+ # Drags the window with the mouse down that's being handled, as a title bar would.
47
+ def drag(window)
48
+ application = ObjC.send_for_pointer(ObjC.get_class('NSApplication'), ObjC.selector('sharedApplication'))
49
+ event = ObjC.send_for_pointer(application, ObjC.selector('currentEvent'))
50
+ ObjC.send_pointer(window, ObjC.selector('performWindowDragWithEvent:'), event) unless event.null?
51
+ end
52
+
53
+ # The NSWindow. webview_ruby keeps webview.h's handle to itself and doesn't bind webview_get_window, which its
54
+ # library exports, so it's bound here.
55
+ def native_window(webview)
56
+ WebviewRuby.attach_function(:webview_get_window, [:pointer], :pointer) unless WebviewRuby.respond_to?(:webview_get_window)
57
+ WebviewRuby.webview_get_window(webview.instance_variable_get(:@window))
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,24 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>CFBundleName</key>
6
+ <string><%= app_name %></string>
7
+ <key>CFBundleDisplayName</key>
8
+ <string><%= app_name %></string>
9
+ <key>CFBundleIdentifier</key>
10
+ <string><%= bundle_id %></string>
11
+ <key>CFBundleVersion</key>
12
+ <string>1.0</string>
13
+ <key>CFBundleShortVersionString</key>
14
+ <string>1.0</string>
15
+ <key>CFBundlePackageType</key>
16
+ <string>APPL</string>
17
+ <key>CFBundleExecutable</key>
18
+ <string>launch</string>
19
+ <key>CFBundleIconFile</key>
20
+ <string>AppIcon</string>
21
+ <key>NSHighResolutionCapable</key>
22
+ <true/>
23
+ </dict>
24
+ </plist>
@@ -0,0 +1,6 @@
1
+ #!/bin/bash
2
+ set -e
3
+ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
4
+ cd "$DIR"
5
+ export PATH="$HOME/.rbenv/shims:$HOME/.rbenv/bin:$PATH"
6
+ exec bundle exec bin/desktop