rori 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.
Files changed (86) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +25 -0
  3. data/LICENSE +21 -0
  4. data/README.md +279 -0
  5. data/app/assets/stylesheets/rori/_init.css +79 -0
  6. data/app/assets/stylesheets/rori/layout.css +162 -0
  7. data/app/assets/stylesheets/rori/menubar.css +117 -0
  8. data/app/assets/stylesheets/rori/notifications.css +120 -0
  9. data/app/assets/stylesheets/rori/palette.css +114 -0
  10. data/app/assets/stylesheets/rori/shortcuts.css +110 -0
  11. data/app/assets/stylesheets/rori/terminal.css +98 -0
  12. data/app/assets/stylesheets/rori/themes.css +156 -0
  13. data/app/assets/stylesheets/rori/typing_badge.css +56 -0
  14. data/app/assets/stylesheets/rori/wallpaper.css +52 -0
  15. data/app/assets/stylesheets/rori/window.css +174 -0
  16. data/app/controllers/concerns/rori/windowed.rb +15 -0
  17. data/app/controllers/rori/commands/bars_controller.rb +9 -0
  18. data/app/controllers/rori/commands/runs_controller.rb +22 -0
  19. data/app/controllers/rori/commands/themes_controller.rb +9 -0
  20. data/app/controllers/rori/commands/ui_controller.rb +9 -0
  21. data/app/controllers/rori/commands/wallpapers_controller.rb +9 -0
  22. data/app/controllers/rori/commands_controller.rb +7 -0
  23. data/app/controllers/rori/desktops_controller.rb +7 -0
  24. data/app/helpers/rori/assets_helper.rb +9 -0
  25. data/app/helpers/rori/keymap_helper.rb +50 -0
  26. data/app/helpers/rori/prefs_helper.rb +13 -0
  27. data/app/helpers/rori/theme_helper.rb +14 -0
  28. data/app/helpers/rori/wallpaper_helper.rb +19 -0
  29. data/app/helpers/rori/window_helper.rb +36 -0
  30. data/app/javascript/rori/clock_controller.js +18 -0
  31. data/app/javascript/rori/command_index.js +84 -0
  32. data/app/javascript/rori/fuzzy.js +50 -0
  33. data/app/javascript/rori/index.js +21 -0
  34. data/app/javascript/rori/notifications_controller.js +45 -0
  35. data/app/javascript/rori/palette_controller.js +307 -0
  36. data/app/javascript/rori/prefs_controller.js +68 -0
  37. data/app/javascript/rori/rori_controller.js +996 -0
  38. data/app/javascript/rori/terminal_controller.js +198 -0
  39. data/app/javascript/rori/theme_controller.js +35 -0
  40. data/app/javascript/rori/wallpaper_controller.js +67 -0
  41. data/app/models/rori/command.rb +105 -0
  42. data/app/models/rori/notification.rb +20 -0
  43. data/app/models/rori/theme/color.rb +30 -0
  44. data/app/models/rori/theme.rb +87 -0
  45. data/app/models/rori/wallpaper.rb +54 -0
  46. data/app/views/layouts/rori/_head.html.haml +2 -0
  47. data/app/views/layouts/rori/shell.html.haml +97 -0
  48. data/app/views/layouts/rori/window.html.haml +10 -0
  49. data/app/views/rori/_menubar.html.haml +14 -0
  50. data/app/views/rori/_notification.html.haml +9 -0
  51. data/app/views/rori/_notifications.html.haml +10 -0
  52. data/app/views/rori/_palette.html.haml +13 -0
  53. data/app/views/rori/_shortcuts.html.haml +47 -0
  54. data/app/views/rori/_terminal.html.haml +9 -0
  55. data/app/views/rori/_wallpaper.html.haml +7 -0
  56. data/app/views/rori/_wallpaper_credit.html.haml +3 -0
  57. data/app/views/rori/_window.html.haml +15 -0
  58. data/app/views/rori/commands/_command.html.haml +17 -0
  59. data/app/views/rori/commands/index.html.haml +5 -0
  60. data/app/views/rori/desktops/show.html.haml +1 -0
  61. data/config/importmap.rb +3 -0
  62. data/config/locales/rori.en.yml +133 -0
  63. data/config/routes/rori.rb +13 -0
  64. data/lib/generators/rori/install/install_generator.rb +67 -0
  65. data/lib/generators/rori/install/templates/rori.rb.tt +22 -0
  66. data/lib/rori/engine.rb +19 -0
  67. data/lib/rori/version.rb +3 -0
  68. data/lib/rori.rb +180 -0
  69. data/lib/tasks/rori.rake +9 -0
  70. data/vendor/themes/LICENSE +25 -0
  71. data/vendor/themes/ghostty/Catppuccin Latte +22 -0
  72. data/vendor/themes/ghostty/Catppuccin Mocha +22 -0
  73. data/vendor/themes/ghostty/Everforest Dark Med +22 -0
  74. data/vendor/themes/ghostty/Flexoki Light +22 -0
  75. data/vendor/themes/ghostty/GitHub Dark Dimmed +22 -0
  76. data/vendor/themes/ghostty/Gruvbox Material Dark +22 -0
  77. data/vendor/themes/ghostty/Kanagawa Wave +22 -0
  78. data/vendor/themes/ghostty/Melange Dark +22 -0
  79. data/vendor/themes/ghostty/Nord +22 -0
  80. data/vendor/themes/ghostty/Rose Pine Dawn +22 -0
  81. data/vendor/themes/ghostty/Rose Pine Moon +22 -0
  82. data/vendor/themes/ghostty/TokyoNight Storm +22 -0
  83. data/vendor/themes/ghostty/Tomorrow Night Eighties +22 -0
  84. data/vendor/themes/ghostty/Zenbones Light +22 -0
  85. data/vendor/wallpapers/unsplash.yml +23 -0
  86. metadata +219 -0
