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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +25 -0
- data/LICENSE +21 -0
- data/README.md +279 -0
- data/app/assets/stylesheets/rori/_init.css +79 -0
- data/app/assets/stylesheets/rori/layout.css +162 -0
- data/app/assets/stylesheets/rori/menubar.css +117 -0
- data/app/assets/stylesheets/rori/notifications.css +120 -0
- data/app/assets/stylesheets/rori/palette.css +114 -0
- data/app/assets/stylesheets/rori/shortcuts.css +110 -0
- data/app/assets/stylesheets/rori/terminal.css +98 -0
- data/app/assets/stylesheets/rori/themes.css +156 -0
- data/app/assets/stylesheets/rori/typing_badge.css +56 -0
- data/app/assets/stylesheets/rori/wallpaper.css +52 -0
- data/app/assets/stylesheets/rori/window.css +174 -0
- data/app/controllers/concerns/rori/windowed.rb +15 -0
- data/app/controllers/rori/commands/bars_controller.rb +9 -0
- data/app/controllers/rori/commands/runs_controller.rb +22 -0
- data/app/controllers/rori/commands/themes_controller.rb +9 -0
- data/app/controllers/rori/commands/ui_controller.rb +9 -0
- data/app/controllers/rori/commands/wallpapers_controller.rb +9 -0
- data/app/controllers/rori/commands_controller.rb +7 -0
- data/app/controllers/rori/desktops_controller.rb +7 -0
- data/app/helpers/rori/assets_helper.rb +9 -0
- data/app/helpers/rori/keymap_helper.rb +50 -0
- data/app/helpers/rori/prefs_helper.rb +13 -0
- data/app/helpers/rori/theme_helper.rb +14 -0
- data/app/helpers/rori/wallpaper_helper.rb +19 -0
- data/app/helpers/rori/window_helper.rb +36 -0
- data/app/javascript/rori/clock_controller.js +18 -0
- data/app/javascript/rori/command_index.js +84 -0
- data/app/javascript/rori/fuzzy.js +50 -0
- data/app/javascript/rori/index.js +21 -0
- data/app/javascript/rori/notifications_controller.js +45 -0
- data/app/javascript/rori/palette_controller.js +307 -0
- data/app/javascript/rori/prefs_controller.js +68 -0
- data/app/javascript/rori/rori_controller.js +996 -0
- data/app/javascript/rori/terminal_controller.js +198 -0
- data/app/javascript/rori/theme_controller.js +35 -0
- data/app/javascript/rori/wallpaper_controller.js +67 -0
- data/app/models/rori/command.rb +105 -0
- data/app/models/rori/notification.rb +20 -0
- data/app/models/rori/theme/color.rb +30 -0
- data/app/models/rori/theme.rb +87 -0
- data/app/models/rori/wallpaper.rb +54 -0
- data/app/views/layouts/rori/_head.html.haml +2 -0
- data/app/views/layouts/rori/shell.html.haml +97 -0
- data/app/views/layouts/rori/window.html.haml +10 -0
- data/app/views/rori/_menubar.html.haml +14 -0
- data/app/views/rori/_notification.html.haml +9 -0
- data/app/views/rori/_notifications.html.haml +10 -0
- data/app/views/rori/_palette.html.haml +13 -0
- data/app/views/rori/_shortcuts.html.haml +47 -0
- data/app/views/rori/_terminal.html.haml +9 -0
- data/app/views/rori/_wallpaper.html.haml +7 -0
- data/app/views/rori/_wallpaper_credit.html.haml +3 -0
- data/app/views/rori/_window.html.haml +15 -0
- data/app/views/rori/commands/_command.html.haml +17 -0
- data/app/views/rori/commands/index.html.haml +5 -0
- data/app/views/rori/desktops/show.html.haml +1 -0
- data/config/importmap.rb +3 -0
- data/config/locales/rori.en.yml +133 -0
- data/config/routes/rori.rb +13 -0
- data/lib/generators/rori/install/install_generator.rb +67 -0
- data/lib/generators/rori/install/templates/rori.rb.tt +22 -0
- data/lib/rori/engine.rb +19 -0
- data/lib/rori/version.rb +3 -0
- data/lib/rori.rb +180 -0
- data/lib/tasks/rori.rake +9 -0
- data/vendor/themes/LICENSE +25 -0
- data/vendor/themes/ghostty/Catppuccin Latte +22 -0
- data/vendor/themes/ghostty/Catppuccin Mocha +22 -0
- data/vendor/themes/ghostty/Everforest Dark Med +22 -0
- data/vendor/themes/ghostty/Flexoki Light +22 -0
- data/vendor/themes/ghostty/GitHub Dark Dimmed +22 -0
- data/vendor/themes/ghostty/Gruvbox Material Dark +22 -0
- data/vendor/themes/ghostty/Kanagawa Wave +22 -0
- data/vendor/themes/ghostty/Melange Dark +22 -0
- data/vendor/themes/ghostty/Nord +22 -0
- data/vendor/themes/ghostty/Rose Pine Dawn +22 -0
- data/vendor/themes/ghostty/Rose Pine Moon +22 -0
- data/vendor/themes/ghostty/TokyoNight Storm +22 -0
- data/vendor/themes/ghostty/Tomorrow Night Eighties +22 -0
- data/vendor/themes/ghostty/Zenbones Light +22 -0
- data/vendor/wallpapers/unsplash.yml +23 -0
- metadata +219 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { CommandIndex, isNested } from "rori/command_index"
|
|
3
|
+
|
|
4
|
+
const BUS = "rori-command"
|
|
5
|
+
const HISTORY_KEY = "rori:terminal:history"
|
|
6
|
+
const HISTORY_LIMIT = 100
|
|
7
|
+
const SUGGESTIONS = 8
|
|
8
|
+
const FIELDS = "input, textarea, select, [contenteditable]"
|
|
9
|
+
const YES = /^y(es)?$/i
|
|
10
|
+
|
|
11
|
+
const format = (template, values) => template.replace(/%\{(\w+)\}/g, (_, key) => values[key] ?? "")
|
|
12
|
+
// "Pick theme…" → "pick-theme": one word per label, so a trail reads as words.
|
|
13
|
+
const token = (label) => label.toLowerCase().replace(/[…›✓]/g, "").trim().replace(/\s+/g, "-")
|
|
14
|
+
const words = (trail) => trail.map(({ label }) => token(label)).join(" ")
|
|
15
|
+
|
|
16
|
+
// A drop-down command line over the same command tree as ⌘K: the same
|
|
17
|
+
// server-rendered lists, client-side sources, fuzzy matcher and command bus.
|
|
18
|
+
// Input matches whole paths through the tree, so `ui wallpaper cover` and
|
|
19
|
+
// just `uthen` (UI › Theme › Nord) both work; a path ending on a nested item
|
|
20
|
+
// lists it. Opens with Rori.terminal_key outside text fields. Commands that
|
|
21
|
+
// ask first (`confirm`) wait for a `y` on the next line; anything else cancels.
|
|
22
|
+
export default class extends Controller {
|
|
23
|
+
static targets = ["panel", "output", "input", "suggestions"]
|
|
24
|
+
static values = { root: String, key: String, prompt: String, messages: Object }
|
|
25
|
+
|
|
26
|
+
connect() {
|
|
27
|
+
this.history = this.#loadHistory()
|
|
28
|
+
this.cursor = this.history.length
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
keydown(event) {
|
|
32
|
+
if (event.code !== this.keyValue || event.metaKey || event.ctrlKey || event.altKey) return
|
|
33
|
+
const inTerminal = this.panelTarget.contains(event.target)
|
|
34
|
+
// A field in a closed dialog (see rori_controller.js `typing`) isn't being typed in.
|
|
35
|
+
if (!inTerminal && event.target.closest?.(FIELDS) && !event.target.closest("dialog:not([open])")) return
|
|
36
|
+
if (document.querySelector("dialog:modal")) return
|
|
37
|
+
event.preventDefault()
|
|
38
|
+
this.toggle()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
toggle() {
|
|
42
|
+
this.panelTarget.hidden ? this.open() : this.close()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
open() {
|
|
46
|
+
this.returnFocus = document.activeElement
|
|
47
|
+
this.#reindex()
|
|
48
|
+
this.panelTarget.hidden = false
|
|
49
|
+
if (!this.outputTarget.childElementCount) this.#print(this.messagesValue.welcome, "muted")
|
|
50
|
+
this.inputTarget.focus()
|
|
51
|
+
this.suggest()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
close() {
|
|
55
|
+
this.confirming = null
|
|
56
|
+
this.panelTarget.hidden = true
|
|
57
|
+
this.suggestionsTarget.replaceChildren()
|
|
58
|
+
if (this.returnFocus?.isConnected) this.returnFocus.focus({ preventScroll: true })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
navigate(event) {
|
|
62
|
+
if (event.key === "Escape") {
|
|
63
|
+
event.preventDefault()
|
|
64
|
+
this.close()
|
|
65
|
+
} else if (event.key === "Enter") {
|
|
66
|
+
event.preventDefault()
|
|
67
|
+
this.#submit()
|
|
68
|
+
} else if (event.key === "Tab") {
|
|
69
|
+
event.preventDefault()
|
|
70
|
+
this.#complete()
|
|
71
|
+
} else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
|
|
72
|
+
event.preventDefault()
|
|
73
|
+
this.#recall(event.key === "ArrowUp" ? -1 : 1)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async suggest() {
|
|
78
|
+
const query = this.inputTarget.value.trim()
|
|
79
|
+
const ticket = (this.ticket = (this.ticket || 0) + 1)
|
|
80
|
+
const suggested = query
|
|
81
|
+
? await this.index.search(query, { limit: SUGGESTIONS })
|
|
82
|
+
: (await this.index.list({ children: this.rootValue })).map((item) => ({ item, trail: [item] }))
|
|
83
|
+
if (ticket !== this.ticket) return
|
|
84
|
+
|
|
85
|
+
this.suggested = suggested
|
|
86
|
+
this.suggestionsTarget.replaceChildren(...suggested.slice(0, SUGGESTIONS).map(({ item, trail }, i) => {
|
|
87
|
+
const li = document.createElement("li")
|
|
88
|
+
li.className = "rori-terminal__suggestion"
|
|
89
|
+
li.toggleAttribute("aria-selected", i === 0)
|
|
90
|
+
const label = document.createElement("span")
|
|
91
|
+
label.textContent = words(trail) + (isNested(item) ? " ›" : "")
|
|
92
|
+
const group = document.createElement("span")
|
|
93
|
+
group.className = "rori-terminal__group"
|
|
94
|
+
group.textContent = (item.current ? "✓ " : "") + item.group
|
|
95
|
+
li.append(label, group)
|
|
96
|
+
return li
|
|
97
|
+
}))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Tab: spell out the best match's whole path.
|
|
101
|
+
#complete() {
|
|
102
|
+
const best = this.suggested?.[0]
|
|
103
|
+
if (!best) return
|
|
104
|
+
this.inputTarget.value = words(best.trail) + (isNested(best.item) ? " " : "")
|
|
105
|
+
this.suggest()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async #submit() {
|
|
109
|
+
const line = this.inputTarget.value.trim()
|
|
110
|
+
this.inputTarget.value = ""
|
|
111
|
+
this.#print(`${this.promptValue} ${line}`, "echo")
|
|
112
|
+
if (this.confirming) return this.#answer(line)
|
|
113
|
+
if (!line) return this.suggest()
|
|
114
|
+
this.#remember(line)
|
|
115
|
+
|
|
116
|
+
if (line === "clear") {
|
|
117
|
+
this.outputTarget.replaceChildren()
|
|
118
|
+
} else if (line === "help") {
|
|
119
|
+
this.#printList(this.messagesValue.help, await this.index.list({ children: this.rootValue }))
|
|
120
|
+
} else {
|
|
121
|
+
await this.#run(line)
|
|
122
|
+
}
|
|
123
|
+
this.suggest()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async #run(query) {
|
|
127
|
+
const [best] = await this.index.search(query, { limit: 1 })
|
|
128
|
+
if (!best) return this.#print(format(this.messagesValue.no_match, { query }), "error")
|
|
129
|
+
|
|
130
|
+
const { item, trail } = best
|
|
131
|
+
const path = trail.map(({ label }) => label).join(" › ")
|
|
132
|
+
if (isNested(item)) return this.#printList(format(this.messagesValue.list, { path }), await this.index.list(item))
|
|
133
|
+
if (item.confirm) {
|
|
134
|
+
this.confirming = { item, path }
|
|
135
|
+
return this.#print(format(this.messagesValue.confirm, { path }), "muted")
|
|
136
|
+
}
|
|
137
|
+
this.#execute(item, path)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
#answer(line) {
|
|
141
|
+
const { item, path } = this.confirming
|
|
142
|
+
this.confirming = null
|
|
143
|
+
if (YES.test(line)) this.#execute(item, path)
|
|
144
|
+
else this.#print(this.messagesValue.cancelled, "muted")
|
|
145
|
+
this.suggest()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
#execute(item, path) {
|
|
149
|
+
const { url, action, param, run } = item
|
|
150
|
+
this.dispatch("run", { prefix: BUS, detail: { url, action, param, run, newWindow: false } })
|
|
151
|
+
this.#print(format(url ? this.messagesValue.opened : this.messagesValue.ran, { path }), "ok")
|
|
152
|
+
this.#reindex() // runs change state: the current theme, workspaces, records
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
#reindex() {
|
|
156
|
+
this.index = new CommandIndex(this.rootValue, {
|
|
157
|
+
source: (source) => this.dispatch("source", { prefix: BUS, detail: { source, items: [] } }).detail.items,
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
#print(text, kind) {
|
|
162
|
+
const line = document.createElement("div")
|
|
163
|
+
line.className = `rori-terminal__entry rori-terminal__entry--${kind}`
|
|
164
|
+
line.textContent = text
|
|
165
|
+
this.outputTarget.append(line)
|
|
166
|
+
this.outputTarget.scrollTop = this.outputTarget.scrollHeight
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
#printList(heading, items) {
|
|
170
|
+
this.#print(heading, "muted")
|
|
171
|
+
if (!items.length) return this.#print(this.messagesValue.empty, "muted")
|
|
172
|
+
for (const item of items) {
|
|
173
|
+
const nested = isNested(item) ? " ›" : ""
|
|
174
|
+
this.#print(` ${token(item.label)}${nested} ${item.current ? "✓ " : ""}${item.group}`, "item")
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
#recall(step) {
|
|
179
|
+
if (!this.history.length) return
|
|
180
|
+
this.cursor = Math.max(0, Math.min(this.history.length, this.cursor + step))
|
|
181
|
+
this.inputTarget.value = this.history[this.cursor] ?? ""
|
|
182
|
+
this.suggest()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
#remember(line) {
|
|
186
|
+
this.history = [...this.history.filter((entry) => entry !== line), line].slice(-HISTORY_LIMIT)
|
|
187
|
+
this.cursor = this.history.length
|
|
188
|
+
localStorage.setItem(HISTORY_KEY, JSON.stringify(this.history))
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
#loadHistory() {
|
|
192
|
+
try {
|
|
193
|
+
return JSON.parse(localStorage.getItem(HISTORY_KEY)) || []
|
|
194
|
+
} catch {
|
|
195
|
+
return []
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
const ONE_YEAR = 60 * 60 * 24 * 365
|
|
4
|
+
|
|
5
|
+
// Applies palette "theme" commands. Moving through the theme list previews
|
|
6
|
+
// each one; closing the palette without picking reverts to the saved theme.
|
|
7
|
+
// The pick lives in a cookie so the server renders it on <html> (no flash).
|
|
8
|
+
export default class extends Controller {
|
|
9
|
+
static values = { cookie: String }
|
|
10
|
+
|
|
11
|
+
run({ detail: { action, param } }) {
|
|
12
|
+
if (action !== "theme") return
|
|
13
|
+
this.#apply(param)
|
|
14
|
+
document.cookie = `${this.cookieValue}=${encodeURIComponent(param)}; path=/; max-age=${param ? ONE_YEAR : 0}; samesite=lax`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
preview({ detail: { action, param } }) {
|
|
18
|
+
action === "theme" ? this.#apply(param) : this.revert()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
revert() {
|
|
22
|
+
this.#apply(this.#saved())
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
#apply(slug) {
|
|
26
|
+
const root = document.documentElement
|
|
27
|
+
if (slug) root.dataset.theme = slug
|
|
28
|
+
else delete root.dataset.theme
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
#saved() {
|
|
32
|
+
const pair = document.cookie.split("; ").find((entry) => entry.startsWith(`${this.cookieValue}=`))
|
|
33
|
+
return pair ? decodeURIComponent(pair.split("=")[1]) : ""
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
const ONE_YEAR = 60 * 60 * 24 * 365
|
|
4
|
+
|
|
5
|
+
// Applies the UI › Wallpaper commands:
|
|
6
|
+
// wallpaper (safe | cover | off) — classes on <body>: the photo inside the
|
|
7
|
+
// desk, behind the menu bar too, or none. Previews as you move through the
|
|
8
|
+
// list and reverts if you don't pick, like rori-theme.
|
|
9
|
+
// wallpaper_next — the next photo in the pool, swapped in place.
|
|
10
|
+
// wallpaper_pin — keep the current photo across launches (toggles).
|
|
11
|
+
// Mode and pin live in cookies so the server renders them on load.
|
|
12
|
+
export default class extends Controller {
|
|
13
|
+
static targets = ["credit"]
|
|
14
|
+
static values = { cookie: String, mode: String, pinCookie: String, pool: Array, current: String, pinned: Boolean }
|
|
15
|
+
|
|
16
|
+
run({ detail: { action, param } }) {
|
|
17
|
+
if (action === "wallpaper") {
|
|
18
|
+
this.modeValue = param
|
|
19
|
+
this.#setCookie(this.cookieValue, param)
|
|
20
|
+
} else if (action === "wallpaper_next") {
|
|
21
|
+
this.#next()
|
|
22
|
+
} else if (action === "wallpaper_pin") {
|
|
23
|
+
this.pinnedValue = !this.pinnedValue
|
|
24
|
+
this.#setCookie(this.pinCookieValue, this.pinnedValue ? this.currentValue : "")
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
preview({ detail: { action, param } }) {
|
|
29
|
+
action === "wallpaper" ? this.#apply(param) : this.revert()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
revert() {
|
|
33
|
+
this.#apply(this.modeValue)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
modeValueChanged(mode) {
|
|
37
|
+
this.#apply(mode)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#next() {
|
|
41
|
+
const pool = this.poolValue
|
|
42
|
+
if (pool.length < 2) return
|
|
43
|
+
const index = pool.findIndex(({ id }) => id === this.currentValue)
|
|
44
|
+
const wallpaper = pool[(index + 1) % pool.length]
|
|
45
|
+
|
|
46
|
+
this.element.style.setProperty("--rori-wallpaper", wallpaper.image)
|
|
47
|
+
this.currentValue = wallpaper.id
|
|
48
|
+
if (this.pinnedValue) this.#setCookie(this.pinCookieValue, wallpaper.id)
|
|
49
|
+
if (this.hasCreditTarget) {
|
|
50
|
+
this.creditTarget.hidden = !wallpaper.credit
|
|
51
|
+
if (wallpaper.credit) this.creditTarget.href = wallpaper.credit
|
|
52
|
+
}
|
|
53
|
+
this.#apply(this.modeValue)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#apply(mode) {
|
|
57
|
+
// Without a photo on the page (Rori.wallpapers off) there's nothing to show.
|
|
58
|
+
const photo = this.element.style.getPropertyValue("--rori-wallpaper") !== ""
|
|
59
|
+
this.element.classList.toggle("has-wallpaper", photo && mode !== "off")
|
|
60
|
+
this.element.classList.toggle("wallpaper-cover", mode === "cover")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// An empty value removes the cookie.
|
|
64
|
+
#setCookie(name, value) {
|
|
65
|
+
document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${value ? ONE_YEAR : 0}; samesite=lax`
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# A command-palette entry. Running it either opens `url` in a desk window,
|
|
2
|
+
# fires a client-side `action` (with an optional `param`) that the desk or
|
|
3
|
+
# theme controller picks up, runs a server-side command (`run`, see
|
|
4
|
+
# `Rori.command`; `confirm` asks first), or drills into a nested list: served
|
|
5
|
+
# at the `children` URL, or built in the browser from a named `source` for
|
|
6
|
+
# state the server can't see (see rori-palette's `source` event).
|
|
7
|
+
class Rori::Command < Data.define(:label, :group, :url, :action, :param, :children, :source, :current, :run, :confirm)
|
|
8
|
+
ACTIONS = %w[ overview new_workspace cycle_width full_width center_column close_window reopen_window toggle_terminal shortcuts ].freeze
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def all = routes + records + actions + [ workspace_mover, theme_picker, ui_menu ] + runs + app_commands
|
|
12
|
+
|
|
13
|
+
# Every parameterless GET route becomes a command once it has a label under
|
|
14
|
+
# `desk.commands.routes.<controller>.<action>` — adding the locale key opts it in.
|
|
15
|
+
def routes
|
|
16
|
+
Rails.application.routes.routes.filter_map do |route|
|
|
17
|
+
controller, action = route.defaults.values_at(:controller, :action)
|
|
18
|
+
next unless controller && route.verb == "GET" && route.required_parts.empty?
|
|
19
|
+
|
|
20
|
+
key = "rori.commands.routes.#{controller.tr("/", ".")}.#{action}"
|
|
21
|
+
new(label: I18n.t(key), group: group(:open), url: route.format({})) if I18n.exists?(key)
|
|
22
|
+
end.uniq(&:url)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The most recently updated records of every model in `Rori.records`.
|
|
26
|
+
def records
|
|
27
|
+
Rori.records.map(&:constantize).flat_map do |model|
|
|
28
|
+
model.order(updated_at: :desc).limit(Rori.record_limit).map do |record|
|
|
29
|
+
new(label: record.to_s, group: model.model_name.human, url: url_helpers.polymorphic_path(record))
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def actions
|
|
35
|
+
ACTIONS.map { new(label: I18n.t(it, scope: "rori.commands.actions"), group: group(:rori), action: it) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The app's server-side commands (`Rori.command`).
|
|
39
|
+
def runs
|
|
40
|
+
Rori.runnables.values.map { new(label: it.label, group: group(:run), run: it.name, confirm: it.confirm) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def workspace_mover
|
|
44
|
+
new(label: I18n.t("rori.commands.workspaces.move"), group: group(:rori), source: "workspaces")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def theme_picker
|
|
48
|
+
new(label: I18n.t("rori.commands.themes.pick"), group: group(:rori), children: url_helpers.rori_commands_themes_path)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def ui_menu
|
|
52
|
+
new(label: I18n.t("rori.commands.ui.label"), group: group(:rori), children: url_helpers.rori_commands_ui_path)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# "UI ›": one nested list per appearance setting.
|
|
56
|
+
def ui
|
|
57
|
+
theme = new(label: I18n.t("rori.commands.ui.theme"), group: group(:ui), children: url_helpers.rori_commands_themes_path)
|
|
58
|
+
wallpaper = new(label: I18n.t("rori.commands.ui.wallpaper"), group: group(:ui), children: url_helpers.rori_commands_wallpapers_path)
|
|
59
|
+
bars = new(label: I18n.t("rori.commands.ui.bars"), group: group(:ui), children: url_helpers.rori_commands_bars_path)
|
|
60
|
+
Rori.wallpapers ? [ theme, wallpaper, bars ] : [ theme, bars ]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# "UI › Menu bar ›": top or bottom.
|
|
64
|
+
def bars(current:)
|
|
65
|
+
Rori::PrefsHelper::BAR_POSITIONS.map do |position|
|
|
66
|
+
new(label: I18n.t(position, scope: "rori.commands.bars"), group: I18n.t("rori.commands.ui.bars"),
|
|
67
|
+
action: "bars", param: position, current: position == current)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The modes, then Next (swap the photo now) and Pin (keep it across launches).
|
|
72
|
+
def wallpapers(current:, pinned: false)
|
|
73
|
+
group = I18n.t("rori.commands.ui.wallpaper")
|
|
74
|
+
modes = Rori::Wallpaper::MODES.map do |mode|
|
|
75
|
+
new(label: I18n.t(mode, scope: "rori.commands.wallpapers"), group:, action: "wallpaper", param: mode, current: mode == current)
|
|
76
|
+
end
|
|
77
|
+
modes + [
|
|
78
|
+
new(label: I18n.t("rori.commands.wallpapers.next"), group:, action: "wallpaper_next"),
|
|
79
|
+
new(label: I18n.t("rori.commands.wallpapers.pin"), group:, action: "wallpaper_pin", current: pinned)
|
|
80
|
+
]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# `param: ""` is the built-in theme (no data-theme attribute).
|
|
84
|
+
def themes(current:)
|
|
85
|
+
default = new(label: I18n.t("rori.commands.themes.default"), group: I18n.t("rori.commands.themes.auto"),
|
|
86
|
+
action: "theme", param: "", current: current.nil?)
|
|
87
|
+
|
|
88
|
+
[ default ] + Rori::Theme.all.map do |theme|
|
|
89
|
+
new(label: theme.name, group: I18n.t(theme.dark? ? "dark" : "light", scope: "rori.commands.themes"),
|
|
90
|
+
action: "theme", param: theme.slug, current: theme.slug == current&.slug)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
def app_commands = Rori.commands.flat_map { Array(it.call) }
|
|
96
|
+
|
|
97
|
+
def group(name) = I18n.t(name, scope: "rori.commands.groups")
|
|
98
|
+
|
|
99
|
+
def url_helpers = Rails.application.routes.url_helpers
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def initialize(label:, group:, url: nil, action: nil, param: nil, children: nil, source: nil, current: false, run: nil, confirm: false) = super
|
|
103
|
+
|
|
104
|
+
def to_partial_path = "rori/commands/command"
|
|
105
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# A notification in the desk's corner (rori/_notification): the result of a
|
|
2
|
+
# server-side command, or anything the app sends with `Rori.notify`.
|
|
3
|
+
# Errors stay until dismissed; the rest fade out on their own.
|
|
4
|
+
class Rori::Notification < Data.define(:title, :body, :kind)
|
|
5
|
+
KINDS = %i[ info success error ].freeze
|
|
6
|
+
|
|
7
|
+
def initialize(title:, body: nil, kind: :info)
|
|
8
|
+
kind = kind.to_sym
|
|
9
|
+
raise ArgumentError, "notification kind must be one of #{KINDS.join(", ")}" unless KINDS.include?(kind)
|
|
10
|
+
|
|
11
|
+
super
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def sticky? = kind == :error
|
|
15
|
+
|
|
16
|
+
def broadcast
|
|
17
|
+
Turbo::StreamsChannel.broadcast_append_to Rori.notifications_stream,
|
|
18
|
+
target: "rori-notifications", partial: "rori/notification", locals: { notification: self }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# An OKLCH color. Theme files are sRGB hex; the app's CSS speaks oklch().
|
|
2
|
+
class Rori::Theme::Color < Data.define(:lightness, :chroma, :hue)
|
|
3
|
+
class << self
|
|
4
|
+
# sRGB hex → linear RGB → OKLab → OKLCH (https://bottosson.github.io/posts/oklab/).
|
|
5
|
+
def from_hex(hex)
|
|
6
|
+
r, g, b = hex.delete_prefix("#").scan(/../).map { linear(it.to_i(16) / 255.0) }
|
|
7
|
+
|
|
8
|
+
l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
|
|
9
|
+
m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
|
|
10
|
+
s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
|
|
11
|
+
|
|
12
|
+
lab_l = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s
|
|
13
|
+
lab_a = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s
|
|
14
|
+
lab_b = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s
|
|
15
|
+
|
|
16
|
+
new(lightness: lab_l, chroma: Math.hypot(lab_a, lab_b), hue: Math.atan2(lab_b, lab_a) * 180 / Math::PI % 360)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
def linear(channel) = channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055)**2.4
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def darken(amount) = with(lightness: (lightness - amount).clamp(0, 1))
|
|
24
|
+
|
|
25
|
+
# Near-greys have no meaningful hue; pin it so rounding noise doesn't churn the stylesheet.
|
|
26
|
+
def to_s
|
|
27
|
+
hue = chroma < 0.002 ? 0 : self.hue
|
|
28
|
+
format("oklch(%.4f %.4f %.2f)", lightness, chroma, hue)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# A terminal colour scheme in Ghostty's format, vendored from
|
|
2
|
+
# iTerm2-Color-Schemes into `Rori.themes_directory`. Each theme overrides the
|
|
3
|
+
# host's semantic color tokens (--color-*), so it re-colours the whole app; the
|
|
4
|
+
# desk's own --rori-* tokens derive from them (rori/_init.css); `bin/rails rori:themes:build` compiles them all into
|
|
5
|
+
# `Rori.themes_stylesheet`. Drop in another file and rebuild to add one.
|
|
6
|
+
class Rori::Theme
|
|
7
|
+
# Window gutters sit this much darker than windows, like the default theme.
|
|
8
|
+
CANVAS_DARKEN = 0.04
|
|
9
|
+
|
|
10
|
+
attr_reader :name, :colors
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
# Bundled themes plus the app's `Rori.themes_folder`; an app theme with a
|
|
14
|
+
# bundled theme's name replaces it.
|
|
15
|
+
def all = (bundled + local).index_by(&:slug).values.sort_by(&:name)
|
|
16
|
+
|
|
17
|
+
def bundled = load(Rori.themes_directory)
|
|
18
|
+
|
|
19
|
+
def local = Rori.themes_folder ? load(Rails.root.join(Rori.themes_folder)) : []
|
|
20
|
+
|
|
21
|
+
def find(slug) = slug.presence && all.find { it.slug == slug }
|
|
22
|
+
|
|
23
|
+
def from_file(path)
|
|
24
|
+
colors = path.readlines.each_with_object({}) do |line, colors|
|
|
25
|
+
key, value = line.split("=", 2).map(&:strip)
|
|
26
|
+
next if value.blank?
|
|
27
|
+
|
|
28
|
+
if key == "palette"
|
|
29
|
+
index, hex = value.split("=", 2)
|
|
30
|
+
colors[index.to_i] = hex.strip
|
|
31
|
+
else
|
|
32
|
+
colors[key] = value
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
new(path.basename.to_s, colors)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The compiled file holds the bundled themes; app themes are rendered into
|
|
39
|
+
# the shell instead (Rori::ThemeHelper), so apps never write into the gem.
|
|
40
|
+
def stylesheet
|
|
41
|
+
<<~CSS
|
|
42
|
+
/* Generated by `bin/rails rori:themes:build` from vendor/themes/ghostty — do not edit. */
|
|
43
|
+
#{css(bundled)}
|
|
44
|
+
CSS
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def css(themes) = "@layer rori.themes {\n#{themes.map(&:to_css).join("\n")}}"
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
def load(directory) = Pathname(directory).glob("*").select(&:file?).sort.map { from_file(it) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def initialize(name, colors)
|
|
54
|
+
@name = name
|
|
55
|
+
@colors = colors
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def slug = name.parameterize
|
|
59
|
+
|
|
60
|
+
def dark? = background.lightness < 0.6
|
|
61
|
+
|
|
62
|
+
def tokens
|
|
63
|
+
{
|
|
64
|
+
"--color-canvas" => background.darken(CANVAS_DARKEN),
|
|
65
|
+
"--color-surface" => background,
|
|
66
|
+
"--color-ink" => color("foreground"),
|
|
67
|
+
"--color-danger" => color(1),
|
|
68
|
+
"--color-success" => color(2),
|
|
69
|
+
"--color-warning" => color(3),
|
|
70
|
+
"--color-info" => color(4)
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def to_css
|
|
75
|
+
declarations = [ "color-scheme: #{dark? ? "dark" : "light"}", *tokens.map { |token, value| "#{token}: #{value}" } ]
|
|
76
|
+
<<~CSS.indent(2)
|
|
77
|
+
:root[data-theme="#{slug}"] {
|
|
78
|
+
#{declarations.map { " #{it};" }.join("\n")}
|
|
79
|
+
}
|
|
80
|
+
CSS
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
def background = color("background")
|
|
85
|
+
|
|
86
|
+
def color(key) = Color.from_hex(colors.fetch(key))
|
|
87
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# An optional background photo behind the strips (Rori.wallpapers). One is
|
|
2
|
+
# picked at random per full page load — the desk's "launch" — unless one is
|
|
3
|
+
# pinned; window frames never re-render the shell, so it stays put while you
|
|
4
|
+
# work. UI › Wallpaper › Next swaps it in place (rori-wallpaper).
|
|
5
|
+
#
|
|
6
|
+
# The pool is the bundled Unsplash photos (hotlinked, resized by their CDN), or
|
|
7
|
+
# the images in the app's own `Rori.wallpapers_folder` asset folder.
|
|
8
|
+
class Rori::Wallpaper < Data.define(:id, :local)
|
|
9
|
+
CDN = "https://images.unsplash.com"
|
|
10
|
+
IMAGE = /\.(jpe?g|png|webp|avif)\z/i
|
|
11
|
+
|
|
12
|
+
# safe: inside the desk only; cover: behind the menu bar too.
|
|
13
|
+
MODES = %w[ safe cover off ].freeze
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def all = Rori.wallpapers_folder ? local : unsplash
|
|
17
|
+
|
|
18
|
+
def unsplash = YAML.load_file(Rori.wallpapers_file).map { new(id: it.to_s) }
|
|
19
|
+
|
|
20
|
+
# Images under the folder, as asset logical paths (`paths` for tests).
|
|
21
|
+
def local(paths = asset_paths)
|
|
22
|
+
prefix = "#{Rori.wallpapers_folder.to_s.delete_suffix("/")}/"
|
|
23
|
+
paths.select { it.start_with?(prefix) && it.match?(IMAGE) }.sort.map { new(id: it, local: true) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def find(id) = id.presence && all.find { it.id == id }
|
|
27
|
+
|
|
28
|
+
# The pinned one if it's still in the pool, a random one otherwise.
|
|
29
|
+
def pick(pinned = nil)
|
|
30
|
+
(find(pinned) || all.sample) if Rori.wallpapers
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def mode(value) = MODES.include?(value) ? value : MODES.first
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
def asset_paths = Rails.application.assets.load_path.assets.map { it.logical_path.to_s }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize(id:, local: false) = super
|
|
40
|
+
|
|
41
|
+
# imgix resizes on the fly; image-set lets hi-dpi screens take the sharper one.
|
|
42
|
+
def url(width) = "#{CDN}/photo-#{id}?auto=format&fit=crop&w=#{width}&q=75"
|
|
43
|
+
|
|
44
|
+
def css_image
|
|
45
|
+
local ? %(url("#{ActionController::Base.helpers.asset_path(id)}")) : %(image-set(url("#{url(1920)}") 1x, url("#{url(3200)}") 2x))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Where the status-bar credit links: the full photo on Unsplash; local
|
|
49
|
+
# wallpapers are the app's own and get no credit.
|
|
50
|
+
def credit_url = (url(3200) unless local)
|
|
51
|
+
|
|
52
|
+
# What rori-wallpaper needs to swap it in without a reload.
|
|
53
|
+
def as_json(*) = { id:, image: css_image, credit: credit_url }
|
|
54
|
+
end
|