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,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,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
|
+
# "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,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
|
+
}
|