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,174 @@
1
+ @layer rori.components {
2
+
3
+ /* Author styles beat the UA's dialog rules, so restate the closed state. */
4
+ .rori-win {
5
+ --rori-bar-height: 30px;
6
+ position: relative;
7
+ isolation: isolate;
8
+ flex: 1;
9
+ min-height: 0;
10
+ width: auto;
11
+ height: auto;
12
+ max-width: none;
13
+ max-height: none;
14
+ margin: 0;
15
+ padding: 0;
16
+ display: flex;
17
+ flex-direction: column;
18
+ background: var(--rori-surface);
19
+ color: var(--rori-ink);
20
+ border: 1px solid var(--rori-line);
21
+ border-radius: var(--rori-radius);
22
+ overflow: hidden;
23
+ outline: none;
24
+ transition: border-color 150ms, box-shadow 150ms;
25
+ }
26
+
27
+ .rori-win:not([open]) {
28
+ display: none;
29
+ }
30
+
31
+ .rori-win.is-focused {
32
+ border-color: var(--rori-ink);
33
+ box-shadow: 0 0 0 1px var(--rori-ink);
34
+ }
35
+
36
+ .rori-win:modal {
37
+ position: fixed;
38
+ inset: 0;
39
+ margin: auto;
40
+ flex: none;
41
+ height: fit-content;
42
+ max-height: min(85vh, 48rem);
43
+ border-color: var(--rori-ink);
44
+ pointer-events: auto;
45
+ }
46
+
47
+ .rori-win::backdrop {
48
+ background: var(--rori-backdrop);
49
+ }
50
+
51
+ .rori-win__bar {
52
+ display: flex;
53
+ align-items: center;
54
+ gap: 2px;
55
+ flex: none;
56
+ height: var(--rori-bar-height);
57
+ padding: 0 4px;
58
+ border-bottom: 1px solid var(--rori-line);
59
+ font: 11px var(--rori-font-mono);
60
+ color: var(--rori-ink-muted);
61
+ user-select: none;
62
+ }
63
+
64
+ .rori-win.is-focused .rori-win__bar,
65
+ .rori-win:modal .rori-win__bar {
66
+ color: var(--rori-ink);
67
+ }
68
+
69
+ .rori-win__title {
70
+ flex: 1;
71
+ padding: 0 6px;
72
+ overflow: hidden;
73
+ text-overflow: ellipsis;
74
+ white-space: nowrap;
75
+ }
76
+
77
+ .rori-win__btn {
78
+ width: 22px;
79
+ height: 22px;
80
+ display: grid;
81
+ place-items: center;
82
+ border-radius: 4px;
83
+ line-height: 1;
84
+ cursor: pointer;
85
+ }
86
+
87
+ .rori-win__btn:hover:not(:disabled) {
88
+ background: var(--rori-hover);
89
+ }
90
+
91
+ .rori-win__btn:disabled {
92
+ opacity: 0.3;
93
+ cursor: default;
94
+ }
95
+
96
+ /* Pages lay out against the window, not the viewport (see forms.css). */
97
+ /* contain: scrolling a wide table to its edge stops there instead of
98
+ chaining on to the page (where a sideways overscroll means "back"). */
99
+ .rori-win__body {
100
+ flex: 1;
101
+ min-height: 0;
102
+ overflow: auto;
103
+ overscroll-behavior: contain;
104
+ container-type: inline-size;
105
+ }
106
+
107
+ /* Inactive windows are dimmed by an overlay rather than opacity, so a single
108
+ field can rise above it (opacity on a parent can't be undone by a child). */
109
+ .rori-win::after {
110
+ content: "";
111
+ position: absolute;
112
+ inset: var(--rori-bar-height) 0 0;
113
+ z-index: 1;
114
+ background: color-mix(in oklch, var(--rori-surface) 45%, transparent);
115
+ pointer-events: none;
116
+ opacity: 0;
117
+ transition: opacity 150ms;
118
+ }
119
+
120
+ .rori-win:not(.is-focused, :modal)::after {
121
+ opacity: 1;
122
+ }
123
+
124
+ .is-overview .rori-win::after {
125
+ opacity: 0;
126
+ }
127
+
128
+ /* Hover keys (Rori.hover_keys) will act on this window: say so plainly. */
129
+ .rori-win.is-armed {
130
+ outline: 2px dashed var(--rori-ink);
131
+ outline-offset: 2px;
132
+ }
133
+
134
+ .rori-win.is-armed::after {
135
+ opacity: 0.4;
136
+ }
137
+
138
+ .rori-win__armed {
139
+ display: none;
140
+ padding: 0 6px;
141
+ border-radius: 4px;
142
+ background: var(--rori-primary);
143
+ color: var(--rori-on-primary);
144
+ white-space: nowrap;
145
+ }
146
+
147
+ .rori-win.is-armed .rori-win__armed {
148
+ display: block;
149
+ }
150
+
151
+ /* The field under the pointer in an inactive window lights up: one click
152
+ activates the window and focuses that field. */
153
+ .rori-win:not(.is-focused, :modal) :is(.simple_form .input, input, select, textarea):hover {
154
+ position: relative;
155
+ z-index: 2;
156
+ }
157
+
158
+ .rori-win:not(.is-focused, :modal) :is(.simple_form .input:hover :is(input, select, textarea), input:hover, select:hover, textarea:hover):not([type="hidden"], [type="checkbox"]) {
159
+ outline: 2px solid var(--rori-ink);
160
+ outline-offset: 1px;
161
+ }
162
+
163
+ /* --container-padding: edge-to-edge content (tables.css) bleeds by this much. */
164
+ .rori-win__body>turbo-frame {
165
+ --container-padding: 16px;
166
+ display: grid;
167
+ gap: 16px;
168
+ padding: 14px var(--container-padding) 16px;
169
+ }
170
+
171
+ .rori-win__error {
172
+ color: var(--rori-danger);
173
+ }
174
+ }
@@ -0,0 +1,15 @@
1
+ # Renders every page as a desk window. Frame requests get a layout that wraps
2
+ # the page in whichever window frame asked for it (their ids are generated);
3
+ # full page loads get the desk shell with the page as its first window.
4
+ # Controllers that render the bare desk instead override `windowed?`.
5
+ module Rori::Windowed
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ layout -> { turbo_frame_request? ? "rori/window" : "rori/shell" }
10
+ helper_method :windowed?
11
+ end
12
+
13
+ private
14
+ def windowed? = true
15
+ end
@@ -0,0 +1,9 @@
1
+ # "UI › Menu bar ›": top or bottom.
2
+ class Rori::Commands::BarsController < ApplicationController
3
+ layout false
4
+
5
+ def index
6
+ @commands = Rori::Command.bars(current: helpers.rori_bars)
7
+ render "rori/commands/index"
8
+ end
9
+ end
@@ -0,0 +1,22 @@
1
+ # Runs a server-side command (`Rori.command`) picked in ⌘K or the terminal,
2
+ # answering with its notification as a turbo stream (rori-notifications#run).
3
+ class Rori::Commands::RunsController < ApplicationController
4
+ def create
5
+ runnable = Rori.runnables[params.expect(:name)]
6
+ return head :not_found unless runnable
7
+
8
+ notification = run(runnable)
9
+ render turbo_stream: turbo_stream.append("rori-notifications", partial: "rori/notification", locals: { notification: }),
10
+ status: notification.sticky? ? :unprocessable_entity : :ok
11
+ end
12
+
13
+ private
14
+ def run(runnable)
15
+ result = runnable.call
16
+ Rori::Notification.new(title: runnable.label, body: result.is_a?(String) ? result : t(".done"), kind: :success)
17
+ rescue => error
18
+ Rails.error.report(error, handled: true)
19
+ # The exception's message may reveal internals; only local environments show it.
20
+ Rori::Notification.new(title: runnable.label, body: Rails.env.local? ? error.message : t(".failed"), kind: :error)
21
+ end
22
+ end
@@ -0,0 +1,9 @@
1
+ # The palette's nested "Pick theme…" list, rendered into the same frame as the root commands.
2
+ class Rori::Commands::ThemesController < ApplicationController
3
+ layout false
4
+
5
+ def index
6
+ @commands = Rori::Command.themes(current: helpers.current_theme)
7
+ render "rori/commands/index"
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # The "UI ›" list: appearance settings, each a nested list of its own.
2
+ class Rori::Commands::UiController < ApplicationController
3
+ layout false
4
+
5
+ def show
6
+ @commands = Rori::Command.ui
7
+ render "rori/commands/index"
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # "UI › Wallpaper ›": how (or whether) the wallpaper shows, next and pin.
2
+ class Rori::Commands::WallpapersController < ApplicationController
3
+ layout false
4
+
5
+ def index
6
+ @commands = Rori::Command.wallpapers(current: helpers.rori_wallpaper_mode, pinned: helpers.rori_wallpaper_pinned?)
7
+ render "rori/commands/index"
8
+ end
9
+ end
@@ -0,0 +1,7 @@
1
+ class Rori::CommandsController < ApplicationController
2
+ layout false
3
+
4
+ def index
5
+ @commands = Rori::Command.all
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ class Rori::DesktopsController < ApplicationController
2
+ def show
3
+ end
4
+
5
+ private
6
+ def windowed? = false
7
+ end
@@ -0,0 +1,9 @@
1
+ module Rori::AssetsHelper
2
+ # The desk's stylesheets. Propshaft's `stylesheet_link_tag :app` only globs
3
+ # the host's app/assets, so the shell links these right after it — after the
4
+ # host's tokens and `@layer` order (_init.css), which the desk's CSS relies on.
5
+ def rori_stylesheet_link_tags(**options)
6
+ paths = Rails.application.assets.load_path.asset_paths_by_glob("#{Rori::Engine.root.join("app/assets")}/**/*.css")
7
+ stylesheet_link_tag(*paths, **options)
8
+ end
9
+ end
@@ -0,0 +1,50 @@
1
+ module Rori::KeymapHelper
2
+ KEY_SYMBOLS = {
3
+ "Alt" => "⌥", "Meta" => "⌘", "Control" => "⌃", "Shift" => "⇧",
4
+ "ArrowLeft" => "←", "ArrowRight" => "→", "ArrowUp" => "↑", "ArrowDown" => "↓",
5
+ "BracketLeft" => "[", "BracketRight" => "]", "Backquote" => "`", "Slash" => "/",
6
+ "Enter" => "↵", "Space" => "␣"
7
+ }.freeze
8
+
9
+ # The keyboard shortcuts modal, in reading order; keymap actions missing from
10
+ # Rori.keymap (an app removed them) are skipped.
11
+ SHORTCUT_SECTIONS = {
12
+ focus: %i[ focus_left focus_right focus_up focus_down ],
13
+ move: %i[ move_left move_right move_up move_down ],
14
+ workspaces: %i[ switch_to_workspace move_to_workspace ],
15
+ columns: %i[ consume_left consume_right cycle_width full_width center_column overview ],
16
+ windows: %i[ close_window reopen_window toggle_terminal shortcuts ]
17
+ }.freeze
18
+
19
+ # Alt in a browser, ⌘ inside the native shell (see Rori.native_user_agent).
20
+ def rori_modifier = Rori.modifier_for(request.user_agent)
21
+
22
+ def rori_native? = Rori.native?(request.user_agent)
23
+
24
+ # The first chord bound to a desk action, as symbols ("⌥⇧T"), or nil.
25
+ def rori_shortcut(action)
26
+ chord = Rori.resolved_keymap(rori_modifier)[action&.to_sym]&.first
27
+ chord && rori_chord_symbols(chord)
28
+ end
29
+
30
+ # "Alt+Shift+KeyT" → "⌥⇧T", "Meta+Digit*" → "⌘1–9", "Meta+Shift+Slash" → "⌘?".
31
+ def rori_chord_symbols(chord)
32
+ keys = chord.split("+")
33
+ keys = keys - %w[ Shift Slash ] + [ "?" ] if (keys & %w[ Shift Slash ]).size == 2
34
+ keys.map { |key| KEY_SYMBOLS.fetch(key) { key.sub(/\A(Key|Digit)/, "").sub("*", "1–9") } }.join
35
+ end
36
+
37
+ # [ [section, [ [action, ["⌥←", "⌥H"]], … ]], … ] for the shortcuts modal.
38
+ def rori_shortcut_sections
39
+ keymap = Rori.resolved_keymap(rori_modifier)
40
+ SHORTCUT_SECTIONS.filter_map do |section, actions|
41
+ rows = actions.filter_map { |action| [ action, keymap[action].map { rori_chord_symbols(it) } ] if keymap[action] }
42
+ [ section, rows ] if rows.any?
43
+ end
44
+ end
45
+
46
+ # Bare keys (Rori.hover_keymap) as [ [action, ["W"]], … ], empty when off.
47
+ def rori_hover_shortcuts
48
+ Rori.resolved_hover_keymap.map { |action, chords| [ action, chords.map { rori_chord_symbols(it) } ] }
49
+ end
50
+ end
@@ -0,0 +1,13 @@
1
+ module Rori::PrefsHelper
2
+ BAR_POSITIONS = %w[ top bottom ].freeze
3
+
4
+ # Where the menu bar sits (UI › Menu bar): top or bottom edge.
5
+ def rori_bars = BAR_POSITIONS.include?(cookies[Rori.bars_cookie]) ? cookies[Rori.bars_cookie] : BAR_POSITIONS.first
6
+
7
+ # The empty-desk hint was dismissed (brought back from the shortcuts modal).
8
+ def rori_hint_hidden? = cookies[Rori.hint_cookie] == "hidden"
9
+
10
+ def rori_body_classes
11
+ class_names(rori_wallpaper_classes, "bars-bottom" => rori_bars == "bottom", "hint-hidden" => rori_hint_hidden?)
12
+ end
13
+ end
@@ -0,0 +1,14 @@
1
+ module Rori::ThemeHelper
2
+ # rori-theme (theme_controller.js) stores the pick in `Rori.theme_cookie` so the
3
+ # server can render it on <html> and pages don't flash the default theme.
4
+ def current_theme
5
+ Rori::Theme.find(cookies[Rori.theme_cookie])
6
+ end
7
+
8
+ # The app's own themes (Rori.themes_folder), inline: every one, so the palette
9
+ # can preview them. After the desk's stylesheets, so rori.themes is in order.
10
+ def rori_local_themes_style_tag
11
+ themes = Rori::Theme.local
12
+ tag.style(Rori::Theme.css(themes).html_safe, nonce: content_security_policy_nonce) if themes.any?
13
+ end
14
+ end
@@ -0,0 +1,19 @@
1
+ module Rori::WallpaperHelper
2
+ # This page load's photo (the pinned one, else random), or nil when
3
+ # Rori.wallpapers is off.
4
+ def rori_wallpaper
5
+ return @rori_wallpaper if defined?(@rori_wallpaper)
6
+ @rori_wallpaper = Rori::Wallpaper.pick(cookies[Rori.wallpaper_pin_cookie])
7
+ end
8
+
9
+ # A pin whose photo left the pool no longer counts.
10
+ def rori_wallpaper_pinned? = Rori.wallpapers && Rori::Wallpaper.find(cookies[Rori.wallpaper_pin_cookie]).present?
11
+
12
+ # safe | cover | off, picked from UI › Wallpaper (rori-wallpaper stores the cookie).
13
+ def rori_wallpaper_mode = Rori::Wallpaper.mode(cookies[Rori.wallpaper_cookie])
14
+
15
+ def rori_wallpaper_classes
16
+ class_names("has-wallpaper" => rori_wallpaper && rori_wallpaper_mode != "off",
17
+ "wallpaper-cover" => rori_wallpaper_mode == "cover")
18
+ end
19
+ end
@@ -0,0 +1,36 @@
1
+ module Rori::WindowHelper
2
+ # Declares how the current page wants to be shown on the desk. The window
3
+ # layout serializes it into a <template data-window-meta>, which the desk
4
+ # (rori_controller.js) reads on every frame load:
5
+ #
6
+ # size: column width :sm ⅓ | :md ½ | :lg ⅔ | :xl full (default :md);
7
+ # for modals, the dialog width
8
+ # mode: :tile (a new column next to the focused one) | :modal |
9
+ # :fullscreen (a full-width column) (default :tile)
10
+ # workspace: "name" (found or created) | :new (first load only)
11
+ # key: windows sharing a key are reused when opened
12
+ # (default controller_path, nil for modals)
13
+ def window(**options)
14
+ @window_options = options
15
+ end
16
+
17
+ def window_title
18
+ content_for(:title) || t("#{controller_path.tr("/", ".")}.#{template_action}.title")
19
+ end
20
+
21
+ def window_meta_tag
22
+ options = { size: :md, mode: :tile }.merge(@window_options || {})
23
+ options[:key] = options.fetch(:key) { controller_path unless options[:mode] == :modal }
24
+ tag.template(data: { window_meta: options.merge(title: window_title) })
25
+ end
26
+
27
+ # Opens the URL through the desk (a new or reused window) instead of
28
+ # navigating the current one. Without JavaScript it's a plain full-page link.
29
+ def rori_link_to(name, url, **options)
30
+ link_to name, url, **options, data: { **options.fetch(:data, {}), turbo_frame: "_top" }
31
+ end
32
+
33
+ private
34
+ # 422 re-renders run under create/update but show the new/edit template.
35
+ def template_action = { "create" => "new", "update" => "edit" }.fetch(action_name, action_name)
36
+ end
@@ -0,0 +1,18 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ connect() {
5
+ this.tick()
6
+ this.timer = setInterval(() => this.tick(), 10_000)
7
+ }
8
+
9
+ disconnect() {
10
+ clearInterval(this.timer)
11
+ }
12
+
13
+ tick() {
14
+ const now = new Date()
15
+ this.element.dateTime = now.toISOString()
16
+ this.element.textContent = now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
17
+ }
18
+ }
@@ -0,0 +1,84 @@
1
+ import { score } from "rori/fuzzy"
2
+
3
+ const MAX_DEPTH = 4
4
+ // Each level deeper costs this much, so "theme" still prefers "Pick theme…"
5
+ // over "UI › Theme", while "uthen" (only a deep path fits) still finds Nord.
6
+ const DEPTH_PENALTY = 1.5
7
+
8
+ // A command list as the palette renders it (rori/commands/index), as data.
9
+ export function parseList(html) {
10
+ const page = new DOMParser().parseFromString(html, "text/html")
11
+ return [...page.querySelectorAll(".rori-palette__item")].map((item) => ({
12
+ label: item.querySelector(".rori-palette__label").firstChild.textContent.trim(),
13
+ group: item.querySelector(".rori-palette__group").lastChild.textContent.trim(),
14
+ url: item.dataset.url,
15
+ action: item.dataset.roriAction,
16
+ param: item.dataset.param,
17
+ children: item.dataset.children,
18
+ source: item.dataset.source,
19
+ run: item.dataset.run,
20
+ confirm: "confirm" in item.dataset,
21
+ shortcut: item.dataset.shortcut,
22
+ current: "current" in item.dataset,
23
+ }))
24
+ }
25
+
26
+ export const isNested = (item) => Boolean(item.children || item.source)
27
+
28
+ // What running an entry does; entries reachable by two paths share it.
29
+ const target = (item) =>
30
+ item.url || item.children || item.source || (item.run && `run:${item.run}`) || `${item.action}:${item.param ?? ""}`
31
+
32
+ // Every command reachable from the root list, flattened with its trail
33
+ // ("UI › Theme › Nord"), so a query matches whole paths instead of one level
34
+ // at a time. Server lists are fetched once per index; `source` answers
35
+ // browser-only lists (workspaces) the way the command bus does.
36
+ export class CommandIndex {
37
+ constructor(root, { source }) {
38
+ this.root = root
39
+ this.source = source
40
+ this.lists = new Map()
41
+ }
42
+
43
+ list({ children, source }) {
44
+ if (source) return Promise.resolve(this.source(source))
45
+ if (!this.lists.has(children)) {
46
+ this.lists.set(children, fetch(children, { headers: { Accept: "text/html", "Turbo-Frame": "commands" } })
47
+ .then((response) => response.text())
48
+ .then(parseList))
49
+ }
50
+ return this.lists.get(children)
51
+ }
52
+
53
+ entries() {
54
+ this.crawl ||= this.#crawl({ children: this.root }, [], new Set([this.root]))
55
+ return this.crawl
56
+ }
57
+
58
+ // Best entries for `query`, one per target, best first. `bonus(item)` adds
59
+ // to an entry's score (the palette's frecency).
60
+ async search(query, { limit = 20, bonus = () => 0 } = {}) {
61
+ const ranked = (await this.entries())
62
+ .map((entry, index) => {
63
+ const match = score(entry.text, query)
64
+ return { ...entry, index, score: match && match - (entry.trail.length - 1) * DEPTH_PENALTY + bonus(entry.item) }
65
+ })
66
+ .filter(({ score }) => score > 0)
67
+ .sort((a, b) => b.score - a.score || a.index - b.index)
68
+
69
+ const seen = new Set()
70
+ return ranked.filter(({ item }) => !seen.has(target(item)) && seen.add(target(item))).slice(0, limit)
71
+ }
72
+
73
+ async #crawl(node, trail, visited) {
74
+ const items = await this.list(node)
75
+ const branches = await Promise.all(items.map(async (item) => {
76
+ const path = [...trail, item]
77
+ const entry = { item, trail: path, text: path.map(({ label }) => label).join(" › ") }
78
+ const key = item.children || item.source
79
+ if (!key || path.length >= MAX_DEPTH || visited.has(key)) return [entry]
80
+ return [entry, ...(await this.#crawl(item, path, new Set([...visited, key])))]
81
+ }))
82
+ return branches.flat()
83
+ }
84
+ }
@@ -0,0 +1,50 @@
1
+ // The one fuzzy matcher behind ⌘K and the terminal.
2
+ //
3
+ // Every query character must appear in order (spaces and dashes in the query
4
+ // are ignored, so "cover-menu" and "cover menu" match "Cover menu bar"). Among all
5
+ // ways to place them, it keeps the best-scoring one: characters at word starts
6
+ // — including each "›" segment of a path — and consecutive runs score high,
7
+ // gaps cost a little. That's what lets "uthen" find "UI › Theme › Nord":
8
+ // u, the and n each start a segment.
9
+ const SEPARATORS = " ›/-_.:…"
10
+ const BOUNDARY = 8 // character starts a word or segment
11
+ const FIRST = 4 // …and it's the very first character
12
+ const CONSECUTIVE = 5 // follows the previous match directly
13
+ const GAP_OPEN = 3 // cost of skipping ahead…
14
+ const GAP_EXTEND = 1 // …plus this per extra skipped character
15
+
16
+ export function score(text, query) {
17
+ query = query.toLowerCase().replace(/[\s-]+/g, "")
18
+ if (!query) return 1
19
+ text = text.toLowerCase()
20
+ const n = text.length
21
+ const m = query.length
22
+ if (m > n) return 0
23
+
24
+ const boundary = (i) => i === 0 || SEPARATORS.includes(text[i - 1])
25
+ // previous[i]: best score with query[j - 1] placed at text[i].
26
+ let previous = new Array(n).fill(-Infinity)
27
+
28
+ for (let j = 0; j < m; j++) {
29
+ const current = new Array(n).fill(-Infinity)
30
+ let gapped = -Infinity // best previous[k] for k <= i - 2, minus its gap cost to i
31
+
32
+ for (let i = 0; i < n; i++) {
33
+ if (j > 0 && i >= 2) gapped = Math.max(gapped - GAP_EXTEND, previous[i - 2] - GAP_OPEN)
34
+ if (text[i] !== query[j]) continue
35
+
36
+ const bonus = 1 + (boundary(i) ? BOUNDARY : 0)
37
+ if (j === 0) {
38
+ current[i] = bonus + (i === 0 ? FIRST : 0) - i * 0.05
39
+ } else {
40
+ const direct = i >= 1 ? previous[i - 1] + CONSECUTIVE : -Infinity
41
+ current[i] = Math.max(direct, gapped) + bonus
42
+ }
43
+ }
44
+ previous = current
45
+ }
46
+
47
+ const best = Math.max(...previous)
48
+ // Shorter texts win ties; a match always stays above zero.
49
+ return best === -Infinity ? 0 : Math.max(best - n * 0.01, 0.001)
50
+ }
@@ -0,0 +1,21 @@
1
+ // The desk's Stimulus controllers, registered under a `desk` prefix so they
2
+ // can't collide with the app's own (a plain `palette` or `theme`, say).
3
+ import RoriController from "rori/rori_controller"
4
+ import PaletteController from "rori/palette_controller"
5
+ import ThemeController from "rori/theme_controller"
6
+ import ClockController from "rori/clock_controller"
7
+ import WallpaperController from "rori/wallpaper_controller"
8
+ import TerminalController from "rori/terminal_controller"
9
+ import PrefsController from "rori/prefs_controller"
10
+ import NotificationsController from "rori/notifications_controller"
11
+
12
+ export function registerRori(application) {
13
+ application.register("rori", RoriController)
14
+ application.register("rori-palette", PaletteController)
15
+ application.register("rori-theme", ThemeController)
16
+ application.register("rori-clock", ClockController)
17
+ application.register("rori-wallpaper", WallpaperController)
18
+ application.register("rori-terminal", TerminalController)
19
+ application.register("rori-prefs", PrefsController)
20
+ application.register("rori-notifications", NotificationsController)
21
+ }
@@ -0,0 +1,45 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { Turbo } from "@hotwired/turbo-rails"
3
+
4
+ const STREAM = "text/vnd.turbo-stream.html"
5
+
6
+ // Corner notifications (rori/_notifications). They arrive as turbo streams
7
+ // appended to the stack: a server-side command's result (run here, from
8
+ // `rori-command:run`) or a `Rori.notify` broadcast. The stack is a manual
9
+ // popover, open while it holds any. Each notification fades out on its own
10
+ // (a CSS animation, paused while the pointer is on the stack) unless sticky.
11
+ export default class extends Controller {
12
+ static targets = ["stack", "notification", "failure"]
13
+ static values = { url: String }
14
+
15
+ async run({ detail: { run } }) {
16
+ if (!run) return
17
+ try {
18
+ const response = await fetch(this.urlValue, {
19
+ method: "POST",
20
+ body: new URLSearchParams({ name: run }),
21
+ headers: { Accept: STREAM, "X-CSRF-Token": document.querySelector("meta[name=csrf-token]")?.content },
22
+ })
23
+ if (!response.headers.get("Content-Type")?.startsWith(STREAM)) throw new Error(`${response.status}`)
24
+ Turbo.renderStreamMessage(await response.text())
25
+ } catch {
26
+ this.stackTarget.append(this.failureTarget.content.cloneNode(true))
27
+ }
28
+ }
29
+
30
+ notificationTargetConnected() {
31
+ if (!this.stackTarget.matches(":popover-open")) this.stackTarget.showPopover()
32
+ }
33
+
34
+ notificationTargetDisconnected() {
35
+ if (!this.hasNotificationTarget && this.stackTarget.matches(":popover-open")) this.stackTarget.hidePopover()
36
+ }
37
+
38
+ dismiss({ target }) {
39
+ target.closest(".rori-notification").remove()
40
+ }
41
+
42
+ expire({ target, animationName }) {
43
+ if (animationName === "rori-notification-out") target.remove()
44
+ }
45
+ }