Skip to content

Commit c7dc6f6

Browse files
seletzclaude
andauthored
Add configurable TUI key bindings via config file (#6)
## Summary - Add `[keys]` section to TOML config for overriding TUI key bindings - Action names are context-prefixed (`cursor_`, `grid_`, `detail_`, `search_`, `global_`) for clarity - Single string or array of strings supported; only overridden keys change, others keep defaults - Help text auto-updates to reflect configured bindings ## Implementation - `KeysConfig` type with custom `UnmarshalTOML` normalizing single strings to `[]string` - `ApplyKeysConfig()` maps config action names to `KeyMap` fields via registry - `NewModel` accepts optional `KeysConfig` parameter - Config merge overlays per-action (child overrides parent) ## Test plan - [x] Config parsing tests: single string, array, mixed, no `[keys]` section, merge overlay/nil - [x] `ApplyKeysConfig` tests: single/multiple actions, unknown ignored, nil config, help text updated, all 16 actions recognized - [x] All existing tests updated and passing - [x] `mise run lint` — 0 issues Closes #3 --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1f88678 commit c7dc6f6

9 files changed

Lines changed: 473 additions & 15 deletions

File tree

.odoo-work-cli.toml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,30 @@ filters = [
2626
{ field = "company_id.name", op = "=", value = "digitalgedacht GmbH" },
2727
{ field = "stage_id.name", op = "=", value = "Umsetzung" },
2828
]
29+
30+
[keys]
31+
# Cursor movement (shared across grid, detail, search views)
32+
cursor_up = ["up", "k"]
33+
cursor_down = ["down", "j"]
34+
35+
# Grid view
36+
grid_next_col = ["right", "l"]
37+
grid_prev_col = ["left", "h"]
38+
grid_enter = ["enter"]
39+
grid_search = ["/"]
40+
41+
# Detail view
42+
detail_edit = ["e"]
43+
detail_add = ["a"]
44+
detail_delete = ["d"]
45+
46+
# Search view
47+
search_toggle = ["ctrl+a"]
48+
49+
# Global (available in all non-modal views)
50+
global_prev_week = ["p"]
51+
global_next_week = ["n"]
52+
global_back = ["esc"]
53+
global_refresh = ["r"]
54+
global_help = ["?"]
55+
global_quit = ["q", "ctrl+c"]

cmd/odoo-work-cli/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -582,7 +582,7 @@ var tuiCmd = &cobra.Command{
582582
return err
583583
}
584584

585-
m := tui.NewModel(client, tui.MondayTime{Time: monday}, cfg.Hours, cfg.Bundesland)
585+
m := tui.NewModel(client, tui.MondayTime{Time: monday}, cfg.Hours, cfg.Bundesland, cfg.Keys)
586586
p := tea.NewProgram(m)
587587
_, err = p.Run()
588588
return err

internal/config/config.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,40 @@ import (
77
"github.com/BurntSushi/toml"
88
)
99

10+
// KeysConfig maps action names to key binding strings.
11+
// Each action maps to one or more key strings (e.g. "q", "ctrl+c").
12+
type KeysConfig map[string][]string
13+
14+
// UnmarshalTOML normalizes TOML values: a single string becomes a
15+
// one-element slice, and an array of strings stays as-is.
16+
func (kc *KeysConfig) UnmarshalTOML(data interface{}) error {
17+
m, ok := data.(map[string]interface{})
18+
if !ok {
19+
return fmt.Errorf("keys: expected table, got %T", data)
20+
}
21+
result := make(KeysConfig, len(m))
22+
for action, val := range m {
23+
switch v := val.(type) {
24+
case string:
25+
result[action] = []string{v}
26+
case []interface{}:
27+
keys := make([]string, 0, len(v))
28+
for _, elem := range v {
29+
s, ok := elem.(string)
30+
if !ok {
31+
return fmt.Errorf("keys.%s: expected string element, got %T", action, elem)
32+
}
33+
keys = append(keys, s)
34+
}
35+
result[action] = keys
36+
default:
37+
return fmt.Errorf("keys.%s: expected string or array, got %T", action, val)
38+
}
39+
}
40+
*kc = result
41+
return nil
42+
}
43+
1044
// ConfigReader provides access to configuration values.
1145
type ConfigReader interface {
1246
OdooURL() string
@@ -64,6 +98,7 @@ type Config struct {
6498
Password string `toml:"-"`
6599
Models map[string]ModelConfig `toml:"models"`
66100
Hours HoursLimits `toml:"hours"`
101+
Keys KeysConfig `toml:"keys"`
67102
Bundesland string `toml:"bundesland"` // German federal state for holidays (e.g. "Bayern")
68103
}
69104

@@ -175,6 +210,14 @@ func (c *Config) Merge(other *Config) {
175210
if other.Hours.WeeklyHigh != 0 {
176211
c.Hours.WeeklyHigh = other.Hours.WeeklyHigh
177212
}
213+
if other.Keys != nil {
214+
if c.Keys == nil {
215+
c.Keys = make(KeysConfig)
216+
}
217+
for action, keys := range other.Keys {
218+
c.Keys[action] = keys
219+
}
220+
}
178221
if other.Models != nil {
179222
if c.Models == nil {
180223
c.Models = make(map[string]ModelConfig)

internal/config/config_test.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,151 @@ func TestMerge_HoursLimits(t *testing.T) {
563563
}
564564
}
565565

566+
func TestKeysConfig_UnmarshalTOML_SingleString(t *testing.T) {
567+
content := `
568+
[keys]
569+
quit = "q"
570+
edit = "e"
571+
`
572+
dir := t.TempDir()
573+
path := filepath.Join(dir, "config.toml")
574+
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
575+
t.Fatal(err)
576+
}
577+
578+
cfg, err := LoadFromTOML(path)
579+
if err != nil {
580+
t.Fatalf("unexpected error: %v", err)
581+
}
582+
if cfg.Keys == nil {
583+
t.Fatal("Keys is nil")
584+
}
585+
if got := cfg.Keys["quit"]; len(got) != 1 || got[0] != "q" {
586+
t.Errorf("Keys[quit] = %v, want [q]", got)
587+
}
588+
if got := cfg.Keys["edit"]; len(got) != 1 || got[0] != "e" {
589+
t.Errorf("Keys[edit] = %v, want [e]", got)
590+
}
591+
}
592+
593+
func TestKeysConfig_UnmarshalTOML_Array(t *testing.T) {
594+
content := `
595+
[keys]
596+
quit = ["q", "ctrl+c"]
597+
up = ["up", "k"]
598+
`
599+
dir := t.TempDir()
600+
path := filepath.Join(dir, "config.toml")
601+
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
602+
t.Fatal(err)
603+
}
604+
605+
cfg, err := LoadFromTOML(path)
606+
if err != nil {
607+
t.Fatalf("unexpected error: %v", err)
608+
}
609+
if got := cfg.Keys["quit"]; len(got) != 2 || got[0] != "q" || got[1] != "ctrl+c" {
610+
t.Errorf("Keys[quit] = %v, want [q ctrl+c]", got)
611+
}
612+
if got := cfg.Keys["up"]; len(got) != 2 || got[0] != "up" || got[1] != "k" {
613+
t.Errorf("Keys[up] = %v, want [up k]", got)
614+
}
615+
}
616+
617+
func TestKeysConfig_UnmarshalTOML_Mixed(t *testing.T) {
618+
content := `
619+
[keys]
620+
quit = ["q", "ctrl+c"]
621+
edit = "e"
622+
`
623+
dir := t.TempDir()
624+
path := filepath.Join(dir, "config.toml")
625+
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
626+
t.Fatal(err)
627+
}
628+
629+
cfg, err := LoadFromTOML(path)
630+
if err != nil {
631+
t.Fatalf("unexpected error: %v", err)
632+
}
633+
if got := cfg.Keys["quit"]; len(got) != 2 {
634+
t.Errorf("Keys[quit] = %v, want 2 elements", got)
635+
}
636+
if got := cfg.Keys["edit"]; len(got) != 1 || got[0] != "e" {
637+
t.Errorf("Keys[edit] = %v, want [e]", got)
638+
}
639+
}
640+
641+
func TestLoadFromTOML_NoKeysSection(t *testing.T) {
642+
content := `url = "https://odoo.example.com"`
643+
dir := t.TempDir()
644+
path := filepath.Join(dir, "config.toml")
645+
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
646+
t.Fatal(err)
647+
}
648+
649+
cfg, err := LoadFromTOML(path)
650+
if err != nil {
651+
t.Fatalf("unexpected error: %v", err)
652+
}
653+
if cfg.Keys != nil {
654+
t.Errorf("Keys = %v, want nil when [keys] section absent", cfg.Keys)
655+
}
656+
}
657+
658+
func TestMerge_Keys(t *testing.T) {
659+
base := &Config{
660+
Keys: KeysConfig{
661+
"quit": {"q", "ctrl+c"},
662+
"edit": {"e"},
663+
},
664+
}
665+
overlay := &Config{
666+
Keys: KeysConfig{
667+
"quit": {"ctrl+q"},
668+
},
669+
}
670+
671+
base.Merge(overlay)
672+
673+
if got := base.Keys["quit"]; len(got) != 1 || got[0] != "ctrl+q" {
674+
t.Errorf("Keys[quit] = %v, want [ctrl+q] (overlay replaces)", got)
675+
}
676+
if got := base.Keys["edit"]; len(got) != 1 || got[0] != "e" {
677+
t.Errorf("Keys[edit] = %v, want [e] (base preserved)", got)
678+
}
679+
}
680+
681+
func TestMerge_Keys_NilOverlay(t *testing.T) {
682+
base := &Config{
683+
Keys: KeysConfig{
684+
"quit": {"q"},
685+
},
686+
}
687+
overlay := &Config{}
688+
689+
base.Merge(overlay)
690+
691+
if got := base.Keys["quit"]; len(got) != 1 || got[0] != "q" {
692+
t.Errorf("Keys[quit] = %v, want [q] (nil overlay preserves base)", got)
693+
}
694+
}
695+
696+
func TestMerge_Keys_NilBase(t *testing.T) {
697+
base := &Config{}
698+
overlay := &Config{
699+
Keys: KeysConfig{
700+
"quit": {"ctrl+q"},
701+
},
702+
}
703+
704+
base.Merge(overlay)
705+
706+
if got := base.Keys["quit"]; len(got) != 1 || got[0] != "ctrl+q" {
707+
t.Errorf("Keys[quit] = %v, want [ctrl+q]", got)
708+
}
709+
}
710+
566711
func TestMerge_FiltersSameFieldOverride(t *testing.T) {
567712
base := &Config{
568713
Models: map[string]ModelConfig{

internal/tui/keymap.go

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
package tui
22

3-
import "charm.land/bubbles/v2/key"
3+
import (
4+
"strings"
5+
6+
"charm.land/bubbles/v2/key"
7+
"github.com/seletz/odoo-work-cli/internal/config"
8+
)
49

510
// KeyMap defines key bindings for the TUI.
611
type KeyMap struct {
@@ -13,8 +18,6 @@ type KeyMap struct {
1318
Refresh key.Binding
1419
Help key.Binding
1520
Quit key.Binding
16-
PrevWeek key.Binding
17-
NextWeek key.Binding
1821
Enter key.Binding
1922
Back key.Binding
2023
Edit key.Binding
@@ -94,6 +97,85 @@ func DefaultKeyMap() KeyMap {
9497
}
9598
}
9699

100+
// actionHelpDesc maps config action names to their help description text.
101+
// Action names are prefixed with the context they apply to:
102+
// - cursor_ : shared cursor movement (grid, detail, search)
103+
// - grid_ : grid view actions
104+
// - detail_ : detail view actions
105+
// - search_ : search view actions
106+
// - global_ : actions available in all non-modal views
107+
var actionHelpDesc = map[string]string{
108+
"cursor_up": "up",
109+
"cursor_down": "down",
110+
"grid_next_col": "next day",
111+
"grid_prev_col": "prev day",
112+
"grid_enter": "detail",
113+
"grid_search": "search",
114+
"detail_edit": "edit",
115+
"detail_add": "add",
116+
"detail_delete": "delete",
117+
"search_toggle": "toggle filter",
118+
"global_quit": "quit",
119+
"global_help": "help",
120+
"global_refresh": "refresh",
121+
"global_back": "back",
122+
"global_prev_week": "prev week",
123+
"global_next_week": "next week",
124+
}
125+
126+
// ApplyKeysConfig overrides key bindings in km from the given config.
127+
// Unknown action names are silently ignored. Returns the modified KeyMap.
128+
func ApplyKeysConfig(km KeyMap, cfg config.KeysConfig) KeyMap {
129+
if cfg == nil {
130+
return km
131+
}
132+
for action, keys := range cfg {
133+
desc, ok := actionHelpDesc[action]
134+
if !ok {
135+
continue
136+
}
137+
binding := key.NewBinding(
138+
key.WithKeys(keys...),
139+
key.WithHelp(strings.Join(keys, "/"), desc),
140+
)
141+
switch action {
142+
case "cursor_up":
143+
km.Up = binding
144+
case "cursor_down":
145+
km.Down = binding
146+
case "grid_next_col":
147+
km.NextCol = binding
148+
case "grid_prev_col":
149+
km.PrevCol = binding
150+
case "grid_enter":
151+
km.Enter = binding
152+
case "grid_search":
153+
km.Search = binding
154+
case "detail_edit":
155+
km.Edit = binding
156+
case "detail_add":
157+
km.Add = binding
158+
case "detail_delete":
159+
km.Delete = binding
160+
case "search_toggle":
161+
km.SearchToggle = binding
162+
case "global_quit":
163+
km.Quit = binding
164+
case "global_help":
165+
km.Help = binding
166+
case "global_refresh":
167+
km.Refresh = binding
168+
case "global_back":
169+
km.Back = binding
170+
case "global_prev_week":
171+
km.Left = binding
172+
case "global_next_week":
173+
km.Right = binding
174+
}
175+
}
176+
return km
177+
}
178+
97179
// ShortHelp returns key bindings for the short help view.
98180
func (k KeyMap) ShortHelp() []key.Binding {
99181
return []key.Binding{k.Up, k.Down, k.NextCol, k.Left, k.Right, k.Enter, k.Edit, k.Add, k.Delete, k.Search, k.Refresh, k.Help, k.Quit}

0 commit comments

Comments
 (0)