Skip to content

Commit cd65e0c

Browse files
authored
feat: declarative keyboard shortcut overrides (#199)
Resolves #138.
1 parent 54137d3 commit cd65e0c

2 files changed

Lines changed: 274 additions & 0 deletions

File tree

.github/README.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ This is a nix flake for the Zen browser.
1212
- Browser update checks are disabled by default
1313
- The default twilight version is reliable and reproducible
1414
- [Declarative \[Work\]Spaces (including themes, icons, containers)](#spaces)
15+
- [Declarative keyboard shortcuts with version protection](#keyboard-shortcuts)
1516

1617
## Installation
1718

@@ -181,6 +182,7 @@ Check
181182
- [bookmarks](#bookmarks)
182183
- [spaces](#spaces)
183184
- [pinned tabs](#pinned-tabs-pins)
185+
- [keyboard shortcuts](#keyboard-shortcuts)
184186
- [userChrome](#userchromecss)
185187

186188
### Extensions
@@ -528,6 +530,69 @@ You are also able to declare your pinned tabs! For more info, see
528530
}
529531
```
530532

533+
### Keyboard Shortcuts
534+
535+
Declarative overrides of Zen Browser's keyboard shortcuts with version protection against breaking changes.
536+
537+
```nix
538+
{
539+
programs.zen-browser.profiles.default = {
540+
keyboardShortcuts = [
541+
# Change compact mode toggle to Ctrl+Alt+S
542+
{
543+
id = "zen-compact-mode-toggle";
544+
key = "s";
545+
modifiers = {
546+
control = true;
547+
alt = true;
548+
};
549+
}
550+
# Disable the quit shortcut to prevent accidental closes
551+
{
552+
id = "key_quitApplication";
553+
disabled = true;
554+
}
555+
];
556+
# Fails activation on schema changes to detect potential regressions
557+
# Find this in about:config or prefs.js of your profile
558+
keyboardShortcutsVersion = 14;
559+
};
560+
}
561+
```
562+
563+
When you declare a shortcut override:
564+
565+
- Identity fields (`id`, `group`, `action`, `l10nId`, `reserved`, `internal`) are preserved from Zen's defaults
566+
- Binding fields (`key`, `keycode`, `modifiers`, `disabled`) are completely replaced with your declaration
567+
568+
#### Configuration Options
569+
570+
- `profiles.*.keyboardShortcuts` (list of submodules): Declarative keyboard shortcuts configuration.
571+
- `id` (string) **Required.** Unique identifier for the shortcut to modify.
572+
- `key` (null or string) Character key (e.g., "a", "1", "+"). Leave null to use default.
573+
- `keycode` (null or string) Virtual key code for special keys (e.g., "VK_F1", "VK_DELETE"). Leave null to use default.
574+
- `disabled` (null or boolean) Set to true to disable the shortcut. Leave null to use default.
575+
- `modifiers` (null or submodule) Modifier keys configuration. Leave null to use defaults.
576+
- `control` (null or boolean) Ctrl key modifier.
577+
- `alt` (null or boolean) Alt key modifier.
578+
- `shift` (null or boolean) Shift key modifier.
579+
- `meta` (null or boolean) Meta/Windows/Command key modifier.
580+
- `accel` (null or boolean) Accelerator key (Ctrl on Linux/Windows, Cmd on macOS).
581+
582+
- `profiles.*.keyboardShortcutsVersion` (null or integer) Expected version of the keyboard shortcuts schema. If set, activation will fail if the Zen Browser shortcuts version doesn't match, preventing silent breakage after Zen Browser updates. Find the current version in `about:config` as `zen.keyboard.shortcuts.version`.
583+
584+
### Finding Shortcut IDs
585+
586+
Find all shortcuts in `~/.zen/<profile>/zen-keyboard-shortcuts.json`. For example:
587+
588+
```bash
589+
jq -c '.shortcuts[] | {id, key, keycode, action}' ~/.zen/default/zen-keyboard-shortcuts.json
590+
```
591+
592+
### Notes on activation
593+
594+
Keyboard shortcuts are still managed by Zen and the home manager module only overrides them on activation. That means, that zen needs to be started at least once to create the shortcuts file if it doesn't exist yet. Then, every rebuild of your configuration (`nixos-rebuild switch` or `home-manager switch`) will apply your keybindings. Also note that you can just re-run activation scripts with `systemctl start home-manager-${USER}.service`.
595+
531596
### userChrome.css
532597

533598
```nix

hm-module.nix

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,85 @@ in {
278278
);
279279
default = {};
280280
};
281+
keyboardShortcuts = mkOption {
282+
type = listOf (
283+
submodule (
284+
{...}: {
285+
options = {
286+
id = mkOption {
287+
type = str;
288+
description = "Unique identifier for the keyboard shortcut to modify.";
289+
};
290+
key = mkOption {
291+
type = nullOr str;
292+
default = null;
293+
description = "The character key (e.g., 'a', 's', '1'). Leave null to keep existing value.";
294+
};
295+
keycode = mkOption {
296+
type = nullOr str;
297+
default = null;
298+
description = "Virtual key code (e.g., 'VK_F1', 'VK_DELETE'). Leave null to keep existing value.";
299+
};
300+
modifiers = mkOption {
301+
type = nullOr (submodule {
302+
options = {
303+
control = mkOption {
304+
type = nullOr bool;
305+
default = null;
306+
description = "Ctrl key modifier.";
307+
};
308+
alt = mkOption {
309+
type = nullOr bool;
310+
default = null;
311+
description = "Alt key modifier.";
312+
};
313+
shift = mkOption {
314+
type = nullOr bool;
315+
default = null;
316+
description = "Shift key modifier.";
317+
};
318+
meta = mkOption {
319+
type = nullOr bool;
320+
default = null;
321+
description = "Meta/Windows/Command key modifier.";
322+
};
323+
accel = mkOption {
324+
type = nullOr bool;
325+
default = null;
326+
description = "Accelerator key (Ctrl on Windows/Linux, Cmd on macOS).";
327+
};
328+
};
329+
});
330+
default = null;
331+
description = "Modifier keys for the shortcut. Leave null to keep existing values.";
332+
};
333+
disabled = mkOption {
334+
type = nullOr bool;
335+
default = null;
336+
description = "Whether the shortcut is disabled. Leave null to keep existing value.";
337+
};
338+
};
339+
}
340+
)
341+
);
342+
default = [];
343+
description = ''
344+
Declarative keyboard shortcuts configuration.
345+
Each item specifies a shortcut to modify by its id.
346+
Only the fields you specify will be overridden; others keep their defaults from Zen.
347+
'';
348+
};
349+
keyboardShortcutsVersion = mkOption {
350+
type = nullOr int;
351+
default = null;
352+
example = 1;
353+
description = ''
354+
Expected version of the keyboard shortcuts schema.
355+
If set, activation will fail if the Zen Browser shortcuts version doesn't match,
356+
preventing silent breakage after Zen Browser updates.
357+
Find the current version in about:config as "zen.keyboard.shortcuts.version".
358+
'';
359+
};
281360
};
282361
}
283362
)
@@ -656,5 +735,135 @@ in {
656735
force = true;
657736
}
658737
) (filterAttrs (_: profile: profile.spaces != {} || profile.spacesForce || profile.pins != {} || profile.pinsForce) cfg.profiles));
738+
739+
home.activation =
740+
let
741+
inherit (builtins) toJSON;
742+
inherit
743+
(lib)
744+
filterAttrs
745+
mapAttrs'
746+
nameValuePair
747+
optionalString;
748+
# Filter profiles that have keyboard shortcuts configured
749+
profilesWithShortcuts = filterAttrs
750+
(_: profile: profile.keyboardShortcuts != [ ])
751+
cfg.profiles;
752+
753+
in
754+
(mapAttrs'
755+
(profileName: profile:
756+
let
757+
shortcutsFile = "${profilePath}/${profileName}/zen-keyboard-shortcuts.json";
758+
shortcutsFilePath = "${config.home.homeDirectory}/${shortcutsFile}";
759+
prefsFile = "${config.home.homeDirectory}/${profilePath}/${profileName}/prefs.js";
760+
761+
# Convert Nix shortcut config to JSON format
762+
# All binding fields are included (with null/false defaults) to fully replace the binding
763+
shortcutToJson = shortcut: {
764+
inherit (shortcut) id;
765+
key = if shortcut.key != null then shortcut.key else "";
766+
keycode = shortcut.keycode;
767+
modifiers = if shortcut.modifiers != null then shortcut.modifiers else {
768+
control = false;
769+
alt = false;
770+
shift = false;
771+
meta = false;
772+
accel = false;
773+
};
774+
disabled = if shortcut.disabled != null then shortcut.disabled else false;
775+
};
776+
777+
# Generate the shortcuts overrides array
778+
declaredShortcuts = map shortcutToJson profile.keyboardShortcuts;
779+
780+
# Script to update shortcuts
781+
updateScript = pkgs.writeShellScript "zen-shortcuts-update-${profileName}" ''
782+
SHORTCUTS_FILE="${shortcutsFilePath}"
783+
PREFS_FILE="${prefsFile}"
784+
OVERRIDES='${toJSON declaredShortcuts}'
785+
786+
# Wait for Zen to create the shortcuts file if it doesn't exist yet
787+
if [ ! -f "$SHORTCUTS_FILE" ]; then
788+
echo "zen-keyboard-shortcuts: Shortcuts file doesn't exist yet at $SHORTCUTS_FILE"
789+
echo "zen-keyboard-shortcuts: Zen Browser will create it on first run"
790+
exit 0
791+
fi
792+
793+
${optionalString (profile.keyboardShortcutsVersion != null) ''
794+
# Version check: ensure shortcuts schema matches expected version
795+
if [ -f "$PREFS_FILE" ]; then
796+
ACTUAL_VERSION=$(${pkgs.gnugrep}/bin/grep -oP 'user_pref\("zen\.keyboard\.shortcuts\.version",\s*\K\d+' "$PREFS_FILE" || echo "")
797+
EXPECTED_VERSION="${toString profile.keyboardShortcutsVersion}"
798+
799+
if [ -n "$ACTUAL_VERSION" ] && [ "$ACTUAL_VERSION" != "$EXPECTED_VERSION" ]; then
800+
echo "ERROR: Zen Browser keyboard shortcuts version mismatch!"
801+
echo " Expected version: $EXPECTED_VERSION"
802+
echo " Actual version: $ACTUAL_VERSION"
803+
echo ""
804+
echo "This likely means Zen Browser was updated and keyboard shortcuts changed."
805+
echo "To fix this:"
806+
echo " 1. Check the new shortcuts in settings or ${shortcutsFilePath}"
807+
echo " 2. Review and update your keyboard shortcuts overrides if needed"
808+
echo " 3. Update keyboardShortcutsVersion = $ACTUAL_VERSION in your configuration"
809+
exit 1
810+
fi
811+
fi
812+
''}
813+
814+
# Read existing shortcuts
815+
EXISTING_SHORTCUTS=$(cat "$SHORTCUTS_FILE")
816+
817+
# Use jq to merge overrides into existing shortcuts
818+
# For each override, preserve identity fields but completely replace binding fields
819+
MERGED=$(echo "$EXISTING_SHORTCUTS" | ${pkgs.jq}/bin/jq --argjson overrides "$OVERRIDES" '
820+
.shortcuts |= map(
821+
. as $existing |
822+
# Find if there is an override for this shortcut
823+
($overrides | map(select(.id == $existing.id)) | .[0]) as $override |
824+
if $override then
825+
# Preserve identity/metadata fields from existing
826+
{
827+
id: $existing.id,
828+
group: $existing.group,
829+
l10nId: $existing.l10nId,
830+
action: $existing.action,
831+
reserved: $existing.reserved,
832+
internal: $existing.internal
833+
}
834+
# Replace binding fields with override
835+
+ {
836+
key: $override.key,
837+
keycode: $override.keycode,
838+
modifiers: $override.modifiers,
839+
disabled: $override.disabled
840+
}
841+
else
842+
# No override, keep as is
843+
$existing
844+
end
845+
)
846+
')
847+
848+
echo "$MERGED" > "$SHORTCUTS_FILE"
849+
850+
# Validate JSON
851+
if ! ${pkgs.jq}/bin/jq empty "$SHORTCUTS_FILE" 2>/dev/null; then
852+
echo "Error: Generated invalid JSON in $SHORTCUTS_FILE"
853+
exit 1
854+
fi
855+
'';
856+
857+
in
858+
nameValuePair "zen-keyboard-shortcuts-${profileName}" (lib.hm.dag.entryAfter [ "writeBoundary" ] ''
859+
${updateScript}
860+
if [[ "$?" -eq 0 ]]; then
861+
$VERBOSE_ECHO "zen-keyboard-shortcuts: Updated keyboard shortcuts for profile '${profileName}'"
862+
else
863+
echo "zen-keyboard-shortcuts: Failed to update keyboard shortcuts for profile '${profileName}'!" >&2
864+
fi
865+
'')
866+
)
867+
profilesWithShortcuts);
659868
};
660869
}

0 commit comments

Comments
 (0)