Shortcuts and the keymap registry
The shortcut system in Glamorous Toolkit is designed around a central registry that decouples shortcut definitions from their key bindings. This allows shortcuts to be customized, platform-specific, and extensible across packages.
The system has four layers:
BlShortcutKeymapRegistry
Object << #BlShortcutKeymapRegistry
slots: { #registryCategoriesByName };
tag: 'Events-KeyBinding';
package: 'Bloc'
: Global singleton that holds all registry categories. Each category models an individual set for keybinding discovered via the <gtKeymapRegistry> pragma.
BlShortcutKeymapRegistryCategory
Object << #BlShortcutKeymapRegistryCategory
slots: { #keymaps . #label };
tag: 'Events-KeyBinding';
package: 'Bloc'
: Abstract base for a group of shortcuts. Stores a dictionary of shortcut IDs to BlRegistryKeymapEntry
Object << #BlRegistryKeymapEntry
slots: { #combination . #combinationForUnix . #combinationForMacOS . #combinationForWindows . #shortcutId };
tag: 'Events-KeyBinding';
package: 'Bloc'
objects. Supports per-platform overrides (macOS, Windows, Unix.)
BlShortcutIds
Object << #BlShortcutIds
slots: { #extraShortcutIdsByIdentifier };
tag: 'Events-KeyBinding';
package: 'Bloc'
: Abstract singleton base for shortcut identifiers. Subclasses define named IDs as instance variables. Shortcuts are instances of BlShortcutId
Object << #BlShortcutId
slots: { #description . #identifier };
tag: 'Events-KeyBinding';
package: 'Bloc'
.
BlDynamicShortcut
BlBasicShortcut << #BlDynamicShortcut
slots: { #shortcutId . #registry };
tag: 'Events-KeyBinding';
package: 'Bloc'
: Base shortcut class that resolves its combination from the registry at runtime via its shortcutId and registry slots.
BlShortcutKeymapRegistry
Object << #BlShortcutKeymapRegistry
slots: { #registryCategoriesByName };
tag: 'Events-KeyBinding';
package: 'Bloc'
is a singleton accessed via BlShortcutKeymapRegistry>>#default
default
^self uniqueInstance
. On initialization, it discovers all registry categories by scanning for methods annotated with <gtKeymapRegistry> (see BlShortcutKeymapRegistry>>#initializeRegistryCategories
initializeRegistryCategories
| pragmas |
pragmas := Pragma
allNamed: #gtKeymapRegistry
from: self class
to: BlShortcutKeymapRegistry.
pragmas do: [ :aPragma | self perform: aPragma methodSelector]
). This means new categories can be added from any package using extension methods.
BlShortcutKeymapRegistry default
Several categories are currently registered:
BrEditorKeymapRegistry
BlShortcutKeymapRegistryCategory << #BrEditorKeymapRegistry
slots: {};
tag: 'Shortcuts';
package: 'Brick-Editor'
: text editing: cursor movement, selection, copy/paste, undo/redo.
LeKeymapRegistry
BlShortcutKeymapRegistryCategory << #LeKeymapRegistry
slots: {};
tag: 'Shortcuts';
package: 'Lepiter-UI-Snippet'
: snippet management: move, indent, split, delete.
GtCoderKeymapRegistry
BlShortcutKeymapRegistryCategory << #GtCoderKeymapRegistry
slots: {};
tag: 'Shortcuts';
package: 'GToolkit-Coder-UI'
: source code: evaluate, browse, format, debug.
BlSpaceKeymapRegistry
BlShortcutKeymapRegistryCategory << #BlSpaceKeymapRegistry
slots: {};
tag: 'Space - Task Queue';
package: 'Bloc'
: window-level: zoom, screenshot, tabs, spotter.
GtDebuggerKeymapRegistry
BlShortcutKeymapRegistryCategory << #GtDebuggerKeymapRegistry
slots: {};
tag: 'Actions';
package: 'GToolkit-Debugger'
: debugger actions: step over/into/through, resume, restart.
Each BlShortcutKeymapRegistryCategory
Object << #BlShortcutKeymapRegistryCategory
slots: { #keymaps . #label };
tag: 'Events-KeyBinding';
package: 'Bloc'
subclass is responsible for:
- Defining default key combinations in BlShortcutKeymapRegistryCategory>>#initializeBasicCombinations
initializeBasicCombinations
.
- Discovering pluggable extensions via BlShortcutKeymapRegistryCategory>>#initializePluggableCombinations
initializePluggableCombinations
| pragmas |
pragmas := Pragma allNamed: #gtKeymapRegistration from: self class to: BlShortcutKeymapRegistryCategory.
pragmas do: [ :aPragma |
self perform: aPragma methodSelector ]
, which scans for <gtKeymapRegistration> pragmas in the category class hierarchy.
- Storing entries in a keymaps dictionary mapping BlShortcutId
Object << #BlShortcutId
slots: { #description . #identifier };
tag: 'Events-KeyBinding';
package: 'Bloc'
to BlRegistryKeymapEntry
Object << #BlRegistryKeymapEntry
slots: { #combination . #combinationForUnix . #combinationForMacOS . #combinationForWindows . #shortcutId };
tag: 'Events-KeyBinding';
package: 'Bloc'
.
- A default combination (used on all platforms)
- Optional platform-specific overrides: #selector
combinationForMacOS
^combinationForMacOS ifNil: [self combination]
, #selector
combinationForWindows
^combinationForWindows ifNil: [self combination]
, #selector
combinationForUnix
^combinationForUnix ifNil: [self combination]
When a shortcut asks for its combination, the entry returns the platform-appropriate one.
Each shortcut is identified by a BlShortcutId
Object << #BlShortcutId
slots: { #description . #identifier };
tag: 'Events-KeyBinding';
package: 'Bloc'
(a simple value object with an identifier symbol). IDs are organized in singleton classes that subclass BlShortcutIds
Object << #BlShortcutIds
slots: { #extraShortcutIdsByIdentifier };
tag: 'Events-KeyBinding';
package: 'Bloc'
. For example:
- BrEditorShortcutIds
BlShortcutIds << #BrEditorShortcutIds
slots: {
#newLineShortcutId .
#loseFocusShortcutId .
#carriageReturnShortcutId .
#moveCursorLeftShortcutId .
#moveCursorRightShortcutId .
#moveCursorUpShortcutId .
#moveCursorDownShortcutId .
#moveCursorToBeginningShortcutId .
#moveCursorToEndShortcutId .
#moveCursorToLineStartShortcutId .
#moveCursorToLineEndShortcutId .
#moveCursorToNextWordShortcutId .
#moveCursorToPreviousWordShortcutId .
#selectAllShortcutId .
#selectLetterBeforeCursorShortcutId .
#selectLetterAfterCursorShortcutId .
#selectWordBeforeCursorShortcutId .
#selectWordAfterCursorShortcutId .
#selectLineBeforeCursorShortcutId .
#selectLineAfterCursorShortcutId .
#selectLineAboveCursorShortcutId .
#selectLineBelowCursorShortcutId .
#selectToBeginningShortcutId .
#selectToEndShortcutId .
#deselectAllShortcutId .
#copyShortcutId .
#cutShortcutId .
#pasteShortcutId .
#undoShortcutId .
#redoShortcutId .
#deleteOneLetterBeforeCursorShortcutId .
#deleteOneLetterAfterCursorShortcutId .
#deleteNextWordShortcutId .
#deletePreviousWordShortcutId .
#shiftRightShortcutId .
#shiftLeftShortcutId .
#focusNextShortcutId .
#focusPreviousShortcutId };
tag: 'Shortcuts';
package: 'Brick-Editor'
- LeShortcutIds
BlShortcutIds << #LeShortcutIds
slots: {
#moveSnippetUpShortcutId .
#splitSnippetShortcutId .
#deleteNextShortcutId .
#deletePreviousShortcutId .
#indentSnippetShortcutId .
#moveSnippetDownShortcutId .
#renameSnippetShortcutId .
#secondaryNewLineShortcutId .
#unindentSnippetShortcutId };
tag: 'Shortcuts';
package: 'Lepiter-UI-Snippet'
- GtCoderShortcutIds
BlShortcutIds << #GtCoderShortcutIds
slots: {
#browseBehaviorShortcutId .
#browseImplementorsShortcutId .
#browseReferencesShortcutId .
#compileItShortcutId .
#debugShortcutId .
#discardChangesShortcutId .
#doItAndInspectAsynchronousShortcutId .
#doItAndInspectSerializedShortcutId .
#doItAndInspectShortcutId .
#doItAsynchronousShortcutId .
#doItShortcutId .
#extractMethodShortcutId .
#foldNoiseShortcutId .
#formatShortcutId .
#moveStatementDownShortcutId .
#moveStatementUpShortcutId .
#playAndInspectShortcutId .
#playShortcutId .
#printItShortcutId .
#saveShortcutId .
#searchTextShortcutId .
#selectStatementDownShortcutId .
#selectStatementUpShortcutId };
tag: 'Shortcuts';
package: 'GToolkit-Coder-UI'
- BlSpaceShortcutIds
BlShortcutIds << #BlSpaceShortcutIds
slots: { #zoomInShortcutId . #zoomOutShortcutId . #zoomNormalShortcutId . #takeScreenshotShortcutId };
tag: 'Space - Task Queue';
package: 'Bloc'
- GtDebuggerShortcutIds
BlShortcutIds << #GtDebuggerShortcutIds
slots: { #stepOverShortcutId . #stepIntoShortcutId . #stepThroughShortcutId . #resumeShortcutId . #restartShortcutId };
tag: 'Actions';
package: 'GToolkit-Debugger'
BlShortcutIds
Object << #BlShortcutIds
slots: { #extraShortcutIdsByIdentifier };
tag: 'Events-KeyBinding';
package: 'Bloc'
provides an extraShortcutIdsByIdentifier dictionary and a BlShortcutIds>>#shortcutIdForIdentifier:
shortcutIdForIdentifier: aSymbol
^ extraShortcutIdsByIdentifier
at: aSymbol
ifAbsentPut: [ BlShortcutId identifier: aSymbol ]
method. This allows packages that cannot add instance variables (because they are extensions) to register IDs dynamically. The accessor lazily creates the BlShortcutId
Object << #BlShortcutId
slots: { #description . #identifier };
tag: 'Events-KeyBinding';
package: 'Bloc'
on first access and caches it for subsequent calls.
Extension packages define named accessor methods (e.g., GtCoderShortcutIds>>#fixItShortcutId
fixItShortcutId
^ self shortcutIdForIdentifier: #CoderFixIt
) on GtCoderShortcutIds
BlShortcutIds << #GtCoderShortcutIds
slots: {
#browseBehaviorShortcutId .
#browseImplementorsShortcutId .
#browseReferencesShortcutId .
#compileItShortcutId .
#debugShortcutId .
#discardChangesShortcutId .
#doItAndInspectAsynchronousShortcutId .
#doItAndInspectSerializedShortcutId .
#doItAndInspectShortcutId .
#doItAsynchronousShortcutId .
#doItShortcutId .
#extractMethodShortcutId .
#foldNoiseShortcutId .
#formatShortcutId .
#moveStatementDownShortcutId .
#moveStatementUpShortcutId .
#playAndInspectShortcutId .
#playShortcutId .
#printItShortcutId .
#saveShortcutId .
#searchTextShortcutId .
#selectStatementDownShortcutId .
#selectStatementUpShortcutId };
tag: 'Shortcuts';
package: 'GToolkit-Coder-UI'
in their own package protocol, internally calling BlShortcutIds>>#shortcutIdForIdentifier:
shortcutIdForIdentifier: aSymbol
^ extraShortcutIdsByIdentifier
at: aSymbol
ifAbsentPut: [ BlShortcutId identifier: aSymbol ]
.
GtCoderShortcutIds default
BlDynamicShortcut
BlBasicShortcut << #BlDynamicShortcut
slots: { #shortcutId . #registry };
tag: 'Events-KeyBinding';
package: 'Bloc'
is the base class for all shortcuts. Its key method is BlDynamicShortcut>>#combination
combination
^ shortcutId
ifNil: [ combination ]
ifNotNil: [ :anId | self registry combinationFor: anId ]
:
- If shortcutId is set, it asks the registry for the current combination.
- If shortcutId is nil, it falls back to the locally stored combination instance variable.
This means that once a shortcut has a registry ID, its key binding is always resolved live from the registry. Changing the registry entry immediately affects all existing shortcut instances.
BlBasicShortcut
Object << #BlBasicShortcut
traits: {TBlDebug};
slots: { #combination . #repeatable . #overridesChildren };
tag: 'Events-KeyBinding';
package: 'Bloc'
└─ BlDynamicShortcut
BlBasicShortcut << #BlDynamicShortcut
slots: { #shortcutId . #registry };
tag: 'Events-KeyBinding';
package: 'Bloc'
(shortcutId, registry)
└─ BrEditorShortcut
BlDynamicShortcut << #BrEditorShortcut
slots: { #name . #performBlock . #description . #combinationForUnix . #combinationForMacOS . #combinationForWindows };
tag: 'Shortcuts';
package: 'Brick-Editor'
(name, performBlock, platform overrides)
└─ GtSourceCoderShortcut
BrEditorShortcut << #GtSourceCoderShortcut
slots: {};
tag: 'Shortcuts';
package: 'GToolkit-Coder-UI'
(coder-specific perform)
└─ GtSourceCoderBrowseImplementorsShortcut
GtSourceCoderShortcut << #GtSourceCoderBrowseImplementorsShortcut
slots: {};
tag: 'Shortcuts';
package: 'GToolkit-Coder-UI'
, etc.
└─ LeSnippetEditorShortcut
BrEditorShortcut << #LeSnippetEditorShortcut
slots: {};
tag: 'Shortcuts';
package: 'Lepiter-UI-Snippet'
(Lepiter snippet shortcuts)
Note that not all shortcuts use BlDynamicShortcut
BlBasicShortcut << #BlDynamicShortcut
slots: { #shortcutId . #registry };
tag: 'Events-KeyBinding';
package: 'Bloc'
subclasses. Space-level shortcuts (zoom, screenshot, spotter) and debugger shortcuts are registered as plain BlShortcutWithAction
BlDynamicShortcut << #BlShortcutWithAction
slots: { #name . #description . #action };
tag: 'Events-KeyBinding';
package: 'Bloc'
instances that receive their combination from the registry at construction time. The debugger additionally provides a utility method GtDebuggerKeymapRegistry>>#combinationForPlatformFor:
combinationForPlatformFor: aShortcutId
"Returns the platform-appropriate combination for the given shortcut id.
This is a utility for places that need the combination directly."
| entry |
entry := self keymapFor: aShortcutId ifAbsent: [ ^ nil ].
^ entry combinationForPlatform
for places like debug action toolbar labels that need the combination directly without storing a shortcut id.
Each shortcut class sets its registry connection in initialize. There are convenience methods:
- BlDynamicShortcut>>#editorShortcutId:
editorShortcutId: anId
self
shortcutId: anId
inRegistry: BlShortcutKeymapRegistry default editor
— connects to the editor registry
- GtSourceCoderShortcut>>#coderShortcutId:
coderShortcutId: anId
self
shortcutId: anId
inRegistry: BlShortcutKeymapRegistry default coder
— connects to the coder registry
Both internally call BlDynamicShortcut>>#shortcutId:inRegistry:
shortcutId: anId inRegistry: aRegistry
shortcutId := anId.
registry := aRegistry.
which stores the id and registry reference.
Example from a coder shortcut:
shortcutResolvesFromCoderRegistry
<gtExample>
<return: #GtSourceCoderBrowseImplementorsShortcut>
"Verifies that a coder shortcut resolves its combination from the registry"
| shortcut |
shortcut := GtSourceCoderBrowseImplementorsShortcut new.
self assert: shortcut shortcutId notNil.
self assert: shortcut combination notNil.
^ shortcut
When registering a shortcut, you can provide platform-specific overrides using:
- BlShortcutKeymapRegistryCategory>>#initializeShortcut:with:macOs:
initializeShortcut: aShortcutId with: aKeyCombination macOs: aMacOsKeyCombination
keymaps at: aShortcutId put: (BlRegistryKeymapEntry new
shortcutId: aShortcutId;
combination: aKeyCombination;
combinationForMacOS: aMacOsKeyCombination)
- BlShortcutKeymapRegistryCategory>>#initializeShortcut:with:windows:
initializeShortcut: aShortcutId with: aKeyCombination windows: aWindowsKeyCombination
keymaps at: aShortcutId put: (BlRegistryKeymapEntry new
shortcutId: aShortcutId;
combination: aKeyCombination;
combinationForWindows: aWindowsKeyCombination)
- BlShortcutKeymapRegistryCategory>>#initializeShortcut:with:unix:
initializeShortcut: aShortcutId with: aKeyCombination unix: aUnixKeyCombination
keymaps at: aShortcutId put: (BlRegistryKeymapEntry new
shortcutId: aShortcutId;
combination: aKeyCombination;
combinationForUnix: aUnixKeyCombination)
- BlShortcutKeymapRegistryCategory>>#initializeShortcut:windows:unix:macOs:default:
initializeShortcut: aShortcutId windows: aWindowsKeyCombination unix: aUnixKeyCombination macOs: aMacOsKeyCombination default: aDefaultCombination
keymaps at: aShortcutId put: (BlRegistryKeymapEntry new
shortcutId: aShortcutId;
combination: aDefaultCombination;
combinationForWindows: aWindowsKeyCombination;
combinationForUnix: aUnixKeyCombination;
combinationForMacOS: aMacOsKeyCombination)
The default combination is used when no platform override is defined. Shortcuts like "Move cursor to next word" use Ctrl+Right on Windows/Linux but Alt+Right on macOS.
The system is designed to be extensible. For example, a package that adds new coder shortcuts needs to:
1. Define an ID accessor as an extension method on GtCoderShortcutIds
BlShortcutIds << #GtCoderShortcutIds
slots: {
#browseBehaviorShortcutId .
#browseImplementorsShortcutId .
#browseReferencesShortcutId .
#compileItShortcutId .
#debugShortcutId .
#discardChangesShortcutId .
#doItAndInspectAsynchronousShortcutId .
#doItAndInspectSerializedShortcutId .
#doItAndInspectShortcutId .
#doItAsynchronousShortcutId .
#doItShortcutId .
#extractMethodShortcutId .
#foldNoiseShortcutId .
#formatShortcutId .
#moveStatementDownShortcutId .
#moveStatementUpShortcutId .
#playAndInspectShortcutId .
#playShortcutId .
#printItShortcutId .
#saveShortcutId .
#searchTextShortcutId .
#selectStatementDownShortcutId .
#selectStatementUpShortcutId };
tag: 'Shortcuts';
package: 'GToolkit-Coder-UI'
(calling BlShortcutIds>>#shortcutIdForIdentifier:
shortcutIdForIdentifier: aSymbol
^ extraShortcutIdsByIdentifier
at: aSymbol
ifAbsentPut: [ BlShortcutId identifier: aSymbol ]
).
2. Define a registration method on GtCoderKeymapRegistry
BlShortcutKeymapRegistryCategory << #GtCoderKeymapRegistry
slots: {};
tag: 'Shortcuts';
package: 'GToolkit-Coder-UI'
with the <gtKeymapRegistration> pragma.
3. In the shortcut class initialize, call BlDynamicShortcut>>#coderShortcutId:
coderShortcutId: anId
self
shortcutId: anId
inRegistry: BlShortcutKeymapRegistry default coder
with the new ID.
The same pattern applies to editor, lepiter, space, and debugger shortcuts, with the appropriate registry and ID classes.
Use BlShortcutKeymapRegistryCategory>>#updateShortcutFor:with:
updateShortcutFor: aShortcutId with: aCombination
(self keymapFor: aShortcutId)
combination: aCombination
to change a binding at runtime. Since shortcuts resolve their combination live from the registry, the change is immediate and affects all instances.
"Change browse implementors to Ctrl+Shift+M"
BlShortcutKeymapRegistry default coder
updateShortcutFor: GtCoderShortcutIds default browseImplementorsShortcutId
with: (BlKeyCombination builder primary shift m build)
BlShortcutKeymapRegistry>>#reset
reset
uniqueInstance ifNotNil: [ :each |
each reset ]
reinitializes the current singleton regystri. This re-runs all #selector
initializeBasicCombinations
and #selector
initializePluggableCombinations
| pragmas |
pragmas := Pragma allNamed: #gtKeymapRegistration from: self class to: BlShortcutKeymapRegistryCategory.
pragmas do: [ :aPragma |
self perform: aPragma methodSelector ]
, restoring defaults.
BlShortcutKeymapRegistry reset
BlShortcutKeymapRegistry>>#cleanUp
cleanUp
uniqueInstance := nil
destroys the singleton and forces re-creation from scratch. This re-runs all #selector
initializeBasicCombinations
and #selector
initializePluggableCombinations
| pragmas |
pragmas := Pragma allNamed: #gtKeymapRegistration from: self class to: BlShortcutKeymapRegistryCategory.
pragmas do: [ :aPragma |
self perform: aPragma methodSelector ]
, restoring defaults
BlShortcutKeymapRegistry cleanUp
The system is validated by several example classes: GtCoderKeymapRegistryExamples
Object << #GtCoderKeymapRegistryExamples
slots: {};
tag: 'Examples';
package: 'GToolkit-Coder-UI'
, BrEditorKeymapRegistryExamples
Object << #BrEditorKeymapRegistryExamples
slots: {};
tag: 'Shortcuts';
package: 'Brick-Editor-Examples'
, BlShortcutIdsExamples
Object << #BlShortcutIdsExamples
slots: {};
tag: 'KeyBinding';
package: 'Bloc-Tests'
, BlSpaceKeymapRegistryExamples
Object << #BlSpaceKeymapRegistryExamples
slots: {};
tag: 'KeyBinding';
package: 'Bloc-Tests'
, and GtDebuggerKeymapRegistryExamples
Object << #GtDebuggerKeymapRegistryExamples
slots: {};
tag: 'Examples';
package: 'GToolkit-Debugger'
.
allEntriesHaveShortcutId
<gtExample>
<return: #GtCoderKeymapRegistry>
"Verifies that every entry in the coder registry has its shortcutId set"
| registry |
registry := self coderKeymapRegistry.
registry keymapsById keysAndValuesDo: [ :shortcutId :entry |
self assert: entry shortcutId notNil.
self assert: entry shortcutId equals: shortcutId ].
^ registry
macOsOverrideEntryHasShortcutId
<gtExample>
<return: #BlRegistryKeymapEntry>
"Verifies that entries with macOS overrides have shortcutId set correctly"
| registry entry shortcutId |
registry := self editorKeymapRegistry.
shortcutId := BrEditorShortcutIds default moveCursorToNextWordShortcutId.
entry := registry keymapFor: shortcutId.
self assert: entry shortcutId equals: shortcutId.
self assert: entry combination notNil.
self assert: entry combinationForMacOS notNil.
self assert: entry combination ~= entry combinationForMacOS.
^ entry
For practical usage instructions, see How to customize shortcuts.