Skip to content

Commit 09633de

Browse files
authored
feat: revamp hotkey to support custom bash script and IPC calls (#82)
1 parent 60af71b commit 09633de

8 files changed

Lines changed: 223 additions & 149 deletions

File tree

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -323,12 +323,12 @@ See [`configs/default-config.toml`](configs/default-config.toml) for all availab
323323
```toml
324324
[hotkeys]
325325
# all hotkeys can be disabled by either setting the key to "" or just commenting it out
326-
activate_hint_mode = "Ctrl+F"
327-
activate_hint_mode_with_actions = "Ctrl+G"
328-
activate_scroll_mode = "Ctrl+S"
326+
"Ctrl+F" = "hints"
327+
"Ctrl+G" = "hints_action"
328+
"Ctrl+S" = "scroll"
329329
```
330330

331-
You shoul be also able to just clear the keybind and bind it with something like skhd or any similar tools, since we exposes commands in the cli through IPC. For example in skhd:
331+
You should be also able to just clear the keybind and bind it with something like skhd or any similar tools, since we exposes commands in the cli through IPC. For example in skhd:
332332

333333
```bash
334334
ctrl - f : neru hints

cmd/neru/main.go

Lines changed: 73 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"image"
66
"os"
7+
"os/exec"
78
"os/signal"
89
"path/filepath"
910
"strings"
@@ -161,12 +162,15 @@ func NewApp(cfg *config.Config) (*App, error) {
161162
if app.eventTap == nil {
162163
log.Warn("Event tap creation failed - key capture won't work")
163164
} else {
165+
166+
keys := make([]string, 0, len(cfg.Hotkeys.Bindings))
167+
for k := range cfg.Hotkeys.Bindings {
168+
keys = append(keys, k)
169+
}
170+
164171
// Configure hotkeys that should pass through to the global hotkey system
165-
app.eventTap.SetHotkeys(
166-
cfg.Hotkeys.ActivateHintMode,
167-
cfg.Hotkeys.ActivateHintModeWithActions,
168-
cfg.Hotkeys.ActivateScrollMode,
169-
)
172+
app.eventTap.SetHotkeys(keys)
173+
170174
// Ensure event tap is disabled initially (only enable in active modes)
171175
app.eventTap.Disable()
172176
}
@@ -232,15 +236,15 @@ func (a *App) Run() error {
232236
a.logger.Info("Neru is running")
233237
fmt.Println("✓ Neru is running")
234238

235-
// Print configured hotkeys
236-
if key := strings.TrimSpace(a.config.Hotkeys.ActivateHintMode); key != "" {
237-
fmt.Printf(" Hint mode (direct): %s\n", key)
238-
}
239-
if key := strings.TrimSpace(a.config.Hotkeys.ActivateHintModeWithActions); key != "" {
240-
fmt.Printf(" Hint mode (with actions): %s\n", key)
241-
}
242-
if key := strings.TrimSpace(a.config.Hotkeys.ActivateScrollMode); key != "" {
243-
fmt.Printf(" Scroll mode: %s\n", key)
239+
for k, v := range a.config.Hotkeys.Bindings {
240+
toShow := v
241+
if strings.HasPrefix(v, "exec") {
242+
runes := []rune(v)
243+
if len(runes) > 30 {
244+
toShow = string(runes[:30]) + "..."
245+
}
246+
}
247+
fmt.Printf(" %s: %s\n", k, toShow)
244248
}
245249

246250
// Wait for interrupt signal with force-quit support
@@ -282,35 +286,67 @@ func (a *App) Run() error {
282286

283287
// registerHotkeys registers all global hotkeys
284288
func (a *App) registerHotkeys() error {
285-
// Hint mode hotkey (direct click)
286-
if key := strings.TrimSpace(a.config.Hotkeys.ActivateHintMode); key != "" {
287-
a.logger.Info("Registering hint mode hotkey", zap.String("key", key))
288-
if _, err := a.hotkeyManager.Register(key, func() {
289-
a.activateHintMode(false)
290-
}); err != nil {
291-
return fmt.Errorf("failed to register hint mode hotkey: %w", err)
292-
}
293-
}
289+
// Note: Escape key for exiting modes is hardcoded in handleKeyPress, not registered as global hotkey
294290

295-
// Hint mode with actions hotkey
296-
if key := strings.TrimSpace(a.config.Hotkeys.ActivateHintModeWithActions); key != "" {
297-
a.logger.Info("Registering hint mode with actions hotkey", zap.String("key", key))
298-
if _, err := a.hotkeyManager.Register(key, func() {
299-
a.activateHintMode(true)
300-
}); err != nil {
301-
return fmt.Errorf("failed to register hint mode with actions hotkey: %w", err)
291+
// Register arbitrary bindings from config.Hotkeys.Bindings
292+
// We intentionally don't fail the entire registration process if one binding fails;
293+
// instead we log the error and continue so the daemon remains running.
294+
for k, v := range a.config.Hotkeys.Bindings {
295+
key := strings.TrimSpace(k)
296+
action := strings.TrimSpace(v)
297+
if key == "" || action == "" {
298+
continue
302299
}
303-
}
304300

305-
// Scroll mode hotkey
306-
if key := strings.TrimSpace(a.config.Hotkeys.ActivateScrollMode); key != "" {
307-
a.logger.Info("Registering scroll mode hotkey", zap.String("key", key))
308-
if _, err := a.hotkeyManager.Register(key, a.activateScrollMode); err != nil {
309-
return fmt.Errorf("failed to register scroll mode hotkey: %w", err)
301+
a.logger.Info("Registering hotkey binding", zap.String("key", key), zap.String("action", action))
302+
303+
// Capture values for closure
304+
bindKey := key
305+
bindAction := action
306+
307+
if _, err := a.hotkeyManager.Register(bindKey, func() {
308+
// Run handler in separate goroutine so the hotkey callback returns quickly.
309+
go func() {
310+
defer func() {
311+
if r := recover(); r != nil {
312+
a.logger.Error("panic in hotkey handler", zap.Any("recover", r), zap.String("key", bindKey))
313+
}
314+
}()
315+
316+
// Exec mode: run arbitrary bash command
317+
if strings.HasPrefix(bindAction, "exec ") {
318+
cmdStr := strings.TrimSpace(strings.TrimPrefix(bindAction, "exec"))
319+
if cmdStr == "" {
320+
a.logger.Error("hotkey exec has empty command", zap.String("key", bindKey))
321+
return
322+
}
323+
324+
a.logger.Debug("Executing shell command from hotkey", zap.String("key", bindKey), zap.String("cmd", cmdStr))
325+
cmd := exec.Command("/bin/bash", "-lc", cmdStr)
326+
out, err := cmd.CombinedOutput()
327+
if err != nil {
328+
a.logger.Error("hotkey exec failed", zap.String("key", bindKey), zap.String("cmd", cmdStr), zap.ByteString("output", out), zap.Error(err))
329+
} else {
330+
a.logger.Info("hotkey exec completed", zap.String("key", bindKey), zap.String("cmd", cmdStr), zap.ByteString("output", out))
331+
}
332+
return
333+
}
334+
335+
// Otherwise treat the action as an internal neru command and dispatch it
336+
resp := a.handleIPCCommand(ipc.Command{Action: bindAction})
337+
if !resp.Success {
338+
a.logger.Error("hotkey action failed", zap.String("key", bindKey), zap.String("action", bindAction), zap.String("message", resp.Message))
339+
} else {
340+
a.logger.Info("hotkey action executed", zap.String("key", bindKey), zap.String("action", bindAction))
341+
}
342+
}()
343+
}); err != nil {
344+
a.logger.Error("Failed to register hotkey binding", zap.String("key", key), zap.String("action", action), zap.Error(err))
345+
// continue registering other bindings
346+
continue
310347
}
311348
}
312349

313-
// Note: Escape key for exiting modes is hardcoded in handleKeyPress, not registered as global hotkey
314350
return nil
315351
}
316352

configs/default-config.toml

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -129,23 +129,16 @@ additional_firefox_bundles = []
129129

130130
[hotkeys]
131131
# all hotkeys can be disabled by either setting the key to "" or just commenting it out
132+
"Cmd+Shift+Space" = "hints" # Same as running `neru hints` in the terminal
133+
"Cmd+Shift+A" = "hints_action" # Same as running `neru hints_action` in the terminal
134+
"Cmd+Shift+J" = "scroll" # Same as running `neru scroll` in the terminal
132135

133-
# Activate hint mode (direct click)
134-
activate_hint_mode = "Cmd+Shift+Space"
135-
136-
# Activate hint mode with action selection (choose click type after typing hint)
137-
activate_hint_mode_with_actions = "Cmd+Shift+A"
138-
139-
# Activate scroll mode for vim-style scrolling
140-
activate_scroll_mode = "Cmd+Shift+J"
136+
# To run a shell command from a hotkey, prefix the value with `exec `:
137+
# "Ctrl+Alt+T" = "exec open -a Terminal"
138+
# "Ctrl+Alt+F" = "exec osascript -e 'display notification \"This is a test notification!\" with title \"Hello from Terminal\" subtitle \"Just testing 🚀\"'"
141139

142140
# Note: Escape key is hardcoded to exit any active mode
143141

144-
# Hotkey format notes:
145-
# - Use `Cmd`, `Ctrl`, `Alt`, `Shift`, or `Option` as modifiers.
146-
# - Keys can be letters, function keys, or names like `Space`.
147-
# - Set any hotkey to "" to disable that action.
148-
149142
[hints]
150143
# Characters used to build hint labels for alphabet style.
151144
# - At least 2 characters; choose distinct, easy-to-type ones.

internal/bridge/eventtap.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,6 @@ EventTap createEventTap(EventTapCallback callback, void* userData);
1414
void enableEventTap(EventTap tap);
1515
void disableEventTap(EventTap tap);
1616
void destroyEventTap(EventTap tap);
17-
void setEventTapHotkeys(EventTap tap, const char* hintModeHotkey, const char* hintModeWithActionsHotkey, const char* scrollModeHotkey);
17+
void setEventTapHotkeys(EventTap tap, const char** hotkeys, int count);
1818

1919
#endif // EVENTTAP_H

0 commit comments

Comments
 (0)