Skip to content

Commit 3a3ee0e

Browse files
authored
feat(modes): add support for user declared custom mode (#1629)
1 parent 30cc89b commit 3a3ee0e

60 files changed

Lines changed: 2132 additions & 162 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ neru hints # trigger hints mode
167167
neru grid # trigger grid mode
168168
neru recursive_grid # trigger recursive grid mode
169169
neru scroll # trigger scroll mode
170+
neru mode window # enter a mode you declared in config
170171
neru config reload # hot-reload config without restarting
171172
neru status # check daemon state and permissions
172173
```

configs/default-config.toml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,22 @@ text = "#E8EEFF"
6060
# [hints.hotkeys]
6161
# "Enter" = "macro click_and_exit"
6262

63+
# Declared modes — a keymap with a name and an indicator, no logic of its own.
64+
# Enter one with "mode <name>" from a hotkey, a sequence step, or `neru mode
65+
# <name>`. Escape returns to idle unless the mode rebinds it.
66+
# See https://github.com/y3owk1n/neru/blob/main/docs/CONFIGURATION.md#modes
67+
#
68+
# [modes.window]
69+
# indicator = "Window"
70+
#
71+
# [modes.window.hotkeys]
72+
# "h" = "exec yabai -m window --focus west"
73+
# "l" = "exec yabai -m window --focus east"
74+
# "s" = "scroll"
75+
#
76+
# [hotkeys]
77+
# "Primary+Shift+W" = "mode window"
78+
6379
# Hint mode
6480
# See https://github.com/y3owk1n/neru/blob/main/docs/CONFIGURATION.md#hints
6581
[hints]

docs/CLI.md

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ The same content is available as manpages (`man neru`) after installation.
1919
- [Global flags](#global-flags)
2020
- [Command index](#command-index)
2121
- [Daemon lifecycle](#daemon-lifecycle)`launch` · `start` · `stop` · `idle` · `status` · `doctor`
22-
- [Navigation modes](#navigation-modes)`hints` · `grid` · `recursive_grid` · `scroll` · `monitor_select`
22+
- [Navigation modes](#navigation-modes)`hints` · `grid` · `recursive_grid` · `scroll` · `monitor_select` · `mode`
2323
- [Actions](#actions)`action` and its subcommands
2424
- [Sequences](#sequences)`run` · `macro`
2525
- [Configuration commands](#configuration-commands)`config`
@@ -90,6 +90,7 @@ Accepted by every command.
9090
| [`recursive_grid`](#neru-recursive_grid) | Recursive cell navigation | Yes | All |
9191
| [`scroll`](#neru-scroll) | Vim-style scrolling | Yes | All |
9292
| [`monitor_select`](#neru-monitor_select) | Jump the cursor to a display | Yes | macOS · Linux |
93+
| [`mode`](#neru-mode) | Enter a mode declared in config | Yes | All |
9394
| [`action`](#actions) | One-shot mouse/scroll/key input | Yes | All ² |
9495
| [`run`](#neru-run) | Run several actions in order | Yes | All |
9596
| [`macro`](#neru-macro) | Run a named sequence from config | Yes | All |
@@ -189,7 +190,7 @@ Requires a running daemon.
189190
| Field | Values |
190191
| -------- | ------------------------------------------------------- |
191192
| `Status` | `running`, `disabled` |
192-
| `Mode` | `idle`, `hints`, `grid`, `recursive_grid`, `scroll`, `monitor_select` |
193+
| `Mode` | `idle`, `hints`, `grid`, `recursive_grid`, `scroll`, `monitor_select`, or the name of the open [declared mode](CONFIGURATION.md#modes) |
193194

194195
**JSON output**
195196

@@ -270,8 +271,8 @@ the check. The full set is
270271

271272
# Navigation modes
272273

273-
Modes take over the keyboard until you select a target or exit. All five
274-
require a running daemon.
274+
Modes take over the keyboard until you select a target or exit. Every mode
275+
command requires a running daemon.
275276

276277
## Mode flag reference
277278

@@ -295,7 +296,7 @@ nothing.
295296
| `--modifier` | | value | `hints` · `grid` · `recursive_grid` | Comma-separated modifier keys to hold during action (cmd, super, meta, shift, alt, option, ctrl) (requires --action) |
296297
| `--on-exit` | | value, repeatable | `hints` · `grid` · `recursive_grid` | Step to run after the action is fulfilled and the mode exits (same syntax as hotkeys, e.g. 'action left_click' or 'exec notify-send done'). Repeat the flag to run several steps in order. Requires --action; not run on manual escape/idle |
297298
| `--repeat` | `-r` | none | `hints` · `grid` · `recursive_grid` | Re-activate mode after performing the action (requires --action) |
298-
| `--toggle` | `-t` | none | `hints` · `grid` · `recursive_grid` · `scroll` · `monitor_select` | Toggle mode on/off (exit to idle if already active) |
299+
| `--toggle` | `-t` | none | `hints` · `grid` · `recursive_grid` · `scroll` · `monitor_select` · `mode` | Toggle mode on/off (exit to idle if already active) |
299300
| `--search` | `-s` | none | `hints` | Show search input when the mode is activated |
300301
| `--hide-on-empty-search` | | none | `hints` | Hide all hints when search query is empty (requires --search) |
301302
| `--role` | | value, repeatable | `hints` | Filter by element role (comma-separated: button,link — the hints.clickable_roles vocabulary, see 'neru roles'). Repeat the flag to add more |
@@ -522,6 +523,35 @@ neru monitor_select --toggle
522523

523524
---
524525

526+
## neru mode
527+
528+
Enter a mode you declared under [`[modes.<name>]`](CONFIGURATION.md#modes).
529+
530+
```
531+
neru mode <name> [flags]
532+
```
533+
534+
A declared mode has no logic of its own: it captures the keyboard, shows its
535+
indicator, and answers every key from its own `[modes.<name>.hotkeys]` table.
536+
`Escape` returns to idle unless the table rebinds it. The same command is a
537+
binding step, `"mode <name>"`, from any hotkey table or macro.
538+
539+
**Flags** — every flag listed for `mode` in the
540+
[mode flag reference](#mode-flag-reference): `--toggle` and nothing else, since
541+
a declared mode makes no selection.
542+
543+
A name nothing declares is refused with `ERR_INVALID_INPUT`, the same way a
544+
binding into one is refused at load.
545+
546+
**Examples**
547+
548+
```bash
549+
neru mode window
550+
neru mode window --toggle
551+
```
552+
553+
---
554+
525555
# Actions
526556

527557
One-shot input that runs without entering a mode. All action subcommands
@@ -1631,8 +1661,9 @@ scripts are safe.
16311661
```
16321662
16331663
`action` names either a mode command`hints`, `grid`, `recursive_grid`,
1634-
`scroll`, `monitor_select`, `idle` — or one of the standalone commands. `args`
1635-
carries the same flags a user would type.
1664+
`scroll`, `monitor_select`, `idle`, or `mode` with the declared name as the
1665+
first entry of `args` — or one of the standalone commands. `args` carries the
1666+
same flags a user would type.
16361667
16371668
A mode command's flags are read exactly as the CLI reads them, and answered
16381669
with the same message: an unknown flag, a flag the named mode does not accept,

docs/CONFIGURATION.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ keeps its default. "The daemon" below means the process started by
3131
| Section | Controls |
3232
| --------------------------------------------- | -------------------------------------------- |
3333
| [`[macros]`](#macros) | Named action sequences reused across bindings |
34+
| [`[modes]`](#modes) | Modes you declare: a keymap and an indicator |
3435
| [`[general]`](#general) | Global behaviour, passthrough, `exec` shell |
3536
| [`[theme]`](#theme) | Base palette all components derive from |
3637
| [`[hints]`](#hints) | Hints mode and element discovery |
@@ -710,6 +711,70 @@ shares the same definition rather than keeping its own copy:
710711
neru macro window_click 100 70
711712
```
712713

714+
---
715+
716+
## [modes]
717+
718+
Modes you declare yourself. A declared mode has no logic of its own: it is a
719+
name, an indicator label, and a hotkey table. While it is open Neru captures
720+
the keyboard, shows the indicator, and answers every key from that table, the
721+
way [scroll mode](#scroll) does with its own. Use one as a layer: a set of
722+
bare-letter bindings that only mean something after you enter the mode.
723+
724+
```toml
725+
[modes.window]
726+
indicator = "Window" # mode-indicator text; "" hides it
727+
728+
[modes.window.hotkeys]
729+
"h" = "exec yabai -m window --focus west"
730+
"l" = "exec yabai -m window --focus east"
731+
"f" = ["exec yabai -m window --toggle zoom-fullscreen", "idle"]
732+
"s" = "scroll" # mode-to-mode transition, like any other
733+
734+
[hotkeys]
735+
"Primary+Shift+W" = "mode window"
736+
```
737+
738+
**Entering one.** The step is `mode <name>`, from a global hotkey, a macro, a
739+
`run`, or the command line as [`neru mode <name>`](CLI.md#neru-mode). It
740+
accepts `--toggle` and nothing else: a declared mode makes no selection, so
741+
none of the selection flags apply. A step naming a mode that is not declared
742+
is refused at load, like a typo'd flag.
743+
744+
**Leaving one.** `Escape` is bound to `idle` by default, as it is in every
745+
mode, and `"Escape" = "__disabled__"` removes it. Any binding whose steps end
746+
in `idle` or another mode leaves too. While the mode is open, an unbound
747+
Ctrl/Alt/Cmd chord falls back to `[hotkeys]` under the
748+
[usual precedence](#per-mode-hotkeys); an unbound bare key is swallowed, as
749+
in scroll mode.
750+
751+
### Options
752+
753+
| Option | Type | Default | Description |
754+
| ----------- | ------ | ------- | ----------------------------------------------------------- |
755+
| `indicator` | string | `""` | [Mode indicator](#mode_indicator) text while the mode is open; empty hides it |
756+
| `hotkeys` | map | `{ "Escape" = "idle" }` | The mode's [hotkeys](#per-mode-hotkeys), merged over the default |
757+
758+
The name is the table key: letters, digits, `_` and `-`, starting with a
759+
letter. A built-in mode's name (`hints`, `grid`, `recursive_grid`, `scroll`,
760+
`monitor_select`, `idle`) and the word `mode` are refused.
761+
762+
### Per-App Config
763+
764+
| Field | Type | Description |
765+
| ----------- | ------ | ----------------------------------------------------- |
766+
| `bundle_id` | string | App bundle ID |
767+
| `hotkeys` | map | [per-app hotkey overrides](#per-app-hotkey-overrides) |
768+
769+
```toml
770+
[[modes.window.app_configs]]
771+
bundle_id = "com.apple.Safari"
772+
hotkeys = { "h" = "action scroll_left", "l" = "action scroll_right" }
773+
```
774+
775+
The indicator's style comes from [`[mode_indicator.ui]`](#mode_indicator),
776+
shared with the built-in modes.
777+
713778
## [general]
714779

715780
Global behaviour that is not tied to a single mode: app exclusions, keyboard

internal/adapter/overlay/adapter.go

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ func (a *Adapter) ShowFrame(ctx context.Context, frame ports.Frame) error {
9797
return err
9898
}
9999

100-
mode, modeErr := overlayMode(frame.Mode())
100+
mode, modeErr := frameSurface(frame)
101101
if modeErr != nil {
102102
return modeErr
103103
}
@@ -128,7 +128,9 @@ func (a *Adapter) ShowFrame(ctx context.Context, frame ports.Frame) error {
128128
// Linux the mode and sticky-modifier indicators are badges painted on that
129129
// surface: the shared window's visibility is theirs. Deciding otherwise would
130130
// encode macOS's one-window-per-indicator model in shared code and leave a
131-
// Linux user in scroll mode with no indicator after a monitor move.
131+
// Linux user in scroll mode with no indicator after a monitor move. A declared
132+
// mode draws nothing of its own either, and needs the window for the same
133+
// reason.
132134
//
133135
// Every mode is named rather than defaulted, so a mode added without an answer
134136
// here fails the `exhaustive` linter instead of silently inheriting one. The
@@ -139,7 +141,7 @@ func drawsOnSharedWindow(frame ports.Frame) bool {
139141
case domain.ModeMonitorSelect:
140142
return false
141143
case domain.ModeHints, domain.ModeGrid, domain.ModeRecursiveGrid,
142-
domain.ModeScroll, domain.ModeIdle:
144+
domain.ModeScroll, domain.ModeCustom, domain.ModeIdle:
143145
return true
144146
}
145147

@@ -371,6 +373,20 @@ func (a *Adapter) UpdateGridPointer(mode domain.Mode, pointer ports.GridPointer)
371373
)
372374
}
373375

376+
// frameSurface names the overlay mode a frame is realized in.
377+
//
378+
// A declared mode's frame is the one case where the enum is not the name: every
379+
// declaration shares domain.ModeCustom, and the overlay tells them apart by the
380+
// declared name, which is what the indicator's label is looked up by. So the
381+
// surface is the name itself, and the built-in modes go through overlayMode.
382+
func frameSurface(frame ports.Frame) (Mode, error) {
383+
if custom, isCustom := frame.(ports.CustomFrame); isCustom {
384+
return Mode(custom.Name), nil
385+
}
386+
387+
return overlayMode(frame.Mode())
388+
}
389+
374390
// overlayMode translates a mode into the overlay's own name for it. The two
375391
// vocabularies share their spelling deliberately; a mode this overlay has none
376392
// for is reported rather than drawn in whatever mode happened to be current.

internal/adapter/overlay/manager/components.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,12 @@ func (b *Base) BuildComponents(spec ComponentSpec) (Components, error) {
100100
}
101101
}
102102

103-
modeIndicator, err := modeindicator.NewOverlay(cfg.ModeIndicator, spec.Theme, logger)
103+
modeIndicator, err := modeindicator.NewOverlay(
104+
cfg.ModeIndicator,
105+
cfg.CustomModeIndicators(),
106+
spec.Theme,
107+
logger,
108+
)
104109
if err != nil {
105110
logger.Warn("Failed to build the mode indicator overlay", zap.Error(err))
106111
} else {
@@ -193,7 +198,7 @@ func (b *Base) ConfigureComponents(cfg *config.Config, pointer PointerAppearance
193198
}
194199

195200
if overlay := b.modeIndicatorOverlay; overlay != nil {
196-
overlay.SetConfig(cfg.ModeIndicator)
201+
overlay.SetConfig(cfg.ModeIndicator, cfg.CustomModeIndicators())
197202
}
198203

199204
if overlay := b.stickyModifiersOverlay; overlay != nil {
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package modeindicator
2+
3+
import "github.com/y3owk1n/neru/internal/config"
4+
5+
// customModeConfig is the per-mode indicator config of a declared mode, or nil
6+
// for a name no declaration gave a label.
7+
//
8+
// A declared mode has no [mode_indicator.<name>] section: its text is the
9+
// declaration's, and its colors are left zero so every backend falls back to
10+
// [mode_indicator.ui] through the same override it applies to a built-in mode
11+
// without colors of its own. Enabled is what the non-darwin label resolution
12+
// checks, and a label that exists is one that is shown.
13+
func customModeConfig(customLabels map[string]string, mode string) *config.ModeIndicatorModeConfig {
14+
label, declared := customLabels[mode]
15+
if !declared {
16+
return nil
17+
}
18+
19+
return &config.ModeIndicatorModeConfig{Enabled: true, Text: label}
20+
}

internal/adapter/overlay/render/modeindicator/overlay_darwin.go

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,12 @@ const (
3535
type Overlay struct {
3636
window C.OverlayWindow
3737
indicatorConfig config.ModeIndicatorConfig
38-
theme config.ThemeProvider
39-
logger *zap.Logger
38+
// customLabels is the indicator text of every declared mode that has one,
39+
// by name. A declared mode has no per-mode section of its own: its label
40+
// is the declaration's, and its colors are the shared UI defaults.
41+
customLabels map[string]string
42+
theme config.ThemeProvider
43+
logger *zap.Logger
4044

4145
// styleCache holds cached C style strings for the currently drawn mode.
4246
// It is shared across all modes and invalidated on mode change via
@@ -65,6 +69,7 @@ type Overlay struct {
6569
// NewOverlay creates a new mode indicator overlay instance with its own window.
6670
func NewOverlay(
6771
indicatorCfg config.ModeIndicatorConfig,
72+
customLabels map[string]string,
6873
theme config.ThemeProvider,
6974
logger *zap.Logger,
7075
) (*Overlay, error) {
@@ -76,6 +81,7 @@ func NewOverlay(
7681
return &Overlay{
7782
window: (C.OverlayWindow)(base.Window),
7883
indicatorConfig: indicatorCfg,
84+
customLabels: customLabels,
7985
theme: theme,
8086
logger: logger,
8187
styleCache: base.StyleCache,
@@ -277,10 +283,15 @@ func (o *Overlay) DrawModeIndicator(mode string, xCoordinate, yCoordinate int) {
277283
C.NeruDrawHints(o.window, &hint, 1, style)
278284
}
279285

280-
// SetConfig sets the overlay configuration.
281-
func (o *Overlay) SetConfig(indicatorCfg config.ModeIndicatorConfig) {
286+
// SetConfig sets the overlay configuration, and the labels of the declared
287+
// modes alongside it.
288+
func (o *Overlay) SetConfig(
289+
indicatorCfg config.ModeIndicatorConfig,
290+
customLabels map[string]string,
291+
) {
282292
o.configMu.Lock()
283293
o.indicatorConfig = indicatorCfg
294+
o.customLabels = customLabels
284295
o.configMu.Unlock()
285296
// Invalidate caches when config changes
286297
o.freeAllCaches()
@@ -319,7 +330,7 @@ func (o *Overlay) resolveModeConfig(mode string) *config.ModeIndicatorModeConfig
319330
case domain.ModeNameRecursiveGrid:
320331
return &o.indicatorConfig.RecursiveGrid
321332
default:
322-
return nil
333+
return customModeConfig(o.customLabels, mode)
323334
}
324335
}
325336

0 commit comments

Comments
 (0)