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' .

An entry can hold:

- 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.