opencode-tweaks 0.0.0-stage → 0.4.1
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.
- package/LICENSE +21 -0
- package/README.md +222 -2
- package/package.json +62 -4
- package/src/index.ts +20 -0
- package/src/quota/format.ts +97 -0
- package/src/quota/pace.ts +180 -0
- package/src/quota/parse.ts +101 -0
- package/src/quota/service.ts +224 -0
- package/src/rpc.ts +84 -0
- package/src/tui.tsx +66 -0
- package/src/tweaks/menu-dialog.tsx +424 -0
- package/src/tweaks/menu-model.ts +55 -0
- package/src/tweaks/menu.ts +1 -0
- package/src/tweaks/quota-panel.tsx +514 -0
- package/src/tweaks/quota.ts +163 -0
- package/src/tweaks/registry.ts +10 -0
- package/src/tweaks/settings.ts +73 -0
- package/src/tweaks/token-inject.ts +724 -0
- package/src/tweaks/token-slots.tsx +327 -0
- package/src/tweaks/tokens-model.ts +213 -0
- package/src/tweaks/tokens.ts +113 -0
- package/src/tweaks/types.ts +187 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import { usePlugin } from "@opencode/plugin/tui"
|
|
2
|
+
import type { Context } from "@opencode/plugin/tui/context"
|
|
3
|
+
import { CliRenderEvents, type ScrollBoxRenderable, TextAttributes } from "@opentui/core"
|
|
4
|
+
import { useTerminalDimensions } from "@opentui/solid"
|
|
5
|
+
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
|
|
6
|
+
import { filterRows, groupRows, type MenuRow, moveRow, orderedRows } from "./menu-model"
|
|
7
|
+
import { tweaks } from "./registry"
|
|
8
|
+
import type { SettingsStore } from "./settings"
|
|
9
|
+
import { displayValue, nextValue, type Tweak, type TweakSetting } from "./types"
|
|
10
|
+
|
|
11
|
+
interface Row extends MenuRow {
|
|
12
|
+
readonly footer: () => string
|
|
13
|
+
readonly activate: () => void | Promise<void>
|
|
14
|
+
readonly cycle?: (direction: 1 | -1) => Promise<void>
|
|
15
|
+
readonly reset?: () => Promise<void>
|
|
16
|
+
/** Rows that cycle through values show arrows around the value while selected. */
|
|
17
|
+
readonly cycleable?: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type View = { readonly kind: "tweaks" } | { readonly kind: "tweak"; readonly tweak: Tweak }
|
|
21
|
+
|
|
22
|
+
type DisplayRow =
|
|
23
|
+
| { readonly kind: "category"; readonly title: string }
|
|
24
|
+
| { readonly kind: "row"; readonly row: Row }
|
|
25
|
+
|
|
26
|
+
interface DialogProps {
|
|
27
|
+
readonly context: Context
|
|
28
|
+
readonly store: SettingsStore
|
|
29
|
+
readonly close: () => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const tweakRowID = (tweak: Tweak) => `tweak:${tweak.id}`
|
|
33
|
+
const settingRowID = (tweak: Tweak, setting: TweakSetting) => `setting:${tweak.id}.${setting.id}`
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Opens the tweak list in one dialog that never closes while values change.
|
|
37
|
+
* The layout follows OpenCode's own settings screen row for row, and values
|
|
38
|
+
* update in place instead of reopening the menu after every choice.
|
|
39
|
+
*/
|
|
40
|
+
export async function openTweaksMenu(context: Context, store: SettingsStore): Promise<void> {
|
|
41
|
+
await new Promise<void>((resolve) => {
|
|
42
|
+
let settled = false
|
|
43
|
+
const settle = () => {
|
|
44
|
+
if (settled) return
|
|
45
|
+
settled = true
|
|
46
|
+
resolve()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
context.ui.dialog.show(
|
|
50
|
+
() => (
|
|
51
|
+
<TweaksDialog context={context} store={store} close={() => context.ui.dialog.clear()} />
|
|
52
|
+
),
|
|
53
|
+
settle,
|
|
54
|
+
)
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function TweaksDialog(props: DialogProps) {
|
|
59
|
+
const theme = () => props.context.theme.surface("dialog")
|
|
60
|
+
const dimensions = useTerminalDimensions()
|
|
61
|
+
const [view, setView] = createSignal<View>({ kind: "tweaks" })
|
|
62
|
+
const [filter, setFilter] = createSignal("")
|
|
63
|
+
const firstTweak = tweaks[0]
|
|
64
|
+
const [selectedID, setSelectedID] = createSignal<string | undefined>(
|
|
65
|
+
firstTweak ? tweakRowID(firstTweak) : undefined,
|
|
66
|
+
)
|
|
67
|
+
const [saving, setSaving] = createSignal(false)
|
|
68
|
+
|
|
69
|
+
let scroll: ScrollBoxRenderable | undefined
|
|
70
|
+
let pendingScroll: (() => void) | undefined
|
|
71
|
+
|
|
72
|
+
async function cycleSetting(tweak: Tweak, setting: TweakSetting, direction: 1 | -1) {
|
|
73
|
+
const current = props.store.read(tweak, setting).value
|
|
74
|
+
const next = nextValue(setting, current, direction)
|
|
75
|
+
if (next !== undefined) await props.store.set(tweak, setting, next)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function run(action: () => void | Promise<void>) {
|
|
79
|
+
if (saving()) return
|
|
80
|
+
|
|
81
|
+
setSaving(true)
|
|
82
|
+
try {
|
|
83
|
+
await action()
|
|
84
|
+
} catch (error) {
|
|
85
|
+
props.context.ui.toast.show({
|
|
86
|
+
variant: "error",
|
|
87
|
+
title: "Tweaks",
|
|
88
|
+
message: error instanceof Error ? error.message : String(error),
|
|
89
|
+
})
|
|
90
|
+
} finally {
|
|
91
|
+
setSaving(false)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function summarize(tweak: Tweak): string {
|
|
96
|
+
const enabled = tweak.settings.find((setting) => setting.id === "enabled")
|
|
97
|
+
if (enabled) return displayValue(enabled, props.store.read(tweak, enabled).value)
|
|
98
|
+
|
|
99
|
+
return tweak.settings.length === 1 ? "1 setting" : `${tweak.settings.length} settings`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function openTweak(tweak: Tweak) {
|
|
103
|
+
const first = tweak.settings[0]
|
|
104
|
+
setFilter("")
|
|
105
|
+
setSelectedID(first ? settingRowID(tweak, first) : undefined)
|
|
106
|
+
setView({ kind: "tweak", tweak })
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function goBack() {
|
|
110
|
+
const current = view()
|
|
111
|
+
if (current.kind === "tweak") {
|
|
112
|
+
setFilter("")
|
|
113
|
+
setSelectedID(tweakRowID(current.tweak))
|
|
114
|
+
setView({ kind: "tweaks" })
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
props.close()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function tweakRows(): Row[] {
|
|
122
|
+
return tweaks.map((tweak) => ({
|
|
123
|
+
id: tweakRowID(tweak),
|
|
124
|
+
title: tweak.title,
|
|
125
|
+
category: tweak.category,
|
|
126
|
+
// The chevron marks a row that opens another page. Settings live one level down.
|
|
127
|
+
footer: () => `${summarize(tweak)} ›`,
|
|
128
|
+
activate: () => openTweak(tweak),
|
|
129
|
+
}))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function settingRows(tweak: Tweak): Row[] {
|
|
133
|
+
return tweak.settings.map((setting) => ({
|
|
134
|
+
id: settingRowID(tweak, setting),
|
|
135
|
+
title: setting.title,
|
|
136
|
+
category: setting.category,
|
|
137
|
+
footer: () => displayValue(setting, props.store.read(tweak, setting).value),
|
|
138
|
+
activate: () => cycleSetting(tweak, setting, 1),
|
|
139
|
+
cycle: (direction) => cycleSetting(tweak, setting, direction),
|
|
140
|
+
reset: () => props.store.reset(tweak, setting),
|
|
141
|
+
cycleable: true,
|
|
142
|
+
}))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const rows = createMemo<Row[]>(() => {
|
|
146
|
+
const current = view()
|
|
147
|
+
return current.kind === "tweaks" ? tweakRows() : settingRows(current.tweak)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
const visible = createMemo(() => filterRows(rows(), filter()))
|
|
151
|
+
|
|
152
|
+
const display = createMemo<DisplayRow[]>(() => {
|
|
153
|
+
const query = filter().trim()
|
|
154
|
+
const result: DisplayRow[] = []
|
|
155
|
+
|
|
156
|
+
for (const [category, group] of groupRows(visible())) {
|
|
157
|
+
if (!query && category) result.push({ kind: "category", title: category })
|
|
158
|
+
for (const row of group) result.push({ kind: "row", row })
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return result
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
const ordered = createMemo(() => orderedRows(visible()))
|
|
165
|
+
const selected = createMemo(() => ordered().find((row) => row.id === selectedID()))
|
|
166
|
+
const title = createMemo(() => {
|
|
167
|
+
const current = view()
|
|
168
|
+
return current.kind === "tweaks" ? "Tweaks" : current.tweak.title
|
|
169
|
+
})
|
|
170
|
+
const height = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 6))
|
|
171
|
+
|
|
172
|
+
function move(delta: number) {
|
|
173
|
+
const next = moveRow(ordered(), selectedID(), delta)
|
|
174
|
+
if (next) setSelectedID(next.id)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function activate(row = selected()) {
|
|
178
|
+
if (row) void run(row.activate)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function cycle(direction: 1 | -1) {
|
|
182
|
+
const runCycle = selected()?.cycle
|
|
183
|
+
if (runCycle) void run(() => runCycle(direction))
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function reset() {
|
|
187
|
+
const runReset = selected()?.reset
|
|
188
|
+
if (runReset) void run(runReset)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function updateFilter(value: string) {
|
|
192
|
+
setFilter(value)
|
|
193
|
+
setSelectedID(orderedRows(filterRows(rows(), value))[0]?.id)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Keep a valid selection when the filter changes the list.
|
|
197
|
+
createEffect(() => {
|
|
198
|
+
const list = ordered()
|
|
199
|
+
if (list.length === 0) {
|
|
200
|
+
if (selectedID() !== undefined) setSelectedID(undefined)
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (!list.some((row) => row.id === selectedID())) setSelectedID(list[0]?.id)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
// Keep the selected row inside the scroll viewport.
|
|
208
|
+
createEffect(() => {
|
|
209
|
+
const id = selectedID()
|
|
210
|
+
if (!id) return
|
|
211
|
+
|
|
212
|
+
const index = display().findIndex((item) => item.kind === "row" && item.row.id === id)
|
|
213
|
+
if (index < 0) return
|
|
214
|
+
|
|
215
|
+
if (pendingScroll) props.context.renderer.off(CliRenderEvents.FRAME, pendingScroll)
|
|
216
|
+
pendingScroll = () => {
|
|
217
|
+
pendingScroll = undefined
|
|
218
|
+
if (!scroll) return
|
|
219
|
+
|
|
220
|
+
const target = scroll.getChildren()[index]
|
|
221
|
+
if (!target) return
|
|
222
|
+
|
|
223
|
+
const y = target.y - scroll.y
|
|
224
|
+
if (y >= scroll.height) scroll.scrollBy(y - scroll.height + 1)
|
|
225
|
+
if (y < 0) scroll.scrollBy(y)
|
|
226
|
+
}
|
|
227
|
+
props.context.renderer.once(CliRenderEvents.FRAME, pendingScroll)
|
|
228
|
+
props.context.renderer.requestRender()
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
props.context.keymap.layer(() => ({
|
|
232
|
+
mode: "modal",
|
|
233
|
+
priority: 10,
|
|
234
|
+
commands: [
|
|
235
|
+
{
|
|
236
|
+
id: "opencode-tweaks.dialog.up",
|
|
237
|
+
bind: "up",
|
|
238
|
+
run: () => move(-1),
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
id: "opencode-tweaks.dialog.down",
|
|
242
|
+
bind: "down",
|
|
243
|
+
run: () => move(1),
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
id: "opencode-tweaks.dialog.enter",
|
|
247
|
+
bind: "return",
|
|
248
|
+
run: () => activate(),
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
id: "opencode-tweaks.dialog.left",
|
|
252
|
+
bind: "left",
|
|
253
|
+
enabled: () => view().kind === "tweak",
|
|
254
|
+
run: () => cycle(-1),
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
id: "opencode-tweaks.dialog.right",
|
|
258
|
+
bind: "right",
|
|
259
|
+
enabled: () => view().kind === "tweak",
|
|
260
|
+
run: () => cycle(1),
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
id: "opencode-tweaks.dialog.reset",
|
|
264
|
+
bind: "ctrl+r",
|
|
265
|
+
enabled: () => view().kind === "tweak",
|
|
266
|
+
run: () => reset(),
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
id: "opencode-tweaks.dialog.back",
|
|
270
|
+
bind: "escape",
|
|
271
|
+
run: () => goBack(),
|
|
272
|
+
},
|
|
273
|
+
],
|
|
274
|
+
}))
|
|
275
|
+
|
|
276
|
+
return (
|
|
277
|
+
<box flexDirection="column" gap={1} paddingLeft={3} paddingRight={3} paddingBottom={1}>
|
|
278
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
279
|
+
<box flexDirection="row" justifyContent="space-between" gap={2}>
|
|
280
|
+
<text attributes={TextAttributes.BOLD} fg={theme().text.base}>
|
|
281
|
+
{title()}
|
|
282
|
+
</text>
|
|
283
|
+
<text fg={theme().text.muted} onMouseUp={goBack}>
|
|
284
|
+
esc
|
|
285
|
+
</text>
|
|
286
|
+
</box>
|
|
287
|
+
<box paddingTop={1}>
|
|
288
|
+
<input
|
|
289
|
+
flexGrow={1}
|
|
290
|
+
value={filter()}
|
|
291
|
+
onInput={updateFilter}
|
|
292
|
+
focusedBackgroundColor={theme().background.formfield.focused}
|
|
293
|
+
cursorColor={theme().text.formfield.focused}
|
|
294
|
+
focusedTextColor={theme().text.formfield.focused}
|
|
295
|
+
placeholder="Search"
|
|
296
|
+
placeholderColor={theme().text.muted}
|
|
297
|
+
ref={(renderable) => {
|
|
298
|
+
setTimeout(() => {
|
|
299
|
+
if (!renderable.isDestroyed) renderable.focus()
|
|
300
|
+
}, 1)
|
|
301
|
+
}}
|
|
302
|
+
/>
|
|
303
|
+
</box>
|
|
304
|
+
</box>
|
|
305
|
+
|
|
306
|
+
<box marginLeft={-3} marginRight={-3}>
|
|
307
|
+
<scrollbox
|
|
308
|
+
paddingLeft={1}
|
|
309
|
+
paddingRight={1}
|
|
310
|
+
maxHeight={height()}
|
|
311
|
+
scrollbarOptions={{ visible: false }}
|
|
312
|
+
ref={(renderable) => {
|
|
313
|
+
scroll = renderable
|
|
314
|
+
}}
|
|
315
|
+
>
|
|
316
|
+
<Show
|
|
317
|
+
when={display().length > 0}
|
|
318
|
+
fallback={
|
|
319
|
+
<box paddingLeft={3}>
|
|
320
|
+
<text fg={theme().text.muted}>No results found</text>
|
|
321
|
+
</box>
|
|
322
|
+
}
|
|
323
|
+
>
|
|
324
|
+
<For each={display()}>
|
|
325
|
+
{(item, index) =>
|
|
326
|
+
item.kind === "category" ? (
|
|
327
|
+
<box paddingTop={index() === 0 ? 0 : 1} paddingLeft={3}>
|
|
328
|
+
<text fg={theme().hue.accent[200]} attributes={TextAttributes.BOLD}>
|
|
329
|
+
{item.title}
|
|
330
|
+
</text>
|
|
331
|
+
</box>
|
|
332
|
+
) : (
|
|
333
|
+
<RowView
|
|
334
|
+
row={item.row}
|
|
335
|
+
active={item.row.id === selectedID()}
|
|
336
|
+
onHover={() => setSelectedID(item.row.id)}
|
|
337
|
+
onActivate={() => activate(item.row)}
|
|
338
|
+
/>
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
</For>
|
|
342
|
+
</Show>
|
|
343
|
+
</scrollbox>
|
|
344
|
+
</box>
|
|
345
|
+
|
|
346
|
+
<box paddingLeft={1} paddingRight={1}>
|
|
347
|
+
<Footer editing={view().kind === "tweak"} />
|
|
348
|
+
</box>
|
|
349
|
+
</box>
|
|
350
|
+
)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function RowView(props: {
|
|
354
|
+
readonly row: Row
|
|
355
|
+
readonly active: boolean
|
|
356
|
+
readonly onHover: () => void
|
|
357
|
+
readonly onActivate: () => void
|
|
358
|
+
}) {
|
|
359
|
+
const theme = useDialogTheme()
|
|
360
|
+
const active = () => props.active
|
|
361
|
+
const value = () => {
|
|
362
|
+
const text = props.row.footer()
|
|
363
|
+
if (!props.row.cycleable) return text
|
|
364
|
+
|
|
365
|
+
// Keep the column width stable: arrows appear only on the selected row.
|
|
366
|
+
return active() ? `‹ ${text} ›` : ` ${text} `
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return (
|
|
370
|
+
<box
|
|
371
|
+
flexDirection="row"
|
|
372
|
+
gap={1}
|
|
373
|
+
paddingLeft={active() ? 1 : 3}
|
|
374
|
+
paddingRight={3}
|
|
375
|
+
backgroundColor={active() ? theme().background.action.primary.focused : undefined}
|
|
376
|
+
onMouseDown={props.onHover}
|
|
377
|
+
onMouseMove={props.onHover}
|
|
378
|
+
onMouseUp={props.onActivate}
|
|
379
|
+
>
|
|
380
|
+
<Show when={active()}>
|
|
381
|
+
<text width={1} flexShrink={0} fg={theme().text.action.primary.focused}>
|
|
382
|
+
●
|
|
383
|
+
</text>
|
|
384
|
+
</Show>
|
|
385
|
+
<text
|
|
386
|
+
flexGrow={1}
|
|
387
|
+
paddingLeft={3}
|
|
388
|
+
overflow="hidden"
|
|
389
|
+
wrapMode="none"
|
|
390
|
+
fg={active() ? theme().text.action.primary.focused : theme().text.base}
|
|
391
|
+
attributes={active() ? TextAttributes.BOLD : undefined}
|
|
392
|
+
>
|
|
393
|
+
{props.row.title}
|
|
394
|
+
</text>
|
|
395
|
+
<text flexShrink={0} fg={active() ? theme().text.action.primary.focused : theme().text.muted}>
|
|
396
|
+
{value()}
|
|
397
|
+
</text>
|
|
398
|
+
</box>
|
|
399
|
+
)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function Footer(props: { readonly editing: boolean }) {
|
|
403
|
+
return (
|
|
404
|
+
<box flexDirection="row" gap={2} flexShrink={0}>
|
|
405
|
+
<Hint title={props.editing ? "change" : "open"} key={props.editing ? "←/→" : "enter"} />
|
|
406
|
+
</box>
|
|
407
|
+
)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function Hint(props: { readonly title: string; readonly key: string }) {
|
|
411
|
+
const theme = useDialogTheme()
|
|
412
|
+
|
|
413
|
+
return (
|
|
414
|
+
<text>
|
|
415
|
+
{props.title}
|
|
416
|
+
<span style={{ fg: theme().text.muted }}>{` ${props.key}`}</span>
|
|
417
|
+
</text>
|
|
418
|
+
)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function useDialogTheme() {
|
|
422
|
+
const context = usePlugin()
|
|
423
|
+
return () => context.theme.surface("dialog")
|
|
424
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export interface MenuRow {
|
|
2
|
+
readonly id: string
|
|
3
|
+
readonly title: string
|
|
4
|
+
readonly category: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Keeps rows whose title or category matches the query. */
|
|
8
|
+
export function filterRows<Row extends MenuRow>(rows: readonly Row[], query: string): Row[] {
|
|
9
|
+
const needle = query.trim().toLowerCase()
|
|
10
|
+
if (!needle) return [...rows]
|
|
11
|
+
|
|
12
|
+
return rows.filter(
|
|
13
|
+
(row) =>
|
|
14
|
+
row.title.toLowerCase().includes(needle) || row.category.toLowerCase().includes(needle),
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Groups rows by category while preserving the order they were defined in. */
|
|
19
|
+
export function groupRows<Row extends MenuRow>(rows: readonly Row[]): Array<[string, Row[]]> {
|
|
20
|
+
const groups = new Map<string, Row[]>()
|
|
21
|
+
|
|
22
|
+
for (const row of rows) {
|
|
23
|
+
const group = groups.get(row.category)
|
|
24
|
+
if (group) {
|
|
25
|
+
group.push(row)
|
|
26
|
+
} else {
|
|
27
|
+
groups.set(row.category, [row])
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return [...groups.entries()]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Moves through a wrapping list, starting at the first or last row when the current id is gone. */
|
|
35
|
+
export function moveRow<Row extends MenuRow>(
|
|
36
|
+
rows: readonly Row[],
|
|
37
|
+
current: string | undefined,
|
|
38
|
+
delta: number,
|
|
39
|
+
): Row | undefined {
|
|
40
|
+
if (rows.length === 0) return undefined
|
|
41
|
+
|
|
42
|
+
const index = rows.findIndex((row) => row.id === current)
|
|
43
|
+
if (index < 0) return delta > 0 ? rows[0] : rows[rows.length - 1]
|
|
44
|
+
|
|
45
|
+
return rows[(index + delta + rows.length) % rows.length]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The order the menu draws rows in: grouped by category, headers left out.
|
|
50
|
+
* Navigation must follow this order, not the order the settings were defined
|
|
51
|
+
* in, or the selection appears to skip rows.
|
|
52
|
+
*/
|
|
53
|
+
export function orderedRows<Row extends MenuRow>(rows: readonly Row[]): Row[] {
|
|
54
|
+
return groupRows(rows).flatMap(([, group]) => group)
|
|
55
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { openTweaksMenu } from "./menu-dialog"
|