finished ShortcutManager

This commit is contained in:
André Michelle
2025-12-23 16:40:49 +01:00
parent 5a7a9233c0
commit a5a3e8f108
11 changed files with 226 additions and 89 deletions
+2 -2
View File
@@ -170,9 +170,9 @@
@if $semitransparent
backdrop-filter: blur(2px)
background-color: hsla(200, 12%, 9%, 0.98)
background-color: color-mix(in srgb, var(--color-panel-background-dark), transparent 1%)
@else
background-color: hsl(200, 12%, 9%)
background-color: var(--color-panel-background-dark)
@mixin corner-frame($color: rgba(white, 0.06))
position: relative
@@ -1,8 +1,11 @@
import {Terminator} from "@opendaw/lib-std"
import {Notifier, Terminator} from "@opendaw/lib-std"
import {Promises} from "@opendaw/lib-runtime"
import {Dialogs} from "@/ui/components/dialogs"
import {PreferencePanel} from "@/ui/PreferencePanel"
import {ShortcutManagerView} from "@/ui/components/ShortcutManagerView"
import {GlobalShortcuts, GlobalShortcutsFactory} from "@/shortcuts/GlobalShortcuts"
import {ShortcutDefinitions} from "@/shortcuts/ShortcutDefinitions"
import {StudioShortcutManager} from "@/service/StudioShortcutManager"
export namespace StudioDialogs {
export const showPreferences = async () => {
@@ -17,11 +20,31 @@ export namespace StudioDialogs {
export const showShortcutManager = async () => {
const lifecycle = new Terminator()
const abortController = new AbortController()
const updateNotifier = new Notifier<void>()
const contexts = {
"Global": ShortcutDefinitions.copy(GlobalShortcuts)
} satisfies Record<string, ShortcutDefinitions>
await Promises.tryCatch(Dialogs.show({
headline: "Shortcut Manager",
content: ShortcutManagerView({lifecycle}),
growWidth: true
}))
content: ShortcutManagerView({lifecycle, contexts, updateNotifier}),
growWidth: true,
abortSignal: abortController.signal,
buttons: [
{
text: "Reset", onClick: () => {
contexts["Global"] = ShortcutDefinitions.copy(GlobalShortcutsFactory)
updateNotifier.notify()
}
},
{text: "Cancel", onClick: () => abortController.abort()}
]
})).then(() => {
if (!abortController.signal.aborted) {
ShortcutDefinitions.copyInto(contexts.Global, GlobalShortcuts)
StudioShortcutManager.store()
}
})
lifecycle.terminate()
}
}
@@ -65,7 +65,6 @@ import {Surface} from "@/ui/surface/Surface"
import {SoftwareMIDIPanel} from "@/ui/software-midi/SoftwareMIDIPanel"
import {Mixdowns} from "@/service/Mixdowns"
import {ShadertoyState} from "@/ui/shadertoy/ShadertoyState"
import {StudioDialogs} from "@/service/StudioDialogs"
/**
* I am just piling stuff after stuff in here to boot the environment.
@@ -137,9 +136,6 @@ export class StudioService implements ProjectEnv {
this.#configBeforeUnload()
this.#checkRecovery()
this.#listenPreferences()
// TODO Remove when done
setTimeout(() => StudioDialogs.showShortcutManager().then(), 500)
}
get sampleRate(): number {return this.audioContext.sampleRate}
@@ -1,53 +1,89 @@
import {ShortcutManager, ShortcutOptions} from "@opendaw/lib-dom"
import {StudioService} from "@/service/StudioService"
import {PanelType} from "@/ui/workspace/PanelType"
import {Arrays, asInstanceOf, isNull, RuntimeNotifier, Subscription, Terminable} from "@opendaw/lib-std"
import {
Arrays,
asInstanceOf,
isDefined,
isNull,
RuntimeNotifier,
Subscription,
Terminable,
tryCatch
} from "@opendaw/lib-std"
import {DefaultWorkspace} from "@/ui/workspace/Default"
import {Workspace} from "@/ui/workspace/Workspace"
import {AudioUnitBox} from "@opendaw/studio-boxes"
import {ProjectUtils} from "@opendaw/studio-adapters"
import {StudioDialogs} from "@/service/StudioDialogs"
import {GlobalShortcuts} from "@/shortcuts/GlobalShortcuts"
import {GlobalShortcuts, GlobalShortcutsFactory} from "@/shortcuts/GlobalShortcuts"
import {ShortcutDefinitions} from "@/shortcuts/ShortcutDefinitions"
export namespace StudioShortcutManager {
const localStorageKey = "shortcuts"
export const reset = (): void => {
for (const [key, {keys}] of Object.entries(GlobalShortcutsFactory)) {
GlobalShortcuts[key].keys.overrideWith(keys)
}
}
export const store = () => {
try {
const jsonString = JSON.stringify({global: ShortcutDefinitions.toJSON(GlobalShortcuts)})
localStorage.setItem(localStorageKey, jsonString)
console.debug("Shortcuts saved.")
} catch (reason) {
console.warn(reason)
}
}
export const install = (service: StudioService): Subscription => {
const {global: g} = ShortcutManager.get()
const {global: gc} = ShortcutManager.get()
const {
engine: {metronomeEnabled},
panelLayout,
timeline: {clips: {visible: clipsVisibility}, followCursor, primaryVisibility: {markers, tempo}}
} = service
const s = GlobalShortcuts
const gs = GlobalShortcuts
const storedShortcuts = localStorage.getItem(localStorageKey)
if (isDefined(storedShortcuts)) {
const {status, value} = tryCatch(() => JSON.parse(storedShortcuts))
if (status === "success" && "global" in value) {
console.debug("Custom shortcuts loaded.")
ShortcutDefinitions.fromJSON(gs, value.global)
}
}
const subscriptions = Terminable.many(
g.register(s["project-undo"].keys, () => service.runIfProject(project => project.editing.undo())),
g.register(s["project-redo"].keys, () => service.runIfProject(project => project.editing.redo())),
g.register(s["project-open"].keys, async () => await service.browseLocalProjects()),
g.register(s["project-save"].keys, async () => await service.projectProfileService.save(),
gc.register(gs["project-undo"].keys, () => service.runIfProject(project => project.editing.undo())),
gc.register(gs["project-redo"].keys, () => service.runIfProject(project => project.editing.redo())),
gc.register(gs["project-open"].keys, async () => await service.browseLocalProjects()),
gc.register(gs["project-save"].keys, async () => await service.projectProfileService.save(),
ShortcutOptions.of({activeInTextField: true})),
g.register(s["project-save-as"].keys, async () => await service.projectProfileService.saveAs(),
gc.register(gs["project-save-as"].keys, async () => await service.projectProfileService.saveAs(),
ShortcutOptions.of({activeInTextField: true})),
g.register(s["toggle-playback"].keys, () => {
gc.register(gs["toggle-playback"].keys, () => {
const {engine} = service
const isPlaying = engine.isPlaying.getValue()
if (isPlaying) {engine.stop()} else {engine.play()}
}),
g.register(s["toggle-software-keyboard"].keys, () => service.toggleSoftwareKeyboard()),
g.register(s["toggle-device-panel"].keys, () => panelLayout.getByType(PanelType.DevicePanel).toggleMinimize()),
g.register(s["toggle-content-editor-panel"].keys, () => panelLayout.getByType(PanelType.ContentEditor).toggleMinimize()),
g.register(s["toggle-browser-panel"].keys, () => panelLayout.getByType(PanelType.BrowserPanel).toggleMinimize()),
g.register(s["toggle-tempo-track"].keys, () => tempo.setValue(!tempo.getValue())),
g.register(s["toggle-markers-track"].keys, () => markers.setValue(!markers.getValue())),
g.register(s["toggle-clips"].keys, () => clipsVisibility.setValue(!clipsVisibility.getValue())),
g.register(s["toggle-follow-cursor"].keys, () => followCursor.setValue(!followCursor.getValue())),
g.register(s["toggle-metronome"].keys, () => metronomeEnabled.setValue(!metronomeEnabled.getValue())),
g.register(s["copy-device"].keys, () => service.runIfProject(
gc.register(gs["toggle-software-keyboard"].keys, () => service.toggleSoftwareKeyboard()),
gc.register(gs["toggle-device-panel"].keys, () => panelLayout.getByType(PanelType.DevicePanel).toggleMinimize()),
gc.register(gs["toggle-content-editor-panel"].keys, () => panelLayout.getByType(PanelType.ContentEditor).toggleMinimize()),
gc.register(gs["toggle-browser-panel"].keys, () => panelLayout.getByType(PanelType.BrowserPanel).toggleMinimize()),
gc.register(gs["toggle-tempo-track"].keys, () => tempo.setValue(!tempo.getValue())),
gc.register(gs["toggle-markers-track"].keys, () => markers.setValue(!markers.getValue())),
gc.register(gs["toggle-clips"].keys, () => clipsVisibility.setValue(!clipsVisibility.getValue())),
gc.register(gs["toggle-follow-cursor"].keys, () => followCursor.setValue(!followCursor.getValue())),
gc.register(gs["toggle-metronome"].keys, () => metronomeEnabled.setValue(!metronomeEnabled.getValue())),
gc.register(gs["copy-device"].keys, () => service.runIfProject(
({editing, userEditingManager, skeleton}) => userEditingManager.audioUnit.get().ifSome(({box}) => {
const audioUnitBox = asInstanceOf(box, AudioUnitBox)
const copies = editing.modify(() => ProjectUtils
.extractAudioUnits([audioUnitBox], skeleton), false).unwrap()
userEditingManager.audioUnit.edit(copies[0].editing)
}))),
g.register(s["workspace-next-screen"].keys, () => {
gc.register(gs["workspace-next-screen"].keys, () => {
const keys = Object.entries(DefaultWorkspace).map(([key]) => key as Workspace.ScreenKeys)
const screen = service.layout.screen
const current = screen.getValue()
@@ -55,7 +91,7 @@ export namespace StudioShortcutManager {
screen.setValue(Arrays.getNext(keys, current))
}
),
g.register(s["workspace-prev-screen"].keys, () => {
gc.register(gs["workspace-prev-screen"].keys, () => {
const keys = Object.entries(DefaultWorkspace).map(([key]) => key as Workspace.ScreenKeys)
const screen = service.layout.screen
const current = screen.getValue()
@@ -63,16 +99,16 @@ export namespace StudioShortcutManager {
screen.setValue(Arrays.getPrev(keys, current))
}
),
g.register(s["workspace-screen-dashboard"].keys, async () => await service.closeProject()),
g.register(s["workspace-screen-default"].keys, () => service.runIfProject(() => service.switchScreen("default"))),
g.register(s["workspace-screen-mixer"].keys, () => service.runIfProject(() => service.switchScreen("mixer"))),
g.register(s["workspace-screen-piano"].keys, () => service.runIfProject(() => service.switchScreen("piano"))),
g.register(s["workspace-screen-project"].keys, () => service.runIfProject(() => service.switchScreen("project"))),
g.register(s["workspace-screen-meter"].keys, () => service.runIfProject(() => service.switchScreen("meter"))),
g.register(s["workspace-screen-shadertoy"].keys, () => service.runIfProject(() => service.switchScreen("shadertoy"))),
g.register(s["show-preferences"].keys, () => StudioDialogs.showPreferences())
gc.register(gs["workspace-screen-dashboard"].keys, async () => await service.closeProject()),
gc.register(gs["workspace-screen-default"].keys, () => service.runIfProject(() => service.switchScreen("default"))),
gc.register(gs["workspace-screen-mixer"].keys, () => service.runIfProject(() => service.switchScreen("mixer"))),
gc.register(gs["workspace-screen-piano"].keys, () => service.runIfProject(() => service.switchScreen("piano"))),
gc.register(gs["workspace-screen-project"].keys, () => service.runIfProject(() => service.switchScreen("project"))),
gc.register(gs["workspace-screen-meter"].keys, () => service.runIfProject(() => service.switchScreen("meter"))),
gc.register(gs["workspace-screen-shadertoy"].keys, () => service.runIfProject(() => service.switchScreen("shadertoy"))),
gc.register(gs["show-preferences"].keys, () => StudioDialogs.showPreferences())
)
const conflicts = g.hasConflicts()
const conflicts = gc.hasConflicts()
if (conflicts) {
RuntimeNotifier.info({
headline: "Shortcut Conflict",
@@ -1,10 +1,11 @@
import {Key, ShortcutKeys} from "@opendaw/lib-dom"
import {ShortcutValidator} from "@/shortcuts/ShortcutValidator"
import {ShortcutDefinitions} from "@/shortcuts/ShortcutDefinitions"
const shift = true
const ctrl = true
export const GlobalShortcuts = ShortcutValidator.validate({
export const GlobalShortcutsFactory = ShortcutValidator.validate({
"project-undo": {
keys: ShortcutKeys.of(Key.KeyZ, {ctrl}),
description: "Undo last action"
@@ -110,3 +111,5 @@ export const GlobalShortcuts = ShortcutValidator.validate({
description: "Open preferences"
}
})
export const GlobalShortcuts = ShortcutDefinitions.copy(GlobalShortcutsFactory)
@@ -0,0 +1,3 @@
import {ShortcutKeys} from "@opendaw/lib-dom"
export type ShortcutDefinition = { keys: ShortcutKeys, description: string }
@@ -0,0 +1,38 @@
import {ShortcutKeys} from "@opendaw/lib-dom"
import {ShortcutDefinition} from "@/shortcuts/ShortcutDefinition"
import {isAbsent, JSONValue} from "@opendaw/lib-std"
export type ShortcutDefinitions = Record<string, ShortcutDefinition>
export namespace ShortcutDefinitions {
export const copy = (defs: ShortcutDefinitions): ShortcutDefinitions => {
const result: ShortcutDefinitions = {}
for (const [key, {keys, description}] of Object.entries(defs)) {
result[key] = {keys: keys.copy(), description}
}
return result
}
export const copyInto = (source: ShortcutDefinitions, target: ShortcutDefinitions): void => {
for (const [key, {keys}] of Object.entries(source)) {
target[key].keys.overrideWith(keys.copy())
}
}
export const toJSON = (defs: ShortcutDefinitions): JSONValue => {
const result: Record<string, JSONValue> = {}
for (const [key, {keys}] of Object.entries(defs)) {
result[key] = keys.toJSON()
}
return result
}
export const fromJSON = (defs: ShortcutDefinitions, values: JSONValue): void => {
if (typeof values !== "object" || values === null || Array.isArray(values)) {return}
for (const [key, value] of Object.entries(values) as Array<[string, JSONValue]>) {
const def = defs[key]
if (isAbsent(def)) {continue}
ShortcutKeys.fromJSON(value).ifSome(keys => def.keys.overrideWith(keys))
}
}
}
@@ -1,7 +1,4 @@
import {ShortcutKeys} from "@opendaw/lib-dom"
export type ShortcutDefinition = { keys: ShortcutKeys, description: string }
export type ShortcutDefinitions = Record<string, ShortcutDefinition>
import {ShortcutDefinitions} from "@/shortcuts/ShortcutDefinitions"
export namespace ShortcutValidator {
export const validate = <T extends ShortcutDefinitions>(actions: T): T => {
@@ -1,33 +1,49 @@
component
display: flex
flex-direction: column
row-gap: 1em
outline: none
height: 280px
overflow: hidden auto
> div.shortcuts
flex: 0 0 320px
display: flex
flex-direction: column
row-gap: 0.25em
padding: 0 1em 0 0
overflow: hidden auto
> details.context
outline: none
border: none
> div.shortcut
> summary
list-style: none
color: var(--color-blue)
display: flex
justify-content: space-between
align-items: center
cursor: pointer
color: var(--color-dark)
> span
white-space: nowrap
> h3
display: inline-block
> hr
margin: 0.5em
height: 1px
border: none
background-color: var(--color-dark)
opacity: 0.1
flex: 1
> div.shortcuts
margin-top: 0.5em
display: flex
flex-direction: column
row-gap: 0.25em
padding: 0 1em 0 0
outline: none
&:hover
color: var(--color-bright)
> div.shortcut
display: flex
justify-content: space-between
cursor: pointer
color: var(--color-dark)
> span
white-space: nowrap
> hr
margin: 0.5em
height: 1px
border: none
background-color: var(--color-dark)
opacity: 0.1
flex: 1
&:hover
color: var(--color-bright)
@@ -1,20 +1,23 @@
import css from "./ShortcutManagerView.sass?inline"
import {Events, Html, ShortcutKeys} from "@opendaw/lib-dom"
import {DefaultObservableValue, isAbsent, Lifecycle, Objects, Terminator} from "@opendaw/lib-std"
import {DefaultObservableValue, isAbsent, Lifecycle, Notifier, Objects, Terminator} from "@opendaw/lib-std"
import {createElement, replaceChildren} from "@opendaw/lib-jsx"
import {GlobalShortcuts} from "@/shortcuts/GlobalShortcuts"
import {ShortcutDefinition, ShortcutDefinitions} from "@/shortcuts/ShortcutValidator"
import {Dialogs} from "@/ui/components/dialogs"
import {Surface} from "@/ui/surface/Surface"
import {Colors} from "@opendaw/studio-enums"
import {ShortcutDefinition} from "@/shortcuts/ShortcutDefinition"
import {ShortcutDefinitions} from "@/shortcuts/ShortcutDefinitions"
const className = Html.adoptStyleSheet(css, "ShortcutManagerView")
type Construct = {
lifecycle: Lifecycle
contexts: Record<string, ShortcutDefinitions>
updateNotifier: Notifier<void>
}
const editShortcut = async (definitions: ShortcutDefinitions, original: ShortcutDefinition): Promise<ShortcutKeys> => {
const editShortcut = async (definitions: ShortcutDefinitions,
original: ShortcutDefinition): Promise<ShortcutKeys> => {
const lifecycle = new Terminator()
const abortController = new AbortController()
const shortcut = lifecycle.own(new DefaultObservableValue(original.keys))
@@ -56,24 +59,29 @@ const editShortcut = async (definitions: ShortcutDefinitions, original: Shortcut
}).then(() => shortcut.getValue(), () => original.keys).finally(() => lifecycle.terminate())
}
export const ShortcutManagerView = ({}: Construct) => {
export const ShortcutManagerView = ({lifecycle, contexts, updateNotifier}: Construct) => {
return (
<div className={className}>
<div className="shortcuts" onInit={element => {
const update = () => replaceChildren(element, (
Objects.entries(GlobalShortcuts).map(([key, entry]) => (
<div className="shortcut" onclick={async () => {
const keys = await editShortcut(GlobalShortcuts, entry)
GlobalShortcuts[key].keys.overrideWith(keys)
update()
}}><span>{entry.description}</span>
<hr/>
<span>{entry.keys.format()}</span>
</div>
))
))
update()
}}/>
<div className={className} onInit={element => {
const update = () => replaceChildren(element, Objects.entries(contexts).map(([name, shortcuts], index) => (
<details className="context" open={index === 0}>
<summary><h3>{name}</h3></summary>
<div className="shortcuts">
{Objects.entries(shortcuts).map(([key, entry]) => (
<div className="shortcut" onclick={async () => {
const keys = await editShortcut(shortcuts, entry)
shortcuts[key].keys.overrideWith(keys)
update()
}}><span>{entry.description}</span>
<hr/>
<span>{entry.keys.format()}</span>
</div>
))}
</div>
</details>
)))
lifecycle.own(updateNotifier.subscribe(update))
update()
}}>
</div>
)
}
+18 -1
View File
@@ -2,6 +2,7 @@ import {
BinarySearch,
Exec,
isAbsent,
JSONValue,
Lazy,
NumberComparator,
Option,
@@ -111,12 +112,24 @@ export class ShortcutKeys {
return new ShortcutKeys(code, modifiers?.ctrl, modifiers?.shift, modifiers?.alt)
}
static fromJSON(value: JSONValue): Option<ShortcutKeys> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {return Option.None}
const {code, ctrl, shift, alt} = value as Record<string, unknown>
if (typeof code !== "string") {return Option.None}
if (typeof ctrl !== "boolean") {return Option.None}
if (typeof shift !== "boolean") {return Option.None}
if (typeof alt !== "boolean") {return Option.None}
return Option.wrap(new ShortcutKeys(code, ctrl, shift, alt))
}
static fromEvent(event: KeyboardEvent): Option<ShortcutKeys> {
const code = event.code
if (code.startsWith("Shift")
|| code.startsWith("Control")
|| code.startsWith("Alt")
|| code.startsWith("Meta")) {
|| code.startsWith("Meta")
|| code === Key.Escape
) {
return Option.None
}
return Option.wrap(new ShortcutKeys(code, Keyboard.isControlKey(event), event.shiftKey, event.altKey))
@@ -204,6 +217,10 @@ export class ShortcutKeys {
this.#alt = keys.#alt
}
toJSON(): JSONValue {return {code: this.#code, ctrl: this.#ctrl, shift: this.#shift, alt: this.#alt}}
copy(): ShortcutKeys {return new ShortcutKeys(this.#code, this.#ctrl, this.#shift, this.#alt)}
toString(): string {return `{ShortcutKeys ${this.format()}}`}
}