@@ -0,0 +1,307 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { score } from "rori/fuzzy"
3
+ import { CommandIndex } from "rori/command_index"
4
+
5
+ const USAGE_KEY = "rori:palette:usage"
6
+ // Shared with the terminal: whoever runs commands listens on rori-command:*.
7
+ const BUS = "rori-command"
8
+
9
+ // ⌘K command palette. Command lists are server-rendered into a turbo-frame
10
+ // (the root list is fetched on every open so new records show up) and ranked
11
+ // client-side, with a bonus for commands you run often. Enter pressed while a
12
+ // list is still arriving runs once it lands.
13
+ //
14
+ // Commands with `data-children` are nested lists: picking one swaps the frame
15
+ // to that URL and pushes a breadcrumb; Backspace on an empty query or Esc goes
16
+ // back up. Runs are handed out as `rori-command:run`, the selection as
17
+ // `rori-command:preview` (e.g. live theme preview), and `rori-command:closed`
18
+ // on close. Commands with `data-confirm` run on the second ↵ (or click); the
19
+ // first one arms them, and moving the selection disarms.
20
+ export default class extends Controller {
21
+ static targets = ["dialog", "input", "frame", "list", "item", "itemTemplate", "empty", "crumbs"]
22
+ static values = { root: String, confirm: String }
23
+
24
+ connect() {
25
+ this.stack = []
26
+ }
27
+
28
+ keydown(event) {
29
+ if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "k") return
30
+ event.preventDefault()
31
+ this.dialogTarget.open ? this.close() : this.open()
32
+ }
33
+
34
+ open() {
35
+ this.returnFocus = document.activeElement
36
+ this.inputTarget.value = ""
37
+ this.dialogTarget.showModal()
38
+ // Flattened tree for top-level queries, crawled in the background.
39
+ this.index = new CommandIndex(this.rootValue, {
40
+ source: (source) => this.dispatch("source", { prefix: BUS, detail: { source, items: [] } }).detail.items,
41
+ })
42
+ this.index.entries()
43
+ this.#go([])
44
+ this.filter()
45
+ }
46
+
47
+ // Every close goes through here (Esc included, see navigate), so focus is back
48
+ // on the page before the next keystroke: Chrome sometimes leaves it on the
49
+ // closed dialog's input, where the next key would vanish.
50
+ close() {
51
+ this.pendingRun = null
52
+ this.dialogTarget.close()
53
+ if (!this.dialogTarget.contains(document.activeElement)) return
54
+ const back = this.returnFocus
55
+ back?.isConnected && back !== document.body ? back.focus({ preventScroll: true }) : document.activeElement.blur()
56
+ }
57
+
58
+ closed() {
59
+ this.dispatch("closed", { prefix: BUS })
60
+ }
61
+
62
+ backdrop(event) {
63
+ if (event.target === this.dialogTarget) this.close()
64
+ }
65
+
66
+ // The new list is in: rank it, then run an Enter that was waiting for it.
67
+ loaded() {
68
+ // A response that had already arrived still renders after its request was
69
+ // cancelled, so a stale list can land after drilling in; put the right one back.
70
+ const top = this.stack.at(-1)
71
+ if (top?.source) return this.#showSourceList(top.source)
72
+ const expected = top?.src || this.rootValue
73
+ if (this.#path(this.frameTarget.getAttribute("src")) !== this.#path(expected)) {
74
+ return this.frameTarget.setAttribute("src", expected)
75
+ }
76
+
77
+ this.navigating = false
78
+ this.filter()
79
+ this.#runPending()
80
+ }
81
+
82
+ filter() {
83
+ const query = this.inputTarget.value
84
+ // At the top, queries match whole paths through the tree ("uthen" → UI › Theme › Nord).
85
+ if (!this.stack.length && query.trim()) return this.#searchPaths(query)
86
+ this.#clearPathResults()
87
+ const usage = this.#usage()
88
+ const ranked = this.itemTargets.map((item, i) => {
89
+ item.dataset.index ||= i
90
+ const match = score(item.dataset.search, query)
91
+ item.hidden = match <= 0
92
+ return { item, score: match + Math.log2(1 + (usage[this.#id(item)] || 0)), match }
93
+ })
94
+ const visible = ranked
95
+ .filter(({ match }) => match > 0)
96
+ .sort((a, b) => b.score - a.score || a.item.dataset.index - b.item.dataset.index)
97
+
98
+ // Nested lists keep their server order, so a browsed list reads the same every time.
99
+ if (this.hasListTarget && (query.trim() || !this.stack.length)) this.listTarget.append(...visible.map(({ item }) => item))
100
+ this.emptyTarget.hidden = visible.length > 0 || this.#loading()
101
+
102
+ const current = !query.trim() && visible.find(({ item }) => "current" in item.dataset)
103
+ this.#select((current || visible[0])?.item)
104
+ }
105
+
106
+ navigate(event) {
107
+ const items = this.itemTargets.filter((item) => !item.hidden)
108
+ const index = items.indexOf(this.selected)
109
+
110
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
111
+ event.preventDefault()
112
+ const next = event.key === "ArrowDown" ? index + 1 : index - 1
113
+ this.#select(items[(next + items.length) % items.length])
114
+ } else if (event.key === "Enter") {
115
+ event.preventDefault()
116
+ const newWindow = event.shiftKey || event.metaKey || event.ctrlKey
117
+ if (this.navigating || this.searching) this.pendingRun = { newWindow }
118
+ else if (this.selected) this.#run(this.selected, newWindow)
119
+ } else if (event.key === "Escape" && !this.stack.length) {
120
+ event.preventDefault()
121
+ this.close()
122
+ } else if (this.stack.length && (event.key === "Escape" || (event.key === "Backspace" && !this.inputTarget.value))) {
123
+ event.preventDefault()
124
+ this.#go(this.stack.slice(0, -1))
125
+ }
126
+ }
127
+
128
+ pick(event) {
129
+ this.#run(event.currentTarget, event.shiftKey || event.metaKey || event.ctrlKey)
130
+ }
131
+
132
+ // Browsers fire mousemove when a list re-renders under a resting pointer; only
133
+ // a real move may take the selection from the keyboard.
134
+ hover(event) {
135
+ const pointer = `${event.screenX},${event.screenY}`
136
+ const moved = this.pointer !== undefined && this.pointer !== pointer
137
+ this.pointer = pointer
138
+ if (moved && this.selected !== event.currentTarget) this.#select(event.currentTarget)
139
+ }
140
+
141
+ #run(item, newWindow) {
142
+ if ("confirm" in item.dataset && !("armed" in item.dataset)) return this.#arm(item)
143
+
144
+ const usage = this.#usage()
145
+ const id = this.#id(item)
146
+ usage[id] = (usage[id] || 0) + 1
147
+ localStorage.setItem(USAGE_KEY, JSON.stringify(usage))
148
+
149
+ const { children: src, source, trail } = item.dataset
150
+ if (trail && (src || source)) {
151
+ return this.#go([...this.stack, ...JSON.parse(trail).map(({ label, children, source }) => ({ label, src: children, source }))])
152
+ }
153
+ if (src || source) {
154
+ return this.#go([...this.stack, { label: item.querySelector(".rori-palette__label").firstChild.textContent.trim(), src, source }])
155
+ }
156
+ this.close()
157
+ this.dispatch("run", { prefix: BUS, detail: this.#detail(item, { newWindow }) })
158
+ }
159
+
160
+ #arm(item) {
161
+ item.dataset.armed = ""
162
+ const hint = document.createElement("span")
163
+ hint.className = "rori-palette__confirm"
164
+ hint.textContent = this.confirmValue
165
+ item.querySelector(".rori-palette__group").before(hint)
166
+ }
167
+
168
+ #disarm(except) {
169
+ for (const item of this.itemTargets) {
170
+ if (item === except || !("armed" in item.dataset)) continue
171
+ delete item.dataset.armed
172
+ item.querySelector(".rori-palette__confirm")?.remove()
173
+ }
174
+ }
175
+
176
+ async #searchPaths(query) {
177
+ const ticket = (this.ticket = (this.ticket || 0) + 1)
178
+ this.searching = true
179
+ const usage = this.#usage()
180
+ const results = await this.index.search(query, { limit: 30, bonus: (item) => Math.log2(1 + (usage[this.#idOf(item)] || 0)) })
181
+ if (ticket !== this.ticket || !this.dialogTarget.open) return
182
+
183
+ this.searching = false
184
+ // Until the root list has loaded there's nowhere to put results; loaded() refilters.
185
+ if (!this.hasListTarget) return
186
+ this.itemTargets.forEach((item) => { item.hidden = true })
187
+ this.#clearPathResults()
188
+ const items = results.map(({ item, trail, text }) => {
189
+ const trailData = trail.map(({ label, children, source }) => ({ label, children, source }))
190
+ const built = this.#buildItem({ ...item, label: text, trail: JSON.stringify(trailData) })
191
+ built.dataset.pathResult = ""
192
+ return built
193
+ })
194
+ this.listTarget.prepend(...items)
195
+ this.emptyTarget.hidden = items.length > 0
196
+ this.#select(items[0])
197
+ this.#runPending()
198
+ }
199
+
200
+ #clearPathResults() {
201
+ this.ticket = (this.ticket || 0) + 1
202
+ this.searching = false
203
+ this.itemTargets.filter((item) => "pathResult" in item.dataset).forEach((item) => item.remove())
204
+ }
205
+
206
+ #runPending() {
207
+ if (this.navigating || this.searching) return
208
+ const pending = this.pendingRun
209
+ this.pendingRun = null
210
+ if (pending && this.selected) this.#run(this.selected, pending.newWindow)
211
+ }
212
+
213
+ // Shows the list at the end of `stack` (the root list when empty).
214
+ #go(stack) {
215
+ this.#clearPathResults()
216
+ this.stack = stack
217
+ this.inputTarget.value = ""
218
+ this.crumbsTarget.hidden = !stack.length
219
+ this.crumbsTarget.textContent = stack.map(({ label }) => `${label} ›`).join(" ")
220
+ const top = stack.at(-1)
221
+ if (top?.source) return this.#showSourceList(top.source)
222
+
223
+ // Until the new list arrives the old one is still showing; don't run it.
224
+ this.navigating = true
225
+ this.pendingRun = null
226
+ this.frameTarget.setAttribute("src", top?.src || this.rootValue)
227
+ this.inputTarget.focus()
228
+ }
229
+
230
+ // A nested list another controller provides at runtime, for state that only
231
+ // exists in the browser: `rori-command:source` asks, the listener fills
232
+ // `detail.items` with { label, group, action, param }.
233
+ #showSourceList(source) {
234
+ const { detail } = this.dispatch("source", { prefix: BUS, detail: { source, items: [] } })
235
+ const list = document.createElement("ul")
236
+ list.className = "rori-palette__list"
237
+ list.setAttribute("role", "listbox")
238
+ list.dataset.roriPaletteTarget = "list"
239
+ list.append(...detail.items.map((attributes) => this.#buildItem(attributes)))
240
+
241
+ this.navigating = false
242
+ this.frameTarget.removeAttribute("src")
243
+ this.frameTarget.replaceChildren(list)
244
+ this.filter()
245
+ this.inputTarget.focus()
246
+ }
247
+
248
+ #buildItem({ label, group, action, param, url, children, source, run, confirm, trail, current, shortcut }) {
249
+ const item = this.itemTemplateTarget.content.firstElementChild.cloneNode(true)
250
+ const data = { roriAction: action, param: param ?? "", url, children, source, run, trail, shortcut, search: `${label} ${group}` }
251
+ for (const [key, value] of Object.entries(data)) if (value !== undefined) item.dataset[key] = value
252
+ if (current) item.dataset.current = ""
253
+ if (confirm) item.dataset.confirm = ""
254
+ item.querySelector(".rori-palette__label").textContent = label + (children || source ? " ›" : "")
255
+ const groupElement = item.querySelector(".rori-palette__group")
256
+ groupElement.textContent = (current ? "✓ " : "") + group
257
+ if (shortcut) {
258
+ const kbd = document.createElement("kbd")
259
+ kbd.className = "rori-palette__shortcut"
260
+ kbd.textContent = shortcut
261
+ groupElement.before(kbd)
262
+ }
263
+ return item
264
+ }
265
+
266
+ #path(url) {
267
+ if (!url) return null
268
+ const { pathname, search } = new URL(url, location.href)
269
+ return pathname + search
270
+ }
271
+
272
+ #loading() {
273
+ const frame = this.frameTarget
274
+ return frame.hasAttribute("busy") || (frame.hasAttribute("src") && !frame.hasAttribute("complete"))
275
+ }
276
+
277
+ #select(item) {
278
+ this.#disarm(item)
279
+ this.itemTargets.forEach((other) => other.setAttribute("aria-selected", other === item))
280
+ this.selected = item
281
+ item?.scrollIntoView({ block: "nearest" })
282
+ // A list can finish loading after the palette closed; previewing then would undo the pick.
283
+ if (item && this.dialogTarget.open) this.dispatch("preview", { prefix: BUS, detail: this.#detail(item) })
284
+ }
285
+
286
+ #detail(item, extra = {}) {
287
+ const { url, roriAction: action, param, run } = item.dataset
288
+ return { url, action, param, run, ...extra }
289
+ }
290
+
291
+ #id(item) {
292
+ const { url, roriAction: action, param, children, source, run } = item.dataset
293
+ return this.#idOf({ url, action, param, children, source, run })
294
+ }
295
+
296
+ #idOf({ url, action, param, children, source, run }) {
297
+ return url || children || source || (run && `run:${run}`) || [action, param].filter((part) => part !== undefined).join(":")
298
+ }
299
+
300
+ #usage() {
301
+ try {
302
+ return JSON.parse(localStorage.getItem(USAGE_KEY)) || {}
303
+ } catch {
304
+ return {}
305
+ }
306
+ }
307
+ }
@@ -0,0 +1,68 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ const ONE_YEAR = 60 * 60 * 24 * 365
4
+
5
+ // Small cookie-backed preferences, applied as classes on <body> so the server
6
+ // renders them on load too:
7
+ // bars (UI › Menu bar › top | bottom) — previews while browsing the list and
8
+ // reverts if you don't pick, like themes.
9
+ // the empty-desk hint — dismissed from the hint, brought back from the
10
+ // keyboard shortcuts modal (which this controller also opens).
11
+ export default class extends Controller {
12
+ static targets = ["shortcuts", "hintToggle"]
13
+ static values = { barsCookie: String, bars: String, hintCookie: String }
14
+
15
+ run({ detail: { action, param } }) {
16
+ if (action !== "bars") return
17
+ this.barsValue = param
18
+ this.#setCookie(this.barsCookieValue, param)
19
+ }
20
+
21
+ preview({ detail: { action, param } }) {
22
+ action === "bars" ? this.#applyBars(param) : this.revert()
23
+ }
24
+
25
+ revert() {
26
+ this.#applyBars(this.barsValue)
27
+ }
28
+
29
+ barsValueChanged(bars) {
30
+ this.#applyBars(bars)
31
+ }
32
+
33
+ openShortcuts() {
34
+ if (this.shortcutsTarget.open) return this.shortcutsTarget.close()
35
+ this.hintToggleTarget.checked = !this.element.classList.contains("hint-hidden")
36
+ this.shortcutsTarget.showModal()
37
+ }
38
+
39
+ closeShortcuts() {
40
+ this.shortcutsTarget.close()
41
+ }
42
+
43
+ shortcutsBackdrop(event) {
44
+ if (event.target === this.shortcutsTarget) this.closeShortcuts()
45
+ }
46
+
47
+ dismissHint() {
48
+ this.#setHint(false)
49
+ }
50
+
51
+ toggleHint() {
52
+ this.#setHint(this.hintToggleTarget.checked)
53
+ }
54
+
55
+ #setHint(shown) {
56
+ this.element.classList.toggle("hint-hidden", !shown)
57
+ this.#setCookie(this.hintCookieValue, shown ? "" : "hidden")
58
+ }
59
+
60
+ #applyBars(bars) {
61
+ this.element.classList.toggle("bars-bottom", bars === "bottom")
62
+ }
63
+
64
+ // An empty value removes the cookie.
65
+ #setCookie(name, value) {
66
+ document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${value ? ONE_YEAR : 0}; samesite=lax`
67
+ }
68
+ }