feat(config): add live reload and theme scheme propagation
- commit
- dc993a0ac4d22b3726f73662376d394d061d524b
- author
- brice · · 2026-07-05 16:26 UTC
- committer
- brice · · 2026-07-05 16:59 UTC
- parent
- 5eef83d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
37 files · +2156 -169
A internal/adapters/config/config.go +189 -0
| @@ -0,0 +1,189 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "io" | |
| 9 | + | "os" | |
| 10 | + | "strings" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/vev/internal/domain" | |
| 14 | + | "github.com/bnema/vev/internal/ports" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | const pollInterval = 2 * time.Second | |
| 18 | + | ||
| 19 | + | // Parse reads vev's flat action = value config format. Duplicate action keys | |
| 20 | + | // are accepted with a warning; the last value wins while first-seen action order | |
| 21 | + | // is preserved for binding conflict resolution. | |
| 22 | + | func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |
| 23 | + | cfg := domain.Defaults() | |
| 24 | + | var warnings []domain.Warning | |
| 25 | + | ||
| 26 | + | scanner := bufio.NewScanner(r) | |
| 27 | + | lineNo := 0 | |
| 28 | + | for scanner.Scan() { | |
| 29 | + | lineNo++ | |
| 30 | + | line := strings.TrimSpace(strings.TrimSuffix(scanner.Text(), "\r")) | |
| 31 | + | if line == "" || strings.HasPrefix(line, "#") { | |
| 32 | + | continue | |
| 33 | + | } | |
| 34 | + | line = stripInlineComment(line) | |
| 35 | + | if line == "" { | |
| 36 | + | continue | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | key, value, ok := strings.Cut(line, "=") | |
| 40 | + | if !ok { | |
| 41 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing '='"}) | |
| 42 | + | continue | |
| 43 | + | } | |
| 44 | + | key = strings.TrimSpace(key) | |
| 45 | + | value = strings.TrimSpace(value) | |
| 46 | + | if key == "" { | |
| 47 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing key"}) | |
| 48 | + | continue | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | switch { | |
| 52 | + | case key == "theme": | |
| 53 | + | mode, ok := parseTheme(value) | |
| 54 | + | if !ok { | |
| 55 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid theme %q", value)}) | |
| 56 | + | continue | |
| 57 | + | } | |
| 58 | + | cfg.Theme = mode | |
| 59 | + | case strings.HasPrefix(key, "code."): | |
| 60 | + | codeKey := strings.TrimPrefix(key, "code.") | |
| 61 | + | if codeKey == "" { | |
| 62 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing code slug"}) | |
| 63 | + | continue | |
| 64 | + | } | |
| 65 | + | if _, exists := cfg.Codes[codeKey]; exists { | |
| 66 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)}) | |
| 67 | + | } | |
| 68 | + | cfg.Codes[codeKey] = value | |
| 69 | + | default: | |
| 70 | + | if _, exists := cfg.Bindings[key]; exists { | |
| 71 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)}) | |
| 72 | + | updateBindingEntry(cfg.BindingEntries, key, value) | |
| 73 | + | } else { | |
| 74 | + | cfg.BindingEntries = append(cfg.BindingEntries, domain.ConfigEntry{Key: key, Value: value}) | |
| 75 | + | } | |
| 76 | + | cfg.Bindings[key] = value | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | if err := scanner.Err(); err != nil { | |
| 80 | + | return cfg, warnings, err | |
| 81 | + | } | |
| 82 | + | return cfg, warnings, nil | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // Load reads path and parses it. Missing config files are treated as defaults. | |
| 86 | + | func Load(path string) (domain.Config, []domain.Warning, error) { | |
| 87 | + | f, err := os.Open(path) | |
| 88 | + | if errors.Is(err, os.ErrNotExist) { | |
| 89 | + | return domain.Defaults(), nil, nil | |
| 90 | + | } | |
| 91 | + | if err != nil { | |
| 92 | + | return domain.Defaults(), nil, err | |
| 93 | + | } | |
| 94 | + | cfg, warnings, parseErr := Parse(f) | |
| 95 | + | if closeErr := f.Close(); parseErr == nil && closeErr != nil { | |
| 96 | + | return cfg, warnings, closeErr | |
| 97 | + | } | |
| 98 | + | return cfg, warnings, parseErr | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // Watch polls path for mtime/size changes every two seconds and calls onChange | |
| 102 | + | // with each successfully loaded config. Missing files are allowed and reload to | |
| 103 | + | // defaults when the watched state changes. | |
| 104 | + | func Watch(ctx context.Context, clock ports.Clock, path string, onChange func(domain.Config, []domain.Warning)) error { | |
| 105 | + | last, err := fileStamp(path) | |
| 106 | + | lastErrMsg := "" | |
| 107 | + | if err != nil { | |
| 108 | + | lastErrMsg = err.Error() | |
| 109 | + | onChange(domain.Defaults(), []domain.Warning{{Msg: lastErrMsg}}) | |
| 110 | + | } | |
| 111 | + | timer := clock.NewTimer(pollInterval) | |
| 112 | + | defer timer.Stop() | |
| 113 | + | ||
| 114 | + | for { | |
| 115 | + | select { | |
| 116 | + | case <-ctx.Done(): | |
| 117 | + | return ctx.Err() | |
| 118 | + | case <-timer.C(): | |
| 119 | + | current, statErr := fileStamp(path) | |
| 120 | + | if statErr != nil { | |
| 121 | + | if statErr.Error() != lastErrMsg { | |
| 122 | + | lastErrMsg = statErr.Error() | |
| 123 | + | onChange(domain.Defaults(), []domain.Warning{{Msg: lastErrMsg}}) | |
| 124 | + | } | |
| 125 | + | } else if current != last || lastErrMsg != "" { | |
| 126 | + | lastErrMsg = "" | |
| 127 | + | last = current | |
| 128 | + | cfg, warnings, err := Load(path) | |
| 129 | + | if err != nil { | |
| 130 | + | warnings = append(warnings, domain.Warning{Msg: err.Error()}) | |
| 131 | + | } | |
| 132 | + | onChange(cfg, warnings) | |
| 133 | + | } | |
| 134 | + | timer.Reset(pollInterval) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | type stamp struct { | |
| 140 | + | modTime time.Time | |
| 141 | + | size int64 | |
| 142 | + | exists bool | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func fileStamp(path string) (stamp, error) { | |
| 146 | + | st, err := os.Stat(path) | |
| 147 | + | if errors.Is(err, os.ErrNotExist) { | |
| 148 | + | return stamp{}, nil | |
| 149 | + | } | |
| 150 | + | if err != nil { | |
| 151 | + | return stamp{}, err | |
| 152 | + | } | |
| 153 | + | return stamp{modTime: st.ModTime(), size: st.Size(), exists: true}, nil | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func updateBindingEntry(entries []domain.ConfigEntry, key, value string) { | |
| 157 | + | for i := range entries { | |
| 158 | + | if entries[i].Key == key { | |
| 159 | + | entries[i].Value = value | |
| 160 | + | return | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func parseTheme(value string) (domain.ThemeMode, bool) { | |
| 166 | + | switch strings.ToLower(strings.TrimSpace(value)) { | |
| 167 | + | case "auto", "": | |
| 168 | + | return domain.ThemeAuto, true | |
| 169 | + | case "dark": | |
| 170 | + | return domain.ThemeDark, true | |
| 171 | + | case "light": | |
| 172 | + | return domain.ThemeLight, true | |
| 173 | + | default: | |
| 174 | + | return domain.ThemeAuto, false | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func stripInlineComment(line string) string { | |
| 179 | + | for i, r := range line { | |
| 180 | + | if r == '#' && (i == 0 || isSpace(rune(line[i-1]))) { | |
| 181 | + | return strings.TrimSpace(line[:i]) | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | return line | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | func isSpace(r rune) bool { | |
| 188 | + | return r == ' ' || r == '\t' || r == '\r' || r == '\n' | |
| 189 | + | } | |
| @@ -0,0 +1,189 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "context" | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "io" | |||
| 9 | + | "os" | |||
| 10 | + | "strings" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/vev/internal/domain" | |||
| 14 | + | "github.com/bnema/vev/internal/ports" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | const pollInterval = 2 * time.Second | |||
| 18 | + | ||||
| 19 | + | // Parse reads vev's flat action = value config format. Duplicate action keys | |||
| 20 | + | // are accepted with a warning; the last value wins while first-seen action order | |||
| 21 | + | // is preserved for binding conflict resolution. | |||
| 22 | + | func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 23 | + | cfg := domain.Defaults() | |||
| 24 | + | var warnings []domain.Warning | |||
| 25 | + | ||||
| 26 | + | scanner := bufio.NewScanner(r) | |||
| 27 | + | lineNo := 0 | |||
| 28 | + | for scanner.Scan() { | |||
| 29 | + | lineNo++ | |||
| 30 | + | line := strings.TrimSpace(strings.TrimSuffix(scanner.Text(), "\r")) | |||
| 31 | + | if line == "" || strings.HasPrefix(line, "#") { | |||
| 32 | + | continue | |||
| 33 | + | } | |||
| 34 | + | line = stripInlineComment(line) | |||
| 35 | + | if line == "" { | |||
| 36 | + | continue | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | key, value, ok := strings.Cut(line, "=") | |||
| 40 | + | if !ok { | |||
| 41 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing '='"}) | |||
| 42 | + | continue | |||
| 43 | + | } | |||
| 44 | + | key = strings.TrimSpace(key) | |||
| 45 | + | value = strings.TrimSpace(value) | |||
| 46 | + | if key == "" { | |||
| 47 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing key"}) | |||
| 48 | + | continue | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | switch { | |||
| 52 | + | case key == "theme": | |||
| 53 | + | mode, ok := parseTheme(value) | |||
| 54 | + | if !ok { | |||
| 55 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid theme %q", value)}) | |||
| 56 | + | continue | |||
| 57 | + | } | |||
| 58 | + | cfg.Theme = mode | |||
| 59 | + | case strings.HasPrefix(key, "code."): | |||
| 60 | + | codeKey := strings.TrimPrefix(key, "code.") | |||
| 61 | + | if codeKey == "" { | |||
| 62 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing code slug"}) | |||
| 63 | + | continue | |||
| 64 | + | } | |||
| 65 | + | if _, exists := cfg.Codes[codeKey]; exists { | |||
| 66 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)}) | |||
| 67 | + | } | |||
| 68 | + | cfg.Codes[codeKey] = value | |||
| 69 | + | default: | |||
| 70 | + | if _, exists := cfg.Bindings[key]; exists { | |||
| 71 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)}) | |||
| 72 | + | updateBindingEntry(cfg.BindingEntries, key, value) | |||
| 73 | + | } else { | |||
| 74 | + | cfg.BindingEntries = append(cfg.BindingEntries, domain.ConfigEntry{Key: key, Value: value}) | |||
| 75 | + | } | |||
| 76 | + | cfg.Bindings[key] = value | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | if err := scanner.Err(); err != nil { | |||
| 80 | + | return cfg, warnings, err | |||
| 81 | + | } | |||
| 82 | + | return cfg, warnings, nil | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // Load reads path and parses it. Missing config files are treated as defaults. | |||
| 86 | + | func Load(path string) (domain.Config, []domain.Warning, error) { | |||
| 87 | + | f, err := os.Open(path) | |||
| 88 | + | if errors.Is(err, os.ErrNotExist) { | |||
| 89 | + | return domain.Defaults(), nil, nil | |||
| 90 | + | } | |||
| 91 | + | if err != nil { | |||
| 92 | + | return domain.Defaults(), nil, err | |||
| 93 | + | } | |||
| 94 | + | cfg, warnings, parseErr := Parse(f) | |||
| 95 | + | if closeErr := f.Close(); parseErr == nil && closeErr != nil { | |||
| 96 | + | return cfg, warnings, closeErr | |||
| 97 | + | } | |||
| 98 | + | return cfg, warnings, parseErr | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // Watch polls path for mtime/size changes every two seconds and calls onChange | |||
| 102 | + | // with each successfully loaded config. Missing files are allowed and reload to | |||
| 103 | + | // defaults when the watched state changes. | |||
| 104 | + | func Watch(ctx context.Context, clock ports.Clock, path string, onChange func(domain.Config, []domain.Warning)) error { | |||
| 105 | + | last, err := fileStamp(path) | |||
| 106 | + | lastErrMsg := "" | |||
| 107 | + | if err != nil { | |||
| 108 | + | lastErrMsg = err.Error() | |||
| 109 | + | onChange(domain.Defaults(), []domain.Warning{{Msg: lastErrMsg}}) | |||
| 110 | + | } | |||
| 111 | + | timer := clock.NewTimer(pollInterval) | |||
| 112 | + | defer timer.Stop() | |||
| 113 | + | ||||
| 114 | + | for { | |||
| 115 | + | select { | |||
| 116 | + | case <-ctx.Done(): | |||
| 117 | + | return ctx.Err() | |||
| 118 | + | case <-timer.C(): | |||
| 119 | + | current, statErr := fileStamp(path) | |||
| 120 | + | if statErr != nil { | |||
| 121 | + | if statErr.Error() != lastErrMsg { | |||
| 122 | + | lastErrMsg = statErr.Error() | |||
| 123 | + | onChange(domain.Defaults(), []domain.Warning{{Msg: lastErrMsg}}) | |||
| 124 | + | } | |||
| 125 | + | } else if current != last || lastErrMsg != "" { | |||
| 126 | + | lastErrMsg = "" | |||
| 127 | + | last = current | |||
| 128 | + | cfg, warnings, err := Load(path) | |||
| 129 | + | if err != nil { | |||
| 130 | + | warnings = append(warnings, domain.Warning{Msg: err.Error()}) | |||
| 131 | + | } | |||
| 132 | + | onChange(cfg, warnings) | |||
| 133 | + | } | |||
| 134 | + | timer.Reset(pollInterval) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | type stamp struct { | |||
| 140 | + | modTime time.Time | |||
| 141 | + | size int64 | |||
| 142 | + | exists bool | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | func fileStamp(path string) (stamp, error) { | |||
| 146 | + | st, err := os.Stat(path) | |||
| 147 | + | if errors.Is(err, os.ErrNotExist) { | |||
| 148 | + | return stamp{}, nil | |||
| 149 | + | } | |||
| 150 | + | if err != nil { | |||
| 151 | + | return stamp{}, err | |||
| 152 | + | } | |||
| 153 | + | return stamp{modTime: st.ModTime(), size: st.Size(), exists: true}, nil | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func updateBindingEntry(entries []domain.ConfigEntry, key, value string) { | |||
| 157 | + | for i := range entries { | |||
| 158 | + | if entries[i].Key == key { | |||
| 159 | + | entries[i].Value = value | |||
| 160 | + | return | |||
| 161 | + | } | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func parseTheme(value string) (domain.ThemeMode, bool) { | |||
| 166 | + | switch strings.ToLower(strings.TrimSpace(value)) { | |||
| 167 | + | case "auto", "": | |||
| 168 | + | return domain.ThemeAuto, true | |||
| 169 | + | case "dark": | |||
| 170 | + | return domain.ThemeDark, true | |||
| 171 | + | case "light": | |||
| 172 | + | return domain.ThemeLight, true | |||
| 173 | + | default: | |||
| 174 | + | return domain.ThemeAuto, false | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func stripInlineComment(line string) string { | |||
| 179 | + | for i, r := range line { | |||
| 180 | + | if r == '#' && (i == 0 || isSpace(rune(line[i-1]))) { | |||
| 181 | + | return strings.TrimSpace(line[:i]) | |||
| 182 | + | } | |||
| 183 | + | } | |||
| 184 | + | return line | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | func isSpace(r rune) bool { | |||
| 188 | + | return r == ' ' || r == '\t' || r == '\r' || r == '\n' | |||
| 189 | + | } | |||
A internal/adapters/config/config_test.go +332 -0
| @@ -0,0 +1,332 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "reflect" | |
| 9 | + | "strings" | |
| 10 | + | "sync" | |
| 11 | + | "testing" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/vev/internal/domain" | |
| 15 | + | "github.com/bnema/vev/internal/ports" | |
| 16 | + | "github.com/stretchr/testify/require" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | func TestParse(t *testing.T) { | |
| 20 | + | t.Parallel() | |
| 21 | + | ||
| 22 | + | tests := []struct { | |
| 23 | + | name string | |
| 24 | + | input string | |
| 25 | + | want domain.Config | |
| 26 | + | wantWarnings []domain.Warning | |
| 27 | + | }{ | |
| 28 | + | { | |
| 29 | + | name: "comments and inline comments", | |
| 30 | + | input: "# full line\nnew-tab = alt+t # open tab\ndetach = alt+#\n", | |
| 31 | + | want: domain.Config{ | |
| 32 | + | Theme: domain.ThemeAuto, | |
| 33 | + | Bindings: map[string]string{ | |
| 34 | + | "new-tab": "alt+t", | |
| 35 | + | "detach": "alt+#", | |
| 36 | + | }, | |
| 37 | + | BindingEntries: []domain.ConfigEntry{ | |
| 38 | + | {Key: "new-tab", Value: "alt+t"}, | |
| 39 | + | {Key: "detach", Value: "alt+#"}, | |
| 40 | + | }, | |
| 41 | + | Codes: map[string]string{}, | |
| 42 | + | }, | |
| 43 | + | }, | |
| 44 | + | { | |
| 45 | + | name: "crlf and code prefix", | |
| 46 | + | input: "theme = light\r\ncode.detach = dt\r\n", | |
| 47 | + | want: domain.Config{ | |
| 48 | + | Theme: domain.ThemeLight, | |
| 49 | + | Bindings: map[string]string{}, | |
| 50 | + | BindingEntries: []domain.ConfigEntry{}, | |
| 51 | + | Codes: map[string]string{"detach": "dt"}, | |
| 52 | + | }, | |
| 53 | + | }, | |
| 54 | + | { | |
| 55 | + | name: "invalid theme warns and keeps default", | |
| 56 | + | input: "theme = blue\n", | |
| 57 | + | want: domain.Config{ | |
| 58 | + | Theme: domain.ThemeAuto, | |
| 59 | + | Bindings: map[string]string{}, | |
| 60 | + | BindingEntries: []domain.ConfigEntry{}, | |
| 61 | + | Codes: map[string]string{}, | |
| 62 | + | }, | |
| 63 | + | wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid theme \"blue\""}}, | |
| 64 | + | }, | |
| 65 | + | { | |
| 66 | + | name: "unknown binding key stored", | |
| 67 | + | input: "unknown.action = alt+x\n", | |
| 68 | + | want: domain.Config{ | |
| 69 | + | Theme: domain.ThemeAuto, | |
| 70 | + | Bindings: map[string]string{"unknown.action": "alt+x"}, | |
| 71 | + | BindingEntries: []domain.ConfigEntry{{Key: "unknown.action", Value: "alt+x"}}, | |
| 72 | + | Codes: map[string]string{}, | |
| 73 | + | }, | |
| 74 | + | }, | |
| 75 | + | { | |
| 76 | + | name: "duplicates warn and last wins", | |
| 77 | + | input: "new-tab = alt+t\nnew-tab = alt+n\ncode.detach = DT\ncode.detach = DX\n", | |
| 78 | + | want: domain.Config{ | |
| 79 | + | Theme: domain.ThemeAuto, | |
| 80 | + | Bindings: map[string]string{"new-tab": "alt+n"}, | |
| 81 | + | BindingEntries: []domain.ConfigEntry{{Key: "new-tab", Value: "alt+n"}}, | |
| 82 | + | Codes: map[string]string{"detach": "DX"}, | |
| 83 | + | }, | |
| 84 | + | wantWarnings: []domain.Warning{ | |
| 85 | + | {Line: 2, Msg: "duplicate key \"new-tab\""}, | |
| 86 | + | {Line: 4, Msg: "duplicate key \"code.detach\""}, | |
| 87 | + | }, | |
| 88 | + | }, | |
| 89 | + | { | |
| 90 | + | name: "invalid lines report line numbers", | |
| 91 | + | input: "\nno equals\n = value\ntheme = dark\n", | |
| 92 | + | want: domain.Config{ | |
| 93 | + | Theme: domain.ThemeDark, | |
| 94 | + | Bindings: map[string]string{}, | |
| 95 | + | BindingEntries: []domain.ConfigEntry{}, | |
| 96 | + | Codes: map[string]string{}, | |
| 97 | + | }, | |
| 98 | + | wantWarnings: []domain.Warning{ | |
| 99 | + | {Line: 2, Msg: "missing '='"}, | |
| 100 | + | {Line: 3, Msg: "missing key"}, | |
| 101 | + | }, | |
| 102 | + | }, | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | for _, tt := range tests { | |
| 106 | + | t.Run(tt.name, func(t *testing.T) { | |
| 107 | + | t.Parallel() | |
| 108 | + | ||
| 109 | + | got, warnings, err := Parse(strings.NewReader(tt.input)) | |
| 110 | + | if err != nil { | |
| 111 | + | t.Fatalf("Parse() error = %v", err) | |
| 112 | + | } | |
| 113 | + | if !reflect.DeepEqual(got, tt.want) { | |
| 114 | + | t.Fatalf("Parse() config = %#v, want %#v", got, tt.want) | |
| 115 | + | } | |
| 116 | + | if !reflect.DeepEqual(warnings, tt.wantWarnings) { | |
| 117 | + | t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings) | |
| 118 | + | } | |
| 119 | + | }) | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func TestParsePreservesBindingFileOrder(t *testing.T) { | |
| 124 | + | t.Parallel() | |
| 125 | + | ||
| 126 | + | cfg, warnings, err := Parse(strings.NewReader("open-palette = alt+p\nfocus-pane-left = alt+p\n")) | |
| 127 | + | if err != nil { | |
| 128 | + | t.Fatalf("Parse() error = %v", err) | |
| 129 | + | } | |
| 130 | + | if len(warnings) != 0 { | |
| 131 | + | t.Fatalf("Parse() warnings = %#v, want none", warnings) | |
| 132 | + | } | |
| 133 | + | want := []domain.ConfigEntry{ | |
| 134 | + | {Key: "open-palette", Value: "alt+p"}, | |
| 135 | + | {Key: "focus-pane-left", Value: "alt+p"}, | |
| 136 | + | } | |
| 137 | + | if !reflect.DeepEqual(cfg.BindingEntries, want) { | |
| 138 | + | t.Fatalf("Parse() BindingEntries = %#v, want %#v", cfg.BindingEntries, want) | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func TestLoadMissingFileReturnsDefaults(t *testing.T) { | |
| 143 | + | t.Parallel() | |
| 144 | + | ||
| 145 | + | cfg, warnings, err := Load(filepath.Join(t.TempDir(), "missing")) | |
| 146 | + | if err != nil { | |
| 147 | + | t.Fatalf("Load() error = %v", err) | |
| 148 | + | } | |
| 149 | + | if len(warnings) != 0 { | |
| 150 | + | t.Fatalf("Load() warnings = %#v, want none", warnings) | |
| 151 | + | } | |
| 152 | + | if !reflect.DeepEqual(cfg, domain.Defaults()) { | |
| 153 | + | t.Fatalf("Load() config = %#v, want defaults", cfg) | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func TestWatchReloadsChangedFileWithFakeClock(t *testing.T) { | |
| 158 | + | t.Parallel() | |
| 159 | + | ||
| 160 | + | path := filepath.Join(t.TempDir(), "config") | |
| 161 | + | if err := os.WriteFile(path, []byte("theme = dark\n"), 0o600); err != nil { | |
| 162 | + | t.Fatal(err) | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | clk := newFakeClock(time.Unix(0, 0)) | |
| 166 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 167 | + | defer cancel() | |
| 168 | + | ||
| 169 | + | type change struct { | |
| 170 | + | cfg domain.Config | |
| 171 | + | warnings []domain.Warning | |
| 172 | + | } | |
| 173 | + | changes := make(chan change, 2) | |
| 174 | + | done := make(chan error, 1) | |
| 175 | + | go func() { | |
| 176 | + | done <- Watch(ctx, clk, path, func(cfg domain.Config, warnings []domain.Warning) { | |
| 177 | + | changes <- change{cfg: cfg, warnings: warnings} | |
| 178 | + | }) | |
| 179 | + | }() | |
| 180 | + | ||
| 181 | + | clk.waitForTimers(t, 1) | |
| 182 | + | if err := os.WriteFile(path, []byte("theme = light\nbad line\n"), 0o600); err != nil { | |
| 183 | + | t.Fatal(err) | |
| 184 | + | } | |
| 185 | + | clk.advance(2 * time.Second) | |
| 186 | + | ||
| 187 | + | var got change | |
| 188 | + | select { | |
| 189 | + | case got = <-changes: | |
| 190 | + | case <-time.After(2 * time.Second): | |
| 191 | + | t.Fatal("timed out waiting for config reload") | |
| 192 | + | } | |
| 193 | + | if got.cfg.Theme != domain.ThemeLight { | |
| 194 | + | t.Fatalf("Watch() theme = %v, want %v", got.cfg.Theme, domain.ThemeLight) | |
| 195 | + | } | |
| 196 | + | if !reflect.DeepEqual(got.warnings, []domain.Warning{{Line: 2, Msg: "missing '='"}}) { | |
| 197 | + | t.Fatalf("Watch() warnings = %#v", got.warnings) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | cancel() | |
| 201 | + | if err := <-done; !errors.Is(err, context.Canceled) { | |
| 202 | + | t.Fatalf("Watch() error = %v, want context.Canceled", err) | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func TestWatchDoesNotRepeatPersistentStatError(t *testing.T) { | |
| 207 | + | t.Parallel() | |
| 208 | + | ||
| 209 | + | path := filepath.Join(t.TempDir(), strings.Repeat("x", 5000)) | |
| 210 | + | clk := newFakeClock(time.Unix(0, 0)) | |
| 211 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 212 | + | defer cancel() | |
| 213 | + | ||
| 214 | + | changes := make(chan []domain.Warning, 4) | |
| 215 | + | done := make(chan error, 1) | |
| 216 | + | go func() { | |
| 217 | + | done <- Watch(ctx, clk, path, func(_ domain.Config, warnings []domain.Warning) { | |
| 218 | + | changes <- warnings | |
| 219 | + | }) | |
| 220 | + | }() | |
| 221 | + | ||
| 222 | + | first := <-changes | |
| 223 | + | require.Len(t, first, 1) | |
| 224 | + | clk.waitForTimers(t, 1) | |
| 225 | + | clk.advance(2 * time.Second) | |
| 226 | + | clk.advance(2 * time.Second) | |
| 227 | + | ||
| 228 | + | select { | |
| 229 | + | case extra := <-changes: | |
| 230 | + | t.Fatalf("Watch() repeated persistent stat error: %#v", extra) | |
| 231 | + | case <-time.After(100 * time.Millisecond): | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | cancel() | |
| 235 | + | if err := <-done; !errors.Is(err, context.Canceled) { | |
| 236 | + | t.Fatalf("Watch() error = %v, want context.Canceled", err) | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | var _ ports.Clock = (*fakeClock)(nil) | |
| 241 | + | var _ ports.Timer = (*fakeTimer)(nil) | |
| 242 | + | ||
| 243 | + | type fakeClock struct { | |
| 244 | + | mu sync.Mutex | |
| 245 | + | now time.Time | |
| 246 | + | timers []*fakeTimer | |
| 247 | + | notify chan struct{} | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | func newFakeClock(now time.Time) *fakeClock { | |
| 251 | + | return &fakeClock{now: now, notify: make(chan struct{}, 16)} | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func (c *fakeClock) Now() time.Time { | |
| 255 | + | c.mu.Lock() | |
| 256 | + | defer c.mu.Unlock() | |
| 257 | + | return c.now | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | func (c *fakeClock) NewTimer(d time.Duration) ports.Timer { | |
| 261 | + | c.mu.Lock() | |
| 262 | + | defer c.mu.Unlock() | |
| 263 | + | ||
| 264 | + | t := &fakeTimer{clock: c, ch: make(chan time.Time, 1), next: c.now.Add(d), active: true} | |
| 265 | + | c.timers = append(c.timers, t) | |
| 266 | + | c.notify <- struct{}{} | |
| 267 | + | return t | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | func (c *fakeClock) waitForTimers(t *testing.T, n int) { | |
| 271 | + | t.Helper() | |
| 272 | + | deadline := time.After(2 * time.Second) | |
| 273 | + | for c.timerCount() < n { | |
| 274 | + | select { | |
| 275 | + | case <-c.notify: | |
| 276 | + | case <-deadline: | |
| 277 | + | t.Fatalf("timed out waiting for %d timers, got %d", n, c.timerCount()) | |
| 278 | + | } | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | func (c *fakeClock) timerCount() int { | |
| 283 | + | c.mu.Lock() | |
| 284 | + | defer c.mu.Unlock() | |
| 285 | + | return len(c.timers) | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | func (c *fakeClock) advance(d time.Duration) { | |
| 289 | + | c.mu.Lock() | |
| 290 | + | defer c.mu.Unlock() | |
| 291 | + | ||
| 292 | + | c.now = c.now.Add(d) | |
| 293 | + | for _, timer := range c.timers { | |
| 294 | + | timer.mu.Lock() | |
| 295 | + | if timer.active && !timer.next.After(c.now) { | |
| 296 | + | timer.ch <- c.now | |
| 297 | + | timer.active = false | |
| 298 | + | } | |
| 299 | + | timer.mu.Unlock() | |
| 300 | + | } | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | type fakeTimer struct { | |
| 304 | + | clock *fakeClock | |
| 305 | + | mu sync.Mutex | |
| 306 | + | ch chan time.Time | |
| 307 | + | next time.Time | |
| 308 | + | active bool | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | func (t *fakeTimer) C() <-chan time.Time { return t.ch } | |
| 312 | + | ||
| 313 | + | func (t *fakeTimer) Reset(d time.Duration) bool { | |
| 314 | + | t.clock.mu.Lock() | |
| 315 | + | defer t.clock.mu.Unlock() | |
| 316 | + | t.mu.Lock() | |
| 317 | + | defer t.mu.Unlock() | |
| 318 | + | ||
| 319 | + | wasActive := t.active | |
| 320 | + | t.active = true | |
| 321 | + | t.next = t.clock.now.Add(d) | |
| 322 | + | return wasActive | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | func (t *fakeTimer) Stop() bool { | |
| 326 | + | t.mu.Lock() | |
| 327 | + | defer t.mu.Unlock() | |
| 328 | + | ||
| 329 | + | wasActive := t.active | |
| 330 | + | t.active = false | |
| 331 | + | return wasActive | |
| 332 | + | } | |
| @@ -0,0 +1,332 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "reflect" | |||
| 9 | + | "strings" | |||
| 10 | + | "sync" | |||
| 11 | + | "testing" | |||
| 12 | + | "time" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/vev/internal/domain" | |||
| 15 | + | "github.com/bnema/vev/internal/ports" | |||
| 16 | + | "github.com/stretchr/testify/require" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | func TestParse(t *testing.T) { | |||
| 20 | + | t.Parallel() | |||
| 21 | + | ||||
| 22 | + | tests := []struct { | |||
| 23 | + | name string | |||
| 24 | + | input string | |||
| 25 | + | want domain.Config | |||
| 26 | + | wantWarnings []domain.Warning | |||
| 27 | + | }{ | |||
| 28 | + | { | |||
| 29 | + | name: "comments and inline comments", | |||
| 30 | + | input: "# full line\nnew-tab = alt+t # open tab\ndetach = alt+#\n", | |||
| 31 | + | want: domain.Config{ | |||
| 32 | + | Theme: domain.ThemeAuto, | |||
| 33 | + | Bindings: map[string]string{ | |||
| 34 | + | "new-tab": "alt+t", | |||
| 35 | + | "detach": "alt+#", | |||
| 36 | + | }, | |||
| 37 | + | BindingEntries: []domain.ConfigEntry{ | |||
| 38 | + | {Key: "new-tab", Value: "alt+t"}, | |||
| 39 | + | {Key: "detach", Value: "alt+#"}, | |||
| 40 | + | }, | |||
| 41 | + | Codes: map[string]string{}, | |||
| 42 | + | }, | |||
| 43 | + | }, | |||
| 44 | + | { | |||
| 45 | + | name: "crlf and code prefix", | |||
| 46 | + | input: "theme = light\r\ncode.detach = dt\r\n", | |||
| 47 | + | want: domain.Config{ | |||
| 48 | + | Theme: domain.ThemeLight, | |||
| 49 | + | Bindings: map[string]string{}, | |||
| 50 | + | BindingEntries: []domain.ConfigEntry{}, | |||
| 51 | + | Codes: map[string]string{"detach": "dt"}, | |||
| 52 | + | }, | |||
| 53 | + | }, | |||
| 54 | + | { | |||
| 55 | + | name: "invalid theme warns and keeps default", | |||
| 56 | + | input: "theme = blue\n", | |||
| 57 | + | want: domain.Config{ | |||
| 58 | + | Theme: domain.ThemeAuto, | |||
| 59 | + | Bindings: map[string]string{}, | |||
| 60 | + | BindingEntries: []domain.ConfigEntry{}, | |||
| 61 | + | Codes: map[string]string{}, | |||
| 62 | + | }, | |||
| 63 | + | wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid theme \"blue\""}}, | |||
| 64 | + | }, | |||
| 65 | + | { | |||
| 66 | + | name: "unknown binding key stored", | |||
| 67 | + | input: "unknown.action = alt+x\n", | |||
| 68 | + | want: domain.Config{ | |||
| 69 | + | Theme: domain.ThemeAuto, | |||
| 70 | + | Bindings: map[string]string{"unknown.action": "alt+x"}, | |||
| 71 | + | BindingEntries: []domain.ConfigEntry{{Key: "unknown.action", Value: "alt+x"}}, | |||
| 72 | + | Codes: map[string]string{}, | |||
| 73 | + | }, | |||
| 74 | + | }, | |||
| 75 | + | { | |||
| 76 | + | name: "duplicates warn and last wins", | |||
| 77 | + | input: "new-tab = alt+t\nnew-tab = alt+n\ncode.detach = DT\ncode.detach = DX\n", | |||
| 78 | + | want: domain.Config{ | |||
| 79 | + | Theme: domain.ThemeAuto, | |||
| 80 | + | Bindings: map[string]string{"new-tab": "alt+n"}, | |||
| 81 | + | BindingEntries: []domain.ConfigEntry{{Key: "new-tab", Value: "alt+n"}}, | |||
| 82 | + | Codes: map[string]string{"detach": "DX"}, | |||
| 83 | + | }, | |||
| 84 | + | wantWarnings: []domain.Warning{ | |||
| 85 | + | {Line: 2, Msg: "duplicate key \"new-tab\""}, | |||
| 86 | + | {Line: 4, Msg: "duplicate key \"code.detach\""}, | |||
| 87 | + | }, | |||
| 88 | + | }, | |||
| 89 | + | { | |||
| 90 | + | name: "invalid lines report line numbers", | |||
| 91 | + | input: "\nno equals\n = value\ntheme = dark\n", | |||
| 92 | + | want: domain.Config{ | |||
| 93 | + | Theme: domain.ThemeDark, | |||
| 94 | + | Bindings: map[string]string{}, | |||
| 95 | + | BindingEntries: []domain.ConfigEntry{}, | |||
| 96 | + | Codes: map[string]string{}, | |||
| 97 | + | }, | |||
| 98 | + | wantWarnings: []domain.Warning{ | |||
| 99 | + | {Line: 2, Msg: "missing '='"}, | |||
| 100 | + | {Line: 3, Msg: "missing key"}, | |||
| 101 | + | }, | |||
| 102 | + | }, | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | for _, tt := range tests { | |||
| 106 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 107 | + | t.Parallel() | |||
| 108 | + | ||||
| 109 | + | got, warnings, err := Parse(strings.NewReader(tt.input)) | |||
| 110 | + | if err != nil { | |||
| 111 | + | t.Fatalf("Parse() error = %v", err) | |||
| 112 | + | } | |||
| 113 | + | if !reflect.DeepEqual(got, tt.want) { | |||
| 114 | + | t.Fatalf("Parse() config = %#v, want %#v", got, tt.want) | |||
| 115 | + | } | |||
| 116 | + | if !reflect.DeepEqual(warnings, tt.wantWarnings) { | |||
| 117 | + | t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings) | |||
| 118 | + | } | |||
| 119 | + | }) | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func TestParsePreservesBindingFileOrder(t *testing.T) { | |||
| 124 | + | t.Parallel() | |||
| 125 | + | ||||
| 126 | + | cfg, warnings, err := Parse(strings.NewReader("open-palette = alt+p\nfocus-pane-left = alt+p\n")) | |||
| 127 | + | if err != nil { | |||
| 128 | + | t.Fatalf("Parse() error = %v", err) | |||
| 129 | + | } | |||
| 130 | + | if len(warnings) != 0 { | |||
| 131 | + | t.Fatalf("Parse() warnings = %#v, want none", warnings) | |||
| 132 | + | } | |||
| 133 | + | want := []domain.ConfigEntry{ | |||
| 134 | + | {Key: "open-palette", Value: "alt+p"}, | |||
| 135 | + | {Key: "focus-pane-left", Value: "alt+p"}, | |||
| 136 | + | } | |||
| 137 | + | if !reflect.DeepEqual(cfg.BindingEntries, want) { | |||
| 138 | + | t.Fatalf("Parse() BindingEntries = %#v, want %#v", cfg.BindingEntries, want) | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func TestLoadMissingFileReturnsDefaults(t *testing.T) { | |||
| 143 | + | t.Parallel() | |||
| 144 | + | ||||
| 145 | + | cfg, warnings, err := Load(filepath.Join(t.TempDir(), "missing")) | |||
| 146 | + | if err != nil { | |||
| 147 | + | t.Fatalf("Load() error = %v", err) | |||
| 148 | + | } | |||
| 149 | + | if len(warnings) != 0 { | |||
| 150 | + | t.Fatalf("Load() warnings = %#v, want none", warnings) | |||
| 151 | + | } | |||
| 152 | + | if !reflect.DeepEqual(cfg, domain.Defaults()) { | |||
| 153 | + | t.Fatalf("Load() config = %#v, want defaults", cfg) | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func TestWatchReloadsChangedFileWithFakeClock(t *testing.T) { | |||
| 158 | + | t.Parallel() | |||
| 159 | + | ||||
| 160 | + | path := filepath.Join(t.TempDir(), "config") | |||
| 161 | + | if err := os.WriteFile(path, []byte("theme = dark\n"), 0o600); err != nil { | |||
| 162 | + | t.Fatal(err) | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | clk := newFakeClock(time.Unix(0, 0)) | |||
| 166 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 167 | + | defer cancel() | |||
| 168 | + | ||||
| 169 | + | type change struct { | |||
| 170 | + | cfg domain.Config | |||
| 171 | + | warnings []domain.Warning | |||
| 172 | + | } | |||
| 173 | + | changes := make(chan change, 2) | |||
| 174 | + | done := make(chan error, 1) | |||
| 175 | + | go func() { | |||
| 176 | + | done <- Watch(ctx, clk, path, func(cfg domain.Config, warnings []domain.Warning) { | |||
| 177 | + | changes <- change{cfg: cfg, warnings: warnings} | |||
| 178 | + | }) | |||
| 179 | + | }() | |||
| 180 | + | ||||
| 181 | + | clk.waitForTimers(t, 1) | |||
| 182 | + | if err := os.WriteFile(path, []byte("theme = light\nbad line\n"), 0o600); err != nil { | |||
| 183 | + | t.Fatal(err) | |||
| 184 | + | } | |||
| 185 | + | clk.advance(2 * time.Second) | |||
| 186 | + | ||||
| 187 | + | var got change | |||
| 188 | + | select { | |||
| 189 | + | case got = <-changes: | |||
| 190 | + | case <-time.After(2 * time.Second): | |||
| 191 | + | t.Fatal("timed out waiting for config reload") | |||
| 192 | + | } | |||
| 193 | + | if got.cfg.Theme != domain.ThemeLight { | |||
| 194 | + | t.Fatalf("Watch() theme = %v, want %v", got.cfg.Theme, domain.ThemeLight) | |||
| 195 | + | } | |||
| 196 | + | if !reflect.DeepEqual(got.warnings, []domain.Warning{{Line: 2, Msg: "missing '='"}}) { | |||
| 197 | + | t.Fatalf("Watch() warnings = %#v", got.warnings) | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | cancel() | |||
| 201 | + | if err := <-done; !errors.Is(err, context.Canceled) { | |||
| 202 | + | t.Fatalf("Watch() error = %v, want context.Canceled", err) | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | func TestWatchDoesNotRepeatPersistentStatError(t *testing.T) { | |||
| 207 | + | t.Parallel() | |||
| 208 | + | ||||
| 209 | + | path := filepath.Join(t.TempDir(), strings.Repeat("x", 5000)) | |||
| 210 | + | clk := newFakeClock(time.Unix(0, 0)) | |||
| 211 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 212 | + | defer cancel() | |||
| 213 | + | ||||
| 214 | + | changes := make(chan []domain.Warning, 4) | |||
| 215 | + | done := make(chan error, 1) | |||
| 216 | + | go func() { | |||
| 217 | + | done <- Watch(ctx, clk, path, func(_ domain.Config, warnings []domain.Warning) { | |||
| 218 | + | changes <- warnings | |||
| 219 | + | }) | |||
| 220 | + | }() | |||
| 221 | + | ||||
| 222 | + | first := <-changes | |||
| 223 | + | require.Len(t, first, 1) | |||
| 224 | + | clk.waitForTimers(t, 1) | |||
| 225 | + | clk.advance(2 * time.Second) | |||
| 226 | + | clk.advance(2 * time.Second) | |||
| 227 | + | ||||
| 228 | + | select { | |||
| 229 | + | case extra := <-changes: | |||
| 230 | + | t.Fatalf("Watch() repeated persistent stat error: %#v", extra) | |||
| 231 | + | case <-time.After(100 * time.Millisecond): | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | cancel() | |||
| 235 | + | if err := <-done; !errors.Is(err, context.Canceled) { | |||
| 236 | + | t.Fatalf("Watch() error = %v, want context.Canceled", err) | |||
| 237 | + | } | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | var _ ports.Clock = (*fakeClock)(nil) | |||
| 241 | + | var _ ports.Timer = (*fakeTimer)(nil) | |||
| 242 | + | ||||
| 243 | + | type fakeClock struct { | |||
| 244 | + | mu sync.Mutex | |||
| 245 | + | now time.Time | |||
| 246 | + | timers []*fakeTimer | |||
| 247 | + | notify chan struct{} | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | func newFakeClock(now time.Time) *fakeClock { | |||
| 251 | + | return &fakeClock{now: now, notify: make(chan struct{}, 16)} | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | func (c *fakeClock) Now() time.Time { | |||
| 255 | + | c.mu.Lock() | |||
| 256 | + | defer c.mu.Unlock() | |||
| 257 | + | return c.now | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | func (c *fakeClock) NewTimer(d time.Duration) ports.Timer { | |||
| 261 | + | c.mu.Lock() | |||
| 262 | + | defer c.mu.Unlock() | |||
| 263 | + | ||||
| 264 | + | t := &fakeTimer{clock: c, ch: make(chan time.Time, 1), next: c.now.Add(d), active: true} | |||
| 265 | + | c.timers = append(c.timers, t) | |||
| 266 | + | c.notify <- struct{}{} | |||
| 267 | + | return t | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | func (c *fakeClock) waitForTimers(t *testing.T, n int) { | |||
| 271 | + | t.Helper() | |||
| 272 | + | deadline := time.After(2 * time.Second) | |||
| 273 | + | for c.timerCount() < n { | |||
| 274 | + | select { | |||
| 275 | + | case <-c.notify: | |||
| 276 | + | case <-deadline: | |||
| 277 | + | t.Fatalf("timed out waiting for %d timers, got %d", n, c.timerCount()) | |||
| 278 | + | } | |||
| 279 | + | } | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | func (c *fakeClock) timerCount() int { | |||
| 283 | + | c.mu.Lock() | |||
| 284 | + | defer c.mu.Unlock() | |||
| 285 | + | return len(c.timers) | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | func (c *fakeClock) advance(d time.Duration) { | |||
| 289 | + | c.mu.Lock() | |||
| 290 | + | defer c.mu.Unlock() | |||
| 291 | + | ||||
| 292 | + | c.now = c.now.Add(d) | |||
| 293 | + | for _, timer := range c.timers { | |||
| 294 | + | timer.mu.Lock() | |||
| 295 | + | if timer.active && !timer.next.After(c.now) { | |||
| 296 | + | timer.ch <- c.now | |||
| 297 | + | timer.active = false | |||
| 298 | + | } | |||
| 299 | + | timer.mu.Unlock() | |||
| 300 | + | } | |||
| 301 | + | } | |||
| 302 | + | ||||
| 303 | + | type fakeTimer struct { | |||
| 304 | + | clock *fakeClock | |||
| 305 | + | mu sync.Mutex | |||
| 306 | + | ch chan time.Time | |||
| 307 | + | next time.Time | |||
| 308 | + | active bool | |||
| 309 | + | } | |||
| 310 | + | ||||
| 311 | + | func (t *fakeTimer) C() <-chan time.Time { return t.ch } | |||
| 312 | + | ||||
| 313 | + | func (t *fakeTimer) Reset(d time.Duration) bool { | |||
| 314 | + | t.clock.mu.Lock() | |||
| 315 | + | defer t.clock.mu.Unlock() | |||
| 316 | + | t.mu.Lock() | |||
| 317 | + | defer t.mu.Unlock() | |||
| 318 | + | ||||
| 319 | + | wasActive := t.active | |||
| 320 | + | t.active = true | |||
| 321 | + | t.next = t.clock.now.Add(d) | |||
| 322 | + | return wasActive | |||
| 323 | + | } | |||
| 324 | + | ||||
| 325 | + | func (t *fakeTimer) Stop() bool { | |||
| 326 | + | t.mu.Lock() | |||
| 327 | + | defer t.mu.Unlock() | |||
| 328 | + | ||||
| 329 | + | wasActive := t.active | |||
| 330 | + | t.active = false | |||
| 331 | + | return wasActive | |||
| 332 | + | } | |||
M internal/adapters/term/pty_integration_test.go +1 -1
| @@ -102,7 +102,7 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) { | |||
| 102 | 102 | ||
| 103 | 103 | select { | |
| 104 | 104 | case got := <-captured: | |
| 105 | - | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + altScreenExit | |
| 105 | + | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit | |
| 106 | 106 | if string(got) != want { | |
| 107 | 107 | t.Fatalf("captured escapes = %q, want %q", got, want) | |
| 108 | 108 | } | |
| @@ -102,7 +102,7 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) { | |||||
| 102 | 102 | ||||
| 103 | select { | 103 | select { | ||
| 104 | case got := <-captured: | 104 | case got := <-captured: | ||
| 105 | - | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + altScreenExit | 105 | + | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit |
| 106 | if string(got) != want { | 106 | if string(got) != want { | ||
| 107 | t.Fatalf("captured escapes = %q, want %q", got, want) | 107 | t.Fatalf("captured escapes = %q, want %q", got, want) | ||
| 108 | } | 108 | } | ||
M internal/adapters/term/terminal.go +17 -2
| @@ -30,6 +30,8 @@ const ( | |||
| 30 | 30 | mouseDisable = "\x1b[?1002l\x1b[?1006l" | |
| 31 | 31 | bracketedPasteEnable = "\x1b[?2004h" | |
| 32 | 32 | bracketedPasteDisable = "\x1b[?2004l" | |
| 33 | + | colorSchemeEnable = "\x1b[?2031h" | |
| 34 | + | colorSchemeDisable = "\x1b[?2031l" | |
| 33 | 35 | oscColorQuery = "\x1b]10;?\x07\x1b]11;?\x07" | |
| 34 | 36 | ) | |
| 35 | 37 | ||
| @@ -107,7 +109,7 @@ func (t *Terminal) EnterRaw() (func() error, error) { | |||
| 107 | 109 | } | |
| 108 | 110 | t.orig = old | |
| 109 | 111 | ||
| 110 | - | if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + oscColorQuery); err != nil { | |
| 112 | + | if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery); err != nil { | |
| 111 | 113 | _ = t.restoreRawLocked() | |
| 112 | 114 | return nil, fmt.Errorf("term: enter alt screen: %w", err) | |
| 113 | 115 | } | |
| @@ -141,7 +143,7 @@ func (t *Terminal) restore() error { | |||
| 141 | 143 | t.mu.Lock() | |
| 142 | 144 | defer t.mu.Unlock() | |
| 143 | 145 | ||
| 144 | - | _, werr := t.bw.WriteString(cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + altScreenExit) | |
| 146 | + | _, werr := t.bw.WriteString(cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit) | |
| 145 | 147 | ferr := t.bw.Flush() | |
| 146 | 148 | ||
| 147 | 149 | rerr := t.restoreRawLocked() | |
| @@ -234,6 +236,19 @@ func (t *Terminal) stopResizeLocked() { | |||
| 234 | 236 | t.resizeWG.Wait() | |
| 235 | 237 | } | |
| 236 | 238 | ||
| 239 | + | // QueryColors re-emits OSC 10/11 queries for default foreground/background. | |
| 240 | + | func (t *Terminal) QueryColors() error { | |
| 241 | + | t.mu.Lock() | |
| 242 | + | defer t.mu.Unlock() | |
| 243 | + | if _, err := t.bw.WriteString(oscColorQuery); err != nil { | |
| 244 | + | return fmt.Errorf("term: query colors: %w", err) | |
| 245 | + | } | |
| 246 | + | if err := t.bw.Flush(); err != nil { | |
| 247 | + | return fmt.Errorf("term: query colors: %w", err) | |
| 248 | + | } | |
| 249 | + | return nil | |
| 250 | + | } | |
| 251 | + | ||
| 237 | 252 | // In returns the reader for the controlling terminal's input. | |
| 238 | 253 | func (t *Terminal) In() io.Reader { | |
| 239 | 254 | return t.in | |
| @@ -30,6 +30,8 @@ const ( | |||||
| 30 | mouseDisable = "\x1b[?1002l\x1b[?1006l" | 30 | mouseDisable = "\x1b[?1002l\x1b[?1006l" | ||
| 31 | bracketedPasteEnable = "\x1b[?2004h" | 31 | bracketedPasteEnable = "\x1b[?2004h" | ||
| 32 | bracketedPasteDisable = "\x1b[?2004l" | 32 | bracketedPasteDisable = "\x1b[?2004l" | ||
| 33 | + | colorSchemeEnable = "\x1b[?2031h" | |||
| 34 | + | colorSchemeDisable = "\x1b[?2031l" | |||
| 33 | oscColorQuery = "\x1b]10;?\x07\x1b]11;?\x07" | 35 | oscColorQuery = "\x1b]10;?\x07\x1b]11;?\x07" | ||
| 34 | ) | 36 | ) | ||
| 35 | 37 | ||||
| @@ -107,7 +109,7 @@ func (t *Terminal) EnterRaw() (func() error, error) { | |||||
| 107 | } | 109 | } | ||
| 108 | t.orig = old | 110 | t.orig = old | ||
| 109 | 111 | ||||
| 110 | - | if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + oscColorQuery); err != nil { | 112 | + | if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery); err != nil { |
| 111 | _ = t.restoreRawLocked() | 113 | _ = t.restoreRawLocked() | ||
| 112 | return nil, fmt.Errorf("term: enter alt screen: %w", err) | 114 | return nil, fmt.Errorf("term: enter alt screen: %w", err) | ||
| 113 | } | 115 | } | ||
| @@ -141,7 +143,7 @@ func (t *Terminal) restore() error { | |||||
| 141 | t.mu.Lock() | 143 | t.mu.Lock() | ||
| 142 | defer t.mu.Unlock() | 144 | defer t.mu.Unlock() | ||
| 143 | 145 | ||||
| 144 | - | _, werr := t.bw.WriteString(cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + altScreenExit) | 146 | + | _, werr := t.bw.WriteString(cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit) |
| 145 | ferr := t.bw.Flush() | 147 | ferr := t.bw.Flush() | ||
| 146 | 148 | ||||
| 147 | rerr := t.restoreRawLocked() | 149 | rerr := t.restoreRawLocked() | ||
| @@ -234,6 +236,19 @@ func (t *Terminal) stopResizeLocked() { | |||||
| 234 | t.resizeWG.Wait() | 236 | t.resizeWG.Wait() | ||
| 235 | } | 237 | } | ||
| 236 | 238 | ||||
| 239 | + | // QueryColors re-emits OSC 10/11 queries for default foreground/background. | |||
| 240 | + | func (t *Terminal) QueryColors() error { | |||
| 241 | + | t.mu.Lock() | |||
| 242 | + | defer t.mu.Unlock() | |||
| 243 | + | if _, err := t.bw.WriteString(oscColorQuery); err != nil { | |||
| 244 | + | return fmt.Errorf("term: query colors: %w", err) | |||
| 245 | + | } | |||
| 246 | + | if err := t.bw.Flush(); err != nil { | |||
| 247 | + | return fmt.Errorf("term: query colors: %w", err) | |||
| 248 | + | } | |||
| 249 | + | return nil | |||
| 250 | + | } | |||
| 251 | + | ||||
| 237 | // In returns the reader for the controlling terminal's input. | 252 | // In returns the reader for the controlling terminal's input. | ||
| 238 | func (t *Terminal) In() io.Reader { | 253 | func (t *Terminal) In() io.Reader { | ||
| 239 | return t.in | 254 | return t.in | ||
M internal/adapters/term/terminal_test.go +24 -2
| @@ -60,7 +60,7 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) { | |||
| 60 | 60 | _ = outW.Close() | |
| 61 | 61 | <-done | |
| 62 | 62 | ||
| 63 | - | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + altScreenExit | |
| 63 | + | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit | |
| 64 | 64 | if got := captured.String(); got != want { | |
| 65 | 65 | t.Fatalf("captured escapes = %q, want %q", got, want) | |
| 66 | 66 | } | |
| @@ -101,12 +101,34 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) { | |||
| 101 | 101 | // Alt-screen/cursor/theme-query escapes must appear exactly once for enter and | |
| 102 | 102 | // cursor/alt-screen exits exactly once, regardless of how many times | |
| 103 | 103 | // EnterRaw/restore were called. | |
| 104 | - | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + altScreenExit | |
| 104 | + | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit | |
| 105 | 105 | if got := captured.String(); got != want { | |
| 106 | 106 | t.Fatalf("captured escapes = %q, want %q", got, want) | |
| 107 | 107 | } | |
| 108 | 108 | } | |
| 109 | 109 | ||
| 110 | + | func TestTerminal_QueryColors(t *testing.T) { | |
| 111 | + | inR, inW, err := os.Pipe() | |
| 112 | + | if err != nil { | |
| 113 | + | t.Fatalf("os.Pipe(in): %v", err) | |
| 114 | + | } | |
| 115 | + | defer func() { _ = inW.Close() }() | |
| 116 | + | defer func() { _ = inR.Close() }() | |
| 117 | + | ||
| 118 | + | outR, outW, captured, done := pipeCapture(t) | |
| 119 | + | defer func() { _ = outR.Close() }() | |
| 120 | + | ||
| 121 | + | tm := NewWithFiles(inR, outW) | |
| 122 | + | if err := tm.QueryColors(); err != nil { | |
| 123 | + | t.Fatalf("QueryColors: %v", err) | |
| 124 | + | } | |
| 125 | + | _ = outW.Close() | |
| 126 | + | <-done | |
| 127 | + | if got := captured.String(); got != oscColorQuery { | |
| 128 | + | t.Fatalf("captured = %q, want %q", got, oscColorQuery) | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | ||
| 110 | 132 | func TestTerminal_InOut(t *testing.T) { | |
| 111 | 133 | inR, inW, err := os.Pipe() | |
| 112 | 134 | if err != nil { | |
| @@ -60,7 +60,7 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) { | |||||
| 60 | _ = outW.Close() | 60 | _ = outW.Close() | ||
| 61 | <-done | 61 | <-done | ||
| 62 | 62 | ||||
| 63 | - | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + altScreenExit | 63 | + | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit |
| 64 | if got := captured.String(); got != want { | 64 | if got := captured.String(); got != want { | ||
| 65 | t.Fatalf("captured escapes = %q, want %q", got, want) | 65 | t.Fatalf("captured escapes = %q, want %q", got, want) | ||
| 66 | } | 66 | } | ||
| @@ -101,12 +101,34 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) { | |||||
| 101 | // Alt-screen/cursor/theme-query escapes must appear exactly once for enter and | 101 | // Alt-screen/cursor/theme-query escapes must appear exactly once for enter and | ||
| 102 | // cursor/alt-screen exits exactly once, regardless of how many times | 102 | // cursor/alt-screen exits exactly once, regardless of how many times | ||
| 103 | // EnterRaw/restore were called. | 103 | // EnterRaw/restore were called. | ||
| 104 | - | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + altScreenExit | 104 | + | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit |
| 105 | if got := captured.String(); got != want { | 105 | if got := captured.String(); got != want { | ||
| 106 | t.Fatalf("captured escapes = %q, want %q", got, want) | 106 | t.Fatalf("captured escapes = %q, want %q", got, want) | ||
| 107 | } | 107 | } | ||
| 108 | } | 108 | } | ||
| 109 | 109 | ||||
| 110 | + | func TestTerminal_QueryColors(t *testing.T) { | |||
| 111 | + | inR, inW, err := os.Pipe() | |||
| 112 | + | if err != nil { | |||
| 113 | + | t.Fatalf("os.Pipe(in): %v", err) | |||
| 114 | + | } | |||
| 115 | + | defer func() { _ = inW.Close() }() | |||
| 116 | + | defer func() { _ = inR.Close() }() | |||
| 117 | + | ||||
| 118 | + | outR, outW, captured, done := pipeCapture(t) | |||
| 119 | + | defer func() { _ = outR.Close() }() | |||
| 120 | + | ||||
| 121 | + | tm := NewWithFiles(inR, outW) | |||
| 122 | + | if err := tm.QueryColors(); err != nil { | |||
| 123 | + | t.Fatalf("QueryColors: %v", err) | |||
| 124 | + | } | |||
| 125 | + | _ = outW.Close() | |||
| 126 | + | <-done | |||
| 127 | + | if got := captured.String(); got != oscColorQuery { | |||
| 128 | + | t.Fatalf("captured = %q, want %q", got, oscColorQuery) | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | ||||
| 110 | func TestTerminal_InOut(t *testing.T) { | 132 | func TestTerminal_InOut(t *testing.T) { | ||
| 111 | inR, inW, err := os.Pipe() | 133 | inR, inW, err := os.Pipe() | ||
| 112 | if err != nil { | 134 | if err != nil { | ||
M internal/app/run.go +31 -1
| @@ -23,6 +23,7 @@ import ( | |||
| 23 | 23 | ||
| 24 | 24 | "github.com/bnema/vev/internal/adapters/clipboard" | |
| 25 | 25 | "github.com/bnema/vev/internal/adapters/clock" | |
| 26 | + | "github.com/bnema/vev/internal/adapters/config" | |
| 26 | 27 | "github.com/bnema/vev/internal/adapters/dgram" | |
| 27 | 28 | "github.com/bnema/vev/internal/adapters/ipc" | |
| 28 | 29 | "github.com/bnema/vev/internal/adapters/pty" | |
| @@ -224,6 +225,16 @@ func configureLogging(component logging.Component, rotateAtRuntime bool) (*slog. | |||
| 224 | 225 | }) | |
| 225 | 226 | } | |
| 226 | 227 | ||
| 228 | + | func logConfigWarnings(log *slog.Logger, warnings []domain.Warning) { | |
| 229 | + | for _, warning := range warnings { | |
| 230 | + | if warning.Line > 0 { | |
| 231 | + | log.Warn("config warning", "line", warning.Line, "msg", warning.Msg) | |
| 232 | + | continue | |
| 233 | + | } | |
| 234 | + | log.Warn("config warning", "msg", warning.Msg) | |
| 235 | + | } | |
| 236 | + | } | |
| 237 | + | ||
| 227 | 238 | // runDaemon runs the daemon in the foreground (the hidden --daemon path, | |
| 228 | 239 | // entered by an auto-spawned child): it sets up logging, binds the socket, | |
| 229 | 240 | // constructs the daemon, and serves until the last session exits or a | |
| @@ -256,6 +267,14 @@ func runDaemon() error { | |||
| 256 | 267 | ||
| 257 | 268 | log.Info("daemon starting", "socket", ln.Addr()) | |
| 258 | 269 | daemonOpts := []daemon.Option(nil) | |
| 270 | + | configPath := platform.ConfigPath() | |
| 271 | + | cfg, warnings, err := config.Load(configPath) | |
| 272 | + | if err != nil { | |
| 273 | + | log.Warn("loading config failed; using defaults", "path", configPath, "err", err) | |
| 274 | + | cfg = domain.Defaults() | |
| 275 | + | } | |
| 276 | + | logConfigWarnings(log, warnings) | |
| 277 | + | daemonOpts = append(daemonOpts, daemon.WithConfig(cfg)) | |
| 259 | 278 | storePath := persist.StorePath(platform.StateDir()) | |
| 260 | 279 | if store, err := kv.Open(storePath); err != nil { | |
| 261 | 280 | log.Warn("opening session store failed; persistence disabled", "path", storePath, "err", err) | |
| @@ -263,7 +282,18 @@ func runDaemon() error { | |||
| 263 | 282 | log.Info("session persistence enabled", "path", storePath) | |
| 264 | 283 | daemonOpts = append(daemonOpts, daemon.WithStore(store)) | |
| 265 | 284 | } | |
| 266 | - | d := daemon.New(pty.NewFactory(), clock.New(), log, daemonOpts...) | |
| 285 | + | clk := clock.New() | |
| 286 | + | d := daemon.New(pty.NewFactory(), clk, log, daemonOpts...) | |
| 287 | + | watchCtx, stopWatch := context.WithCancel(ctx) | |
| 288 | + | defer stopWatch() | |
| 289 | + | go func() { | |
| 290 | + | if err := config.Watch(watchCtx, clk, configPath, func(cfg domain.Config, warnings []domain.Warning) { | |
| 291 | + | logConfigWarnings(log, warnings) | |
| 292 | + | d.ApplyConfig(cfg) | |
| 293 | + | }); err != nil && !errors.Is(err, context.Canceled) { | |
| 294 | + | log.Warn("config watcher stopped", "path", configPath, "err", err) | |
| 295 | + | } | |
| 296 | + | }() | |
| 267 | 297 | if err := d.Serve(ctx, ln); err != nil { | |
| 268 | 298 | log.Error("daemon exited", "err", err) | |
| 269 | 299 | return err | |
| @@ -23,6 +23,7 @@ import ( | |||||
| 23 | 23 | ||||
| 24 | "github.com/bnema/vev/internal/adapters/clipboard" | 24 | "github.com/bnema/vev/internal/adapters/clipboard" | ||
| 25 | "github.com/bnema/vev/internal/adapters/clock" | 25 | "github.com/bnema/vev/internal/adapters/clock" | ||
| 26 | + | "github.com/bnema/vev/internal/adapters/config" | |||
| 26 | "github.com/bnema/vev/internal/adapters/dgram" | 27 | "github.com/bnema/vev/internal/adapters/dgram" | ||
| 27 | "github.com/bnema/vev/internal/adapters/ipc" | 28 | "github.com/bnema/vev/internal/adapters/ipc" | ||
| 28 | "github.com/bnema/vev/internal/adapters/pty" | 29 | "github.com/bnema/vev/internal/adapters/pty" | ||
| @@ -224,6 +225,16 @@ func configureLogging(component logging.Component, rotateAtRuntime bool) (*slog. | |||||
| 224 | }) | 225 | }) | ||
| 225 | } | 226 | } | ||
| 226 | 227 | ||||
| 228 | + | func logConfigWarnings(log *slog.Logger, warnings []domain.Warning) { | |||
| 229 | + | for _, warning := range warnings { | |||
| 230 | + | if warning.Line > 0 { | |||
| 231 | + | log.Warn("config warning", "line", warning.Line, "msg", warning.Msg) | |||
| 232 | + | continue | |||
| 233 | + | } | |||
| 234 | + | log.Warn("config warning", "msg", warning.Msg) | |||
| 235 | + | } | |||
| 236 | + | } | |||
| 237 | + | ||||
| 227 | // runDaemon runs the daemon in the foreground (the hidden --daemon path, | 238 | // runDaemon runs the daemon in the foreground (the hidden --daemon path, | ||
| 228 | // entered by an auto-spawned child): it sets up logging, binds the socket, | 239 | // entered by an auto-spawned child): it sets up logging, binds the socket, | ||
| 229 | // constructs the daemon, and serves until the last session exits or a | 240 | // constructs the daemon, and serves until the last session exits or a | ||
| @@ -256,6 +267,14 @@ func runDaemon() error { | |||||
| 256 | 267 | ||||
| 257 | log.Info("daemon starting", "socket", ln.Addr()) | 268 | log.Info("daemon starting", "socket", ln.Addr()) | ||
| 258 | daemonOpts := []daemon.Option(nil) | 269 | daemonOpts := []daemon.Option(nil) | ||
| 270 | + | configPath := platform.ConfigPath() | |||
| 271 | + | cfg, warnings, err := config.Load(configPath) | |||
| 272 | + | if err != nil { | |||
| 273 | + | log.Warn("loading config failed; using defaults", "path", configPath, "err", err) | |||
| 274 | + | cfg = domain.Defaults() | |||
| 275 | + | } | |||
| 276 | + | logConfigWarnings(log, warnings) | |||
| 277 | + | daemonOpts = append(daemonOpts, daemon.WithConfig(cfg)) | |||
| 259 | storePath := persist.StorePath(platform.StateDir()) | 278 | storePath := persist.StorePath(platform.StateDir()) | ||
| 260 | if store, err := kv.Open(storePath); err != nil { | 279 | if store, err := kv.Open(storePath); err != nil { | ||
| 261 | log.Warn("opening session store failed; persistence disabled", "path", storePath, "err", err) | 280 | log.Warn("opening session store failed; persistence disabled", "path", storePath, "err", err) | ||
| @@ -263,7 +282,18 @@ func runDaemon() error { | |||||
| 263 | log.Info("session persistence enabled", "path", storePath) | 282 | log.Info("session persistence enabled", "path", storePath) | ||
| 264 | daemonOpts = append(daemonOpts, daemon.WithStore(store)) | 283 | daemonOpts = append(daemonOpts, daemon.WithStore(store)) | ||
| 265 | } | 284 | } | ||
| 266 | - | d := daemon.New(pty.NewFactory(), clock.New(), log, daemonOpts...) | 285 | + | clk := clock.New() |
| 286 | + | d := daemon.New(pty.NewFactory(), clk, log, daemonOpts...) | |||
| 287 | + | watchCtx, stopWatch := context.WithCancel(ctx) | |||
| 288 | + | defer stopWatch() | |||
| 289 | + | go func() { | |||
| 290 | + | if err := config.Watch(watchCtx, clk, configPath, func(cfg domain.Config, warnings []domain.Warning) { | |||
| 291 | + | logConfigWarnings(log, warnings) | |||
| 292 | + | d.ApplyConfig(cfg) | |||
| 293 | + | }); err != nil && !errors.Is(err, context.Canceled) { | |||
| 294 | + | log.Warn("config watcher stopped", "path", configPath, "err", err) | |||
| 295 | + | } | |||
| 296 | + | }() | |||
| 267 | if err := d.Serve(ctx, ln); err != nil { | 297 | if err := d.Serve(ctx, ln); err != nil { | ||
| 268 | log.Error("daemon exited", "err", err) | 298 | log.Error("daemon exited", "err", err) | ||
| 269 | return err | 299 | return err | ||
A internal/domain/config.go +46 -0
| @@ -0,0 +1,46 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | // ThemeMode selects how vev derives its color scheme. | |
| 4 | + | type ThemeMode int | |
| 5 | + | ||
| 6 | + | const ( | |
| 7 | + | // ThemeAuto follows the attached client's reported color scheme. | |
| 8 | + | ThemeAuto ThemeMode = iota | |
| 9 | + | // ThemeDark forces vev's built-in dark palette. | |
| 10 | + | ThemeDark | |
| 11 | + | // ThemeLight forces vev's built-in light palette. | |
| 12 | + | ThemeLight | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // ConfigEntry preserves one parsed config key/value in file order. | |
| 16 | + | type ConfigEntry struct { | |
| 17 | + | Key string | |
| 18 | + | Value string | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // Config is the user-editable vev configuration after parsing. Unknown binding | |
| 22 | + | // keys are preserved here so the usecase layer can decide which actions it | |
| 23 | + | // understands. | |
| 24 | + | type Config struct { | |
| 25 | + | Theme ThemeMode | |
| 26 | + | Bindings map[string]string | |
| 27 | + | BindingEntries []ConfigEntry | |
| 28 | + | Codes map[string]string | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // Warning describes a non-fatal config problem. Parsers and reloaders should | |
| 32 | + | // warn and continue with defaults rather than aborting startup. | |
| 33 | + | type Warning struct { | |
| 34 | + | Line int | |
| 35 | + | Msg string | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // Defaults returns vev's default configuration. | |
| 39 | + | func Defaults() Config { | |
| 40 | + | return Config{ | |
| 41 | + | Theme: ThemeAuto, | |
| 42 | + | Bindings: map[string]string{}, | |
| 43 | + | BindingEntries: []ConfigEntry{}, | |
| 44 | + | Codes: map[string]string{}, | |
| 45 | + | } | |
| 46 | + | } | |
| @@ -0,0 +1,46 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | // ThemeMode selects how vev derives its color scheme. | |||
| 4 | + | type ThemeMode int | |||
| 5 | + | ||||
| 6 | + | const ( | |||
| 7 | + | // ThemeAuto follows the attached client's reported color scheme. | |||
| 8 | + | ThemeAuto ThemeMode = iota | |||
| 9 | + | // ThemeDark forces vev's built-in dark palette. | |||
| 10 | + | ThemeDark | |||
| 11 | + | // ThemeLight forces vev's built-in light palette. | |||
| 12 | + | ThemeLight | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // ConfigEntry preserves one parsed config key/value in file order. | |||
| 16 | + | type ConfigEntry struct { | |||
| 17 | + | Key string | |||
| 18 | + | Value string | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // Config is the user-editable vev configuration after parsing. Unknown binding | |||
| 22 | + | // keys are preserved here so the usecase layer can decide which actions it | |||
| 23 | + | // understands. | |||
| 24 | + | type Config struct { | |||
| 25 | + | Theme ThemeMode | |||
| 26 | + | Bindings map[string]string | |||
| 27 | + | BindingEntries []ConfigEntry | |||
| 28 | + | Codes map[string]string | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // Warning describes a non-fatal config problem. Parsers and reloaders should | |||
| 32 | + | // warn and continue with defaults rather than aborting startup. | |||
| 33 | + | type Warning struct { | |||
| 34 | + | Line int | |||
| 35 | + | Msg string | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // Defaults returns vev's default configuration. | |||
| 39 | + | func Defaults() Config { | |||
| 40 | + | return Config{ | |||
| 41 | + | Theme: ThemeAuto, | |||
| 42 | + | Bindings: map[string]string{}, | |||
| 43 | + | BindingEntries: []ConfigEntry{}, | |||
| 44 | + | Codes: map[string]string{}, | |||
| 45 | + | } | |||
| 46 | + | } | |||
M internal/platform/dir.go +14 -0
| @@ -20,6 +20,20 @@ func StateDir() string { | |||
| 20 | 20 | return filepath.Join(home, ".local", "state", "vev") | |
| 21 | 21 | } | |
| 22 | 22 | ||
| 23 | + | // ConfigPath returns the user config file path: $XDG_CONFIG_HOME/vev/config if | |
| 24 | + | // set, else ~/.config/vev/config. | |
| 25 | + | func ConfigPath() string { | |
| 26 | + | if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { | |
| 27 | + | return filepath.Join(xdg, "vev", "config") | |
| 28 | + | } | |
| 29 | + | home, err := os.UserHomeDir() | |
| 30 | + | if err != nil || home == "" { | |
| 31 | + | // Last resort: a per-uid temp path so config lookup never aborts startup. | |
| 32 | + | return filepath.Join(os.TempDir(), fmt.Sprintf("vev-config-%d", os.Getuid()), "config") | |
| 33 | + | } | |
| 34 | + | return filepath.Join(home, ".config", "vev", "config") | |
| 35 | + | } | |
| 36 | + | ||
| 23 | 37 | // DirOrHome returns dir when it names an existing directory. Otherwise it | |
| 24 | 38 | // falls back to the current user's home directory, then to / as a last resort. | |
| 25 | 39 | func DirOrHome(dir string) string { | |
| @@ -20,6 +20,20 @@ func StateDir() string { | |||||
| 20 | return filepath.Join(home, ".local", "state", "vev") | 20 | return filepath.Join(home, ".local", "state", "vev") | ||
| 21 | } | 21 | } | ||
| 22 | 22 | ||||
| 23 | + | // ConfigPath returns the user config file path: $XDG_CONFIG_HOME/vev/config if | |||
| 24 | + | // set, else ~/.config/vev/config. | |||
| 25 | + | func ConfigPath() string { | |||
| 26 | + | if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { | |||
| 27 | + | return filepath.Join(xdg, "vev", "config") | |||
| 28 | + | } | |||
| 29 | + | home, err := os.UserHomeDir() | |||
| 30 | + | if err != nil || home == "" { | |||
| 31 | + | // Last resort: a per-uid temp path so config lookup never aborts startup. | |||
| 32 | + | return filepath.Join(os.TempDir(), fmt.Sprintf("vev-config-%d", os.Getuid()), "config") | |||
| 33 | + | } | |||
| 34 | + | return filepath.Join(home, ".config", "vev", "config") | |||
| 35 | + | } | |||
| 36 | + | ||||
| 23 | // DirOrHome returns dir when it names an existing directory. Otherwise it | 37 | // DirOrHome returns dir when it names an existing directory. Otherwise it | ||
| 24 | // falls back to the current user's home directory, then to / as a last resort. | 38 | // falls back to the current user's home directory, then to / as a last resort. | ||
| 25 | func DirOrHome(dir string) string { | 39 | func DirOrHome(dir string) string { | ||
A internal/platform/dir_test.go +39 -0
| @@ -0,0 +1,39 @@ | |||
| 1 | + | package platform | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "path/filepath" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestConfigPath(t *testing.T) { | |
| 9 | + | tests := []struct { | |
| 10 | + | name string | |
| 11 | + | xdgConfig string | |
| 12 | + | home string | |
| 13 | + | }{ | |
| 14 | + | { | |
| 15 | + | name: "xdg config home", | |
| 16 | + | xdgConfig: filepath.Join(t.TempDir(), "xdg-config"), | |
| 17 | + | home: filepath.Join(t.TempDir(), "home"), | |
| 18 | + | }, | |
| 19 | + | { | |
| 20 | + | name: "home fallback", | |
| 21 | + | home: filepath.Join(t.TempDir(), "home"), | |
| 22 | + | }, | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | for _, tt := range tests { | |
| 26 | + | t.Run(tt.name, func(t *testing.T) { | |
| 27 | + | t.Setenv("XDG_CONFIG_HOME", tt.xdgConfig) | |
| 28 | + | t.Setenv("HOME", tt.home) | |
| 29 | + | ||
| 30 | + | want := filepath.Join(tt.xdgConfig, "vev", "config") | |
| 31 | + | if tt.xdgConfig == "" { | |
| 32 | + | want = filepath.Join(tt.home, ".config", "vev", "config") | |
| 33 | + | } | |
| 34 | + | if got := ConfigPath(); got != want { | |
| 35 | + | t.Fatalf("ConfigPath() = %q, want %q", got, want) | |
| 36 | + | } | |
| 37 | + | }) | |
| 38 | + | } | |
| 39 | + | } | |
| @@ -0,0 +1,39 @@ | |||||
| 1 | + | package platform | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "path/filepath" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestConfigPath(t *testing.T) { | |||
| 9 | + | tests := []struct { | |||
| 10 | + | name string | |||
| 11 | + | xdgConfig string | |||
| 12 | + | home string | |||
| 13 | + | }{ | |||
| 14 | + | { | |||
| 15 | + | name: "xdg config home", | |||
| 16 | + | xdgConfig: filepath.Join(t.TempDir(), "xdg-config"), | |||
| 17 | + | home: filepath.Join(t.TempDir(), "home"), | |||
| 18 | + | }, | |||
| 19 | + | { | |||
| 20 | + | name: "home fallback", | |||
| 21 | + | home: filepath.Join(t.TempDir(), "home"), | |||
| 22 | + | }, | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | for _, tt := range tests { | |||
| 26 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 27 | + | t.Setenv("XDG_CONFIG_HOME", tt.xdgConfig) | |||
| 28 | + | t.Setenv("HOME", tt.home) | |||
| 29 | + | ||||
| 30 | + | want := filepath.Join(tt.xdgConfig, "vev", "config") | |||
| 31 | + | if tt.xdgConfig == "" { | |||
| 32 | + | want = filepath.Join(tt.home, ".config", "vev", "config") | |||
| 33 | + | } | |||
| 34 | + | if got := ConfigPath(); got != want { | |||
| 35 | + | t.Fatalf("ConfigPath() = %q, want %q", got, want) | |||
| 36 | + | } | |||
| 37 | + | }) | |||
| 38 | + | } | |||
| 39 | + | } | |||
M internal/ports/frame.go +1 -1
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | package ports | |
| 2 | 2 | ||
| 3 | 3 | // ProtocolVersion is the current vev IPC wire protocol version. | |
| 4 | - | const ProtocolVersion uint16 = 7 | |
| 4 | + | const ProtocolVersion uint16 = 8 | |
| 5 | 5 | ||
| 6 | 6 | // MsgType identifies the kind of payload carried by a Frame. | |
| 7 | 7 | type MsgType uint8 | |
| @@ -1,7 +1,7 @@ | |||||
| 1 | package ports | 1 | package ports | ||
| 2 | 2 | ||||
| 3 | // ProtocolVersion is the current vev IPC wire protocol version. | 3 | // ProtocolVersion is the current vev IPC wire protocol version. | ||
| 4 | - | const ProtocolVersion uint16 = 7 | 4 | + | const ProtocolVersion uint16 = 8 |
| 5 | 5 | ||||
| 6 | // MsgType identifies the kind of payload carried by a Frame. | 6 | // MsgType identifies the kind of payload carried by a Frame. | ||
| 7 | type MsgType uint8 | 7 | type MsgType uint8 | ||
M internal/ports/frame_test.go +2 -2
| @@ -3,8 +3,8 @@ package ports | |||
| 3 | 3 | import "testing" | |
| 4 | 4 | ||
| 5 | 5 | func TestProtocolVersion(t *testing.T) { | |
| 6 | - | if ProtocolVersion != 7 { | |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 7", ProtocolVersion) | |
| 6 | + | if ProtocolVersion != 8 { | |
| 7 | + | t.Fatalf("ProtocolVersion = %d, want 8", ProtocolVersion) | |
| 8 | 8 | } | |
| 9 | 9 | } | |
| 10 | 10 | ||
| @@ -3,8 +3,8 @@ package ports | |||||
| 3 | import "testing" | 3 | import "testing" | ||
| 4 | 4 | ||||
| 5 | func TestProtocolVersion(t *testing.T) { | 5 | func TestProtocolVersion(t *testing.T) { | ||
| 6 | - | if ProtocolVersion != 7 { | 6 | + | if ProtocolVersion != 8 { |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 7", ProtocolVersion) | 7 | + | t.Fatalf("ProtocolVersion = %d, want 8", ProtocolVersion) |
| 8 | } | 8 | } | ||
| 9 | } | 9 | } | ||
| 10 | 10 | ||||
M internal/ports/mocks/mocks.go +44 -0
| @@ -684,6 +684,50 @@ func (_c *MockTerminal_Out_Call) RunAndReturn(run func() io.Writer) *MockTermina | |||
| 684 | 684 | return _c | |
| 685 | 685 | } | |
| 686 | 686 | ||
| 687 | + | // QueryColors provides a mock function for the type MockTerminal | |
| 688 | + | func (_mock *MockTerminal) QueryColors() error { | |
| 689 | + | ret := _mock.Called() | |
| 690 | + | ||
| 691 | + | if len(ret) == 0 { | |
| 692 | + | panic("no return value specified for QueryColors") | |
| 693 | + | } | |
| 694 | + | ||
| 695 | + | var r0 error | |
| 696 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |
| 697 | + | r0 = returnFunc() | |
| 698 | + | } else { | |
| 699 | + | r0 = ret.Error(0) | |
| 700 | + | } | |
| 701 | + | return r0 | |
| 702 | + | } | |
| 703 | + | ||
| 704 | + | // MockTerminal_QueryColors_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'QueryColors' | |
| 705 | + | type MockTerminal_QueryColors_Call struct { | |
| 706 | + | *mock.Call | |
| 707 | + | } | |
| 708 | + | ||
| 709 | + | // QueryColors is a helper method to define mock.On call | |
| 710 | + | func (_e *MockTerminal_Expecter) QueryColors() *MockTerminal_QueryColors_Call { | |
| 711 | + | return &MockTerminal_QueryColors_Call{Call: _e.mock.On("QueryColors")} | |
| 712 | + | } | |
| 713 | + | ||
| 714 | + | func (_c *MockTerminal_QueryColors_Call) Run(run func()) *MockTerminal_QueryColors_Call { | |
| 715 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 716 | + | run() | |
| 717 | + | }) | |
| 718 | + | return _c | |
| 719 | + | } | |
| 720 | + | ||
| 721 | + | func (_c *MockTerminal_QueryColors_Call) Return(err error) *MockTerminal_QueryColors_Call { | |
| 722 | + | _c.Call.Return(err) | |
| 723 | + | return _c | |
| 724 | + | } | |
| 725 | + | ||
| 726 | + | func (_c *MockTerminal_QueryColors_Call) RunAndReturn(run func() error) *MockTerminal_QueryColors_Call { | |
| 727 | + | _c.Call.Return(run) | |
| 728 | + | return _c | |
| 729 | + | } | |
| 730 | + | ||
| 687 | 731 | // ResizeEvents provides a mock function for the type MockTerminal | |
| 688 | 732 | func (_mock *MockTerminal) ResizeEvents() <-chan domain.Size { | |
| 689 | 733 | ret := _mock.Called() | |
| @@ -684,6 +684,50 @@ func (_c *MockTerminal_Out_Call) RunAndReturn(run func() io.Writer) *MockTermina | |||||
| 684 | return _c | 684 | return _c | ||
| 685 | } | 685 | } | ||
| 686 | 686 | ||||
| 687 | + | // QueryColors provides a mock function for the type MockTerminal | |||
| 688 | + | func (_mock *MockTerminal) QueryColors() error { | |||
| 689 | + | ret := _mock.Called() | |||
| 690 | + | ||||
| 691 | + | if len(ret) == 0 { | |||
| 692 | + | panic("no return value specified for QueryColors") | |||
| 693 | + | } | |||
| 694 | + | ||||
| 695 | + | var r0 error | |||
| 696 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |||
| 697 | + | r0 = returnFunc() | |||
| 698 | + | } else { | |||
| 699 | + | r0 = ret.Error(0) | |||
| 700 | + | } | |||
| 701 | + | return r0 | |||
| 702 | + | } | |||
| 703 | + | ||||
| 704 | + | // MockTerminal_QueryColors_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'QueryColors' | |||
| 705 | + | type MockTerminal_QueryColors_Call struct { | |||
| 706 | + | *mock.Call | |||
| 707 | + | } | |||
| 708 | + | ||||
| 709 | + | // QueryColors is a helper method to define mock.On call | |||
| 710 | + | func (_e *MockTerminal_Expecter) QueryColors() *MockTerminal_QueryColors_Call { | |||
| 711 | + | return &MockTerminal_QueryColors_Call{Call: _e.mock.On("QueryColors")} | |||
| 712 | + | } | |||
| 713 | + | ||||
| 714 | + | func (_c *MockTerminal_QueryColors_Call) Run(run func()) *MockTerminal_QueryColors_Call { | |||
| 715 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 716 | + | run() | |||
| 717 | + | }) | |||
| 718 | + | return _c | |||
| 719 | + | } | |||
| 720 | + | ||||
| 721 | + | func (_c *MockTerminal_QueryColors_Call) Return(err error) *MockTerminal_QueryColors_Call { | |||
| 722 | + | _c.Call.Return(err) | |||
| 723 | + | return _c | |||
| 724 | + | } | |||
| 725 | + | ||||
| 726 | + | func (_c *MockTerminal_QueryColors_Call) RunAndReturn(run func() error) *MockTerminal_QueryColors_Call { | |||
| 727 | + | _c.Call.Return(run) | |||
| 728 | + | return _c | |||
| 729 | + | } | |||
| 730 | + | ||||
| 687 | // ResizeEvents provides a mock function for the type MockTerminal | 731 | // ResizeEvents provides a mock function for the type MockTerminal | ||
| 688 | func (_mock *MockTerminal) ResizeEvents() <-chan domain.Size { | 732 | func (_mock *MockTerminal) ResizeEvents() <-chan domain.Size { | ||
| 689 | ret := _mock.Called() | 733 | ret := _mock.Called() | ||
M internal/ports/ports.go +1 -0
| @@ -32,6 +32,7 @@ type Terminal interface { | |||
| 32 | 32 | EnterRaw() (restore func() error, err error) | |
| 33 | 33 | Size() (domain.Size, error) | |
| 34 | 34 | ResizeEvents() <-chan domain.Size | |
| 35 | + | QueryColors() error | |
| 35 | 36 | In() io.Reader | |
| 36 | 37 | Out() io.Writer | |
| 37 | 38 | Flush() error | |
| @@ -32,6 +32,7 @@ type Terminal interface { | |||||
| 32 | EnterRaw() (restore func() error, err error) | 32 | EnterRaw() (restore func() error, err error) | ||
| 33 | Size() (domain.Size, error) | 33 | Size() (domain.Size, error) | ||
| 34 | ResizeEvents() <-chan domain.Size | 34 | ResizeEvents() <-chan domain.Size | ||
| 35 | + | QueryColors() error | |||
| 35 | In() io.Reader | 36 | In() io.Reader | ||
| 36 | Out() io.Writer | 37 | Out() io.Writer | ||
| 37 | Flush() error | 38 | Flush() error | ||
M internal/ports/wire.go +10 -0
| @@ -85,6 +85,8 @@ type Theme struct { | |||
| 85 | 85 | HasBackground bool | |
| 86 | 86 | Background renderer.RGB | |
| 87 | 87 | TrueColor bool | |
| 88 | + | SchemeKnown bool | |
| 89 | + | Light bool | |
| 88 | 90 | } | |
| 89 | 91 | ||
| 90 | 92 | // ImagePush carries a clipboard image from a remote client, to be written to | |
| @@ -411,6 +413,12 @@ func MarshalTheme(m Theme) []byte { | |||
| 411 | 413 | if m.TrueColor { | |
| 412 | 414 | flags |= 0x04 | |
| 413 | 415 | } | |
| 416 | + | if m.SchemeKnown { | |
| 417 | + | flags |= 0x08 | |
| 418 | + | } | |
| 419 | + | if m.Light { | |
| 420 | + | flags |= 0x10 | |
| 421 | + | } | |
| 414 | 422 | return []byte{ | |
| 415 | 423 | flags, | |
| 416 | 424 | m.Foreground.R, m.Foreground.G, m.Foreground.B, | |
| @@ -458,6 +466,8 @@ func UnmarshalTheme(b []byte) (Theme, error) { | |||
| 458 | 466 | HasBackground: flags&0x02 != 0, | |
| 459 | 467 | Background: renderer.RGB{R: bgR, G: bgG, B: bgB}, | |
| 460 | 468 | TrueColor: flags&0x04 != 0, | |
| 469 | + | SchemeKnown: flags&0x08 != 0, | |
| 470 | + | Light: flags&0x10 != 0, | |
| 461 | 471 | }, nil | |
| 462 | 472 | } | |
| 463 | 473 | ||
| @@ -85,6 +85,8 @@ type Theme struct { | |||||
| 85 | HasBackground bool | 85 | HasBackground bool | ||
| 86 | Background renderer.RGB | 86 | Background renderer.RGB | ||
| 87 | TrueColor bool | 87 | TrueColor bool | ||
| 88 | + | SchemeKnown bool | |||
| 89 | + | Light bool | |||
| 88 | } | 90 | } | ||
| 89 | 91 | ||||
| 90 | // ImagePush carries a clipboard image from a remote client, to be written to | 92 | // ImagePush carries a clipboard image from a remote client, to be written to | ||
| @@ -411,6 +413,12 @@ func MarshalTheme(m Theme) []byte { | |||||
| 411 | if m.TrueColor { | 413 | if m.TrueColor { | ||
| 412 | flags |= 0x04 | 414 | flags |= 0x04 | ||
| 413 | } | 415 | } | ||
| 416 | + | if m.SchemeKnown { | |||
| 417 | + | flags |= 0x08 | |||
| 418 | + | } | |||
| 419 | + | if m.Light { | |||
| 420 | + | flags |= 0x10 | |||
| 421 | + | } | |||
| 414 | return []byte{ | 422 | return []byte{ | ||
| 415 | flags, | 423 | flags, | ||
| 416 | m.Foreground.R, m.Foreground.G, m.Foreground.B, | 424 | m.Foreground.R, m.Foreground.G, m.Foreground.B, | ||
| @@ -458,6 +466,8 @@ func UnmarshalTheme(b []byte) (Theme, error) { | |||||
| 458 | HasBackground: flags&0x02 != 0, | 466 | HasBackground: flags&0x02 != 0, | ||
| 459 | Background: renderer.RGB{R: bgR, G: bgG, B: bgB}, | 467 | Background: renderer.RGB{R: bgR, G: bgG, B: bgB}, | ||
| 460 | TrueColor: flags&0x04 != 0, | 468 | TrueColor: flags&0x04 != 0, | ||
| 469 | + | SchemeKnown: flags&0x08 != 0, | |||
| 470 | + | Light: flags&0x10 != 0, | |||
| 461 | }, nil | 471 | }, nil | ||
| 462 | } | 472 | } | ||
| 463 | 473 | ||||
M internal/ports/wire_test.go +10 -2
| @@ -181,15 +181,23 @@ func TestThemeGoldenAndRoundTrip(t *testing.T) { | |||
| 181 | 181 | want []byte | |
| 182 | 182 | }{ | |
| 183 | 183 | { | |
| 184 | - | name: "foreground background truecolor", | |
| 184 | + | name: "foreground background truecolor dark scheme", | |
| 185 | 185 | msg: Theme{ | |
| 186 | 186 | HasForeground: true, | |
| 187 | 187 | Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 188 | 188 | HasBackground: true, | |
| 189 | 189 | Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 190 | 190 | TrueColor: true, | |
| 191 | + | SchemeKnown: true, | |
| 191 | 192 | }, | |
| 192 | - | want: []byte{0x07, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, | |
| 193 | + | want: []byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, | |
| 194 | + | }, | |
| 195 | + | { | |
| 196 | + | name: "light scheme bit without known is preserved", | |
| 197 | + | msg: Theme{ | |
| 198 | + | Light: true, | |
| 199 | + | }, | |
| 200 | + | want: []byte{0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, | |
| 193 | 201 | }, | |
| 194 | 202 | { | |
| 195 | 203 | name: "foreground only no truecolor", | |
| @@ -181,15 +181,23 @@ func TestThemeGoldenAndRoundTrip(t *testing.T) { | |||||
| 181 | want []byte | 181 | want []byte | ||
| 182 | }{ | 182 | }{ | ||
| 183 | { | 183 | { | ||
| 184 | - | name: "foreground background truecolor", | 184 | + | name: "foreground background truecolor dark scheme", |
| 185 | msg: Theme{ | 185 | msg: Theme{ | ||
| 186 | HasForeground: true, | 186 | HasForeground: true, | ||
| 187 | Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | 187 | Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | ||
| 188 | HasBackground: true, | 188 | HasBackground: true, | ||
| 189 | Background: renderer.RGB{R: 4, G: 5, B: 6}, | 189 | Background: renderer.RGB{R: 4, G: 5, B: 6}, | ||
| 190 | TrueColor: true, | 190 | TrueColor: true, | ||
| 191 | + | SchemeKnown: true, | |||
| 191 | }, | 192 | }, | ||
| 192 | - | want: []byte{0x07, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, | 193 | + | want: []byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, |
| 194 | + | }, | |||
| 195 | + | { | |||
| 196 | + | name: "light scheme bit without known is preserved", | |||
| 197 | + | msg: Theme{ | |||
| 198 | + | Light: true, | |||
| 199 | + | }, | |||
| 200 | + | want: []byte{0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, | |||
| 193 | }, | 201 | }, | ||
| 194 | { | 202 | { | ||
| 195 | name: "foreground only no truecolor", | 203 | name: "foreground only no truecolor", | ||
M internal/usecase/client/client.go +17 -3
| @@ -348,7 +348,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||
| 348 | 348 | clip = clipboard | |
| 349 | 349 | } | |
| 350 | 350 | go runSender(loopCtx, cancel, transport, sendCh, sendErrCh, log) | |
| 351 | - | go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, clip, log) | |
| 351 | + | go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, term.QueryColors, clip, log) | |
| 352 | 352 | go runResize(loopCtx, term.ResizeEvents(), sendCh, log) | |
| 353 | 353 | ||
| 354 | 354 | // 5. Output/main loop: the only goroutine that touches the terminal. | |
| @@ -472,7 +472,7 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T | |||
| 472 | 472 | // exits. That is harmless here — Attach has already returned and restored | |
| 473 | 473 | // the terminal — and matches the standard pattern for stdin pumps; a | |
| 474 | 474 | // closable stdin duplicate could lift it later if ever needed. | |
| 475 | - | func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, clipboard ports.ClipboardReader, log *slog.Logger) { | |
| 475 | + | func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, queryColors func() error, clipboard ports.ClipboardReader, log *slog.Logger) { | |
| 476 | 476 | defer log.Debug("stdin pump exited") | |
| 477 | 477 | buf := make([]byte, stdinBufSize) | |
| 478 | 478 | var scanner theme.Scanner | |
| @@ -522,6 +522,9 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out | |||
| 522 | 522 | for { | |
| 523 | 523 | n, rerr := in.Read(buf) | |
| 524 | 524 | if n > 0 { | |
| 525 | + | sendTheme := func() { | |
| 526 | + | send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)}) | |
| 527 | + | } | |
| 525 | 528 | scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) { | |
| 526 | 529 | switch kind { | |
| 527 | 530 | case 10: | |
| @@ -533,7 +536,18 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out | |||
| 533 | 536 | default: | |
| 534 | 537 | return | |
| 535 | 538 | } | |
| 536 | - | send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)}) | |
| 539 | + | sendTheme() | |
| 540 | + | }, func(light bool) { | |
| 541 | + | current.SchemeKnown = true | |
| 542 | + | current.Light = light | |
| 543 | + | if queryColors != nil { | |
| 544 | + | go func() { | |
| 545 | + | if err := queryColors(); err != nil { | |
| 546 | + | log.Warn("querying terminal colors", "err", err) | |
| 547 | + | } | |
| 548 | + | }() | |
| 549 | + | } | |
| 550 | + | sendTheme() | |
| 537 | 551 | }, sink) | |
| 538 | 552 | if !sendOK.Load() { | |
| 539 | 553 | return | |
| @@ -348,7 +348,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||||
| 348 | clip = clipboard | 348 | clip = clipboard | ||
| 349 | } | 349 | } | ||
| 350 | go runSender(loopCtx, cancel, transport, sendCh, sendErrCh, log) | 350 | go runSender(loopCtx, cancel, transport, sendCh, sendErrCh, log) | ||
| 351 | - | go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, clip, log) | 351 | + | go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, term.QueryColors, clip, log) |
| 352 | go runResize(loopCtx, term.ResizeEvents(), sendCh, log) | 352 | go runResize(loopCtx, term.ResizeEvents(), sendCh, log) | ||
| 353 | 353 | ||||
| 354 | // 5. Output/main loop: the only goroutine that touches the terminal. | 354 | // 5. Output/main loop: the only goroutine that touches the terminal. | ||
| @@ -472,7 +472,7 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T | |||||
| 472 | // exits. That is harmless here — Attach has already returned and restored | 472 | // exits. That is harmless here — Attach has already returned and restored | ||
| 473 | // the terminal — and matches the standard pattern for stdin pumps; a | 473 | // the terminal — and matches the standard pattern for stdin pumps; a | ||
| 474 | // closable stdin duplicate could lift it later if ever needed. | 474 | // closable stdin duplicate could lift it later if ever needed. | ||
| 475 | - | func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, clipboard ports.ClipboardReader, log *slog.Logger) { | 475 | + | func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, queryColors func() error, clipboard ports.ClipboardReader, log *slog.Logger) { |
| 476 | defer log.Debug("stdin pump exited") | 476 | defer log.Debug("stdin pump exited") | ||
| 477 | buf := make([]byte, stdinBufSize) | 477 | buf := make([]byte, stdinBufSize) | ||
| 478 | var scanner theme.Scanner | 478 | var scanner theme.Scanner | ||
| @@ -522,6 +522,9 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out | |||||
| 522 | for { | 522 | for { | ||
| 523 | n, rerr := in.Read(buf) | 523 | n, rerr := in.Read(buf) | ||
| 524 | if n > 0 { | 524 | if n > 0 { | ||
| 525 | + | sendTheme := func() { | |||
| 526 | + | send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)}) | |||
| 527 | + | } | |||
| 525 | scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) { | 528 | scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) { | ||
| 526 | switch kind { | 529 | switch kind { | ||
| 527 | case 10: | 530 | case 10: | ||
| @@ -533,7 +536,18 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out | |||||
| 533 | default: | 536 | default: | ||
| 534 | return | 537 | return | ||
| 535 | } | 538 | } | ||
| 536 | - | send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)}) | 539 | + | sendTheme() |
| 540 | + | }, func(light bool) { | |||
| 541 | + | current.SchemeKnown = true | |||
| 542 | + | current.Light = light | |||
| 543 | + | if queryColors != nil { | |||
| 544 | + | go func() { | |||
| 545 | + | if err := queryColors(); err != nil { | |||
| 546 | + | log.Warn("querying terminal colors", "err", err) | |||
| 547 | + | } | |||
| 548 | + | }() | |||
| 549 | + | } | |||
| 550 | + | sendTheme() | |||
| 537 | }, sink) | 551 | }, sink) | ||
| 538 | if !sendOK.Load() { | 552 | if !sendOK.Load() { | ||
| 539 | return | 553 | return | ||
M internal/usecase/client/client_test.go +1 -0
| @@ -746,6 +746,7 @@ func (t *runTerminal) EnterRaw() (func() error, error) { | |||
| 746 | 746 | } | |
| 747 | 747 | func (t *runTerminal) Size() (domain.Size, error) { return domain.Size{Cols: 80, Rows: 24}, nil } | |
| 748 | 748 | func (t *runTerminal) ResizeEvents() <-chan domain.Size { return t.resizeCh } | |
| 749 | + | func (t *runTerminal) QueryColors() error { return nil } | |
| 749 | 750 | func (t *runTerminal) In() io.Reader { return t.in } | |
| 750 | 751 | func (t *runTerminal) Out() io.Writer { return &t.out } | |
| 751 | 752 | func (t *runTerminal) Flush() error { return nil } | |
| @@ -746,6 +746,7 @@ func (t *runTerminal) EnterRaw() (func() error, error) { | |||||
| 746 | } | 746 | } | ||
| 747 | func (t *runTerminal) Size() (domain.Size, error) { return domain.Size{Cols: 80, Rows: 24}, nil } | 747 | func (t *runTerminal) Size() (domain.Size, error) { return domain.Size{Cols: 80, Rows: 24}, nil } | ||
| 748 | func (t *runTerminal) ResizeEvents() <-chan domain.Size { return t.resizeCh } | 748 | func (t *runTerminal) ResizeEvents() <-chan domain.Size { return t.resizeCh } | ||
| 749 | + | func (t *runTerminal) QueryColors() error { return nil } | |||
| 749 | func (t *runTerminal) In() io.Reader { return t.in } | 750 | func (t *runTerminal) In() io.Reader { return t.in } | ||
| 750 | func (t *runTerminal) Out() io.Writer { return &t.out } | 751 | func (t *runTerminal) Out() io.Writer { return &t.out } | ||
| 751 | func (t *runTerminal) Flush() error { return nil } | 752 | func (t *runTerminal) Flush() error { return nil } | ||
M internal/usecase/command/command.go +2 -2
| @@ -26,6 +26,6 @@ type Context interface { | |||
| 26 | 26 | ||
| 27 | 27 | // Command describes an executable command. | |
| 28 | 28 | type Command struct { | |
| 29 | - | Code, Name, Desc string | |
| 30 | - | Run func(Context) error | |
| 29 | + | Slug, Code, Name, Desc string | |
| 30 | + | Run func(Context) error | |
| 31 | 31 | } | |
| @@ -26,6 +26,6 @@ type Context interface { | |||||
| 26 | 26 | ||||
| 27 | // Command describes an executable command. | 27 | // Command describes an executable command. | ||
| 28 | type Command struct { | 28 | type Command struct { | ||
| 29 | - | Code, Name, Desc string | 29 | + | Slug, Code, Name, Desc string |
| 30 | - | Run func(Context) error | 30 | + | Run func(Context) error |
| 31 | } | 31 | } | ||
M internal/usecase/command/registry.go +20 -20
| @@ -5,26 +5,26 @@ import "strings" | |||
| 5 | 5 | // Registry returns all commands in display order. | |
| 6 | 6 | func Registry() []Command { | |
| 7 | 7 | commands := []Command{ | |
| 8 | - | {Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context) error { return ctx.CreateTab() }}, | |
| 9 | - | {Code: "CNS", Name: "New session", Desc: "Create and switch to a named session", Run: func(ctx Context) error { return ctx.CreateSession() }}, | |
| 10 | - | {Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context) error { return ctx.CloseTab() }}, | |
| 11 | - | {Code: "SPR", Name: "Split right", Desc: "Split the focused pane to the right", Run: func(ctx Context) error { return ctx.SplitRight() }}, | |
| 12 | - | {Code: "SPL", Name: "Split left", Desc: "Split the focused pane to the left", Run: func(ctx Context) error { return ctx.SplitLeft() }}, | |
| 13 | - | {Code: "SPU", Name: "Split up", Desc: "Split the focused pane upward", Run: func(ctx Context) error { return ctx.SplitUp() }}, | |
| 14 | - | {Code: "SPD", Name: "Split down", Desc: "Split the focused pane downward", Run: func(ctx Context) error { return ctx.SplitDown() }}, | |
| 15 | - | {Code: "STP", Name: "Stack pane", Desc: "Create a new pane in a stack", Run: func(ctx Context) error { return ctx.StackPane() }}, | |
| 16 | - | {Code: "TST", Name: "Toggle stack", Desc: "Toggle the focused pane stack", Run: func(ctx Context) error { return ctx.ToggleStack() }}, | |
| 17 | - | {Code: "CLP", Name: "Close pane", Desc: "Close the focused pane", Run: func(ctx Context) error { return ctx.ClosePane() }}, | |
| 18 | - | {Code: "FPL", Name: "Focus pane left", Desc: "Focus the pane to the left", Run: func(ctx Context) error { return ctx.FocusPaneLeft() }}, | |
| 19 | - | {Code: "FPR", Name: "Focus pane right", Desc: "Focus the pane to the right", Run: func(ctx Context) error { return ctx.FocusPaneRight() }}, | |
| 20 | - | {Code: "FPU", Name: "Focus pane up", Desc: "Focus the pane above", Run: func(ctx Context) error { return ctx.FocusPaneUp() }}, | |
| 21 | - | {Code: "FPD", Name: "Focus pane down", Desc: "Focus the pane below", Run: func(ctx Context) error { return ctx.FocusPaneDown() }}, | |
| 22 | - | {Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }}, | |
| 23 | - | {Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }}, | |
| 24 | - | {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }}, | |
| 25 | - | {Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }}, | |
| 26 | - | {Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }}, | |
| 27 | - | {Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }}, | |
| 8 | + | {Slug: "new-tab", Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context) error { return ctx.CreateTab() }}, | |
| 9 | + | {Slug: "new-session", Code: "CNS", Name: "New session", Desc: "Create and switch to a named session", Run: func(ctx Context) error { return ctx.CreateSession() }}, | |
| 10 | + | {Slug: "close-tab", Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context) error { return ctx.CloseTab() }}, | |
| 11 | + | {Slug: "split-right", Code: "SPR", Name: "Split right", Desc: "Split the focused pane to the right", Run: func(ctx Context) error { return ctx.SplitRight() }}, | |
| 12 | + | {Slug: "split-left", Code: "SPL", Name: "Split left", Desc: "Split the focused pane to the left", Run: func(ctx Context) error { return ctx.SplitLeft() }}, | |
| 13 | + | {Slug: "split-up", Code: "SPU", Name: "Split up", Desc: "Split the focused pane upward", Run: func(ctx Context) error { return ctx.SplitUp() }}, | |
| 14 | + | {Slug: "split-down", Code: "SPD", Name: "Split down", Desc: "Split the focused pane downward", Run: func(ctx Context) error { return ctx.SplitDown() }}, | |
| 15 | + | {Slug: "stack-pane", Code: "STP", Name: "Stack pane", Desc: "Create a new pane in a stack", Run: func(ctx Context) error { return ctx.StackPane() }}, | |
| 16 | + | {Slug: "toggle-stack", Code: "TST", Name: "Toggle stack", Desc: "Toggle the focused pane stack", Run: func(ctx Context) error { return ctx.ToggleStack() }}, | |
| 17 | + | {Slug: "close-pane", Code: "CLP", Name: "Close pane", Desc: "Close the focused pane", Run: func(ctx Context) error { return ctx.ClosePane() }}, | |
| 18 | + | {Slug: "focus-pane-left", Code: "FPL", Name: "Focus pane left", Desc: "Focus the pane to the left", Run: func(ctx Context) error { return ctx.FocusPaneLeft() }}, | |
| 19 | + | {Slug: "focus-pane-right", Code: "FPR", Name: "Focus pane right", Desc: "Focus the pane to the right", Run: func(ctx Context) error { return ctx.FocusPaneRight() }}, | |
| 20 | + | {Slug: "focus-pane-up", Code: "FPU", Name: "Focus pane up", Desc: "Focus the pane above", Run: func(ctx Context) error { return ctx.FocusPaneUp() }}, | |
| 21 | + | {Slug: "focus-pane-down", Code: "FPD", Name: "Focus pane down", Desc: "Focus the pane below", Run: func(ctx Context) error { return ctx.FocusPaneDown() }}, | |
| 22 | + | {Slug: "next-tab", Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }}, | |
| 23 | + | {Slug: "previous-tab", Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }}, | |
| 24 | + | {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }}, | |
| 25 | + | {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }}, | |
| 26 | + | {Slug: "rename-session", Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }}, | |
| 27 | + | {Slug: "detach", Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }}, | |
| 28 | 28 | } | |
| 29 | 29 | ||
| 30 | 30 | return commands | |
| @@ -5,26 +5,26 @@ import "strings" | |||||
| 5 | // Registry returns all commands in display order. | 5 | // Registry returns all commands in display order. | ||
| 6 | func Registry() []Command { | 6 | func Registry() []Command { | ||
| 7 | commands := []Command{ | 7 | commands := []Command{ | ||
| 8 | - | {Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context) error { return ctx.CreateTab() }}, | 8 | + | {Slug: "new-tab", Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context) error { return ctx.CreateTab() }}, |
| 9 | - | {Code: "CNS", Name: "New session", Desc: "Create and switch to a named session", Run: func(ctx Context) error { return ctx.CreateSession() }}, | 9 | + | {Slug: "new-session", Code: "CNS", Name: "New session", Desc: "Create and switch to a named session", Run: func(ctx Context) error { return ctx.CreateSession() }}, |
| 10 | - | {Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context) error { return ctx.CloseTab() }}, | 10 | + | {Slug: "close-tab", Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context) error { return ctx.CloseTab() }}, |
| 11 | - | {Code: "SPR", Name: "Split right", Desc: "Split the focused pane to the right", Run: func(ctx Context) error { return ctx.SplitRight() }}, | 11 | + | {Slug: "split-right", Code: "SPR", Name: "Split right", Desc: "Split the focused pane to the right", Run: func(ctx Context) error { return ctx.SplitRight() }}, |
| 12 | - | {Code: "SPL", Name: "Split left", Desc: "Split the focused pane to the left", Run: func(ctx Context) error { return ctx.SplitLeft() }}, | 12 | + | {Slug: "split-left", Code: "SPL", Name: "Split left", Desc: "Split the focused pane to the left", Run: func(ctx Context) error { return ctx.SplitLeft() }}, |
| 13 | - | {Code: "SPU", Name: "Split up", Desc: "Split the focused pane upward", Run: func(ctx Context) error { return ctx.SplitUp() }}, | 13 | + | {Slug: "split-up", Code: "SPU", Name: "Split up", Desc: "Split the focused pane upward", Run: func(ctx Context) error { return ctx.SplitUp() }}, |
| 14 | - | {Code: "SPD", Name: "Split down", Desc: "Split the focused pane downward", Run: func(ctx Context) error { return ctx.SplitDown() }}, | 14 | + | {Slug: "split-down", Code: "SPD", Name: "Split down", Desc: "Split the focused pane downward", Run: func(ctx Context) error { return ctx.SplitDown() }}, |
| 15 | - | {Code: "STP", Name: "Stack pane", Desc: "Create a new pane in a stack", Run: func(ctx Context) error { return ctx.StackPane() }}, | 15 | + | {Slug: "stack-pane", Code: "STP", Name: "Stack pane", Desc: "Create a new pane in a stack", Run: func(ctx Context) error { return ctx.StackPane() }}, |
| 16 | - | {Code: "TST", Name: "Toggle stack", Desc: "Toggle the focused pane stack", Run: func(ctx Context) error { return ctx.ToggleStack() }}, | 16 | + | {Slug: "toggle-stack", Code: "TST", Name: "Toggle stack", Desc: "Toggle the focused pane stack", Run: func(ctx Context) error { return ctx.ToggleStack() }}, |
| 17 | - | {Code: "CLP", Name: "Close pane", Desc: "Close the focused pane", Run: func(ctx Context) error { return ctx.ClosePane() }}, | 17 | + | {Slug: "close-pane", Code: "CLP", Name: "Close pane", Desc: "Close the focused pane", Run: func(ctx Context) error { return ctx.ClosePane() }}, |
| 18 | - | {Code: "FPL", Name: "Focus pane left", Desc: "Focus the pane to the left", Run: func(ctx Context) error { return ctx.FocusPaneLeft() }}, | 18 | + | {Slug: "focus-pane-left", Code: "FPL", Name: "Focus pane left", Desc: "Focus the pane to the left", Run: func(ctx Context) error { return ctx.FocusPaneLeft() }}, |
| 19 | - | {Code: "FPR", Name: "Focus pane right", Desc: "Focus the pane to the right", Run: func(ctx Context) error { return ctx.FocusPaneRight() }}, | 19 | + | {Slug: "focus-pane-right", Code: "FPR", Name: "Focus pane right", Desc: "Focus the pane to the right", Run: func(ctx Context) error { return ctx.FocusPaneRight() }}, |
| 20 | - | {Code: "FPU", Name: "Focus pane up", Desc: "Focus the pane above", Run: func(ctx Context) error { return ctx.FocusPaneUp() }}, | 20 | + | {Slug: "focus-pane-up", Code: "FPU", Name: "Focus pane up", Desc: "Focus the pane above", Run: func(ctx Context) error { return ctx.FocusPaneUp() }}, |
| 21 | - | {Code: "FPD", Name: "Focus pane down", Desc: "Focus the pane below", Run: func(ctx Context) error { return ctx.FocusPaneDown() }}, | 21 | + | {Slug: "focus-pane-down", Code: "FPD", Name: "Focus pane down", Desc: "Focus the pane below", Run: func(ctx Context) error { return ctx.FocusPaneDown() }}, |
| 22 | - | {Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }}, | 22 | + | {Slug: "next-tab", Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }}, |
| 23 | - | {Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }}, | 23 | + | {Slug: "previous-tab", Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }}, |
| 24 | - | {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }}, | 24 | + | {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }}, |
| 25 | - | {Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }}, | 25 | + | {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }}, |
| 26 | - | {Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }}, | 26 | + | {Slug: "rename-session", Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }}, |
| 27 | - | {Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }}, | 27 | + | {Slug: "detach", Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }}, |
| 28 | } | 28 | } | ||
| 29 | 29 | ||||
| 30 | return commands | 30 | return commands | ||
M internal/usecase/command/registry_test.go +12 -4
| @@ -5,7 +5,7 @@ import ( | |||
| 5 | 5 | "testing" | |
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | - | func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) { | |
| 8 | + | func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) { | |
| 9 | 9 | commands := Registry() | |
| 10 | 10 | wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "SSP", "VIS", "RNS", "DET"} | |
| 11 | 11 | ||
| @@ -14,7 +14,8 @@ func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) { | |||
| 14 | 14 | } | |
| 15 | 15 | ||
| 16 | 16 | codePattern := regexp.MustCompile(`^[A-Z]{3}$`) | |
| 17 | - | seen := make(map[string]bool, len(commands)) | |
| 17 | + | seenCodes := make(map[string]bool, len(commands)) | |
| 18 | + | seenSlugs := make(map[string]bool, len(commands)) | |
| 18 | 19 | for i, cmd := range commands { | |
| 19 | 20 | if cmd.Code != wantCodes[i] { | |
| 20 | 21 | t.Fatalf("Registry()[%d].Code = %q, want %q", i, cmd.Code, wantCodes[i]) | |
| @@ -22,10 +23,17 @@ func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) { | |||
| 22 | 23 | if !codePattern.MatchString(cmd.Code) { | |
| 23 | 24 | t.Errorf("Registry()[%d].Code = %q, want three uppercase letters", i, cmd.Code) | |
| 24 | 25 | } | |
| 25 | - | if seen[cmd.Code] { | |
| 26 | + | if seenCodes[cmd.Code] { | |
| 26 | 27 | t.Errorf("Registry()[%d].Code = %q, duplicate code", i, cmd.Code) | |
| 27 | 28 | } | |
| 28 | - | seen[cmd.Code] = true | |
| 29 | + | seenCodes[cmd.Code] = true | |
| 30 | + | if cmd.Slug == "" { | |
| 31 | + | t.Errorf("Registry()[%d].Slug is empty", i) | |
| 32 | + | } | |
| 33 | + | if seenSlugs[cmd.Slug] { | |
| 34 | + | t.Errorf("Registry()[%d].Slug = %q, duplicate slug", i, cmd.Slug) | |
| 35 | + | } | |
| 36 | + | seenSlugs[cmd.Slug] = true | |
| 29 | 37 | } | |
| 30 | 38 | } | |
| 31 | 39 | ||
| @@ -5,7 +5,7 @@ import ( | |||||
| 5 | "testing" | 5 | "testing" | ||
| 6 | ) | 6 | ) | ||
| 7 | 7 | ||||
| 8 | - | func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) { | 8 | + | func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) { |
| 9 | commands := Registry() | 9 | commands := Registry() | ||
| 10 | wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "SSP", "VIS", "RNS", "DET"} | 10 | wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "SSP", "VIS", "RNS", "DET"} | ||
| 11 | 11 | ||||
| @@ -14,7 +14,8 @@ func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) { | |||||
| 14 | } | 14 | } | ||
| 15 | 15 | ||||
| 16 | codePattern := regexp.MustCompile(`^[A-Z]{3}$`) | 16 | codePattern := regexp.MustCompile(`^[A-Z]{3}$`) | ||
| 17 | - | seen := make(map[string]bool, len(commands)) | 17 | + | seenCodes := make(map[string]bool, len(commands)) |
| 18 | + | seenSlugs := make(map[string]bool, len(commands)) | |||
| 18 | for i, cmd := range commands { | 19 | for i, cmd := range commands { | ||
| 19 | if cmd.Code != wantCodes[i] { | 20 | if cmd.Code != wantCodes[i] { | ||
| 20 | t.Fatalf("Registry()[%d].Code = %q, want %q", i, cmd.Code, wantCodes[i]) | 21 | t.Fatalf("Registry()[%d].Code = %q, want %q", i, cmd.Code, wantCodes[i]) | ||
| @@ -22,10 +23,17 @@ func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) { | |||||
| 22 | if !codePattern.MatchString(cmd.Code) { | 23 | if !codePattern.MatchString(cmd.Code) { | ||
| 23 | t.Errorf("Registry()[%d].Code = %q, want three uppercase letters", i, cmd.Code) | 24 | t.Errorf("Registry()[%d].Code = %q, want three uppercase letters", i, cmd.Code) | ||
| 24 | } | 25 | } | ||
| 25 | - | if seen[cmd.Code] { | 26 | + | if seenCodes[cmd.Code] { |
| 26 | t.Errorf("Registry()[%d].Code = %q, duplicate code", i, cmd.Code) | 27 | t.Errorf("Registry()[%d].Code = %q, duplicate code", i, cmd.Code) | ||
| 27 | } | 28 | } | ||
| 28 | - | seen[cmd.Code] = true | 29 | + | seenCodes[cmd.Code] = true |
| 30 | + | if cmd.Slug == "" { | |||
| 31 | + | t.Errorf("Registry()[%d].Slug is empty", i) | |||
| 32 | + | } | |||
| 33 | + | if seenSlugs[cmd.Slug] { | |||
| 34 | + | t.Errorf("Registry()[%d].Slug = %q, duplicate slug", i, cmd.Slug) | |||
| 35 | + | } | |||
| 36 | + | seenSlugs[cmd.Slug] = true | |||
| 29 | } | 37 | } | ||
| 30 | } | 38 | } | ||
| 31 | 39 | ||||
M internal/usecase/daemon/client.go +30 -56
| @@ -55,6 +55,7 @@ type attachedClient struct { | |||
| 55 | 55 | mouseScan mouse.Scanner | |
| 56 | 56 | themeMu sync.Mutex | |
| 57 | 57 | theme themeui.Theme | |
| 58 | + | clientTheme themeui.Theme | |
| 58 | 59 | lastCursor cursorOut | |
| 59 | 60 | linkMu sync.Mutex | |
| 60 | 61 | sendMu sync.Mutex | |
| @@ -114,12 +115,24 @@ func (ac *attachedClient) getTheme() themeui.Theme { | |||
| 114 | 115 | return ac.theme | |
| 115 | 116 | } | |
| 116 | 117 | ||
| 118 | + | func (ac *attachedClient) getClientTheme() themeui.Theme { | |
| 119 | + | ac.themeMu.Lock() | |
| 120 | + | defer ac.themeMu.Unlock() | |
| 121 | + | return ac.clientTheme | |
| 122 | + | } | |
| 123 | + | ||
| 117 | 124 | func (ac *attachedClient) setTheme(t themeui.Theme) { | |
| 118 | 125 | ac.themeMu.Lock() | |
| 119 | 126 | ac.theme = t | |
| 120 | 127 | ac.themeMu.Unlock() | |
| 121 | 128 | } | |
| 122 | 129 | ||
| 130 | + | func (ac *attachedClient) setClientTheme(t themeui.Theme) { | |
| 131 | + | ac.themeMu.Lock() | |
| 132 | + | ac.clientTheme = t | |
| 133 | + | ac.themeMu.Unlock() | |
| 134 | + | } | |
| 135 | + | ||
| 123 | 136 | func (ac *attachedClient) transport() ports.Transport { | |
| 124 | 137 | ac.linkMu.Lock() | |
| 125 | 138 | defer ac.linkMu.Unlock() | |
| @@ -320,25 +333,15 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, | |||
| 320 | 333 | } | |
| 321 | 334 | ac.initOverlays() | |
| 322 | 335 | ac.setSession(sess) | |
| 323 | - | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}) | |
| 336 | + | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings) | |
| 324 | 337 | sess.mu.Lock() | |
| 325 | 338 | old := sess.client | |
| 326 | 339 | sess.client = ac | |
| 327 | 340 | name := sess.name | |
| 328 | - | tabs := append([]*tab(nil), sess.tabs...) | |
| 329 | 341 | sess.mu.Unlock() | |
| 330 | 342 | d.touchMRU(sess) | |
| 331 | 343 | d.log.Info("client attached", "session", name, "resume", opts.resumeCapable) | |
| 332 | - | for _, tb := range tabs { | |
| 333 | - | tb.mu.Lock() | |
| 334 | - | panes := tb.panesSnapshot() | |
| 335 | - | tb.mu.Unlock() | |
| 336 | - | for _, p := range panes { | |
| 337 | - | p.mu.Lock() | |
| 338 | - | p.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false) | |
| 339 | - | p.mu.Unlock() | |
| 340 | - | } | |
| 341 | - | } | |
| 344 | + | d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{})) | |
| 342 | 345 | return ac, old | |
| 343 | 346 | } | |
| 344 | 347 | ||
| @@ -354,23 +357,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, | |||
| 354 | 357 | // (and run its own attach-time reset), so this call must leave the tabs | |
| 355 | 358 | // alone rather than clobbering that client's freshly applied colors. | |
| 356 | 359 | func (d *Daemon) resetScreenDefaultColors(sess *session) { | |
| 357 | - | sess.mu.Lock() | |
| 358 | - | if sess.client != nil { | |
| 359 | - | sess.mu.Unlock() | |
| 360 | - | return | |
| 361 | - | } | |
| 362 | - | tabs := append([]*tab(nil), sess.tabs...) | |
| 363 | - | sess.mu.Unlock() | |
| 364 | - | for _, tb := range tabs { | |
| 365 | - | tb.mu.Lock() | |
| 366 | - | panes := tb.panesSnapshot() | |
| 367 | - | tb.mu.Unlock() | |
| 368 | - | for _, p := range panes { | |
| 369 | - | p.mu.Lock() | |
| 370 | - | p.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false) | |
| 371 | - | p.mu.Unlock() | |
| 372 | - | } | |
| 373 | - | } | |
| 360 | + | d.applyHostTheme(sess, nil, d.effectiveTheme(themeui.Theme{})) | |
| 374 | 361 | } | |
| 375 | 362 | ||
| 376 | 363 | func (d *Daemon) detachReplacedClient(old *attachedClient) { | |
| @@ -508,35 +495,22 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran | |||
| 508 | 495 | // detachIfCurrent clears the client iff ac is the current one, reporting | |
| 509 | 496 | ||
| 510 | 497 | func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) { | |
| 511 | - | t := themeui.Theme{ | |
| 512 | - | Foreground: msg.Foreground, | |
| 513 | - | Background: msg.Background, | |
| 514 | - | HasFG: msg.HasForeground, | |
| 515 | - | HasBG: msg.HasBackground, | |
| 516 | - | TrueColor: msg.TrueColor, | |
| 517 | - | Known: msg.HasForeground && msg.HasBackground, | |
| 518 | - | } | |
| 519 | - | ||
| 520 | - | knownDefaultColors := t.HasFG && t.HasBG | |
| 521 | - | sess.mu.Lock() | |
| 522 | - | if sess.client != ac { | |
| 523 | - | sess.mu.Unlock() | |
| 498 | + | clientTheme := themeui.Theme{ | |
| 499 | + | Foreground: msg.Foreground, | |
| 500 | + | Background: msg.Background, | |
| 501 | + | HasFG: msg.HasForeground, | |
| 502 | + | HasBG: msg.HasBackground, | |
| 503 | + | TrueColor: msg.TrueColor, | |
| 504 | + | Known: msg.HasForeground && msg.HasBackground, | |
| 505 | + | SchemeKnown: msg.SchemeKnown, | |
| 506 | + | Light: msg.Light, | |
| 507 | + | } | |
| 508 | + | ac.setClientTheme(clientTheme) | |
| 509 | + | t := d.effectiveTheme(clientTheme) | |
| 510 | + | ||
| 511 | + | if !d.applyHostTheme(sess, ac, t) { | |
| 524 | 512 | return | |
| 525 | 513 | } | |
| 526 | - | tabs := append([]*tab(nil), sess.tabs...) | |
| 527 | - | sess.mu.Unlock() | |
| 528 | - | ||
| 529 | - | ac.setTheme(t) | |
| 530 | - | for _, tb := range tabs { | |
| 531 | - | tb.mu.Lock() | |
| 532 | - | panes := tb.panesSnapshot() | |
| 533 | - | tb.mu.Unlock() | |
| 534 | - | for _, p := range panes { | |
| 535 | - | p.mu.Lock() | |
| 536 | - | p.screen.SetDefaultColors(t.Foreground, t.Background, knownDefaultColors) | |
| 537 | - | p.mu.Unlock() | |
| 538 | - | } | |
| 539 | - | } | |
| 540 | 514 | d.paint(sess, ac, true) | |
| 541 | 515 | } | |
| 542 | 516 | ||
| @@ -55,6 +55,7 @@ type attachedClient struct { | |||||
| 55 | mouseScan mouse.Scanner | 55 | mouseScan mouse.Scanner | ||
| 56 | themeMu sync.Mutex | 56 | themeMu sync.Mutex | ||
| 57 | theme themeui.Theme | 57 | theme themeui.Theme | ||
| 58 | + | clientTheme themeui.Theme | |||
| 58 | lastCursor cursorOut | 59 | lastCursor cursorOut | ||
| 59 | linkMu sync.Mutex | 60 | linkMu sync.Mutex | ||
| 60 | sendMu sync.Mutex | 61 | sendMu sync.Mutex | ||
| @@ -114,12 +115,24 @@ func (ac *attachedClient) getTheme() themeui.Theme { | |||||
| 114 | return ac.theme | 115 | return ac.theme | ||
| 115 | } | 116 | } | ||
| 116 | 117 | ||||
| 118 | + | func (ac *attachedClient) getClientTheme() themeui.Theme { | |||
| 119 | + | ac.themeMu.Lock() | |||
| 120 | + | defer ac.themeMu.Unlock() | |||
| 121 | + | return ac.clientTheme | |||
| 122 | + | } | |||
| 123 | + | ||||
| 117 | func (ac *attachedClient) setTheme(t themeui.Theme) { | 124 | func (ac *attachedClient) setTheme(t themeui.Theme) { | ||
| 118 | ac.themeMu.Lock() | 125 | ac.themeMu.Lock() | ||
| 119 | ac.theme = t | 126 | ac.theme = t | ||
| 120 | ac.themeMu.Unlock() | 127 | ac.themeMu.Unlock() | ||
| 121 | } | 128 | } | ||
| 122 | 129 | ||||
| 130 | + | func (ac *attachedClient) setClientTheme(t themeui.Theme) { | |||
| 131 | + | ac.themeMu.Lock() | |||
| 132 | + | ac.clientTheme = t | |||
| 133 | + | ac.themeMu.Unlock() | |||
| 134 | + | } | |||
| 135 | + | ||||
| 123 | func (ac *attachedClient) transport() ports.Transport { | 136 | func (ac *attachedClient) transport() ports.Transport { | ||
| 124 | ac.linkMu.Lock() | 137 | ac.linkMu.Lock() | ||
| 125 | defer ac.linkMu.Unlock() | 138 | defer ac.linkMu.Unlock() | ||
| @@ -320,25 +333,15 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, | |||||
| 320 | } | 333 | } | ||
| 321 | ac.initOverlays() | 334 | ac.initOverlays() | ||
| 322 | ac.setSession(sess) | 335 | ac.setSession(sess) | ||
| 323 | - | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}) | 336 | + | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings) |
| 324 | sess.mu.Lock() | 337 | sess.mu.Lock() | ||
| 325 | old := sess.client | 338 | old := sess.client | ||
| 326 | sess.client = ac | 339 | sess.client = ac | ||
| 327 | name := sess.name | 340 | name := sess.name | ||
| 328 | - | tabs := append([]*tab(nil), sess.tabs...) | |||
| 329 | sess.mu.Unlock() | 341 | sess.mu.Unlock() | ||
| 330 | d.touchMRU(sess) | 342 | d.touchMRU(sess) | ||
| 331 | d.log.Info("client attached", "session", name, "resume", opts.resumeCapable) | 343 | d.log.Info("client attached", "session", name, "resume", opts.resumeCapable) | ||
| 332 | - | for _, tb := range tabs { | 344 | + | d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{})) |
| 333 | - | tb.mu.Lock() | |||
| 334 | - | panes := tb.panesSnapshot() | |||
| 335 | - | tb.mu.Unlock() | |||
| 336 | - | for _, p := range panes { | |||
| 337 | - | p.mu.Lock() | |||
| 338 | - | p.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false) | |||
| 339 | - | p.mu.Unlock() | |||
| 340 | - | } | |||
| 341 | - | } | |||
| 342 | return ac, old | 345 | return ac, old | ||
| 343 | } | 346 | } | ||
| 344 | 347 | ||||
| @@ -354,23 +357,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, | |||||
| 354 | // (and run its own attach-time reset), so this call must leave the tabs | 357 | // (and run its own attach-time reset), so this call must leave the tabs | ||
| 355 | // alone rather than clobbering that client's freshly applied colors. | 358 | // alone rather than clobbering that client's freshly applied colors. | ||
| 356 | func (d *Daemon) resetScreenDefaultColors(sess *session) { | 359 | func (d *Daemon) resetScreenDefaultColors(sess *session) { | ||
| 357 | - | sess.mu.Lock() | 360 | + | d.applyHostTheme(sess, nil, d.effectiveTheme(themeui.Theme{})) |
| 358 | - | if sess.client != nil { | |||
| 359 | - | sess.mu.Unlock() | |||
| 360 | - | return | |||
| 361 | - | } | |||
| 362 | - | tabs := append([]*tab(nil), sess.tabs...) | |||
| 363 | - | sess.mu.Unlock() | |||
| 364 | - | for _, tb := range tabs { | |||
| 365 | - | tb.mu.Lock() | |||
| 366 | - | panes := tb.panesSnapshot() | |||
| 367 | - | tb.mu.Unlock() | |||
| 368 | - | for _, p := range panes { | |||
| 369 | - | p.mu.Lock() | |||
| 370 | - | p.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false) | |||
| 371 | - | p.mu.Unlock() | |||
| 372 | - | } | |||
| 373 | - | } | |||
| 374 | } | 361 | } | ||
| 375 | 362 | ||||
| 376 | func (d *Daemon) detachReplacedClient(old *attachedClient) { | 363 | func (d *Daemon) detachReplacedClient(old *attachedClient) { | ||
| @@ -508,35 +495,22 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran | |||||
| 508 | // detachIfCurrent clears the client iff ac is the current one, reporting | 495 | // detachIfCurrent clears the client iff ac is the current one, reporting | ||
| 509 | 496 | ||||
| 510 | func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) { | 497 | func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) { | ||
| 511 | - | t := themeui.Theme{ | 498 | + | clientTheme := themeui.Theme{ |
| 512 | - | Foreground: msg.Foreground, | 499 | + | Foreground: msg.Foreground, |
| 513 | - | Background: msg.Background, | 500 | + | Background: msg.Background, |
| 514 | - | HasFG: msg.HasForeground, | 501 | + | HasFG: msg.HasForeground, |
| 515 | - | HasBG: msg.HasBackground, | 502 | + | HasBG: msg.HasBackground, |
| 516 | - | TrueColor: msg.TrueColor, | 503 | + | TrueColor: msg.TrueColor, |
| 517 | - | Known: msg.HasForeground && msg.HasBackground, | 504 | + | Known: msg.HasForeground && msg.HasBackground, |
| 518 | - | } | 505 | + | SchemeKnown: msg.SchemeKnown, |
| 519 | - | 506 | + | Light: msg.Light, | |
| 520 | - | knownDefaultColors := t.HasFG && t.HasBG | 507 | + | } |
| 521 | - | sess.mu.Lock() | 508 | + | ac.setClientTheme(clientTheme) |
| 522 | - | if sess.client != ac { | 509 | + | t := d.effectiveTheme(clientTheme) |
| 523 | - | sess.mu.Unlock() | 510 | + | |
| 511 | + | if !d.applyHostTheme(sess, ac, t) { | |||
| 524 | return | 512 | return | ||
| 525 | } | 513 | } | ||
| 526 | - | tabs := append([]*tab(nil), sess.tabs...) | |||
| 527 | - | sess.mu.Unlock() | |||
| 528 | - | ||||
| 529 | - | ac.setTheme(t) | |||
| 530 | - | for _, tb := range tabs { | |||
| 531 | - | tb.mu.Lock() | |||
| 532 | - | panes := tb.panesSnapshot() | |||
| 533 | - | tb.mu.Unlock() | |||
| 534 | - | for _, p := range panes { | |||
| 535 | - | p.mu.Lock() | |||
| 536 | - | p.screen.SetDefaultColors(t.Foreground, t.Background, knownDefaultColors) | |||
| 537 | - | p.mu.Unlock() | |||
| 538 | - | } | |||
| 539 | - | } | |||
| 540 | d.paint(sess, ac, true) | 514 | d.paint(sess, ac, true) | ||
| 541 | } | 515 | } | ||
| 542 | 516 | ||||
A internal/usecase/daemon/config.go +152 -0
| @@ -0,0 +1,152 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "regexp" | |
| 6 | + | "sort" | |
| 7 | + | "strings" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/usecase/command" | |
| 11 | + | "github.com/bnema/vev/internal/usecase/keys" | |
| 12 | + | "github.com/bnema/vev/internal/usecase/theme" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | var commandCodePattern = regexp.MustCompile(`^[A-Z0-9]{2,3}$`) | |
| 16 | + | ||
| 17 | + | // ApplyConfig validates and atomically swaps daemon runtime configuration. | |
| 18 | + | func (d *Daemon) ApplyConfig(cfg domain.Config) { | |
| 19 | + | bindings, warnings := keys.BuildBindingEntries(cfg.BindingEntries) | |
| 20 | + | if len(cfg.BindingEntries) == 0 && len(cfg.Bindings) > 0 { | |
| 21 | + | bindings, warnings = keys.BuildBindings(cfg.Bindings) | |
| 22 | + | } | |
| 23 | + | for _, warning := range warnings { | |
| 24 | + | d.logConfigWarning(warning) | |
| 25 | + | } | |
| 26 | + | overrides := d.buildCodeOverrides(cfg.Codes) | |
| 27 | + | d.bindings.Store(bindings) | |
| 28 | + | d.codeOverrides.Store(&overrides) | |
| 29 | + | d.themeMode.Store(uint32(cfg.Theme)) | |
| 30 | + | d.reapplyThemeAllSessions() | |
| 31 | + | d.repaintAllAttachedClients() | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (d *Daemon) buildCodeOverrides(configured map[string]string) map[string]string { | |
| 35 | + | commands := command.Registry() | |
| 36 | + | bySlug := make(map[string]command.Command, len(commands)) | |
| 37 | + | for _, cmd := range commands { | |
| 38 | + | bySlug[cmd.Slug] = cmd | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | desired := make(map[string]string) | |
| 42 | + | for _, slug := range sortedKeys(configured) { | |
| 43 | + | if _, ok := bySlug[slug]; !ok { | |
| 44 | + | d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("unknown command code slug %q", slug)}) | |
| 45 | + | continue | |
| 46 | + | } | |
| 47 | + | code := strings.ToUpper(strings.TrimSpace(configured[slug])) | |
| 48 | + | if !commandCodePattern.MatchString(code) { | |
| 49 | + | d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("invalid command code for %q", slug)}) | |
| 50 | + | continue | |
| 51 | + | } | |
| 52 | + | desired[slug] = code | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | used := make(map[string]string, len(commands)) | |
| 56 | + | for _, cmd := range commands { | |
| 57 | + | used[cmd.Code] = cmd.Slug | |
| 58 | + | } | |
| 59 | + | overrides := make(map[string]string) | |
| 60 | + | for _, slug := range sortedKeys(desired) { | |
| 61 | + | code := desired[slug] | |
| 62 | + | fallback := bySlug[slug].Code | |
| 63 | + | delete(used, fallback) | |
| 64 | + | if existing, ok := used[code]; ok && existing != slug { | |
| 65 | + | d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("command code %q for %q conflicts with %q", code, slug, existing)}) | |
| 66 | + | if fallbackOwner, fallbackUsed := used[fallback]; fallbackUsed && fallbackOwner != slug { | |
| 67 | + | d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("default command code %q for %q conflicts with %q", fallback, slug, fallbackOwner)}) | |
| 68 | + | continue | |
| 69 | + | } | |
| 70 | + | used[fallback] = slug | |
| 71 | + | continue | |
| 72 | + | } | |
| 73 | + | used[code] = slug | |
| 74 | + | overrides[slug] = code | |
| 75 | + | } | |
| 76 | + | return overrides | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func sortedKeys(values map[string]string) []string { | |
| 80 | + | keys := make([]string, 0, len(values)) | |
| 81 | + | for key := range values { | |
| 82 | + | keys = append(keys, key) | |
| 83 | + | } | |
| 84 | + | sort.Strings(keys) | |
| 85 | + | return keys | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func (d *Daemon) logConfigWarning(w domain.Warning) { | |
| 89 | + | if w.Line > 0 { | |
| 90 | + | d.log.Warn("config warning", "line", w.Line, "msg", w.Msg) | |
| 91 | + | return | |
| 92 | + | } | |
| 93 | + | d.log.Warn("config warning", "msg", w.Msg) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func (d *Daemon) codeOverrideSnapshot() map[string]string { | |
| 97 | + | overrides := d.codeOverrides.Load() | |
| 98 | + | if overrides == nil { | |
| 99 | + | return nil | |
| 100 | + | } | |
| 101 | + | return *overrides | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func commandWithOverrides(cmd command.Command, overrides map[string]string) command.Command { | |
| 105 | + | if code, ok := overrides[cmd.Slug]; ok { | |
| 106 | + | cmd.Code = code | |
| 107 | + | } | |
| 108 | + | return cmd | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func (d *Daemon) commandByEffectiveCode(code string) (command.Command, bool) { | |
| 112 | + | code = strings.ToUpper(strings.TrimSpace(code)) | |
| 113 | + | overrides := d.codeOverrideSnapshot() | |
| 114 | + | for _, cmd := range command.Registry() { | |
| 115 | + | cmd = commandWithOverrides(cmd, overrides) | |
| 116 | + | if cmd.Code == code { | |
| 117 | + | return cmd, true | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | return command.Command{}, false | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme { | |
| 124 | + | switch domain.ThemeMode(d.themeMode.Load()) { | |
| 125 | + | case domain.ThemeDark: | |
| 126 | + | return theme.BuiltinDark | |
| 127 | + | case domain.ThemeLight: | |
| 128 | + | return theme.BuiltinLight | |
| 129 | + | default: | |
| 130 | + | return clientTheme | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func (d *Daemon) reapplyThemeAllSessions() { | |
| 135 | + | d.mu.Lock() | |
| 136 | + | sessions := make([]*session, 0, len(d.sessions)) | |
| 137 | + | for _, sess := range d.sessions { | |
| 138 | + | sessions = append(sessions, sess) | |
| 139 | + | } | |
| 140 | + | d.mu.Unlock() | |
| 141 | + | ||
| 142 | + | for _, sess := range sessions { | |
| 143 | + | sess.mu.Lock() | |
| 144 | + | ac := sess.client | |
| 145 | + | sess.mu.Unlock() | |
| 146 | + | var clientTheme theme.Theme | |
| 147 | + | if ac != nil { | |
| 148 | + | clientTheme = ac.getClientTheme() | |
| 149 | + | } | |
| 150 | + | d.applyHostTheme(sess, ac, d.effectiveTheme(clientTheme)) | |
| 151 | + | } | |
| 152 | + | } | |
| @@ -0,0 +1,152 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "regexp" | |||
| 6 | + | "sort" | |||
| 7 | + | "strings" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/domain" | |||
| 10 | + | "github.com/bnema/vev/internal/usecase/command" | |||
| 11 | + | "github.com/bnema/vev/internal/usecase/keys" | |||
| 12 | + | "github.com/bnema/vev/internal/usecase/theme" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | var commandCodePattern = regexp.MustCompile(`^[A-Z0-9]{2,3}$`) | |||
| 16 | + | ||||
| 17 | + | // ApplyConfig validates and atomically swaps daemon runtime configuration. | |||
| 18 | + | func (d *Daemon) ApplyConfig(cfg domain.Config) { | |||
| 19 | + | bindings, warnings := keys.BuildBindingEntries(cfg.BindingEntries) | |||
| 20 | + | if len(cfg.BindingEntries) == 0 && len(cfg.Bindings) > 0 { | |||
| 21 | + | bindings, warnings = keys.BuildBindings(cfg.Bindings) | |||
| 22 | + | } | |||
| 23 | + | for _, warning := range warnings { | |||
| 24 | + | d.logConfigWarning(warning) | |||
| 25 | + | } | |||
| 26 | + | overrides := d.buildCodeOverrides(cfg.Codes) | |||
| 27 | + | d.bindings.Store(bindings) | |||
| 28 | + | d.codeOverrides.Store(&overrides) | |||
| 29 | + | d.themeMode.Store(uint32(cfg.Theme)) | |||
| 30 | + | d.reapplyThemeAllSessions() | |||
| 31 | + | d.repaintAllAttachedClients() | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func (d *Daemon) buildCodeOverrides(configured map[string]string) map[string]string { | |||
| 35 | + | commands := command.Registry() | |||
| 36 | + | bySlug := make(map[string]command.Command, len(commands)) | |||
| 37 | + | for _, cmd := range commands { | |||
| 38 | + | bySlug[cmd.Slug] = cmd | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | desired := make(map[string]string) | |||
| 42 | + | for _, slug := range sortedKeys(configured) { | |||
| 43 | + | if _, ok := bySlug[slug]; !ok { | |||
| 44 | + | d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("unknown command code slug %q", slug)}) | |||
| 45 | + | continue | |||
| 46 | + | } | |||
| 47 | + | code := strings.ToUpper(strings.TrimSpace(configured[slug])) | |||
| 48 | + | if !commandCodePattern.MatchString(code) { | |||
| 49 | + | d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("invalid command code for %q", slug)}) | |||
| 50 | + | continue | |||
| 51 | + | } | |||
| 52 | + | desired[slug] = code | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | used := make(map[string]string, len(commands)) | |||
| 56 | + | for _, cmd := range commands { | |||
| 57 | + | used[cmd.Code] = cmd.Slug | |||
| 58 | + | } | |||
| 59 | + | overrides := make(map[string]string) | |||
| 60 | + | for _, slug := range sortedKeys(desired) { | |||
| 61 | + | code := desired[slug] | |||
| 62 | + | fallback := bySlug[slug].Code | |||
| 63 | + | delete(used, fallback) | |||
| 64 | + | if existing, ok := used[code]; ok && existing != slug { | |||
| 65 | + | d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("command code %q for %q conflicts with %q", code, slug, existing)}) | |||
| 66 | + | if fallbackOwner, fallbackUsed := used[fallback]; fallbackUsed && fallbackOwner != slug { | |||
| 67 | + | d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("default command code %q for %q conflicts with %q", fallback, slug, fallbackOwner)}) | |||
| 68 | + | continue | |||
| 69 | + | } | |||
| 70 | + | used[fallback] = slug | |||
| 71 | + | continue | |||
| 72 | + | } | |||
| 73 | + | used[code] = slug | |||
| 74 | + | overrides[slug] = code | |||
| 75 | + | } | |||
| 76 | + | return overrides | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func sortedKeys(values map[string]string) []string { | |||
| 80 | + | keys := make([]string, 0, len(values)) | |||
| 81 | + | for key := range values { | |||
| 82 | + | keys = append(keys, key) | |||
| 83 | + | } | |||
| 84 | + | sort.Strings(keys) | |||
| 85 | + | return keys | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func (d *Daemon) logConfigWarning(w domain.Warning) { | |||
| 89 | + | if w.Line > 0 { | |||
| 90 | + | d.log.Warn("config warning", "line", w.Line, "msg", w.Msg) | |||
| 91 | + | return | |||
| 92 | + | } | |||
| 93 | + | d.log.Warn("config warning", "msg", w.Msg) | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func (d *Daemon) codeOverrideSnapshot() map[string]string { | |||
| 97 | + | overrides := d.codeOverrides.Load() | |||
| 98 | + | if overrides == nil { | |||
| 99 | + | return nil | |||
| 100 | + | } | |||
| 101 | + | return *overrides | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func commandWithOverrides(cmd command.Command, overrides map[string]string) command.Command { | |||
| 105 | + | if code, ok := overrides[cmd.Slug]; ok { | |||
| 106 | + | cmd.Code = code | |||
| 107 | + | } | |||
| 108 | + | return cmd | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func (d *Daemon) commandByEffectiveCode(code string) (command.Command, bool) { | |||
| 112 | + | code = strings.ToUpper(strings.TrimSpace(code)) | |||
| 113 | + | overrides := d.codeOverrideSnapshot() | |||
| 114 | + | for _, cmd := range command.Registry() { | |||
| 115 | + | cmd = commandWithOverrides(cmd, overrides) | |||
| 116 | + | if cmd.Code == code { | |||
| 117 | + | return cmd, true | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | return command.Command{}, false | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme { | |||
| 124 | + | switch domain.ThemeMode(d.themeMode.Load()) { | |||
| 125 | + | case domain.ThemeDark: | |||
| 126 | + | return theme.BuiltinDark | |||
| 127 | + | case domain.ThemeLight: | |||
| 128 | + | return theme.BuiltinLight | |||
| 129 | + | default: | |||
| 130 | + | return clientTheme | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func (d *Daemon) reapplyThemeAllSessions() { | |||
| 135 | + | d.mu.Lock() | |||
| 136 | + | sessions := make([]*session, 0, len(d.sessions)) | |||
| 137 | + | for _, sess := range d.sessions { | |||
| 138 | + | sessions = append(sessions, sess) | |||
| 139 | + | } | |||
| 140 | + | d.mu.Unlock() | |||
| 141 | + | ||||
| 142 | + | for _, sess := range sessions { | |||
| 143 | + | sess.mu.Lock() | |||
| 144 | + | ac := sess.client | |||
| 145 | + | sess.mu.Unlock() | |||
| 146 | + | var clientTheme theme.Theme | |||
| 147 | + | if ac != nil { | |||
| 148 | + | clientTheme = ac.getClientTheme() | |||
| 149 | + | } | |||
| 150 | + | d.applyHostTheme(sess, ac, d.effectiveTheme(clientTheme)) | |||
| 151 | + | } | |||
| 152 | + | } | |||
A internal/usecase/daemon/config_test.go +142 -0
| @@ -0,0 +1,142 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/usecase/keys" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | type captureKeyHandler struct { | |
| 13 | + | actions []keys.Action | |
| 14 | + | forward [][]byte | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func (h *captureKeyHandler) Forward(data []byte) { | |
| 18 | + | h.forward = append(h.forward, append([]byte(nil), data...)) | |
| 19 | + | } | |
| 20 | + | func (h *captureKeyHandler) Action(action keys.Action) { h.actions = append(h.actions, action) } | |
| 21 | + | ||
| 22 | + | func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) { | |
| 23 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 24 | + | h := &captureKeyHandler{} | |
| 25 | + | r := keys.NewRouter(stubClock{}, h, &d.bindings) | |
| 26 | + | ||
| 27 | + | r.Route([]byte("\x1bh")) | |
| 28 | + | require.Equal(t, []keys.Action{keys.ActionFocusPaneLeft}, h.actions) | |
| 29 | + | ||
| 30 | + | d.ApplyConfig(domain.Config{ | |
| 31 | + | Theme: domain.ThemeAuto, | |
| 32 | + | Bindings: map[string]string{ | |
| 33 | + | "focus-pane-left": "alt+q", | |
| 34 | + | }, | |
| 35 | + | Codes: map[string]string{ | |
| 36 | + | "new-tab": "nt", | |
| 37 | + | }, | |
| 38 | + | }) | |
| 39 | + | ||
| 40 | + | r.Route([]byte("\x1bh")) | |
| 41 | + | require.Equal(t, []keys.Action{keys.ActionFocusPaneLeft}, h.actions, "old key should no longer be intercepted") | |
| 42 | + | require.Equal(t, [][]byte{{'\x1b', 'h'}}, h.forward) | |
| 43 | + | r.Route([]byte("\x1bq")) | |
| 44 | + | require.Equal(t, []keys.Action{keys.ActionFocusPaneLeft, keys.ActionFocusPaneLeft}, h.actions) | |
| 45 | + | ||
| 46 | + | cmd, ok := d.commandByCode("NT") | |
| 47 | + | require.True(t, ok) | |
| 48 | + | require.Equal(t, "new-tab", cmd.Slug) | |
| 49 | + | require.Equal(t, "NT", cmd.Code) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestPaletteCommandsApplyOverridesAndResolveByCode(t *testing.T) { | |
| 53 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 54 | + | d.ApplyConfig(domain.Config{ | |
| 55 | + | Theme: domain.ThemeAuto, | |
| 56 | + | Codes: map[string]string{ | |
| 57 | + | "new-tab": "NN", | |
| 58 | + | }, | |
| 59 | + | }) | |
| 60 | + | ||
| 61 | + | commands := d.paletteCommands() | |
| 62 | + | require.NotEmpty(t, commands) | |
| 63 | + | require.Equal(t, "new-tab", commands[0].Slug) | |
| 64 | + | require.Equal(t, "NN", commands[0].Code) | |
| 65 | + | ||
| 66 | + | cmd, ok := d.commandByCode("nn") | |
| 67 | + | require.True(t, ok) | |
| 68 | + | require.Equal(t, "new-tab", cmd.Slug) | |
| 69 | + | require.Equal(t, "NN", cmd.Code) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func TestPaletteCommandsUseOverrideSnapshotForRecentAndListing(t *testing.T) { | |
| 73 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 74 | + | d.ApplyConfig(domain.Config{ | |
| 75 | + | Theme: domain.ThemeAuto, | |
| 76 | + | Codes: map[string]string{ | |
| 77 | + | "split-right": "SRX", | |
| 78 | + | }, | |
| 79 | + | }) | |
| 80 | + | d.recordPaletteUse("SRX") | |
| 81 | + | ||
| 82 | + | commands := d.paletteCommands() | |
| 83 | + | require.NotEmpty(t, commands) | |
| 84 | + | require.Equal(t, "split-right", commands[0].Slug) | |
| 85 | + | require.Equal(t, "SRX", commands[0].Code) | |
| 86 | + | ||
| 87 | + | seen := 0 | |
| 88 | + | for _, cmd := range commands { | |
| 89 | + | if cmd.Slug == "split-right" { | |
| 90 | + | seen++ | |
| 91 | + | require.Equal(t, "SRX", cmd.Code) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | require.Equal(t, 1, seen) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func TestCodeOverrideConflictRestoresDefaultWithoutDuplicates(t *testing.T) { | |
| 98 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 99 | + | d.ApplyConfig(domain.Config{ | |
| 100 | + | Theme: domain.ThemeAuto, | |
| 101 | + | Codes: map[string]string{ | |
| 102 | + | "new-session": "CNT", | |
| 103 | + | "new-tab": "DET", | |
| 104 | + | }, | |
| 105 | + | }) | |
| 106 | + | ||
| 107 | + | commands := d.paletteCommands() | |
| 108 | + | byCode := make(map[string]string, len(commands)) | |
| 109 | + | for _, cmd := range commands { | |
| 110 | + | if existing, ok := byCode[cmd.Code]; ok { | |
| 111 | + | t.Fatalf("duplicate effective code %q for %q and %q", cmd.Code, existing, cmd.Slug) | |
| 112 | + | } | |
| 113 | + | byCode[cmd.Code] = cmd.Slug | |
| 114 | + | } | |
| 115 | + | require.Equal(t, "new-tab", byCode["CNT"]) | |
| 116 | + | require.Equal(t, "new-session", byCode["CNS"]) | |
| 117 | + | require.Equal(t, "detach", byCode["DET"]) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func TestApplyConfigInvalidDefaultsWithoutPanic(t *testing.T) { | |
| 121 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 122 | + | require.NotPanics(t, func() { | |
| 123 | + | d.ApplyConfig(domain.Config{ | |
| 124 | + | Theme: domain.ThemeAuto, | |
| 125 | + | Bindings: map[string]string{ | |
| 126 | + | "unknown-action": "alt+x", | |
| 127 | + | "focus-pane-left": "ctrl+x", | |
| 128 | + | }, | |
| 129 | + | Codes: map[string]string{ | |
| 130 | + | "new-tab": "toolong", | |
| 131 | + | "split-right": "det", | |
| 132 | + | "missing-command": "OK", | |
| 133 | + | }, | |
| 134 | + | }) | |
| 135 | + | }) | |
| 136 | + | ||
| 137 | + | cmd, ok := d.commandByCode("CNT") | |
| 138 | + | require.True(t, ok) | |
| 139 | + | require.Equal(t, "new-tab", cmd.Slug) | |
| 140 | + | _, ok = d.commandByCode("TOOLONG") | |
| 141 | + | require.False(t, ok) | |
| 142 | + | } | |
| @@ -0,0 +1,142 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/require" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 9 | + | "github.com/bnema/vev/internal/usecase/keys" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | type captureKeyHandler struct { | |||
| 13 | + | actions []keys.Action | |||
| 14 | + | forward [][]byte | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func (h *captureKeyHandler) Forward(data []byte) { | |||
| 18 | + | h.forward = append(h.forward, append([]byte(nil), data...)) | |||
| 19 | + | } | |||
| 20 | + | func (h *captureKeyHandler) Action(action keys.Action) { h.actions = append(h.actions, action) } | |||
| 21 | + | ||||
| 22 | + | func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) { | |||
| 23 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 24 | + | h := &captureKeyHandler{} | |||
| 25 | + | r := keys.NewRouter(stubClock{}, h, &d.bindings) | |||
| 26 | + | ||||
| 27 | + | r.Route([]byte("\x1bh")) | |||
| 28 | + | require.Equal(t, []keys.Action{keys.ActionFocusPaneLeft}, h.actions) | |||
| 29 | + | ||||
| 30 | + | d.ApplyConfig(domain.Config{ | |||
| 31 | + | Theme: domain.ThemeAuto, | |||
| 32 | + | Bindings: map[string]string{ | |||
| 33 | + | "focus-pane-left": "alt+q", | |||
| 34 | + | }, | |||
| 35 | + | Codes: map[string]string{ | |||
| 36 | + | "new-tab": "nt", | |||
| 37 | + | }, | |||
| 38 | + | }) | |||
| 39 | + | ||||
| 40 | + | r.Route([]byte("\x1bh")) | |||
| 41 | + | require.Equal(t, []keys.Action{keys.ActionFocusPaneLeft}, h.actions, "old key should no longer be intercepted") | |||
| 42 | + | require.Equal(t, [][]byte{{'\x1b', 'h'}}, h.forward) | |||
| 43 | + | r.Route([]byte("\x1bq")) | |||
| 44 | + | require.Equal(t, []keys.Action{keys.ActionFocusPaneLeft, keys.ActionFocusPaneLeft}, h.actions) | |||
| 45 | + | ||||
| 46 | + | cmd, ok := d.commandByCode("NT") | |||
| 47 | + | require.True(t, ok) | |||
| 48 | + | require.Equal(t, "new-tab", cmd.Slug) | |||
| 49 | + | require.Equal(t, "NT", cmd.Code) | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func TestPaletteCommandsApplyOverridesAndResolveByCode(t *testing.T) { | |||
| 53 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 54 | + | d.ApplyConfig(domain.Config{ | |||
| 55 | + | Theme: domain.ThemeAuto, | |||
| 56 | + | Codes: map[string]string{ | |||
| 57 | + | "new-tab": "NN", | |||
| 58 | + | }, | |||
| 59 | + | }) | |||
| 60 | + | ||||
| 61 | + | commands := d.paletteCommands() | |||
| 62 | + | require.NotEmpty(t, commands) | |||
| 63 | + | require.Equal(t, "new-tab", commands[0].Slug) | |||
| 64 | + | require.Equal(t, "NN", commands[0].Code) | |||
| 65 | + | ||||
| 66 | + | cmd, ok := d.commandByCode("nn") | |||
| 67 | + | require.True(t, ok) | |||
| 68 | + | require.Equal(t, "new-tab", cmd.Slug) | |||
| 69 | + | require.Equal(t, "NN", cmd.Code) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func TestPaletteCommandsUseOverrideSnapshotForRecentAndListing(t *testing.T) { | |||
| 73 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 74 | + | d.ApplyConfig(domain.Config{ | |||
| 75 | + | Theme: domain.ThemeAuto, | |||
| 76 | + | Codes: map[string]string{ | |||
| 77 | + | "split-right": "SRX", | |||
| 78 | + | }, | |||
| 79 | + | }) | |||
| 80 | + | d.recordPaletteUse("SRX") | |||
| 81 | + | ||||
| 82 | + | commands := d.paletteCommands() | |||
| 83 | + | require.NotEmpty(t, commands) | |||
| 84 | + | require.Equal(t, "split-right", commands[0].Slug) | |||
| 85 | + | require.Equal(t, "SRX", commands[0].Code) | |||
| 86 | + | ||||
| 87 | + | seen := 0 | |||
| 88 | + | for _, cmd := range commands { | |||
| 89 | + | if cmd.Slug == "split-right" { | |||
| 90 | + | seen++ | |||
| 91 | + | require.Equal(t, "SRX", cmd.Code) | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | require.Equal(t, 1, seen) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func TestCodeOverrideConflictRestoresDefaultWithoutDuplicates(t *testing.T) { | |||
| 98 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 99 | + | d.ApplyConfig(domain.Config{ | |||
| 100 | + | Theme: domain.ThemeAuto, | |||
| 101 | + | Codes: map[string]string{ | |||
| 102 | + | "new-session": "CNT", | |||
| 103 | + | "new-tab": "DET", | |||
| 104 | + | }, | |||
| 105 | + | }) | |||
| 106 | + | ||||
| 107 | + | commands := d.paletteCommands() | |||
| 108 | + | byCode := make(map[string]string, len(commands)) | |||
| 109 | + | for _, cmd := range commands { | |||
| 110 | + | if existing, ok := byCode[cmd.Code]; ok { | |||
| 111 | + | t.Fatalf("duplicate effective code %q for %q and %q", cmd.Code, existing, cmd.Slug) | |||
| 112 | + | } | |||
| 113 | + | byCode[cmd.Code] = cmd.Slug | |||
| 114 | + | } | |||
| 115 | + | require.Equal(t, "new-tab", byCode["CNT"]) | |||
| 116 | + | require.Equal(t, "new-session", byCode["CNS"]) | |||
| 117 | + | require.Equal(t, "detach", byCode["DET"]) | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func TestApplyConfigInvalidDefaultsWithoutPanic(t *testing.T) { | |||
| 121 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 122 | + | require.NotPanics(t, func() { | |||
| 123 | + | d.ApplyConfig(domain.Config{ | |||
| 124 | + | Theme: domain.ThemeAuto, | |||
| 125 | + | Bindings: map[string]string{ | |||
| 126 | + | "unknown-action": "alt+x", | |||
| 127 | + | "focus-pane-left": "ctrl+x", | |||
| 128 | + | }, | |||
| 129 | + | Codes: map[string]string{ | |||
| 130 | + | "new-tab": "toolong", | |||
| 131 | + | "split-right": "det", | |||
| 132 | + | "missing-command": "OK", | |||
| 133 | + | }, | |||
| 134 | + | }) | |||
| 135 | + | }) | |||
| 136 | + | ||||
| 137 | + | cmd, ok := d.commandByCode("CNT") | |||
| 138 | + | require.True(t, ok) | |||
| 139 | + | require.Equal(t, "new-tab", cmd.Slug) | |||
| 140 | + | _, ok = d.commandByCode("TOOLONG") | |||
| 141 | + | require.False(t, ok) | |||
| 142 | + | } | |||
M internal/usecase/daemon/daemon.go +23 -0
| @@ -38,6 +38,8 @@ import ( | |||
| 38 | 38 | "github.com/bnema/vev/internal/persist" | |
| 39 | 39 | "github.com/bnema/vev/internal/platform" | |
| 40 | 40 | "github.com/bnema/vev/internal/ports" | |
| 41 | + | "github.com/bnema/vev/internal/usecase/command" | |
| 42 | + | "github.com/bnema/vev/internal/usecase/keys" | |
| 41 | 43 | ) | |
| 42 | 44 | ||
| 43 | 45 | // Scheduler debounce bounds. Idle updates use the minimum for low latency; | |
| @@ -105,6 +107,10 @@ type Daemon struct { | |||
| 105 | 107 | persistEnabled bool | |
| 106 | 108 | procCwd func(int) (string, error) | |
| 107 | 109 | procComm func(int) (string, error) | |
| 110 | + | commandByCode func(string) (command.Command, bool) | |
| 111 | + | bindings atomic.Pointer[keys.Bindings] | |
| 112 | + | codeOverrides atomic.Pointer[map[string]string] | |
| 113 | + | themeMode atomic.Uint32 | |
| 108 | 114 | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | |
| 109 | 115 | // (see clipboard.go); empty means use os.TempDir(). | |
| 110 | 116 | tempDir string | |
| @@ -181,6 +187,13 @@ func WithTempDir(dir string) Option { | |||
| 181 | 187 | } | |
| 182 | 188 | } | |
| 183 | 189 | ||
| 190 | + | // WithConfig applies the initial user configuration. | |
| 191 | + | func WithConfig(cfg domain.Config) Option { | |
| 192 | + | return func(d *Daemon) { | |
| 193 | + | d.ApplyConfig(cfg) | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | ||
| 184 | 197 | // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the | |
| 185 | 198 | // render debounce, and log receives diagnostics (defaults to slog.Default). | |
| 186 | 199 | func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon { | |
| @@ -218,6 +231,16 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 218 | 231 | if d.procComm == nil { | |
| 219 | 232 | d.procComm = platform.ProcessComm | |
| 220 | 233 | } | |
| 234 | + | if d.commandByCode == nil { | |
| 235 | + | d.commandByCode = d.commandByEffectiveCode | |
| 236 | + | } | |
| 237 | + | if d.bindings.Load() == nil { | |
| 238 | + | d.bindings.Store(keys.DefaultBindings()) | |
| 239 | + | } | |
| 240 | + | if d.codeOverrides.Load() == nil { | |
| 241 | + | empty := map[string]string{} | |
| 242 | + | d.codeOverrides.Store(&empty) | |
| 243 | + | } | |
| 221 | 244 | if records, err := d.persist.LoadAll(); err != nil { | |
| 222 | 245 | d.log.Warn("loading persisted sessions failed", "err", err) | |
| 223 | 246 | } else { | |
| @@ -38,6 +38,8 @@ import ( | |||||
| 38 | "github.com/bnema/vev/internal/persist" | 38 | "github.com/bnema/vev/internal/persist" | ||
| 39 | "github.com/bnema/vev/internal/platform" | 39 | "github.com/bnema/vev/internal/platform" | ||
| 40 | "github.com/bnema/vev/internal/ports" | 40 | "github.com/bnema/vev/internal/ports" | ||
| 41 | + | "github.com/bnema/vev/internal/usecase/command" | |||
| 42 | + | "github.com/bnema/vev/internal/usecase/keys" | |||
| 41 | ) | 43 | ) | ||
| 42 | 44 | ||||
| 43 | // Scheduler debounce bounds. Idle updates use the minimum for low latency; | 45 | // Scheduler debounce bounds. Idle updates use the minimum for low latency; | ||
| @@ -105,6 +107,10 @@ type Daemon struct { | |||||
| 105 | persistEnabled bool | 107 | persistEnabled bool | ||
| 106 | procCwd func(int) (string, error) | 108 | procCwd func(int) (string, error) | ||
| 107 | procComm func(int) (string, error) | 109 | procComm func(int) (string, error) | ||
| 110 | + | commandByCode func(string) (command.Command, bool) | |||
| 111 | + | bindings atomic.Pointer[keys.Bindings] | |||
| 112 | + | codeOverrides atomic.Pointer[map[string]string] | |||
| 113 | + | themeMode atomic.Uint32 | |||
| 108 | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | 114 | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | ||
| 109 | // (see clipboard.go); empty means use os.TempDir(). | 115 | // (see clipboard.go); empty means use os.TempDir(). | ||
| 110 | tempDir string | 116 | tempDir string | ||
| @@ -181,6 +187,13 @@ func WithTempDir(dir string) Option { | |||||
| 181 | } | 187 | } | ||
| 182 | } | 188 | } | ||
| 183 | 189 | ||||
| 190 | + | // WithConfig applies the initial user configuration. | |||
| 191 | + | func WithConfig(cfg domain.Config) Option { | |||
| 192 | + | return func(d *Daemon) { | |||
| 193 | + | d.ApplyConfig(cfg) | |||
| 194 | + | } | |||
| 195 | + | } | |||
| 196 | + | ||||
| 184 | // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the | 197 | // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the | ||
| 185 | // render debounce, and log receives diagnostics (defaults to slog.Default). | 198 | // render debounce, and log receives diagnostics (defaults to slog.Default). | ||
| 186 | func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon { | 199 | func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon { | ||
| @@ -218,6 +231,16 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||||
| 218 | if d.procComm == nil { | 231 | if d.procComm == nil { | ||
| 219 | d.procComm = platform.ProcessComm | 232 | d.procComm = platform.ProcessComm | ||
| 220 | } | 233 | } | ||
| 234 | + | if d.commandByCode == nil { | |||
| 235 | + | d.commandByCode = d.commandByEffectiveCode | |||
| 236 | + | } | |||
| 237 | + | if d.bindings.Load() == nil { | |||
| 238 | + | d.bindings.Store(keys.DefaultBindings()) | |||
| 239 | + | } | |||
| 240 | + | if d.codeOverrides.Load() == nil { | |||
| 241 | + | empty := map[string]string{} | |||
| 242 | + | d.codeOverrides.Store(&empty) | |||
| 243 | + | } | |||
| 221 | if records, err := d.persist.LoadAll(); err != nil { | 244 | if records, err := d.persist.LoadAll(); err != nil { | ||
| 222 | d.log.Warn("loading persisted sessions failed", "err", err) | 245 | d.log.Warn("loading persisted sessions failed", "err", err) | ||
| 223 | } else { | 246 | } else { | ||
M internal/usecase/daemon/palette.go +3 -0
| @@ -31,9 +31,11 @@ func (d *Daemon) paletteCommands() []command.Command { | |||
| 31 | 31 | d.paletteRecentMu.Lock() | |
| 32 | 32 | recent := append([]string(nil), d.paletteRecent...) | |
| 33 | 33 | d.paletteRecentMu.Unlock() | |
| 34 | + | overrides := d.codeOverrideSnapshot() | |
| 34 | 35 | ||
| 35 | 36 | byCode := make(map[string]command.Command, len(commands)) | |
| 36 | 37 | for _, cmd := range commands { | |
| 38 | + | cmd = commandWithOverrides(cmd, overrides) | |
| 37 | 39 | byCode[cmd.Code] = cmd | |
| 38 | 40 | } | |
| 39 | 41 | out := make([]command.Command, 0, len(commands)) | |
| @@ -47,6 +49,7 @@ func (d *Daemon) paletteCommands() []command.Command { | |||
| 47 | 49 | used[code] = true | |
| 48 | 50 | } | |
| 49 | 51 | for _, cmd := range commands { | |
| 52 | + | cmd = commandWithOverrides(cmd, overrides) | |
| 50 | 53 | if !used[cmd.Code] { | |
| 51 | 54 | out = append(out, cmd) | |
| 52 | 55 | } | |
| @@ -31,9 +31,11 @@ func (d *Daemon) paletteCommands() []command.Command { | |||||
| 31 | d.paletteRecentMu.Lock() | 31 | d.paletteRecentMu.Lock() | ||
| 32 | recent := append([]string(nil), d.paletteRecent...) | 32 | recent := append([]string(nil), d.paletteRecent...) | ||
| 33 | d.paletteRecentMu.Unlock() | 33 | d.paletteRecentMu.Unlock() | ||
| 34 | + | overrides := d.codeOverrideSnapshot() | |||
| 34 | 35 | ||||
| 35 | byCode := make(map[string]command.Command, len(commands)) | 36 | byCode := make(map[string]command.Command, len(commands)) | ||
| 36 | for _, cmd := range commands { | 37 | for _, cmd := range commands { | ||
| 38 | + | cmd = commandWithOverrides(cmd, overrides) | |||
| 37 | byCode[cmd.Code] = cmd | 39 | byCode[cmd.Code] = cmd | ||
| 38 | } | 40 | } | ||
| 39 | out := make([]command.Command, 0, len(commands)) | 41 | out := make([]command.Command, 0, len(commands)) | ||
| @@ -47,6 +49,7 @@ func (d *Daemon) paletteCommands() []command.Command { | |||||
| 47 | used[code] = true | 49 | used[code] = true | ||
| 48 | } | 50 | } | ||
| 49 | for _, cmd := range commands { | 51 | for _, cmd := range commands { | ||
| 52 | + | cmd = commandWithOverrides(cmd, overrides) | |||
| 50 | if !used[cmd.Code] { | 53 | if !used[cmd.Code] { | ||
| 51 | out = append(out, cmd) | 54 | out = append(out, cmd) | ||
| 52 | } | 55 | } | ||
M internal/usecase/daemon/session.go +2 -2
| @@ -210,13 +210,13 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||
| 210 | 210 | } | |
| 211 | 211 | tb := newTab(pty, tbSize) | |
| 212 | 212 | if client != nil { | |
| 213 | - | t := client.getTheme() | |
| 213 | + | t := d.effectiveTheme(client.getClientTheme()) | |
| 214 | 214 | tb.mu.Lock() | |
| 215 | 215 | p := tb.focusedPane() | |
| 216 | 216 | tb.mu.Unlock() | |
| 217 | 217 | if p != nil { | |
| 218 | 218 | p.mu.Lock() | |
| 219 | - | p.screen.SetDefaultColors(t.Foreground, t.Background, t.HasFG && t.HasBG) | |
| 219 | + | applyPaneThemeLocked(p, t, false) | |
| 220 | 220 | p.mu.Unlock() | |
| 221 | 221 | } | |
| 222 | 222 | } | |
| @@ -210,13 +210,13 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||||
| 210 | } | 210 | } | ||
| 211 | tb := newTab(pty, tbSize) | 211 | tb := newTab(pty, tbSize) | ||
| 212 | if client != nil { | 212 | if client != nil { | ||
| 213 | - | t := client.getTheme() | 213 | + | t := d.effectiveTheme(client.getClientTheme()) |
| 214 | tb.mu.Lock() | 214 | tb.mu.Lock() | ||
| 215 | p := tb.focusedPane() | 215 | p := tb.focusedPane() | ||
| 216 | tb.mu.Unlock() | 216 | tb.mu.Unlock() | ||
| 217 | if p != nil { | 217 | if p != nil { | ||
| 218 | p.mu.Lock() | 218 | p.mu.Lock() | ||
| 219 | - | p.screen.SetDefaultColors(t.Foreground, t.Background, t.HasFG && t.HasBG) | 219 | + | applyPaneThemeLocked(p, t, false) |
| 220 | p.mu.Unlock() | 220 | p.mu.Unlock() | ||
| 221 | } | 221 | } | ||
| 222 | } | 222 | } | ||
M internal/usecase/daemon/status_test.go +161 -3
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "fmt" | |
| 4 | 5 | "strconv" | |
| 5 | 6 | "strings" | |
| 6 | 7 | "sync" | |
| @@ -79,20 +80,177 @@ func TestStatusApplyThemeStoresClientAndPropagatesScreens(t *testing.T) { | |||
| 79 | 80 | d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2) | |
| 80 | 81 | defer releasePTY1() | |
| 81 | 82 | defer releasePTY2() | |
| 82 | - | msg := ports.Theme{HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}} | |
| 83 | + | msg := ports.Theme{HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, SchemeKnown: true, Light: true} | |
| 83 | 84 | ||
| 84 | 85 | d.applyTheme(sess, ac, msg) | |
| 85 | 86 | ||
| 86 | 87 | require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, ac.getTheme().Foreground) | |
| 88 | + | require.True(t, ac.getTheme().SchemeKnown) | |
| 89 | + | require.True(t, ac.getTheme().Light) | |
| 90 | + | assertSessionDefaultColors(t, sess, renderer.RGB{R: 1, G: 2, B: 3}, renderer.RGB{R: 4, G: 5, B: 6}) | |
| 91 | + | assertSessionColorScheme(t, sess, true) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func TestApplyThemeForcedBuiltinThemePropagatesToChromeAndPanes(t *testing.T) { | |
| 95 | + | tests := []struct { | |
| 96 | + | name string | |
| 97 | + | mode domain.ThemeMode | |
| 98 | + | want themeui.Theme | |
| 99 | + | }{ | |
| 100 | + | {name: "dark", mode: domain.ThemeDark, want: themeui.BuiltinDark}, | |
| 101 | + | {name: "light", mode: domain.ThemeLight, want: themeui.BuiltinLight}, | |
| 102 | + | } | |
| 103 | + | for _, tc := range tests { | |
| 104 | + | t.Run(tc.name, func(t *testing.T) { | |
| 105 | + | p1, releasePTY1 := newBlockingPTY(t) | |
| 106 | + | p2, releasePTY2 := newBlockingPTY(t) | |
| 107 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2) | |
| 108 | + | defer releasePTY1() | |
| 109 | + | defer releasePTY2() | |
| 110 | + | d.ApplyConfig(domain.Config{Theme: tc.mode}) | |
| 111 | + | ||
| 112 | + | d.applyTheme(sess, ac, ports.Theme{ | |
| 113 | + | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 114 | + | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 115 | + | TrueColor: true, SchemeKnown: true, Light: !tc.want.Light, | |
| 116 | + | }) | |
| 117 | + | ||
| 118 | + | require.Equal(t, tc.want, ac.getTheme()) | |
| 119 | + | assertSessionDefaultColors(t, sess, tc.want.Foreground, tc.want.Background) | |
| 120 | + | assertSessionColorScheme(t, sess, tc.want.Light) | |
| 121 | + | }) | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func TestAttachClientAppliesForcedThemeBeforeMsgTheme(t *testing.T) { | |
| 126 | + | tests := []struct { | |
| 127 | + | name string | |
| 128 | + | mode domain.ThemeMode | |
| 129 | + | want themeui.Theme | |
| 130 | + | }{ | |
| 131 | + | {name: "dark", mode: domain.ThemeDark, want: themeui.BuiltinDark}, | |
| 132 | + | {name: "light", mode: domain.ThemeLight, want: themeui.BuiltinLight}, | |
| 133 | + | } | |
| 134 | + | for _, tc := range tests { | |
| 135 | + | t.Run(tc.name, func(t *testing.T) { | |
| 136 | + | p, releasePTY := newBlockingPTY(t) | |
| 137 | + | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| 138 | + | defer releasePTY() | |
| 139 | + | d.ApplyConfig(domain.Config{Theme: tc.mode}) | |
| 140 | + | for _, tb := range sess.tabs { | |
| 141 | + | tb.mu.Lock() | |
| 142 | + | panes := tb.panesSnapshot() | |
| 143 | + | tb.mu.Unlock() | |
| 144 | + | for _, p := range panes { | |
| 145 | + | p.mu.Lock() | |
| 146 | + | p.screen.SetDefaultColors(renderer.RGB{R: 1, G: 2, B: 3}, renderer.RGB{R: 4, G: 5, B: 6}, true) | |
| 147 | + | p.mu.Unlock() | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | tr, _ := newCapturingTransport(t) | |
| 151 | + | ||
| 152 | + | ac, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 153 | + | ||
| 154 | + | require.Equal(t, tc.want, ac.getTheme()) | |
| 155 | + | assertSessionDefaultColors(t, sess, tc.want.Foreground, tc.want.Background) | |
| 156 | + | assertSessionColorScheme(t, sess, tc.want.Light) | |
| 157 | + | }) | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func TestAutoThemeDetachClearsPaneColorScheme(t *testing.T) { | |
| 162 | + | p, releasePTY := newBlockingPTY(t) | |
| 163 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 164 | + | defer releasePTY() | |
| 165 | + | d.ApplyConfig(domain.Config{Theme: domain.ThemeAuto}) | |
| 166 | + | d.applyTheme(sess, ac, ports.Theme{ | |
| 167 | + | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 168 | + | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 169 | + | TrueColor: true, SchemeKnown: true, Light: true, | |
| 170 | + | }) | |
| 171 | + | assertSessionColorScheme(t, sess, true) | |
| 172 | + | ||
| 173 | + | d.clientGone(sess, ac, ac.transport(), true) | |
| 174 | + | ||
| 175 | + | assertSessionColorSchemeUnknown(t, sess) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func TestForcedThemeDetachPreservesBuiltinOnPanes(t *testing.T) { | |
| 179 | + | p, releasePTY := newBlockingPTY(t) | |
| 180 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 181 | + | defer releasePTY() | |
| 182 | + | d.ApplyConfig(domain.Config{Theme: domain.ThemeDark}) | |
| 183 | + | d.applyTheme(sess, ac, ports.Theme{ | |
| 184 | + | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 185 | + | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 186 | + | TrueColor: true, SchemeKnown: true, Light: true, | |
| 187 | + | }) | |
| 188 | + | ||
| 189 | + | d.clientGone(sess, ac, ac.transport(), true) | |
| 190 | + | ||
| 191 | + | assertSessionDefaultColors(t, sess, themeui.BuiltinDark.Foreground, themeui.BuiltinDark.Background) | |
| 192 | + | assertSessionColorScheme(t, sess, false) | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | func TestApplyThemeAutoUnknownDoesNotClobberPaneColorScheme(t *testing.T) { | |
| 196 | + | p, releasePTY := newBlockingPTY(t) | |
| 197 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 198 | + | defer releasePTY() | |
| 199 | + | ||
| 200 | + | d.applyTheme(sess, ac, ports.Theme{ | |
| 201 | + | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 202 | + | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 203 | + | TrueColor: true, SchemeKnown: true, Light: true, | |
| 204 | + | }) | |
| 205 | + | d.applyTheme(sess, ac, ports.Theme{ | |
| 206 | + | HasForeground: true, Foreground: renderer.RGB{R: 7, G: 8, B: 9}, | |
| 207 | + | HasBackground: true, Background: renderer.RGB{R: 10, G: 11, B: 12}, | |
| 208 | + | TrueColor: true, | |
| 209 | + | }) | |
| 210 | + | ||
| 211 | + | assertSessionDefaultColors(t, sess, renderer.RGB{R: 7, G: 8, B: 9}, renderer.RGB{R: 10, G: 11, B: 12}) | |
| 212 | + | assertSessionColorScheme(t, sess, true) | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func assertSessionDefaultColors(t *testing.T, sess *session, fg, bg renderer.RGB) { | |
| 216 | + | t.Helper() | |
| 87 | 217 | for _, tb := range sess.tabs { | |
| 88 | 218 | var got []byte | |
| 89 | 219 | tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) } | |
| 90 | 220 | tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a")) | |
| 91 | - | require.True(t, strings.Contains(string(got), "rgb:0101/0202/0303"), string(got)) | |
| 92 | - | require.True(t, strings.Contains(string(got), "rgb:0404/0505/0606"), string(got)) | |
| 221 | + | require.True(t, strings.Contains(string(got), formatOSCColor(fg)), string(got)) | |
| 222 | + | require.True(t, strings.Contains(string(got), formatOSCColor(bg)), string(got)) | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | func assertSessionColorScheme(t *testing.T, sess *session, light bool) { | |
| 227 | + | t.Helper() | |
| 228 | + | want := "\x1b[?997;1n" | |
| 229 | + | if light { | |
| 230 | + | want = "\x1b[?997;2n" | |
| 231 | + | } | |
| 232 | + | for _, tb := range sess.tabs { | |
| 233 | + | var got []byte | |
| 234 | + | tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) } | |
| 235 | + | tb.focusedPane().screen.Write([]byte("\x1b[?996n")) | |
| 236 | + | require.Equal(t, want, string(got)) | |
| 93 | 237 | } | |
| 94 | 238 | } | |
| 95 | 239 | ||
| 240 | + | func assertSessionColorSchemeUnknown(t *testing.T, sess *session) { | |
| 241 | + | t.Helper() | |
| 242 | + | for _, tb := range sess.tabs { | |
| 243 | + | var got []byte | |
| 244 | + | tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) } | |
| 245 | + | tb.focusedPane().screen.Write([]byte("\x1b[?996n")) | |
| 246 | + | require.Empty(t, got) | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | func formatOSCColor(rgb renderer.RGB) string { | |
| 251 | + | return fmt.Sprintf("rgb:%02x%02x/%02x%02x/%02x%02x", rgb.R, rgb.R, rgb.G, rgb.G, rgb.B, rgb.B) | |
| 252 | + | } | |
| 253 | + | ||
| 96 | 254 | func TestAttachClientClearsStaleScreenDefaultColors(t *testing.T) { | |
| 97 | 255 | p, release := newBlockingPTY(t) | |
| 98 | 256 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "fmt" | |||
| 4 | "strconv" | 5 | "strconv" | ||
| 5 | "strings" | 6 | "strings" | ||
| 6 | "sync" | 7 | "sync" | ||
| @@ -79,20 +80,177 @@ func TestStatusApplyThemeStoresClientAndPropagatesScreens(t *testing.T) { | |||||
| 79 | d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2) | 80 | d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2) | ||
| 80 | defer releasePTY1() | 81 | defer releasePTY1() | ||
| 81 | defer releasePTY2() | 82 | defer releasePTY2() | ||
| 82 | - | msg := ports.Theme{HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}} | 83 | + | msg := ports.Theme{HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, SchemeKnown: true, Light: true} |
| 83 | 84 | ||||
| 84 | d.applyTheme(sess, ac, msg) | 85 | d.applyTheme(sess, ac, msg) | ||
| 85 | 86 | ||||
| 86 | require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, ac.getTheme().Foreground) | 87 | require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, ac.getTheme().Foreground) | ||
| 88 | + | require.True(t, ac.getTheme().SchemeKnown) | |||
| 89 | + | require.True(t, ac.getTheme().Light) | |||
| 90 | + | assertSessionDefaultColors(t, sess, renderer.RGB{R: 1, G: 2, B: 3}, renderer.RGB{R: 4, G: 5, B: 6}) | |||
| 91 | + | assertSessionColorScheme(t, sess, true) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func TestApplyThemeForcedBuiltinThemePropagatesToChromeAndPanes(t *testing.T) { | |||
| 95 | + | tests := []struct { | |||
| 96 | + | name string | |||
| 97 | + | mode domain.ThemeMode | |||
| 98 | + | want themeui.Theme | |||
| 99 | + | }{ | |||
| 100 | + | {name: "dark", mode: domain.ThemeDark, want: themeui.BuiltinDark}, | |||
| 101 | + | {name: "light", mode: domain.ThemeLight, want: themeui.BuiltinLight}, | |||
| 102 | + | } | |||
| 103 | + | for _, tc := range tests { | |||
| 104 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 105 | + | p1, releasePTY1 := newBlockingPTY(t) | |||
| 106 | + | p2, releasePTY2 := newBlockingPTY(t) | |||
| 107 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2) | |||
| 108 | + | defer releasePTY1() | |||
| 109 | + | defer releasePTY2() | |||
| 110 | + | d.ApplyConfig(domain.Config{Theme: tc.mode}) | |||
| 111 | + | ||||
| 112 | + | d.applyTheme(sess, ac, ports.Theme{ | |||
| 113 | + | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |||
| 114 | + | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |||
| 115 | + | TrueColor: true, SchemeKnown: true, Light: !tc.want.Light, | |||
| 116 | + | }) | |||
| 117 | + | ||||
| 118 | + | require.Equal(t, tc.want, ac.getTheme()) | |||
| 119 | + | assertSessionDefaultColors(t, sess, tc.want.Foreground, tc.want.Background) | |||
| 120 | + | assertSessionColorScheme(t, sess, tc.want.Light) | |||
| 121 | + | }) | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func TestAttachClientAppliesForcedThemeBeforeMsgTheme(t *testing.T) { | |||
| 126 | + | tests := []struct { | |||
| 127 | + | name string | |||
| 128 | + | mode domain.ThemeMode | |||
| 129 | + | want themeui.Theme | |||
| 130 | + | }{ | |||
| 131 | + | {name: "dark", mode: domain.ThemeDark, want: themeui.BuiltinDark}, | |||
| 132 | + | {name: "light", mode: domain.ThemeLight, want: themeui.BuiltinLight}, | |||
| 133 | + | } | |||
| 134 | + | for _, tc := range tests { | |||
| 135 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 136 | + | p, releasePTY := newBlockingPTY(t) | |||
| 137 | + | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |||
| 138 | + | defer releasePTY() | |||
| 139 | + | d.ApplyConfig(domain.Config{Theme: tc.mode}) | |||
| 140 | + | for _, tb := range sess.tabs { | |||
| 141 | + | tb.mu.Lock() | |||
| 142 | + | panes := tb.panesSnapshot() | |||
| 143 | + | tb.mu.Unlock() | |||
| 144 | + | for _, p := range panes { | |||
| 145 | + | p.mu.Lock() | |||
| 146 | + | p.screen.SetDefaultColors(renderer.RGB{R: 1, G: 2, B: 3}, renderer.RGB{R: 4, G: 5, B: 6}, true) | |||
| 147 | + | p.mu.Unlock() | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | tr, _ := newCapturingTransport(t) | |||
| 151 | + | ||||
| 152 | + | ac, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |||
| 153 | + | ||||
| 154 | + | require.Equal(t, tc.want, ac.getTheme()) | |||
| 155 | + | assertSessionDefaultColors(t, sess, tc.want.Foreground, tc.want.Background) | |||
| 156 | + | assertSessionColorScheme(t, sess, tc.want.Light) | |||
| 157 | + | }) | |||
| 158 | + | } | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | func TestAutoThemeDetachClearsPaneColorScheme(t *testing.T) { | |||
| 162 | + | p, releasePTY := newBlockingPTY(t) | |||
| 163 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |||
| 164 | + | defer releasePTY() | |||
| 165 | + | d.ApplyConfig(domain.Config{Theme: domain.ThemeAuto}) | |||
| 166 | + | d.applyTheme(sess, ac, ports.Theme{ | |||
| 167 | + | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |||
| 168 | + | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |||
| 169 | + | TrueColor: true, SchemeKnown: true, Light: true, | |||
| 170 | + | }) | |||
| 171 | + | assertSessionColorScheme(t, sess, true) | |||
| 172 | + | ||||
| 173 | + | d.clientGone(sess, ac, ac.transport(), true) | |||
| 174 | + | ||||
| 175 | + | assertSessionColorSchemeUnknown(t, sess) | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func TestForcedThemeDetachPreservesBuiltinOnPanes(t *testing.T) { | |||
| 179 | + | p, releasePTY := newBlockingPTY(t) | |||
| 180 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |||
| 181 | + | defer releasePTY() | |||
| 182 | + | d.ApplyConfig(domain.Config{Theme: domain.ThemeDark}) | |||
| 183 | + | d.applyTheme(sess, ac, ports.Theme{ | |||
| 184 | + | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |||
| 185 | + | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |||
| 186 | + | TrueColor: true, SchemeKnown: true, Light: true, | |||
| 187 | + | }) | |||
| 188 | + | ||||
| 189 | + | d.clientGone(sess, ac, ac.transport(), true) | |||
| 190 | + | ||||
| 191 | + | assertSessionDefaultColors(t, sess, themeui.BuiltinDark.Foreground, themeui.BuiltinDark.Background) | |||
| 192 | + | assertSessionColorScheme(t, sess, false) | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | func TestApplyThemeAutoUnknownDoesNotClobberPaneColorScheme(t *testing.T) { | |||
| 196 | + | p, releasePTY := newBlockingPTY(t) | |||
| 197 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |||
| 198 | + | defer releasePTY() | |||
| 199 | + | ||||
| 200 | + | d.applyTheme(sess, ac, ports.Theme{ | |||
| 201 | + | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |||
| 202 | + | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |||
| 203 | + | TrueColor: true, SchemeKnown: true, Light: true, | |||
| 204 | + | }) | |||
| 205 | + | d.applyTheme(sess, ac, ports.Theme{ | |||
| 206 | + | HasForeground: true, Foreground: renderer.RGB{R: 7, G: 8, B: 9}, | |||
| 207 | + | HasBackground: true, Background: renderer.RGB{R: 10, G: 11, B: 12}, | |||
| 208 | + | TrueColor: true, | |||
| 209 | + | }) | |||
| 210 | + | ||||
| 211 | + | assertSessionDefaultColors(t, sess, renderer.RGB{R: 7, G: 8, B: 9}, renderer.RGB{R: 10, G: 11, B: 12}) | |||
| 212 | + | assertSessionColorScheme(t, sess, true) | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | func assertSessionDefaultColors(t *testing.T, sess *session, fg, bg renderer.RGB) { | |||
| 216 | + | t.Helper() | |||
| 87 | for _, tb := range sess.tabs { | 217 | for _, tb := range sess.tabs { | ||
| 88 | var got []byte | 218 | var got []byte | ||
| 89 | tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) } | 219 | tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) } | ||
| 90 | tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a")) | 220 | tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a")) | ||
| 91 | - | require.True(t, strings.Contains(string(got), "rgb:0101/0202/0303"), string(got)) | 221 | + | require.True(t, strings.Contains(string(got), formatOSCColor(fg)), string(got)) |
| 92 | - | require.True(t, strings.Contains(string(got), "rgb:0404/0505/0606"), string(got)) | 222 | + | require.True(t, strings.Contains(string(got), formatOSCColor(bg)), string(got)) |
| 223 | + | } | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | func assertSessionColorScheme(t *testing.T, sess *session, light bool) { | |||
| 227 | + | t.Helper() | |||
| 228 | + | want := "\x1b[?997;1n" | |||
| 229 | + | if light { | |||
| 230 | + | want = "\x1b[?997;2n" | |||
| 231 | + | } | |||
| 232 | + | for _, tb := range sess.tabs { | |||
| 233 | + | var got []byte | |||
| 234 | + | tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) } | |||
| 235 | + | tb.focusedPane().screen.Write([]byte("\x1b[?996n")) | |||
| 236 | + | require.Equal(t, want, string(got)) | |||
| 93 | } | 237 | } | ||
| 94 | } | 238 | } | ||
| 95 | 239 | ||||
| 240 | + | func assertSessionColorSchemeUnknown(t *testing.T, sess *session) { | |||
| 241 | + | t.Helper() | |||
| 242 | + | for _, tb := range sess.tabs { | |||
| 243 | + | var got []byte | |||
| 244 | + | tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) } | |||
| 245 | + | tb.focusedPane().screen.Write([]byte("\x1b[?996n")) | |||
| 246 | + | require.Empty(t, got) | |||
| 247 | + | } | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | func formatOSCColor(rgb renderer.RGB) string { | |||
| 251 | + | return fmt.Sprintf("rgb:%02x%02x/%02x%02x/%02x%02x", rgb.R, rgb.R, rgb.G, rgb.G, rgb.B, rgb.B) | |||
| 252 | + | } | |||
| 253 | + | ||||
| 96 | func TestAttachClientClearsStaleScreenDefaultColors(t *testing.T) { | 254 | func TestAttachClientClearsStaleScreenDefaultColors(t *testing.T) { | ||
| 97 | p, release := newBlockingPTY(t) | 255 | p, release := newBlockingPTY(t) | ||
| 98 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | 256 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | ||
A internal/usecase/daemon/theme_resolve.go +42 -0
| @@ -0,0 +1,42 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/vev/internal/usecase/theme" | |
| 4 | + | ||
| 5 | + | func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme) bool { | |
| 6 | + | sess.mu.Lock() | |
| 7 | + | if ac != nil { | |
| 8 | + | if sess.client != ac { | |
| 9 | + | sess.mu.Unlock() | |
| 10 | + | return false | |
| 11 | + | } | |
| 12 | + | } else if sess.client != nil { | |
| 13 | + | sess.mu.Unlock() | |
| 14 | + | return false | |
| 15 | + | } | |
| 16 | + | tabs := append([]*tab(nil), sess.tabs...) | |
| 17 | + | sess.mu.Unlock() | |
| 18 | + | ||
| 19 | + | if ac != nil { | |
| 20 | + | ac.setTheme(t) | |
| 21 | + | } | |
| 22 | + | for _, tb := range tabs { | |
| 23 | + | tb.mu.Lock() | |
| 24 | + | panes := tb.panesSnapshot() | |
| 25 | + | tb.mu.Unlock() | |
| 26 | + | for _, p := range panes { | |
| 27 | + | p.mu.Lock() | |
| 28 | + | applyPaneThemeLocked(p, t, ac == nil) | |
| 29 | + | p.mu.Unlock() | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | return true | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func applyPaneThemeLocked(p *pane, t theme.Theme, clearUnknownScheme bool) { | |
| 36 | + | p.screen.SetDefaultColors(t.Foreground, t.Background, t.HasFG && t.HasBG) | |
| 37 | + | if t.SchemeKnown { | |
| 38 | + | p.screen.SetColorScheme(t.Light) | |
| 39 | + | } else if clearUnknownScheme { | |
| 40 | + | p.screen.ClearColorScheme() | |
| 41 | + | } | |
| 42 | + | } | |
| @@ -0,0 +1,42 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/vev/internal/usecase/theme" | |||
| 4 | + | ||||
| 5 | + | func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme) bool { | |||
| 6 | + | sess.mu.Lock() | |||
| 7 | + | if ac != nil { | |||
| 8 | + | if sess.client != ac { | |||
| 9 | + | sess.mu.Unlock() | |||
| 10 | + | return false | |||
| 11 | + | } | |||
| 12 | + | } else if sess.client != nil { | |||
| 13 | + | sess.mu.Unlock() | |||
| 14 | + | return false | |||
| 15 | + | } | |||
| 16 | + | tabs := append([]*tab(nil), sess.tabs...) | |||
| 17 | + | sess.mu.Unlock() | |||
| 18 | + | ||||
| 19 | + | if ac != nil { | |||
| 20 | + | ac.setTheme(t) | |||
| 21 | + | } | |||
| 22 | + | for _, tb := range tabs { | |||
| 23 | + | tb.mu.Lock() | |||
| 24 | + | panes := tb.panesSnapshot() | |||
| 25 | + | tb.mu.Unlock() | |||
| 26 | + | for _, p := range panes { | |||
| 27 | + | p.mu.Lock() | |||
| 28 | + | applyPaneThemeLocked(p, t, ac == nil) | |||
| 29 | + | p.mu.Unlock() | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | return true | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func applyPaneThemeLocked(p *pane, t theme.Theme, clearUnknownScheme bool) { | |||
| 36 | + | p.screen.SetDefaultColors(t.Foreground, t.Background, t.HasFG && t.HasBG) | |||
| 37 | + | if t.SchemeKnown { | |||
| 38 | + | p.screen.SetColorScheme(t.Light) | |||
| 39 | + | } else if clearUnknownScheme { | |||
| 40 | + | p.screen.ClearColorScheme() | |||
| 41 | + | } | |||
| 42 | + | } | |||
A internal/usecase/keys/bindings.go +133 -0
| @@ -0,0 +1,133 @@ | |||
| 1 | + | package keys | |
| 2 | + | ||
| 3 | + | import "unicode/utf8" | |
| 4 | + | ||
| 5 | + | // Bindings maps intercepted Alt keys to actions. A Bindings value is mutable | |
| 6 | + | // while being built, then treated as frozen once published through an atomic | |
| 7 | + | // pointer; callers must clone before using mutators. | |
| 8 | + | type Bindings struct { | |
| 9 | + | altRunes map[rune]Action | |
| 10 | + | altArrows map[byte]Action | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | var defaultBindings = DefaultBindings() | |
| 14 | + | ||
| 15 | + | // DefaultBindings returns vev's built-in Alt-key bindings. | |
| 16 | + | func DefaultBindings() *Bindings { | |
| 17 | + | altRunes := map[rune]Action{ | |
| 18 | + | ' ': ActionOpenPalette, | |
| 19 | + | 'a': ActionJumpAttention, | |
| 20 | + | 'h': ActionFocusPaneLeft, | |
| 21 | + | 'j': ActionFocusPaneDown, | |
| 22 | + | 'k': ActionFocusPaneUp, | |
| 23 | + | 'l': ActionFocusPaneRight, | |
| 24 | + | } | |
| 25 | + | for idx, aliases := range topRowDigitAliases { | |
| 26 | + | for _, alias := range aliases { | |
| 27 | + | altRunes[alias] = ActionSwitchTab1 + Action(idx) | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | return &Bindings{ | |
| 31 | + | altRunes: altRunes, | |
| 32 | + | altArrows: map[byte]Action{ | |
| 33 | + | 'A': ActionFocusPaneUp, | |
| 34 | + | 'B': ActionFocusPaneDown, | |
| 35 | + | 'C': ActionFocusPaneRight, | |
| 36 | + | 'D': ActionFocusPaneLeft, | |
| 37 | + | }, | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func (b *Bindings) clone() *Bindings { | |
| 42 | + | if b == nil { | |
| 43 | + | return DefaultBindings() | |
| 44 | + | } | |
| 45 | + | clone := &Bindings{ | |
| 46 | + | altRunes: make(map[rune]Action, len(b.altRunes)), | |
| 47 | + | altArrows: make(map[byte]Action, len(b.altArrows)), | |
| 48 | + | } | |
| 49 | + | for key, action := range b.altRunes { | |
| 50 | + | clone.altRunes[key] = action | |
| 51 | + | } | |
| 52 | + | for key, action := range b.altArrows { | |
| 53 | + | clone.altArrows[key] = action | |
| 54 | + | } | |
| 55 | + | return clone | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func (b *Bindings) removeAction(action Action) { | |
| 59 | + | for key, bound := range b.altRunes { | |
| 60 | + | if bound == action { | |
| 61 | + | delete(b.altRunes, key) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | for key, bound := range b.altArrows { | |
| 65 | + | if bound == action { | |
| 66 | + | delete(b.altArrows, key) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func (b *Bindings) bind(action Action, spec KeySpec) { | |
| 72 | + | for _, key := range spec.altRunes { | |
| 73 | + | b.altRunes[key] = action | |
| 74 | + | } | |
| 75 | + | if spec.altArrow != 0 { | |
| 76 | + | b.altArrows[spec.altArrow] = action | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func (b *Bindings) restoreDefaultAction(action Action) { | |
| 81 | + | defaults := DefaultBindings() | |
| 82 | + | for key, bound := range defaults.altRunes { | |
| 83 | + | if bound == action { | |
| 84 | + | if _, occupied := b.altRunes[key]; !occupied { | |
| 85 | + | b.altRunes[key] = action | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | for key, bound := range defaults.altArrows { | |
| 90 | + | if bound == action { | |
| 91 | + | if _, occupied := b.altArrows[key]; !occupied { | |
| 92 | + | b.altArrows[key] = action | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func (b *Bindings) conflictingAction(spec KeySpec, action Action) (Action, bool) { | |
| 99 | + | for _, key := range spec.altRunes { | |
| 100 | + | if bound, ok := b.altRunes[key]; ok && bound != action { | |
| 101 | + | return bound, true | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | if spec.altArrow != 0 { | |
| 105 | + | if bound, ok := b.altArrows[spec.altArrow]; ok && bound != action { | |
| 106 | + | return bound, true | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | return 0, false | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func (b *Bindings) actionForAltBytes(data []byte) (Action, int, bool) { | |
| 113 | + | if b == nil { | |
| 114 | + | b = defaultBindings | |
| 115 | + | } | |
| 116 | + | if len(data) == 0 { | |
| 117 | + | return 0, 0, false | |
| 118 | + | } | |
| 119 | + | key, size := utf8.DecodeRune(data) | |
| 120 | + | if key == utf8.RuneError && size == 1 { | |
| 121 | + | return 0, 0, false | |
| 122 | + | } | |
| 123 | + | action, ok := b.altRunes[key] | |
| 124 | + | return action, size, ok | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func (b *Bindings) actionForAltArrow(final byte) (Action, bool) { | |
| 128 | + | if b == nil { | |
| 129 | + | b = defaultBindings | |
| 130 | + | } | |
| 131 | + | action, ok := b.altArrows[final] | |
| 132 | + | return action, ok | |
| 133 | + | } | |
| @@ -0,0 +1,133 @@ | |||||
| 1 | + | package keys | |||
| 2 | + | ||||
| 3 | + | import "unicode/utf8" | |||
| 4 | + | ||||
| 5 | + | // Bindings maps intercepted Alt keys to actions. A Bindings value is mutable | |||
| 6 | + | // while being built, then treated as frozen once published through an atomic | |||
| 7 | + | // pointer; callers must clone before using mutators. | |||
| 8 | + | type Bindings struct { | |||
| 9 | + | altRunes map[rune]Action | |||
| 10 | + | altArrows map[byte]Action | |||
| 11 | + | } | |||
| 12 | + | ||||
| 13 | + | var defaultBindings = DefaultBindings() | |||
| 14 | + | ||||
| 15 | + | // DefaultBindings returns vev's built-in Alt-key bindings. | |||
| 16 | + | func DefaultBindings() *Bindings { | |||
| 17 | + | altRunes := map[rune]Action{ | |||
| 18 | + | ' ': ActionOpenPalette, | |||
| 19 | + | 'a': ActionJumpAttention, | |||
| 20 | + | 'h': ActionFocusPaneLeft, | |||
| 21 | + | 'j': ActionFocusPaneDown, | |||
| 22 | + | 'k': ActionFocusPaneUp, | |||
| 23 | + | 'l': ActionFocusPaneRight, | |||
| 24 | + | } | |||
| 25 | + | for idx, aliases := range topRowDigitAliases { | |||
| 26 | + | for _, alias := range aliases { | |||
| 27 | + | altRunes[alias] = ActionSwitchTab1 + Action(idx) | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | return &Bindings{ | |||
| 31 | + | altRunes: altRunes, | |||
| 32 | + | altArrows: map[byte]Action{ | |||
| 33 | + | 'A': ActionFocusPaneUp, | |||
| 34 | + | 'B': ActionFocusPaneDown, | |||
| 35 | + | 'C': ActionFocusPaneRight, | |||
| 36 | + | 'D': ActionFocusPaneLeft, | |||
| 37 | + | }, | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func (b *Bindings) clone() *Bindings { | |||
| 42 | + | if b == nil { | |||
| 43 | + | return DefaultBindings() | |||
| 44 | + | } | |||
| 45 | + | clone := &Bindings{ | |||
| 46 | + | altRunes: make(map[rune]Action, len(b.altRunes)), | |||
| 47 | + | altArrows: make(map[byte]Action, len(b.altArrows)), | |||
| 48 | + | } | |||
| 49 | + | for key, action := range b.altRunes { | |||
| 50 | + | clone.altRunes[key] = action | |||
| 51 | + | } | |||
| 52 | + | for key, action := range b.altArrows { | |||
| 53 | + | clone.altArrows[key] = action | |||
| 54 | + | } | |||
| 55 | + | return clone | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func (b *Bindings) removeAction(action Action) { | |||
| 59 | + | for key, bound := range b.altRunes { | |||
| 60 | + | if bound == action { | |||
| 61 | + | delete(b.altRunes, key) | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | for key, bound := range b.altArrows { | |||
| 65 | + | if bound == action { | |||
| 66 | + | delete(b.altArrows, key) | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func (b *Bindings) bind(action Action, spec KeySpec) { | |||
| 72 | + | for _, key := range spec.altRunes { | |||
| 73 | + | b.altRunes[key] = action | |||
| 74 | + | } | |||
| 75 | + | if spec.altArrow != 0 { | |||
| 76 | + | b.altArrows[spec.altArrow] = action | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func (b *Bindings) restoreDefaultAction(action Action) { | |||
| 81 | + | defaults := DefaultBindings() | |||
| 82 | + | for key, bound := range defaults.altRunes { | |||
| 83 | + | if bound == action { | |||
| 84 | + | if _, occupied := b.altRunes[key]; !occupied { | |||
| 85 | + | b.altRunes[key] = action | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | for key, bound := range defaults.altArrows { | |||
| 90 | + | if bound == action { | |||
| 91 | + | if _, occupied := b.altArrows[key]; !occupied { | |||
| 92 | + | b.altArrows[key] = action | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func (b *Bindings) conflictingAction(spec KeySpec, action Action) (Action, bool) { | |||
| 99 | + | for _, key := range spec.altRunes { | |||
| 100 | + | if bound, ok := b.altRunes[key]; ok && bound != action { | |||
| 101 | + | return bound, true | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | if spec.altArrow != 0 { | |||
| 105 | + | if bound, ok := b.altArrows[spec.altArrow]; ok && bound != action { | |||
| 106 | + | return bound, true | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | return 0, false | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func (b *Bindings) actionForAltBytes(data []byte) (Action, int, bool) { | |||
| 113 | + | if b == nil { | |||
| 114 | + | b = defaultBindings | |||
| 115 | + | } | |||
| 116 | + | if len(data) == 0 { | |||
| 117 | + | return 0, 0, false | |||
| 118 | + | } | |||
| 119 | + | key, size := utf8.DecodeRune(data) | |||
| 120 | + | if key == utf8.RuneError && size == 1 { | |||
| 121 | + | return 0, 0, false | |||
| 122 | + | } | |||
| 123 | + | action, ok := b.altRunes[key] | |||
| 124 | + | return action, size, ok | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func (b *Bindings) actionForAltArrow(final byte) (Action, bool) { | |||
| 128 | + | if b == nil { | |||
| 129 | + | b = defaultBindings | |||
| 130 | + | } | |||
| 131 | + | action, ok := b.altArrows[final] | |||
| 132 | + | return action, ok | |||
| 133 | + | } | |||
A internal/usecase/keys/keyspec.go +154 -0
| @@ -0,0 +1,154 @@ | |||
| 1 | + | package keys | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "sort" | |
| 6 | + | "strings" | |
| 7 | + | "unicode/utf8" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | const altPrefix = "alt+" | |
| 13 | + | ||
| 14 | + | // KeySpec is one supported user-facing key binding specification. | |
| 15 | + | type KeySpec struct { | |
| 16 | + | altRunes []rune | |
| 17 | + | altArrow byte | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // ParseKeySpec parses vev's config key grammar. | |
| 21 | + | func ParseKeySpec(spec string) (KeySpec, error) { | |
| 22 | + | if !strings.HasPrefix(spec, altPrefix) { | |
| 23 | + | return KeySpec{}, fmt.Errorf("key spec must start with %q", altPrefix) | |
| 24 | + | } | |
| 25 | + | key := strings.TrimPrefix(spec, altPrefix) | |
| 26 | + | switch key { | |
| 27 | + | case "space": | |
| 28 | + | return KeySpec{altRunes: []rune{' '}}, nil | |
| 29 | + | case "left": | |
| 30 | + | return KeySpec{altArrow: 'D'}, nil | |
| 31 | + | case "right": | |
| 32 | + | return KeySpec{altArrow: 'C'}, nil | |
| 33 | + | case "up": | |
| 34 | + | return KeySpec{altArrow: 'A'}, nil | |
| 35 | + | case "down": | |
| 36 | + | return KeySpec{altArrow: 'B'}, nil | |
| 37 | + | } | |
| 38 | + | if utf8.RuneCountInString(key) != 1 { | |
| 39 | + | return KeySpec{}, fmt.Errorf("key spec must be alt+<char>, alt+space, or alt+left/right/up/down") | |
| 40 | + | } | |
| 41 | + | r, _ := utf8.DecodeRuneInString(key) | |
| 42 | + | if idx, ok := topRowDigitIndex(r); ok { | |
| 43 | + | return KeySpec{altRunes: append([]rune(nil), topRowDigitAliases[idx]...)}, nil | |
| 44 | + | } | |
| 45 | + | if r >= '0' && r <= '9' { | |
| 46 | + | return KeySpec{}, fmt.Errorf("only alt+1 through alt+9 are supported digit bindings") | |
| 47 | + | } | |
| 48 | + | return KeySpec{altRunes: []rune{r}}, nil | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | var actionNames = map[Action]string{ | |
| 52 | + | ActionOpenPalette: "open-palette", | |
| 53 | + | ActionJumpAttention: "jump-attention", | |
| 54 | + | ActionFocusPaneLeft: "focus-pane-left", | |
| 55 | + | ActionFocusPaneRight: "focus-pane-right", | |
| 56 | + | ActionFocusPaneUp: "focus-pane-up", | |
| 57 | + | ActionFocusPaneDown: "focus-pane-down", | |
| 58 | + | ActionSwitchTab1: "switch-tab-1", | |
| 59 | + | ActionSwitchTab2: "switch-tab-2", | |
| 60 | + | ActionSwitchTab3: "switch-tab-3", | |
| 61 | + | ActionSwitchTab4: "switch-tab-4", | |
| 62 | + | ActionSwitchTab5: "switch-tab-5", | |
| 63 | + | ActionSwitchTab6: "switch-tab-6", | |
| 64 | + | ActionSwitchTab7: "switch-tab-7", | |
| 65 | + | ActionSwitchTab8: "switch-tab-8", | |
| 66 | + | ActionSwitchTab9: "switch-tab-9", | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | var actionsByName = func() map[string]Action { | |
| 70 | + | byName := make(map[string]Action, len(actionNames)) | |
| 71 | + | for action, name := range actionNames { | |
| 72 | + | if _, dup := byName[name]; dup { | |
| 73 | + | panic(fmt.Sprintf("keys: duplicate action name %q", name)) | |
| 74 | + | } | |
| 75 | + | byName[name] = action | |
| 76 | + | } | |
| 77 | + | return byName | |
| 78 | + | }() | |
| 79 | + | ||
| 80 | + | // Name returns the canonical config name for an action. | |
| 81 | + | func (a Action) Name() string { | |
| 82 | + | name, ok := actionNames[a] | |
| 83 | + | if !ok { | |
| 84 | + | return "unknown" | |
| 85 | + | } | |
| 86 | + | return name | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // ActionByName returns the action for a canonical config name. | |
| 90 | + | func ActionByName(name string) (Action, bool) { | |
| 91 | + | action, ok := actionsByName[name] | |
| 92 | + | return action, ok | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // BuildBindings starts from vev's defaults and applies config overrides. Map | |
| 96 | + | // inputs have no file order, so names are applied lexicographically for stable | |
| 97 | + | // behavior. Parsed configs should call BuildBindingEntries to preserve file | |
| 98 | + | // order for duplicate-key first-wins semantics. | |
| 99 | + | func BuildBindings(overrides map[string]string) (*Bindings, []domain.Warning) { | |
| 100 | + | entries := make([]domain.ConfigEntry, 0, len(overrides)) | |
| 101 | + | for _, name := range sortedOverrideNames(overrides) { | |
| 102 | + | entries = append(entries, domain.ConfigEntry{Key: name, Value: overrides[name]}) | |
| 103 | + | } | |
| 104 | + | return BuildBindingEntries(entries) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // BuildBindingEntries starts from vev's defaults and applies config overrides | |
| 108 | + | // in the order they appeared in the config file. When two actions request the | |
| 109 | + | // same key, the first valid override wins and later actions keep their default. | |
| 110 | + | func BuildBindingEntries(overrides []domain.ConfigEntry) (*Bindings, []domain.Warning) { | |
| 111 | + | bindings := DefaultBindings().clone() | |
| 112 | + | var warnings []domain.Warning | |
| 113 | + | var desired []bindingOverride | |
| 114 | + | for _, entry := range overrides { | |
| 115 | + | action, ok := ActionByName(entry.Key) | |
| 116 | + | if !ok { | |
| 117 | + | warnings = append(warnings, domain.Warning{Msg: fmt.Sprintf("unknown action %q", entry.Key)}) | |
| 118 | + | continue | |
| 119 | + | } | |
| 120 | + | spec, err := ParseKeySpec(entry.Value) | |
| 121 | + | if err != nil { | |
| 122 | + | warnings = append(warnings, domain.Warning{Msg: fmt.Sprintf("invalid key for %q: %v", entry.Key, err)}) | |
| 123 | + | continue | |
| 124 | + | } | |
| 125 | + | desired = append(desired, bindingOverride{name: entry.Key, action: action, spec: spec}) | |
| 126 | + | } | |
| 127 | + | for _, override := range desired { | |
| 128 | + | bindings.removeAction(override.action) | |
| 129 | + | } | |
| 130 | + | for _, override := range desired { | |
| 131 | + | if conflict, ok := bindings.conflictingAction(override.spec, override.action); ok { | |
| 132 | + | warnings = append(warnings, domain.Warning{Msg: fmt.Sprintf("duplicate key for %q conflicts with %q", override.name, conflict.Name())}) | |
| 133 | + | bindings.restoreDefaultAction(override.action) | |
| 134 | + | continue | |
| 135 | + | } | |
| 136 | + | bindings.bind(override.action, override.spec) | |
| 137 | + | } | |
| 138 | + | return bindings, warnings | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | type bindingOverride struct { | |
| 142 | + | name string | |
| 143 | + | action Action | |
| 144 | + | spec KeySpec | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func sortedOverrideNames(overrides map[string]string) []string { | |
| 148 | + | names := make([]string, 0, len(overrides)) | |
| 149 | + | for name := range overrides { | |
| 150 | + | names = append(names, name) | |
| 151 | + | } | |
| 152 | + | sort.Strings(names) | |
| 153 | + | return names | |
| 154 | + | } | |
| @@ -0,0 +1,154 @@ | |||||
| 1 | + | package keys | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "sort" | |||
| 6 | + | "strings" | |||
| 7 | + | "unicode/utf8" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/domain" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | const altPrefix = "alt+" | |||
| 13 | + | ||||
| 14 | + | // KeySpec is one supported user-facing key binding specification. | |||
| 15 | + | type KeySpec struct { | |||
| 16 | + | altRunes []rune | |||
| 17 | + | altArrow byte | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // ParseKeySpec parses vev's config key grammar. | |||
| 21 | + | func ParseKeySpec(spec string) (KeySpec, error) { | |||
| 22 | + | if !strings.HasPrefix(spec, altPrefix) { | |||
| 23 | + | return KeySpec{}, fmt.Errorf("key spec must start with %q", altPrefix) | |||
| 24 | + | } | |||
| 25 | + | key := strings.TrimPrefix(spec, altPrefix) | |||
| 26 | + | switch key { | |||
| 27 | + | case "space": | |||
| 28 | + | return KeySpec{altRunes: []rune{' '}}, nil | |||
| 29 | + | case "left": | |||
| 30 | + | return KeySpec{altArrow: 'D'}, nil | |||
| 31 | + | case "right": | |||
| 32 | + | return KeySpec{altArrow: 'C'}, nil | |||
| 33 | + | case "up": | |||
| 34 | + | return KeySpec{altArrow: 'A'}, nil | |||
| 35 | + | case "down": | |||
| 36 | + | return KeySpec{altArrow: 'B'}, nil | |||
| 37 | + | } | |||
| 38 | + | if utf8.RuneCountInString(key) != 1 { | |||
| 39 | + | return KeySpec{}, fmt.Errorf("key spec must be alt+<char>, alt+space, or alt+left/right/up/down") | |||
| 40 | + | } | |||
| 41 | + | r, _ := utf8.DecodeRuneInString(key) | |||
| 42 | + | if idx, ok := topRowDigitIndex(r); ok { | |||
| 43 | + | return KeySpec{altRunes: append([]rune(nil), topRowDigitAliases[idx]...)}, nil | |||
| 44 | + | } | |||
| 45 | + | if r >= '0' && r <= '9' { | |||
| 46 | + | return KeySpec{}, fmt.Errorf("only alt+1 through alt+9 are supported digit bindings") | |||
| 47 | + | } | |||
| 48 | + | return KeySpec{altRunes: []rune{r}}, nil | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | var actionNames = map[Action]string{ | |||
| 52 | + | ActionOpenPalette: "open-palette", | |||
| 53 | + | ActionJumpAttention: "jump-attention", | |||
| 54 | + | ActionFocusPaneLeft: "focus-pane-left", | |||
| 55 | + | ActionFocusPaneRight: "focus-pane-right", | |||
| 56 | + | ActionFocusPaneUp: "focus-pane-up", | |||
| 57 | + | ActionFocusPaneDown: "focus-pane-down", | |||
| 58 | + | ActionSwitchTab1: "switch-tab-1", | |||
| 59 | + | ActionSwitchTab2: "switch-tab-2", | |||
| 60 | + | ActionSwitchTab3: "switch-tab-3", | |||
| 61 | + | ActionSwitchTab4: "switch-tab-4", | |||
| 62 | + | ActionSwitchTab5: "switch-tab-5", | |||
| 63 | + | ActionSwitchTab6: "switch-tab-6", | |||
| 64 | + | ActionSwitchTab7: "switch-tab-7", | |||
| 65 | + | ActionSwitchTab8: "switch-tab-8", | |||
| 66 | + | ActionSwitchTab9: "switch-tab-9", | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | var actionsByName = func() map[string]Action { | |||
| 70 | + | byName := make(map[string]Action, len(actionNames)) | |||
| 71 | + | for action, name := range actionNames { | |||
| 72 | + | if _, dup := byName[name]; dup { | |||
| 73 | + | panic(fmt.Sprintf("keys: duplicate action name %q", name)) | |||
| 74 | + | } | |||
| 75 | + | byName[name] = action | |||
| 76 | + | } | |||
| 77 | + | return byName | |||
| 78 | + | }() | |||
| 79 | + | ||||
| 80 | + | // Name returns the canonical config name for an action. | |||
| 81 | + | func (a Action) Name() string { | |||
| 82 | + | name, ok := actionNames[a] | |||
| 83 | + | if !ok { | |||
| 84 | + | return "unknown" | |||
| 85 | + | } | |||
| 86 | + | return name | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // ActionByName returns the action for a canonical config name. | |||
| 90 | + | func ActionByName(name string) (Action, bool) { | |||
| 91 | + | action, ok := actionsByName[name] | |||
| 92 | + | return action, ok | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // BuildBindings starts from vev's defaults and applies config overrides. Map | |||
| 96 | + | // inputs have no file order, so names are applied lexicographically for stable | |||
| 97 | + | // behavior. Parsed configs should call BuildBindingEntries to preserve file | |||
| 98 | + | // order for duplicate-key first-wins semantics. | |||
| 99 | + | func BuildBindings(overrides map[string]string) (*Bindings, []domain.Warning) { | |||
| 100 | + | entries := make([]domain.ConfigEntry, 0, len(overrides)) | |||
| 101 | + | for _, name := range sortedOverrideNames(overrides) { | |||
| 102 | + | entries = append(entries, domain.ConfigEntry{Key: name, Value: overrides[name]}) | |||
| 103 | + | } | |||
| 104 | + | return BuildBindingEntries(entries) | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // BuildBindingEntries starts from vev's defaults and applies config overrides | |||
| 108 | + | // in the order they appeared in the config file. When two actions request the | |||
| 109 | + | // same key, the first valid override wins and later actions keep their default. | |||
| 110 | + | func BuildBindingEntries(overrides []domain.ConfigEntry) (*Bindings, []domain.Warning) { | |||
| 111 | + | bindings := DefaultBindings().clone() | |||
| 112 | + | var warnings []domain.Warning | |||
| 113 | + | var desired []bindingOverride | |||
| 114 | + | for _, entry := range overrides { | |||
| 115 | + | action, ok := ActionByName(entry.Key) | |||
| 116 | + | if !ok { | |||
| 117 | + | warnings = append(warnings, domain.Warning{Msg: fmt.Sprintf("unknown action %q", entry.Key)}) | |||
| 118 | + | continue | |||
| 119 | + | } | |||
| 120 | + | spec, err := ParseKeySpec(entry.Value) | |||
| 121 | + | if err != nil { | |||
| 122 | + | warnings = append(warnings, domain.Warning{Msg: fmt.Sprintf("invalid key for %q: %v", entry.Key, err)}) | |||
| 123 | + | continue | |||
| 124 | + | } | |||
| 125 | + | desired = append(desired, bindingOverride{name: entry.Key, action: action, spec: spec}) | |||
| 126 | + | } | |||
| 127 | + | for _, override := range desired { | |||
| 128 | + | bindings.removeAction(override.action) | |||
| 129 | + | } | |||
| 130 | + | for _, override := range desired { | |||
| 131 | + | if conflict, ok := bindings.conflictingAction(override.spec, override.action); ok { | |||
| 132 | + | warnings = append(warnings, domain.Warning{Msg: fmt.Sprintf("duplicate key for %q conflicts with %q", override.name, conflict.Name())}) | |||
| 133 | + | bindings.restoreDefaultAction(override.action) | |||
| 134 | + | continue | |||
| 135 | + | } | |||
| 136 | + | bindings.bind(override.action, override.spec) | |||
| 137 | + | } | |||
| 138 | + | return bindings, warnings | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | type bindingOverride struct { | |||
| 142 | + | name string | |||
| 143 | + | action Action | |||
| 144 | + | spec KeySpec | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | func sortedOverrideNames(overrides map[string]string) []string { | |||
| 148 | + | names := make([]string, 0, len(overrides)) | |||
| 149 | + | for name := range overrides { | |||
| 150 | + | names = append(names, name) | |||
| 151 | + | } | |||
| 152 | + | sort.Strings(names) | |||
| 153 | + | return names | |||
| 154 | + | } | |||
A internal/usecase/keys/keyspec_test.go +232 -0
| @@ -0,0 +1,232 @@ | |||
| 1 | + | package keys | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestParseKeySpecAcceptsValidSpecs(t *testing.T) { | |
| 11 | + | cases := []struct { | |
| 12 | + | name string | |
| 13 | + | spec string | |
| 14 | + | wantRunes []rune | |
| 15 | + | wantArrow byte | |
| 16 | + | }{ | |
| 17 | + | {name: "letter", spec: "alt+x", wantRunes: []rune{'x'}}, | |
| 18 | + | {name: "unicode single char", spec: "alt+ñ", wantRunes: []rune{'ñ'}}, | |
| 19 | + | {name: "space", spec: "alt+space", wantRunes: []rune{' '}}, | |
| 20 | + | {name: "left arrow", spec: "alt+left", wantArrow: 'D'}, | |
| 21 | + | {name: "right arrow", spec: "alt+right", wantArrow: 'C'}, | |
| 22 | + | {name: "up arrow", spec: "alt+up", wantArrow: 'A'}, | |
| 23 | + | {name: "down arrow", spec: "alt+down", wantArrow: 'B'}, | |
| 24 | + | {name: "digit includes layout aliases", spec: "alt+2", wantRunes: []rune{'2', 'é'}}, | |
| 25 | + | {name: "digit eight includes known aliases", spec: "alt+8", wantRunes: []rune{'8', '_', '!'}}, | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | for _, tc := range cases { | |
| 29 | + | t.Run(tc.name, func(t *testing.T) { | |
| 30 | + | got, err := ParseKeySpec(tc.spec) | |
| 31 | + | require.NoError(t, err) | |
| 32 | + | require.Equal(t, tc.wantRunes, got.altRunes) | |
| 33 | + | require.Equal(t, tc.wantArrow, got.altArrow) | |
| 34 | + | }) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestParseKeySpecRejectsMalformedSpecs(t *testing.T) { | |
| 39 | + | cases := []string{ | |
| 40 | + | "", | |
| 41 | + | "x", | |
| 42 | + | "ctrl+x", | |
| 43 | + | "alt+", | |
| 44 | + | "alt+xy", | |
| 45 | + | "alt+0", | |
| 46 | + | "alt+10", | |
| 47 | + | "alt+tab", | |
| 48 | + | "alt+Space", | |
| 49 | + | " alt+x", | |
| 50 | + | "alt+x ", | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | for _, spec := range cases { | |
| 54 | + | t.Run(spec, func(t *testing.T) { | |
| 55 | + | _, err := ParseKeySpec(spec) | |
| 56 | + | require.Error(t, err) | |
| 57 | + | }) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestActionNamesAreCanonical(t *testing.T) { | |
| 62 | + | cases := []struct { | |
| 63 | + | a Action | |
| 64 | + | name string | |
| 65 | + | }{ | |
| 66 | + | {ActionOpenPalette, "open-palette"}, | |
| 67 | + | {ActionJumpAttention, "jump-attention"}, | |
| 68 | + | {ActionFocusPaneLeft, "focus-pane-left"}, | |
| 69 | + | {ActionFocusPaneRight, "focus-pane-right"}, | |
| 70 | + | {ActionFocusPaneUp, "focus-pane-up"}, | |
| 71 | + | {ActionFocusPaneDown, "focus-pane-down"}, | |
| 72 | + | {ActionSwitchTab1, "switch-tab-1"}, | |
| 73 | + | {ActionSwitchTab2, "switch-tab-2"}, | |
| 74 | + | {ActionSwitchTab3, "switch-tab-3"}, | |
| 75 | + | {ActionSwitchTab4, "switch-tab-4"}, | |
| 76 | + | {ActionSwitchTab5, "switch-tab-5"}, | |
| 77 | + | {ActionSwitchTab6, "switch-tab-6"}, | |
| 78 | + | {ActionSwitchTab7, "switch-tab-7"}, | |
| 79 | + | {ActionSwitchTab8, "switch-tab-8"}, | |
| 80 | + | {ActionSwitchTab9, "switch-tab-9"}, | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | for _, tc := range cases { | |
| 84 | + | t.Run(tc.name, func(t *testing.T) { | |
| 85 | + | require.Equal(t, tc.name, tc.a.Name()) | |
| 86 | + | got, ok := ActionByName(tc.name) | |
| 87 | + | require.True(t, ok) | |
| 88 | + | require.Equal(t, tc.a, got) | |
| 89 | + | }) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | require.Equal(t, "unknown", Action(99).Name()) | |
| 93 | + | _, ok := ActionByName("unknown-action") | |
| 94 | + | require.False(t, ok) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func TestBuildBindingsAppliesOverrides(t *testing.T) { | |
| 98 | + | bindings, warnings := BuildBindings(map[string]string{ | |
| 99 | + | "open-palette": "alt+o", | |
| 100 | + | "focus-pane-left": "alt+left", | |
| 101 | + | "switch-tab-2": "alt+2", | |
| 102 | + | }) | |
| 103 | + | require.Empty(t, warnings) | |
| 104 | + | ||
| 105 | + | assertAltRuneAction(t, bindings, 'o', ActionOpenPalette) | |
| 106 | + | assertAltRuneUnbound(t, bindings, ' ') | |
| 107 | + | assertAltArrowAction(t, bindings, 'D', ActionFocusPaneLeft) | |
| 108 | + | assertAltRuneUnbound(t, bindings, 'h') | |
| 109 | + | assertAltRuneAction(t, bindings, '2', ActionSwitchTab2) | |
| 110 | + | assertAltRuneAction(t, bindings, 'é', ActionSwitchTab2) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestBuildBindingsWarnsAndSkipsInvalidEntries(t *testing.T) { | |
| 114 | + | bindings, warnings := BuildBindings(map[string]string{ | |
| 115 | + | "missing": "alt+m", | |
| 116 | + | "open-palette": "ctrl+o", | |
| 117 | + | "jump-attention": "alt+j", | |
| 118 | + | }) | |
| 119 | + | ||
| 120 | + | require.Len(t, warnings, 3) | |
| 121 | + | warningText := warnings[0].Msg + "\n" + warnings[1].Msg + "\n" + warnings[2].Msg | |
| 122 | + | require.Contains(t, warningText, "unknown action") | |
| 123 | + | require.Contains(t, warningText, "invalid key") | |
| 124 | + | require.Contains(t, warningText, "duplicate key") | |
| 125 | + | assertAltRuneAction(t, bindings, ' ', ActionOpenPalette) | |
| 126 | + | assertAltRuneAction(t, bindings, 'a', ActionJumpAttention) | |
| 127 | + | assertAltRuneAction(t, bindings, 'j', ActionFocusPaneDown) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func TestBuildBindingsAllowsSameActionToKeepDefaultKey(t *testing.T) { | |
| 131 | + | bindings, warnings := BuildBindings(map[string]string{ | |
| 132 | + | "focus-pane-left": "alt+h", | |
| 133 | + | }) | |
| 134 | + | ||
| 135 | + | require.Empty(t, warnings) | |
| 136 | + | assertAltRuneAction(t, bindings, 'h', ActionFocusPaneLeft) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func TestBuildBindingEntriesDuplicateKeyConflictsUseFileOrder(t *testing.T) { | |
| 140 | + | tests := []struct { | |
| 141 | + | name string | |
| 142 | + | overrides []domain.ConfigEntry | |
| 143 | + | wantAltP Action | |
| 144 | + | wantOpenDefault bool | |
| 145 | + | wantFocusDefault bool | |
| 146 | + | wantWarningAction string | |
| 147 | + | }{ | |
| 148 | + | { | |
| 149 | + | name: "open palette first wins", | |
| 150 | + | overrides: []domain.ConfigEntry{ | |
| 151 | + | {Key: "open-palette", Value: "alt+p"}, | |
| 152 | + | {Key: "focus-pane-left", Value: "alt+p"}, | |
| 153 | + | }, | |
| 154 | + | wantAltP: ActionOpenPalette, | |
| 155 | + | wantFocusDefault: true, | |
| 156 | + | wantWarningAction: "focus-pane-left", | |
| 157 | + | }, | |
| 158 | + | { | |
| 159 | + | name: "focus pane left first wins", | |
| 160 | + | overrides: []domain.ConfigEntry{ | |
| 161 | + | {Key: "focus-pane-left", Value: "alt+p"}, | |
| 162 | + | {Key: "open-palette", Value: "alt+p"}, | |
| 163 | + | }, | |
| 164 | + | wantAltP: ActionFocusPaneLeft, | |
| 165 | + | wantOpenDefault: true, | |
| 166 | + | wantWarningAction: "open-palette", | |
| 167 | + | }, | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | for _, tt := range tests { | |
| 171 | + | t.Run(tt.name, func(t *testing.T) { | |
| 172 | + | bindings, warnings := BuildBindingEntries(tt.overrides) | |
| 173 | + | ||
| 174 | + | require.Len(t, warnings, 1) | |
| 175 | + | require.Contains(t, warnings[0].Msg, tt.wantWarningAction) | |
| 176 | + | assertAltRuneAction(t, bindings, 'p', tt.wantAltP) | |
| 177 | + | if tt.wantOpenDefault { | |
| 178 | + | assertAltRuneAction(t, bindings, ' ', ActionOpenPalette) | |
| 179 | + | } else { | |
| 180 | + | assertAltRuneUnbound(t, bindings, ' ') | |
| 181 | + | } | |
| 182 | + | if tt.wantFocusDefault { | |
| 183 | + | assertAltRuneAction(t, bindings, 'h', ActionFocusPaneLeft) | |
| 184 | + | } else { | |
| 185 | + | assertAltRuneUnbound(t, bindings, 'h') | |
| 186 | + | } | |
| 187 | + | }) | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func TestBuildBindingsFreesDefaultsForOverriddenActionsBeforeConflictChecks(t *testing.T) { | |
| 192 | + | bindings, warnings := BuildBindings(map[string]string{ | |
| 193 | + | "open-palette": "alt+o", | |
| 194 | + | "focus-pane-down": "alt+space", | |
| 195 | + | }) | |
| 196 | + | ||
| 197 | + | require.Empty(t, warnings) | |
| 198 | + | assertAltRuneAction(t, bindings, 'o', ActionOpenPalette) | |
| 199 | + | assertAltRuneAction(t, bindings, ' ', ActionFocusPaneDown) | |
| 200 | + | assertAltRuneUnbound(t, bindings, 'j') | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func TestBuildBindingEntriesFailedOverrideRestoreDoesNotOverwriteEarlierOverride(t *testing.T) { | |
| 204 | + | bindings, warnings := BuildBindingEntries([]domain.ConfigEntry{ | |
| 205 | + | {Key: "open-palette", Value: "alt+a"}, | |
| 206 | + | {Key: "jump-attention", Value: "alt+a"}, | |
| 207 | + | }) | |
| 208 | + | ||
| 209 | + | require.Len(t, warnings, 1) | |
| 210 | + | assertAltRuneAction(t, bindings, 'a', ActionOpenPalette) | |
| 211 | + | assertAltRuneUnbound(t, bindings, ' ') | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | func assertAltRuneAction(t *testing.T, bindings *Bindings, key rune, want Action) { | |
| 215 | + | t.Helper() | |
| 216 | + | got, _, ok := bindings.actionForAltBytes([]byte(string(key))) | |
| 217 | + | require.True(t, ok) | |
| 218 | + | require.Equal(t, want, got) | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | func assertAltRuneUnbound(t *testing.T, bindings *Bindings, key rune) { | |
| 222 | + | t.Helper() | |
| 223 | + | _, _, ok := bindings.actionForAltBytes([]byte(string(key))) | |
| 224 | + | require.False(t, ok) | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | func assertAltArrowAction(t *testing.T, bindings *Bindings, final byte, want Action) { | |
| 228 | + | t.Helper() | |
| 229 | + | got, ok := bindings.actionForAltArrow(final) | |
| 230 | + | require.True(t, ok) | |
| 231 | + | require.Equal(t, want, got) | |
| 232 | + | } | |
| @@ -0,0 +1,232 @@ | |||||
| 1 | + | package keys | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestParseKeySpecAcceptsValidSpecs(t *testing.T) { | |||
| 11 | + | cases := []struct { | |||
| 12 | + | name string | |||
| 13 | + | spec string | |||
| 14 | + | wantRunes []rune | |||
| 15 | + | wantArrow byte | |||
| 16 | + | }{ | |||
| 17 | + | {name: "letter", spec: "alt+x", wantRunes: []rune{'x'}}, | |||
| 18 | + | {name: "unicode single char", spec: "alt+ñ", wantRunes: []rune{'ñ'}}, | |||
| 19 | + | {name: "space", spec: "alt+space", wantRunes: []rune{' '}}, | |||
| 20 | + | {name: "left arrow", spec: "alt+left", wantArrow: 'D'}, | |||
| 21 | + | {name: "right arrow", spec: "alt+right", wantArrow: 'C'}, | |||
| 22 | + | {name: "up arrow", spec: "alt+up", wantArrow: 'A'}, | |||
| 23 | + | {name: "down arrow", spec: "alt+down", wantArrow: 'B'}, | |||
| 24 | + | {name: "digit includes layout aliases", spec: "alt+2", wantRunes: []rune{'2', 'é'}}, | |||
| 25 | + | {name: "digit eight includes known aliases", spec: "alt+8", wantRunes: []rune{'8', '_', '!'}}, | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | for _, tc := range cases { | |||
| 29 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 30 | + | got, err := ParseKeySpec(tc.spec) | |||
| 31 | + | require.NoError(t, err) | |||
| 32 | + | require.Equal(t, tc.wantRunes, got.altRunes) | |||
| 33 | + | require.Equal(t, tc.wantArrow, got.altArrow) | |||
| 34 | + | }) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func TestParseKeySpecRejectsMalformedSpecs(t *testing.T) { | |||
| 39 | + | cases := []string{ | |||
| 40 | + | "", | |||
| 41 | + | "x", | |||
| 42 | + | "ctrl+x", | |||
| 43 | + | "alt+", | |||
| 44 | + | "alt+xy", | |||
| 45 | + | "alt+0", | |||
| 46 | + | "alt+10", | |||
| 47 | + | "alt+tab", | |||
| 48 | + | "alt+Space", | |||
| 49 | + | " alt+x", | |||
| 50 | + | "alt+x ", | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | for _, spec := range cases { | |||
| 54 | + | t.Run(spec, func(t *testing.T) { | |||
| 55 | + | _, err := ParseKeySpec(spec) | |||
| 56 | + | require.Error(t, err) | |||
| 57 | + | }) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestActionNamesAreCanonical(t *testing.T) { | |||
| 62 | + | cases := []struct { | |||
| 63 | + | a Action | |||
| 64 | + | name string | |||
| 65 | + | }{ | |||
| 66 | + | {ActionOpenPalette, "open-palette"}, | |||
| 67 | + | {ActionJumpAttention, "jump-attention"}, | |||
| 68 | + | {ActionFocusPaneLeft, "focus-pane-left"}, | |||
| 69 | + | {ActionFocusPaneRight, "focus-pane-right"}, | |||
| 70 | + | {ActionFocusPaneUp, "focus-pane-up"}, | |||
| 71 | + | {ActionFocusPaneDown, "focus-pane-down"}, | |||
| 72 | + | {ActionSwitchTab1, "switch-tab-1"}, | |||
| 73 | + | {ActionSwitchTab2, "switch-tab-2"}, | |||
| 74 | + | {ActionSwitchTab3, "switch-tab-3"}, | |||
| 75 | + | {ActionSwitchTab4, "switch-tab-4"}, | |||
| 76 | + | {ActionSwitchTab5, "switch-tab-5"}, | |||
| 77 | + | {ActionSwitchTab6, "switch-tab-6"}, | |||
| 78 | + | {ActionSwitchTab7, "switch-tab-7"}, | |||
| 79 | + | {ActionSwitchTab8, "switch-tab-8"}, | |||
| 80 | + | {ActionSwitchTab9, "switch-tab-9"}, | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | for _, tc := range cases { | |||
| 84 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 85 | + | require.Equal(t, tc.name, tc.a.Name()) | |||
| 86 | + | got, ok := ActionByName(tc.name) | |||
| 87 | + | require.True(t, ok) | |||
| 88 | + | require.Equal(t, tc.a, got) | |||
| 89 | + | }) | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | require.Equal(t, "unknown", Action(99).Name()) | |||
| 93 | + | _, ok := ActionByName("unknown-action") | |||
| 94 | + | require.False(t, ok) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func TestBuildBindingsAppliesOverrides(t *testing.T) { | |||
| 98 | + | bindings, warnings := BuildBindings(map[string]string{ | |||
| 99 | + | "open-palette": "alt+o", | |||
| 100 | + | "focus-pane-left": "alt+left", | |||
| 101 | + | "switch-tab-2": "alt+2", | |||
| 102 | + | }) | |||
| 103 | + | require.Empty(t, warnings) | |||
| 104 | + | ||||
| 105 | + | assertAltRuneAction(t, bindings, 'o', ActionOpenPalette) | |||
| 106 | + | assertAltRuneUnbound(t, bindings, ' ') | |||
| 107 | + | assertAltArrowAction(t, bindings, 'D', ActionFocusPaneLeft) | |||
| 108 | + | assertAltRuneUnbound(t, bindings, 'h') | |||
| 109 | + | assertAltRuneAction(t, bindings, '2', ActionSwitchTab2) | |||
| 110 | + | assertAltRuneAction(t, bindings, 'é', ActionSwitchTab2) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func TestBuildBindingsWarnsAndSkipsInvalidEntries(t *testing.T) { | |||
| 114 | + | bindings, warnings := BuildBindings(map[string]string{ | |||
| 115 | + | "missing": "alt+m", | |||
| 116 | + | "open-palette": "ctrl+o", | |||
| 117 | + | "jump-attention": "alt+j", | |||
| 118 | + | }) | |||
| 119 | + | ||||
| 120 | + | require.Len(t, warnings, 3) | |||
| 121 | + | warningText := warnings[0].Msg + "\n" + warnings[1].Msg + "\n" + warnings[2].Msg | |||
| 122 | + | require.Contains(t, warningText, "unknown action") | |||
| 123 | + | require.Contains(t, warningText, "invalid key") | |||
| 124 | + | require.Contains(t, warningText, "duplicate key") | |||
| 125 | + | assertAltRuneAction(t, bindings, ' ', ActionOpenPalette) | |||
| 126 | + | assertAltRuneAction(t, bindings, 'a', ActionJumpAttention) | |||
| 127 | + | assertAltRuneAction(t, bindings, 'j', ActionFocusPaneDown) | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func TestBuildBindingsAllowsSameActionToKeepDefaultKey(t *testing.T) { | |||
| 131 | + | bindings, warnings := BuildBindings(map[string]string{ | |||
| 132 | + | "focus-pane-left": "alt+h", | |||
| 133 | + | }) | |||
| 134 | + | ||||
| 135 | + | require.Empty(t, warnings) | |||
| 136 | + | assertAltRuneAction(t, bindings, 'h', ActionFocusPaneLeft) | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func TestBuildBindingEntriesDuplicateKeyConflictsUseFileOrder(t *testing.T) { | |||
| 140 | + | tests := []struct { | |||
| 141 | + | name string | |||
| 142 | + | overrides []domain.ConfigEntry | |||
| 143 | + | wantAltP Action | |||
| 144 | + | wantOpenDefault bool | |||
| 145 | + | wantFocusDefault bool | |||
| 146 | + | wantWarningAction string | |||
| 147 | + | }{ | |||
| 148 | + | { | |||
| 149 | + | name: "open palette first wins", | |||
| 150 | + | overrides: []domain.ConfigEntry{ | |||
| 151 | + | {Key: "open-palette", Value: "alt+p"}, | |||
| 152 | + | {Key: "focus-pane-left", Value: "alt+p"}, | |||
| 153 | + | }, | |||
| 154 | + | wantAltP: ActionOpenPalette, | |||
| 155 | + | wantFocusDefault: true, | |||
| 156 | + | wantWarningAction: "focus-pane-left", | |||
| 157 | + | }, | |||
| 158 | + | { | |||
| 159 | + | name: "focus pane left first wins", | |||
| 160 | + | overrides: []domain.ConfigEntry{ | |||
| 161 | + | {Key: "focus-pane-left", Value: "alt+p"}, | |||
| 162 | + | {Key: "open-palette", Value: "alt+p"}, | |||
| 163 | + | }, | |||
| 164 | + | wantAltP: ActionFocusPaneLeft, | |||
| 165 | + | wantOpenDefault: true, | |||
| 166 | + | wantWarningAction: "open-palette", | |||
| 167 | + | }, | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | for _, tt := range tests { | |||
| 171 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 172 | + | bindings, warnings := BuildBindingEntries(tt.overrides) | |||
| 173 | + | ||||
| 174 | + | require.Len(t, warnings, 1) | |||
| 175 | + | require.Contains(t, warnings[0].Msg, tt.wantWarningAction) | |||
| 176 | + | assertAltRuneAction(t, bindings, 'p', tt.wantAltP) | |||
| 177 | + | if tt.wantOpenDefault { | |||
| 178 | + | assertAltRuneAction(t, bindings, ' ', ActionOpenPalette) | |||
| 179 | + | } else { | |||
| 180 | + | assertAltRuneUnbound(t, bindings, ' ') | |||
| 181 | + | } | |||
| 182 | + | if tt.wantFocusDefault { | |||
| 183 | + | assertAltRuneAction(t, bindings, 'h', ActionFocusPaneLeft) | |||
| 184 | + | } else { | |||
| 185 | + | assertAltRuneUnbound(t, bindings, 'h') | |||
| 186 | + | } | |||
| 187 | + | }) | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | func TestBuildBindingsFreesDefaultsForOverriddenActionsBeforeConflictChecks(t *testing.T) { | |||
| 192 | + | bindings, warnings := BuildBindings(map[string]string{ | |||
| 193 | + | "open-palette": "alt+o", | |||
| 194 | + | "focus-pane-down": "alt+space", | |||
| 195 | + | }) | |||
| 196 | + | ||||
| 197 | + | require.Empty(t, warnings) | |||
| 198 | + | assertAltRuneAction(t, bindings, 'o', ActionOpenPalette) | |||
| 199 | + | assertAltRuneAction(t, bindings, ' ', ActionFocusPaneDown) | |||
| 200 | + | assertAltRuneUnbound(t, bindings, 'j') | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | func TestBuildBindingEntriesFailedOverrideRestoreDoesNotOverwriteEarlierOverride(t *testing.T) { | |||
| 204 | + | bindings, warnings := BuildBindingEntries([]domain.ConfigEntry{ | |||
| 205 | + | {Key: "open-palette", Value: "alt+a"}, | |||
| 206 | + | {Key: "jump-attention", Value: "alt+a"}, | |||
| 207 | + | }) | |||
| 208 | + | ||||
| 209 | + | require.Len(t, warnings, 1) | |||
| 210 | + | assertAltRuneAction(t, bindings, 'a', ActionOpenPalette) | |||
| 211 | + | assertAltRuneUnbound(t, bindings, ' ') | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | func assertAltRuneAction(t *testing.T, bindings *Bindings, key rune, want Action) { | |||
| 215 | + | t.Helper() | |||
| 216 | + | got, _, ok := bindings.actionForAltBytes([]byte(string(key))) | |||
| 217 | + | require.True(t, ok) | |||
| 218 | + | require.Equal(t, want, got) | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | func assertAltRuneUnbound(t *testing.T, bindings *Bindings, key rune) { | |||
| 222 | + | t.Helper() | |||
| 223 | + | _, _, ok := bindings.actionForAltBytes([]byte(string(key))) | |||
| 224 | + | require.False(t, ok) | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | func assertAltArrowAction(t *testing.T, bindings *Bindings, final byte, want Action) { | |||
| 228 | + | t.Helper() | |||
| 229 | + | got, ok := bindings.actionForAltArrow(final) | |||
| 230 | + | require.True(t, ok) | |||
| 231 | + | require.Equal(t, want, got) | |||
| 232 | + | } | |||
M internal/usecase/keys/router.go +32 -48
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "bytes" | |
| 7 | 7 | "slices" | |
| 8 | 8 | "sync" | |
| 9 | + | "sync/atomic" | |
| 9 | 10 | "time" | |
| 10 | 11 | "unicode/utf8" | |
| 11 | 12 | ||
| @@ -49,9 +50,10 @@ type Handler interface { | |||
| 49 | 50 | // ESCDelay so split Alt-key sequences can still be intercepted without delaying | |
| 50 | 51 | // known terminal control prefixes (ESC [ and ESC O), which pass through. | |
| 51 | 52 | type Router struct { | |
| 52 | - | clock ports.Clock | |
| 53 | - | delay time.Duration | |
| 54 | - | h Handler | |
| 53 | + | clock ports.Clock | |
| 54 | + | delay time.Duration | |
| 55 | + | h Handler | |
| 56 | + | bindings *atomic.Pointer[Bindings] | |
| 55 | 57 | ||
| 56 | 58 | mu sync.Mutex | |
| 57 | 59 | pending bool | |
| @@ -60,8 +62,12 @@ type Router struct { | |||
| 60 | 62 | pendingDone chan struct{} | |
| 61 | 63 | } | |
| 62 | 64 | ||
| 63 | - | func NewRouter(clock ports.Clock, h Handler) *Router { | |
| 64 | - | return &Router{clock: clock, delay: ESCDelay, h: h} | |
| 65 | + | func NewRouter(clock ports.Clock, h Handler, bindings ...*atomic.Pointer[Bindings]) *Router { | |
| 66 | + | var bindingPtr *atomic.Pointer[Bindings] | |
| 67 | + | if len(bindings) > 0 { | |
| 68 | + | bindingPtr = bindings[0] | |
| 69 | + | } | |
| 70 | + | return &Router{clock: clock, delay: ESCDelay, h: h, bindings: bindingPtr} | |
| 65 | 71 | } | |
| 66 | 72 | ||
| 67 | 73 | // Route routes one transport read. | |
| @@ -120,7 +126,7 @@ func (r *Router) route(data []byte) { | |||
| 120 | 126 | continue | |
| 121 | 127 | } | |
| 122 | 128 | } | |
| 123 | - | if action, size, ok, partial := altArrowCSI(remaining); ok { | |
| 129 | + | if action, size, ok, partial := r.altArrowCSI(remaining); ok { | |
| 124 | 130 | flush() | |
| 125 | 131 | r.h.Action(action) | |
| 126 | 132 | i += 1 + size | |
| @@ -136,7 +142,7 @@ func (r *Router) route(data []byte) { | |||
| 136 | 142 | i += 2 | |
| 137 | 143 | continue | |
| 138 | 144 | } | |
| 139 | - | if action, size, ok := binding(remaining); ok { | |
| 145 | + | if action, size, ok := r.binding(remaining); ok { | |
| 140 | 146 | flush() | |
| 141 | 147 | r.h.Action(action) | |
| 142 | 148 | i += 1 + size | |
| @@ -154,7 +160,7 @@ func (r *Router) route(data []byte) { | |||
| 154 | 160 | } | |
| 155 | 161 | ||
| 156 | 162 | func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int) int { | |
| 157 | - | if action, size, ok, partial := altArrowCSI(data); ok { | |
| 163 | + | if action, size, ok, partial := r.altArrowCSI(data); ok { | |
| 158 | 164 | r.h.Action(action) | |
| 159 | 165 | return size | |
| 160 | 166 | } else if partial { | |
| @@ -162,7 +168,7 @@ func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int) int { | |||
| 162 | 168 | return len(data) | |
| 163 | 169 | } | |
| 164 | 170 | next := data[0] | |
| 165 | - | if action, size, ok := binding(data); ok { | |
| 171 | + | if action, size, ok := r.binding(data); ok { | |
| 166 | 172 | r.h.Action(action) | |
| 167 | 173 | return size | |
| 168 | 174 | } | |
| @@ -238,7 +244,18 @@ func (r *Router) forward(data []byte) { | |||
| 238 | 244 | ||
| 239 | 245 | func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' } | |
| 240 | 246 | ||
| 241 | - | func altArrowCSI(data []byte) (Action, int, bool, bool) { | |
| 247 | + | func (r *Router) currentBindings() *Bindings { | |
| 248 | + | if r.bindings == nil { | |
| 249 | + | return defaultBindings | |
| 250 | + | } | |
| 251 | + | bindings := r.bindings.Load() | |
| 252 | + | if bindings == nil { | |
| 253 | + | return defaultBindings | |
| 254 | + | } | |
| 255 | + | return bindings | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | func (r *Router) altArrowCSI(data []byte) (Action, int, bool, bool) { | |
| 242 | 259 | const seqLen = len("[1;3A") | |
| 243 | 260 | if len(data) < seqLen { | |
| 244 | 261 | return 0, 0, false, hasAltArrowCSIPrefix(data) | |
| @@ -247,18 +264,10 @@ func altArrowCSI(data []byte) (Action, int, bool, bool) { | |||
| 247 | 264 | if seq[0] != '[' || seq[1] != '1' || seq[2] != ';' || (seq[3] != '3' && seq[3] != '9') { | |
| 248 | 265 | return 0, 0, false, false | |
| 249 | 266 | } | |
| 250 | - | switch seq[4] { | |
| 251 | - | case 'A': | |
| 252 | - | return ActionFocusPaneUp, seqLen, true, false | |
| 253 | - | case 'B': | |
| 254 | - | return ActionFocusPaneDown, seqLen, true, false | |
| 255 | - | case 'C': | |
| 256 | - | return ActionFocusPaneRight, seqLen, true, false | |
| 257 | - | case 'D': | |
| 258 | - | return ActionFocusPaneLeft, seqLen, true, false | |
| 259 | - | default: | |
| 260 | - | return 0, 0, false, false | |
| 267 | + | if action, ok := r.currentBindings().actionForAltArrow(seq[4]); ok { | |
| 268 | + | return action, seqLen, true, false | |
| 261 | 269 | } | |
| 270 | + | return 0, 0, false, false | |
| 262 | 271 | } | |
| 263 | 272 | ||
| 264 | 273 | func hasAltArrowCSIPrefix(data []byte) bool { | |
| @@ -280,33 +289,8 @@ func matchesPrefix(data []byte, want string) bool { | |||
| 280 | 289 | return true | |
| 281 | 290 | } | |
| 282 | 291 | ||
| 283 | - | func binding(data []byte) (Action, int, bool) { | |
| 284 | - | if len(data) == 0 { | |
| 285 | - | return 0, 0, false | |
| 286 | - | } | |
| 287 | - | switch data[0] { | |
| 288 | - | case ' ': | |
| 289 | - | return ActionOpenPalette, 1, true | |
| 290 | - | case 'a': | |
| 291 | - | return ActionJumpAttention, 1, true | |
| 292 | - | case 'h': | |
| 293 | - | return ActionFocusPaneLeft, 1, true | |
| 294 | - | case 'j': | |
| 295 | - | return ActionFocusPaneDown, 1, true | |
| 296 | - | case 'k': | |
| 297 | - | return ActionFocusPaneUp, 1, true | |
| 298 | - | case 'l': | |
| 299 | - | return ActionFocusPaneRight, 1, true | |
| 300 | - | } | |
| 301 | - | ||
| 302 | - | key, size := utf8.DecodeRune(data) | |
| 303 | - | if key == utf8.RuneError && size == 1 { | |
| 304 | - | return 0, 0, false | |
| 305 | - | } | |
| 306 | - | if idx, ok := topRowDigitIndex(key); ok { | |
| 307 | - | return ActionSwitchTab1 + Action(idx), size, true | |
| 308 | - | } | |
| 309 | - | return 0, 0, false | |
| 292 | + | func (r *Router) binding(data []byte) (Action, int, bool) { | |
| 293 | + | return r.currentBindings().actionForAltBytes(data) | |
| 310 | 294 | } | |
| 311 | 295 | ||
| 312 | 296 | var topRowDigitAliases = [][]rune{ | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "bytes" | 6 | "bytes" | ||
| 7 | "slices" | 7 | "slices" | ||
| 8 | "sync" | 8 | "sync" | ||
| 9 | + | "sync/atomic" | |||
| 9 | "time" | 10 | "time" | ||
| 10 | "unicode/utf8" | 11 | "unicode/utf8" | ||
| 11 | 12 | ||||
| @@ -49,9 +50,10 @@ type Handler interface { | |||||
| 49 | // ESCDelay so split Alt-key sequences can still be intercepted without delaying | 50 | // ESCDelay so split Alt-key sequences can still be intercepted without delaying | ||
| 50 | // known terminal control prefixes (ESC [ and ESC O), which pass through. | 51 | // known terminal control prefixes (ESC [ and ESC O), which pass through. | ||
| 51 | type Router struct { | 52 | type Router struct { | ||
| 52 | - | clock ports.Clock | 53 | + | clock ports.Clock |
| 53 | - | delay time.Duration | 54 | + | delay time.Duration |
| 54 | - | h Handler | 55 | + | h Handler |
| 56 | + | bindings *atomic.Pointer[Bindings] | |||
| 55 | 57 | ||||
| 56 | mu sync.Mutex | 58 | mu sync.Mutex | ||
| 57 | pending bool | 59 | pending bool | ||
| @@ -60,8 +62,12 @@ type Router struct { | |||||
| 60 | pendingDone chan struct{} | 62 | pendingDone chan struct{} | ||
| 61 | } | 63 | } | ||
| 62 | 64 | ||||
| 63 | - | func NewRouter(clock ports.Clock, h Handler) *Router { | 65 | + | func NewRouter(clock ports.Clock, h Handler, bindings ...*atomic.Pointer[Bindings]) *Router { |
| 64 | - | return &Router{clock: clock, delay: ESCDelay, h: h} | 66 | + | var bindingPtr *atomic.Pointer[Bindings] |
| 67 | + | if len(bindings) > 0 { | |||
| 68 | + | bindingPtr = bindings[0] | |||
| 69 | + | } | |||
| 70 | + | return &Router{clock: clock, delay: ESCDelay, h: h, bindings: bindingPtr} | |||
| 65 | } | 71 | } | ||
| 66 | 72 | ||||
| 67 | // Route routes one transport read. | 73 | // Route routes one transport read. | ||
| @@ -120,7 +126,7 @@ func (r *Router) route(data []byte) { | |||||
| 120 | continue | 126 | continue | ||
| 121 | } | 127 | } | ||
| 122 | } | 128 | } | ||
| 123 | - | if action, size, ok, partial := altArrowCSI(remaining); ok { | 129 | + | if action, size, ok, partial := r.altArrowCSI(remaining); ok { |
| 124 | flush() | 130 | flush() | ||
| 125 | r.h.Action(action) | 131 | r.h.Action(action) | ||
| 126 | i += 1 + size | 132 | i += 1 + size | ||
| @@ -136,7 +142,7 @@ func (r *Router) route(data []byte) { | |||||
| 136 | i += 2 | 142 | i += 2 | ||
| 137 | continue | 143 | continue | ||
| 138 | } | 144 | } | ||
| 139 | - | if action, size, ok := binding(remaining); ok { | 145 | + | if action, size, ok := r.binding(remaining); ok { |
| 140 | flush() | 146 | flush() | ||
| 141 | r.h.Action(action) | 147 | r.h.Action(action) | ||
| 142 | i += 1 + size | 148 | i += 1 + size | ||
| @@ -154,7 +160,7 @@ func (r *Router) route(data []byte) { | |||||
| 154 | } | 160 | } | ||
| 155 | 161 | ||||
| 156 | func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int) int { | 162 | func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int) int { | ||
| 157 | - | if action, size, ok, partial := altArrowCSI(data); ok { | 163 | + | if action, size, ok, partial := r.altArrowCSI(data); ok { |
| 158 | r.h.Action(action) | 164 | r.h.Action(action) | ||
| 159 | return size | 165 | return size | ||
| 160 | } else if partial { | 166 | } else if partial { | ||
| @@ -162,7 +168,7 @@ func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int) int { | |||||
| 162 | return len(data) | 168 | return len(data) | ||
| 163 | } | 169 | } | ||
| 164 | next := data[0] | 170 | next := data[0] | ||
| 165 | - | if action, size, ok := binding(data); ok { | 171 | + | if action, size, ok := r.binding(data); ok { |
| 166 | r.h.Action(action) | 172 | r.h.Action(action) | ||
| 167 | return size | 173 | return size | ||
| 168 | } | 174 | } | ||
| @@ -238,7 +244,18 @@ func (r *Router) forward(data []byte) { | |||||
| 238 | 244 | ||||
| 239 | func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' } | 245 | func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' } | ||
| 240 | 246 | ||||
| 241 | - | func altArrowCSI(data []byte) (Action, int, bool, bool) { | 247 | + | func (r *Router) currentBindings() *Bindings { |
| 248 | + | if r.bindings == nil { | |||
| 249 | + | return defaultBindings | |||
| 250 | + | } | |||
| 251 | + | bindings := r.bindings.Load() | |||
| 252 | + | if bindings == nil { | |||
| 253 | + | return defaultBindings | |||
| 254 | + | } | |||
| 255 | + | return bindings | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | func (r *Router) altArrowCSI(data []byte) (Action, int, bool, bool) { | |||
| 242 | const seqLen = len("[1;3A") | 259 | const seqLen = len("[1;3A") | ||
| 243 | if len(data) < seqLen { | 260 | if len(data) < seqLen { | ||
| 244 | return 0, 0, false, hasAltArrowCSIPrefix(data) | 261 | return 0, 0, false, hasAltArrowCSIPrefix(data) | ||
| @@ -247,18 +264,10 @@ func altArrowCSI(data []byte) (Action, int, bool, bool) { | |||||
| 247 | if seq[0] != '[' || seq[1] != '1' || seq[2] != ';' || (seq[3] != '3' && seq[3] != '9') { | 264 | if seq[0] != '[' || seq[1] != '1' || seq[2] != ';' || (seq[3] != '3' && seq[3] != '9') { | ||
| 248 | return 0, 0, false, false | 265 | return 0, 0, false, false | ||
| 249 | } | 266 | } | ||
| 250 | - | switch seq[4] { | 267 | + | if action, ok := r.currentBindings().actionForAltArrow(seq[4]); ok { |
| 251 | - | case 'A': | 268 | + | return action, seqLen, true, false |
| 252 | - | return ActionFocusPaneUp, seqLen, true, false | |||
| 253 | - | case 'B': | |||
| 254 | - | return ActionFocusPaneDown, seqLen, true, false | |||
| 255 | - | case 'C': | |||
| 256 | - | return ActionFocusPaneRight, seqLen, true, false | |||
| 257 | - | case 'D': | |||
| 258 | - | return ActionFocusPaneLeft, seqLen, true, false | |||
| 259 | - | default: | |||
| 260 | - | return 0, 0, false, false | |||
| 261 | } | 269 | } | ||
| 270 | + | return 0, 0, false, false | |||
| 262 | } | 271 | } | ||
| 263 | 272 | ||||
| 264 | func hasAltArrowCSIPrefix(data []byte) bool { | 273 | func hasAltArrowCSIPrefix(data []byte) bool { | ||
| @@ -280,33 +289,8 @@ func matchesPrefix(data []byte, want string) bool { | |||||
| 280 | return true | 289 | return true | ||
| 281 | } | 290 | } | ||
| 282 | 291 | ||||
| 283 | - | func binding(data []byte) (Action, int, bool) { | 292 | + | func (r *Router) binding(data []byte) (Action, int, bool) { |
| 284 | - | if len(data) == 0 { | 293 | + | return r.currentBindings().actionForAltBytes(data) |
| 285 | - | return 0, 0, false | |||
| 286 | - | } | |||
| 287 | - | switch data[0] { | |||
| 288 | - | case ' ': | |||
| 289 | - | return ActionOpenPalette, 1, true | |||
| 290 | - | case 'a': | |||
| 291 | - | return ActionJumpAttention, 1, true | |||
| 292 | - | case 'h': | |||
| 293 | - | return ActionFocusPaneLeft, 1, true | |||
| 294 | - | case 'j': | |||
| 295 | - | return ActionFocusPaneDown, 1, true | |||
| 296 | - | case 'k': | |||
| 297 | - | return ActionFocusPaneUp, 1, true | |||
| 298 | - | case 'l': | |||
| 299 | - | return ActionFocusPaneRight, 1, true | |||
| 300 | - | } | |||
| 301 | - | ||||
| 302 | - | key, size := utf8.DecodeRune(data) | |||
| 303 | - | if key == utf8.RuneError && size == 1 { | |||
| 304 | - | return 0, 0, false | |||
| 305 | - | } | |||
| 306 | - | if idx, ok := topRowDigitIndex(key); ok { | |||
| 307 | - | return ActionSwitchTab1 + Action(idx), size, true | |||
| 308 | - | } | |||
| 309 | - | return 0, 0, false | |||
| 310 | } | 294 | } | ||
| 311 | 295 | ||||
| 312 | var topRowDigitAliases = [][]rune{ | 296 | var topRowDigitAliases = [][]rune{ | ||
M internal/usecase/keys/router_test.go +34 -0
| @@ -3,6 +3,7 @@ package keys | |||
| 3 | 3 | import ( | |
| 4 | 4 | "runtime" | |
| 5 | 5 | "strings" | |
| 6 | + | "sync/atomic" | |
| 6 | 7 | "testing" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| @@ -45,6 +46,39 @@ func (h *captureHandler) Forward(data []byte) { | |||
| 45 | 46 | } | |
| 46 | 47 | func (h *captureHandler) Action(a Action) { h.actions = append(h.actions, a) } | |
| 47 | 48 | ||
| 49 | + | func TestDefaultBindingsParityRoutesBuiltInBindings(t *testing.T) { | |
| 50 | + | cases := []struct { | |
| 51 | + | name string | |
| 52 | + | in []byte | |
| 53 | + | want Action | |
| 54 | + | }{ | |
| 55 | + | {name: "palette", in: []byte{ESC, ' '}, want: ActionOpenPalette}, | |
| 56 | + | {name: "jump attention", in: []byte{ESC, 'a'}, want: ActionJumpAttention}, | |
| 57 | + | {name: "focus left", in: []byte{ESC, 'h'}, want: ActionFocusPaneLeft}, | |
| 58 | + | {name: "focus down", in: []byte{ESC, 'j'}, want: ActionFocusPaneDown}, | |
| 59 | + | {name: "focus up", in: []byte{ESC, 'k'}, want: ActionFocusPaneUp}, | |
| 60 | + | {name: "focus right", in: []byte{ESC, 'l'}, want: ActionFocusPaneRight}, | |
| 61 | + | {name: "arrow left", in: []byte{ESC, '[', '1', ';', '3', 'D'}, want: ActionFocusPaneLeft}, | |
| 62 | + | {name: "arrow right", in: []byte{ESC, '[', '1', ';', '3', 'C'}, want: ActionFocusPaneRight}, | |
| 63 | + | {name: "arrow up", in: []byte{ESC, '[', '1', ';', '3', 'A'}, want: ActionFocusPaneUp}, | |
| 64 | + | {name: "arrow down", in: []byte{ESC, '[', '1', ';', '3', 'B'}, want: ActionFocusPaneDown}, | |
| 65 | + | {name: "digit", in: []byte{ESC, '1'}, want: ActionSwitchTab1}, | |
| 66 | + | {name: "digit alias", in: append([]byte{ESC}, []byte("é")...), want: ActionSwitchTab2}, | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | var bindings atomic.Pointer[Bindings] | |
| 70 | + | bindings.Store(DefaultBindings()) | |
| 71 | + | for _, tc := range cases { | |
| 72 | + | t.Run(tc.name, func(t *testing.T) { | |
| 73 | + | clk := &fakeClock{} | |
| 74 | + | h := &captureHandler{} | |
| 75 | + | NewRouter(clk, h, &bindings).Route(tc.in) | |
| 76 | + | require.Equal(t, []Action{tc.want}, h.actions) | |
| 77 | + | require.Empty(t, h.forwards) | |
| 78 | + | }) | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 48 | 82 | func TestRouterInterceptsAltSpaceForPalette(t *testing.T) { | |
| 49 | 83 | clk := &fakeClock{} | |
| 50 | 84 | h := &captureHandler{} | |
| @@ -3,6 +3,7 @@ package keys | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "runtime" | 4 | "runtime" | ||
| 5 | "strings" | 5 | "strings" | ||
| 6 | + | "sync/atomic" | |||
| 6 | "testing" | 7 | "testing" | ||
| 7 | "time" | 8 | "time" | ||
| 8 | 9 | ||||
| @@ -45,6 +46,39 @@ func (h *captureHandler) Forward(data []byte) { | |||||
| 45 | } | 46 | } | ||
| 46 | func (h *captureHandler) Action(a Action) { h.actions = append(h.actions, a) } | 47 | func (h *captureHandler) Action(a Action) { h.actions = append(h.actions, a) } | ||
| 47 | 48 | ||||
| 49 | + | func TestDefaultBindingsParityRoutesBuiltInBindings(t *testing.T) { | |||
| 50 | + | cases := []struct { | |||
| 51 | + | name string | |||
| 52 | + | in []byte | |||
| 53 | + | want Action | |||
| 54 | + | }{ | |||
| 55 | + | {name: "palette", in: []byte{ESC, ' '}, want: ActionOpenPalette}, | |||
| 56 | + | {name: "jump attention", in: []byte{ESC, 'a'}, want: ActionJumpAttention}, | |||
| 57 | + | {name: "focus left", in: []byte{ESC, 'h'}, want: ActionFocusPaneLeft}, | |||
| 58 | + | {name: "focus down", in: []byte{ESC, 'j'}, want: ActionFocusPaneDown}, | |||
| 59 | + | {name: "focus up", in: []byte{ESC, 'k'}, want: ActionFocusPaneUp}, | |||
| 60 | + | {name: "focus right", in: []byte{ESC, 'l'}, want: ActionFocusPaneRight}, | |||
| 61 | + | {name: "arrow left", in: []byte{ESC, '[', '1', ';', '3', 'D'}, want: ActionFocusPaneLeft}, | |||
| 62 | + | {name: "arrow right", in: []byte{ESC, '[', '1', ';', '3', 'C'}, want: ActionFocusPaneRight}, | |||
| 63 | + | {name: "arrow up", in: []byte{ESC, '[', '1', ';', '3', 'A'}, want: ActionFocusPaneUp}, | |||
| 64 | + | {name: "arrow down", in: []byte{ESC, '[', '1', ';', '3', 'B'}, want: ActionFocusPaneDown}, | |||
| 65 | + | {name: "digit", in: []byte{ESC, '1'}, want: ActionSwitchTab1}, | |||
| 66 | + | {name: "digit alias", in: append([]byte{ESC}, []byte("é")...), want: ActionSwitchTab2}, | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | var bindings atomic.Pointer[Bindings] | |||
| 70 | + | bindings.Store(DefaultBindings()) | |||
| 71 | + | for _, tc := range cases { | |||
| 72 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 73 | + | clk := &fakeClock{} | |||
| 74 | + | h := &captureHandler{} | |||
| 75 | + | NewRouter(clk, h, &bindings).Route(tc.in) | |||
| 76 | + | require.Equal(t, []Action{tc.want}, h.actions) | |||
| 77 | + | require.Empty(t, h.forwards) | |||
| 78 | + | }) | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | ||||
| 48 | func TestRouterInterceptsAltSpaceForPalette(t *testing.T) { | 82 | func TestRouterInterceptsAltSpaceForPalette(t *testing.T) { | ||
| 49 | clk := &fakeClock{} | 83 | clk := &fakeClock{} | ||
| 50 | h := &captureHandler{} | 84 | h := &captureHandler{} | ||
M internal/usecase/theme/theme.go +73 -7
| @@ -12,14 +12,39 @@ const maxPending = 64 | |||
| 12 | 12 | ||
| 13 | 13 | // Theme describes terminal default colors reported by OSC 10/11. | |
| 14 | 14 | type Theme struct { | |
| 15 | - | Foreground renderer.RGB | |
| 16 | - | Background renderer.RGB | |
| 17 | - | HasFG bool | |
| 18 | - | HasBG bool | |
| 19 | - | TrueColor bool | |
| 20 | - | Known bool | |
| 15 | + | Foreground renderer.RGB | |
| 16 | + | Background renderer.RGB | |
| 17 | + | HasFG bool | |
| 18 | + | HasBG bool | |
| 19 | + | TrueColor bool | |
| 20 | + | Known bool | |
| 21 | + | SchemeKnown bool | |
| 22 | + | Light bool | |
| 21 | 23 | } | |
| 22 | 24 | ||
| 25 | + | var ( | |
| 26 | + | BuiltinDark = Theme{ | |
| 27 | + | Foreground: renderer.RGB{R: 0xd8, G: 0xd8, B: 0xd8}, | |
| 28 | + | Background: renderer.RGB{R: 0x18, G: 0x18, B: 0x18}, | |
| 29 | + | HasFG: true, | |
| 30 | + | HasBG: true, | |
| 31 | + | TrueColor: true, | |
| 32 | + | Known: true, | |
| 33 | + | SchemeKnown: true, | |
| 34 | + | Light: false, | |
| 35 | + | } | |
| 36 | + | BuiltinLight = Theme{ | |
| 37 | + | Foreground: renderer.RGB{R: 0x20, G: 0x20, B: 0x20}, | |
| 38 | + | Background: renderer.RGB{R: 0xf8, G: 0xf8, B: 0xf8}, | |
| 39 | + | HasFG: true, | |
| 40 | + | HasBG: true, | |
| 41 | + | TrueColor: true, | |
| 42 | + | Known: true, | |
| 43 | + | SchemeKnown: true, | |
| 44 | + | Light: true, | |
| 45 | + | } | |
| 46 | + | ) | |
| 47 | + | ||
| 23 | 48 | // ParseXColor parses the XParseColor formats commonly returned by OSC 10/11. | |
| 24 | 49 | func ParseXColor(s string) (renderer.RGB, bool) { | |
| 25 | 50 | if strings.HasPrefix(s, "#") { | |
| @@ -95,7 +120,7 @@ type Scanner struct { | |||
| 95 | 120 | // callers never see it split. Partial OSC color responses are buffered across | |
| 96 | 121 | // calls, but the buffer is bounded so an unterminated OSC cannot block | |
| 97 | 122 | // unrelated input forever. | |
| 98 | - | func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), onBytes func([]byte)) { | |
| 123 | + | func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), onScheme func(light bool), onBytes func([]byte)) { | |
| 99 | 124 | if len(s.pending) > 0 { | |
| 100 | 125 | combined := make([]byte, 0, len(s.pending)+len(data)) | |
| 101 | 126 | combined = append(combined, s.pending...) | |
| @@ -110,6 +135,22 @@ func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), on | |||
| 110 | 135 | continue | |
| 111 | 136 | } | |
| 112 | 137 | ||
| 138 | + | if complete, possible, light := schemeCSINotification(data[i:]); complete { | |
| 139 | + | if byteStart < i { | |
| 140 | + | onBytes(data[byteStart:i]) | |
| 141 | + | } | |
| 142 | + | onScheme(light) | |
| 143 | + | i += len("\x1b[?997;1n") - 1 | |
| 144 | + | byteStart = i + 1 | |
| 145 | + | continue | |
| 146 | + | } else if possible { | |
| 147 | + | if byteStart < i { | |
| 148 | + | onBytes(data[byteStart:i]) | |
| 149 | + | } | |
| 150 | + | s.bufferOrFlush(data[i:], onBytes) | |
| 151 | + | return | |
| 152 | + | } | |
| 153 | + | ||
| 113 | 154 | completePrefix, possiblePrefix := colorOSCPrefix(data[i:]) | |
| 114 | 155 | if !completePrefix { | |
| 115 | 156 | if possiblePrefix { | |
| @@ -183,6 +224,31 @@ func colorOSCPrefix(data []byte) (complete bool, possible bool) { | |||
| 183 | 224 | return false, false | |
| 184 | 225 | } | |
| 185 | 226 | ||
| 227 | + | func schemeCSINotification(data []byte) (complete bool, possible bool, light bool) { | |
| 228 | + | if len(data) < 2 { | |
| 229 | + | return false, false, false | |
| 230 | + | } | |
| 231 | + | prefixes := []struct { | |
| 232 | + | seq []byte | |
| 233 | + | light bool | |
| 234 | + | }{ | |
| 235 | + | {seq: []byte("\x1b[?997;1n"), light: false}, | |
| 236 | + | {seq: []byte("\x1b[?997;2n"), light: true}, | |
| 237 | + | } | |
| 238 | + | for _, prefix := range prefixes { | |
| 239 | + | if len(data) >= len(prefix.seq) { | |
| 240 | + | if bytes.Equal(data[:len(prefix.seq)], prefix.seq) { | |
| 241 | + | return true, true, prefix.light | |
| 242 | + | } | |
| 243 | + | continue | |
| 244 | + | } | |
| 245 | + | if bytes.Equal(data, prefix.seq[:len(data)]) { | |
| 246 | + | return false, true, false | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | return false, false, false | |
| 250 | + | } | |
| 251 | + | ||
| 186 | 252 | func findTerminator(data []byte) (start int, end int) { | |
| 187 | 253 | for i := range data { | |
| 188 | 254 | switch data[i] { | |
| @@ -12,14 +12,39 @@ const maxPending = 64 | |||||
| 12 | 12 | ||||
| 13 | // Theme describes terminal default colors reported by OSC 10/11. | 13 | // Theme describes terminal default colors reported by OSC 10/11. | ||
| 14 | type Theme struct { | 14 | type Theme struct { | ||
| 15 | - | Foreground renderer.RGB | 15 | + | Foreground renderer.RGB |
| 16 | - | Background renderer.RGB | 16 | + | Background renderer.RGB |
| 17 | - | HasFG bool | 17 | + | HasFG bool |
| 18 | - | HasBG bool | 18 | + | HasBG bool |
| 19 | - | TrueColor bool | 19 | + | TrueColor bool |
| 20 | - | Known bool | 20 | + | Known bool |
| 21 | + | SchemeKnown bool | |||
| 22 | + | Light bool | |||
| 21 | } | 23 | } | ||
| 22 | 24 | ||||
| 25 | + | var ( | |||
| 26 | + | BuiltinDark = Theme{ | |||
| 27 | + | Foreground: renderer.RGB{R: 0xd8, G: 0xd8, B: 0xd8}, | |||
| 28 | + | Background: renderer.RGB{R: 0x18, G: 0x18, B: 0x18}, | |||
| 29 | + | HasFG: true, | |||
| 30 | + | HasBG: true, | |||
| 31 | + | TrueColor: true, | |||
| 32 | + | Known: true, | |||
| 33 | + | SchemeKnown: true, | |||
| 34 | + | Light: false, | |||
| 35 | + | } | |||
| 36 | + | BuiltinLight = Theme{ | |||
| 37 | + | Foreground: renderer.RGB{R: 0x20, G: 0x20, B: 0x20}, | |||
| 38 | + | Background: renderer.RGB{R: 0xf8, G: 0xf8, B: 0xf8}, | |||
| 39 | + | HasFG: true, | |||
| 40 | + | HasBG: true, | |||
| 41 | + | TrueColor: true, | |||
| 42 | + | Known: true, | |||
| 43 | + | SchemeKnown: true, | |||
| 44 | + | Light: true, | |||
| 45 | + | } | |||
| 46 | + | ) | |||
| 47 | + | ||||
| 23 | // ParseXColor parses the XParseColor formats commonly returned by OSC 10/11. | 48 | // ParseXColor parses the XParseColor formats commonly returned by OSC 10/11. | ||
| 24 | func ParseXColor(s string) (renderer.RGB, bool) { | 49 | func ParseXColor(s string) (renderer.RGB, bool) { | ||
| 25 | if strings.HasPrefix(s, "#") { | 50 | if strings.HasPrefix(s, "#") { | ||
| @@ -95,7 +120,7 @@ type Scanner struct { | |||||
| 95 | // callers never see it split. Partial OSC color responses are buffered across | 120 | // callers never see it split. Partial OSC color responses are buffered across | ||
| 96 | // calls, but the buffer is bounded so an unterminated OSC cannot block | 121 | // calls, but the buffer is bounded so an unterminated OSC cannot block | ||
| 97 | // unrelated input forever. | 122 | // unrelated input forever. | ||
| 98 | - | func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), onBytes func([]byte)) { | 123 | + | func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), onScheme func(light bool), onBytes func([]byte)) { |
| 99 | if len(s.pending) > 0 { | 124 | if len(s.pending) > 0 { | ||
| 100 | combined := make([]byte, 0, len(s.pending)+len(data)) | 125 | combined := make([]byte, 0, len(s.pending)+len(data)) | ||
| 101 | combined = append(combined, s.pending...) | 126 | combined = append(combined, s.pending...) | ||
| @@ -110,6 +135,22 @@ func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), on | |||||
| 110 | continue | 135 | continue | ||
| 111 | } | 136 | } | ||
| 112 | 137 | ||||
| 138 | + | if complete, possible, light := schemeCSINotification(data[i:]); complete { | |||
| 139 | + | if byteStart < i { | |||
| 140 | + | onBytes(data[byteStart:i]) | |||
| 141 | + | } | |||
| 142 | + | onScheme(light) | |||
| 143 | + | i += len("\x1b[?997;1n") - 1 | |||
| 144 | + | byteStart = i + 1 | |||
| 145 | + | continue | |||
| 146 | + | } else if possible { | |||
| 147 | + | if byteStart < i { | |||
| 148 | + | onBytes(data[byteStart:i]) | |||
| 149 | + | } | |||
| 150 | + | s.bufferOrFlush(data[i:], onBytes) | |||
| 151 | + | return | |||
| 152 | + | } | |||
| 153 | + | ||||
| 113 | completePrefix, possiblePrefix := colorOSCPrefix(data[i:]) | 154 | completePrefix, possiblePrefix := colorOSCPrefix(data[i:]) | ||
| 114 | if !completePrefix { | 155 | if !completePrefix { | ||
| 115 | if possiblePrefix { | 156 | if possiblePrefix { | ||
| @@ -183,6 +224,31 @@ func colorOSCPrefix(data []byte) (complete bool, possible bool) { | |||||
| 183 | return false, false | 224 | return false, false | ||
| 184 | } | 225 | } | ||
| 185 | 226 | ||||
| 227 | + | func schemeCSINotification(data []byte) (complete bool, possible bool, light bool) { | |||
| 228 | + | if len(data) < 2 { | |||
| 229 | + | return false, false, false | |||
| 230 | + | } | |||
| 231 | + | prefixes := []struct { | |||
| 232 | + | seq []byte | |||
| 233 | + | light bool | |||
| 234 | + | }{ | |||
| 235 | + | {seq: []byte("\x1b[?997;1n"), light: false}, | |||
| 236 | + | {seq: []byte("\x1b[?997;2n"), light: true}, | |||
| 237 | + | } | |||
| 238 | + | for _, prefix := range prefixes { | |||
| 239 | + | if len(data) >= len(prefix.seq) { | |||
| 240 | + | if bytes.Equal(data[:len(prefix.seq)], prefix.seq) { | |||
| 241 | + | return true, true, prefix.light | |||
| 242 | + | } | |||
| 243 | + | continue | |||
| 244 | + | } | |||
| 245 | + | if bytes.Equal(data, prefix.seq[:len(data)]) { | |||
| 246 | + | return false, true, false | |||
| 247 | + | } | |||
| 248 | + | } | |||
| 249 | + | return false, false, false | |||
| 250 | + | } | |||
| 251 | + | ||||
| 186 | func findTerminator(data []byte) (start int, end int) { | 252 | func findTerminator(data []byte) (start int, end int) { | ||
| 187 | for i := range data { | 253 | for i := range data { | ||
| 188 | switch data[i] { | 254 | switch data[i] { | ||
M internal/usecase/theme/theme_test.go +26 -10
| @@ -48,7 +48,7 @@ func TestScannerExtractsBELAndSTColors(t *testing.T) { | |||
| 48 | 48 | kind int | |
| 49 | 49 | rgb renderer.RGB | |
| 50 | 50 | }{kind: kind, rgb: rgb}) | |
| 51 | - | }, func(b []byte) { out.Write(b) }) | |
| 51 | + | }, func(light bool) {}, func(b []byte) { out.Write(b) }) | |
| 52 | 52 | ||
| 53 | 53 | if out.String() != "abc" { | |
| 54 | 54 | t.Fatalf("passthrough=%q want %q", out.String(), "abc") | |
| @@ -71,8 +71,8 @@ func TestScannerHandlesSplitSequences(t *testing.T) { | |||
| 71 | 71 | onColor := func(kind int, rgb renderer.RGB) { got = append(got, rgb) } | |
| 72 | 72 | onBytes := func(b []byte) { out.Write(b) } | |
| 73 | 73 | ||
| 74 | - | s.Scan([]byte("x\x1b]10;#12"), onColor, onBytes) | |
| 75 | - | s.Scan([]byte("3456\x07y"), onColor, onBytes) | |
| 74 | + | s.Scan([]byte("x\x1b]10;#12"), onColor, func(light bool) {}, onBytes) | |
| 75 | + | s.Scan([]byte("3456\x07y"), onColor, func(light bool) {}, onBytes) | |
| 76 | 76 | ||
| 77 | 77 | if out.String() != "xy" { | |
| 78 | 78 | t.Fatalf("passthrough=%q want xy", out.String()) | |
| @@ -82,12 +82,28 @@ func TestScannerHandlesSplitSequences(t *testing.T) { | |||
| 82 | 82 | } | |
| 83 | 83 | } | |
| 84 | 84 | ||
| 85 | + | func TestScannerHandlesSplitSchemeNotifications(t *testing.T) { | |
| 86 | + | var s Scanner | |
| 87 | + | var schemes []bool | |
| 88 | + | var out bytes.Buffer | |
| 89 | + | ||
| 90 | + | s.Scan([]byte("a\x1b[?997"), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes = append(schemes, light) }, func(b []byte) { out.Write(b) }) | |
| 91 | + | s.Scan([]byte(";1nb\x1b[?997;2nc"), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes = append(schemes, light) }, func(b []byte) { out.Write(b) }) | |
| 92 | + | ||
| 93 | + | if out.String() != "abc" { | |
| 94 | + | t.Fatalf("passthrough=%q want abc", out.String()) | |
| 95 | + | } | |
| 96 | + | if len(schemes) != 2 || schemes[0] != false || schemes[1] != true { | |
| 97 | + | t.Fatalf("schemes=%v want [false true]", schemes) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 85 | 101 | func TestScannerForwardsInterleavedGarbageInOrder(t *testing.T) { | |
| 86 | 102 | var s Scanner | |
| 87 | 103 | var out bytes.Buffer | |
| 88 | 104 | colors := 0 | |
| 89 | 105 | ||
| 90 | - | s.Scan([]byte("ab\x1b]12;#112233\x07cd\x1b]10;nope\x07ef"), func(kind int, rgb renderer.RGB) { colors++ }, func(b []byte) { out.Write(b) }) | |
| 106 | + | s.Scan([]byte("ab\x1b]12;#112233\x07cd\x1b]10;nope\x07ef"), func(kind int, rgb renderer.RGB) { colors++ }, func(light bool) {}, func(b []byte) { out.Write(b) }) | |
| 91 | 107 | ||
| 92 | 108 | if colors != 0 { | |
| 93 | 109 | t.Fatalf("colors=%d want 0", colors) | |
| @@ -103,7 +119,7 @@ func TestScannerForwardsStandaloneEscapeImmediately(t *testing.T) { | |||
| 103 | 119 | var out bytes.Buffer | |
| 104 | 120 | colors := 0 | |
| 105 | 121 | ||
| 106 | - | s.Scan([]byte("\x1b"), func(kind int, rgb renderer.RGB) { colors++ }, func(b []byte) { out.Write(b) }) | |
| 122 | + | s.Scan([]byte("\x1b"), func(kind int, rgb renderer.RGB) { colors++ }, func(light bool) {}, func(b []byte) { out.Write(b) }) | |
| 107 | 123 | ||
| 108 | 124 | if colors != 0 { | |
| 109 | 125 | t.Fatalf("colors=%d want 0", colors) | |
| @@ -119,7 +135,7 @@ func TestScannerDoesNotSplitSGRMouseReport(t *testing.T) { | |||
| 119 | 135 | colors := 0 | |
| 120 | 136 | ||
| 121 | 137 | in := []byte("\x1b[<0;1;1M") | |
| 122 | - | s.Scan(in, func(kind int, rgb renderer.RGB) { colors++ }, func(b []byte) { | |
| 138 | + | s.Scan(in, func(kind int, rgb renderer.RGB) { colors++ }, func(light bool) {}, func(b []byte) { | |
| 123 | 139 | chunks = append(chunks, append([]byte(nil), b...)) | |
| 124 | 140 | }) | |
| 125 | 141 | ||
| @@ -139,7 +155,7 @@ func TestScannerDoesNotSplitArrowKey(t *testing.T) { | |||
| 139 | 155 | var chunks [][]byte | |
| 140 | 156 | ||
| 141 | 157 | in := []byte("\x1b[A") | |
| 142 | - | s.Scan(in, func(kind int, rgb renderer.RGB) {}, func(b []byte) { | |
| 158 | + | s.Scan(in, func(kind int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { | |
| 143 | 159 | chunks = append(chunks, append([]byte(nil), b...)) | |
| 144 | 160 | }) | |
| 145 | 161 | ||
| @@ -164,7 +180,7 @@ func TestScannerKeepsMouseReportContiguousAroundColorResponse(t *testing.T) { | |||
| 164 | 180 | kind int | |
| 165 | 181 | rgb renderer.RGB | |
| 166 | 182 | }{kind: kind, rgb: rgb}) | |
| 167 | - | }, func(b []byte) { | |
| 183 | + | }, func(light bool) {}, func(b []byte) { | |
| 168 | 184 | chunks = append(chunks, append([]byte(nil), b...)) | |
| 169 | 185 | }) | |
| 170 | 186 | ||
| @@ -189,8 +205,8 @@ func TestScannerFlushesOverflowingPartialQueue(t *testing.T) { | |||
| 189 | 205 | var s Scanner | |
| 190 | 206 | var out bytes.Buffer | |
| 191 | 207 | ||
| 192 | - | s.Scan(append([]byte("\x1b]10;"), bytes.Repeat([]byte("a"), 70)...), func(kind int, rgb renderer.RGB) {}, func(b []byte) { out.Write(b) }) | |
| 193 | - | s.Scan([]byte("Z"), func(kind int, rgb renderer.RGB) {}, func(b []byte) { out.Write(b) }) | |
| 208 | + | s.Scan(append([]byte("\x1b]10;"), bytes.Repeat([]byte("a"), 70)...), func(kind int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) }) | |
| 209 | + | s.Scan([]byte("Z"), func(kind int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) }) | |
| 194 | 210 | ||
| 195 | 211 | if out.Len() == 0 || !bytes.Contains(out.Bytes(), []byte("\x1b]10;")) || !bytes.HasSuffix(out.Bytes(), []byte("Z")) { | |
| 196 | 212 | t.Fatalf("overflow passthrough=%q", out.String()) | |
| @@ -48,7 +48,7 @@ func TestScannerExtractsBELAndSTColors(t *testing.T) { | |||||
| 48 | kind int | 48 | kind int | ||
| 49 | rgb renderer.RGB | 49 | rgb renderer.RGB | ||
| 50 | }{kind: kind, rgb: rgb}) | 50 | }{kind: kind, rgb: rgb}) | ||
| 51 | - | }, func(b []byte) { out.Write(b) }) | 51 | + | }, func(light bool) {}, func(b []byte) { out.Write(b) }) |
| 52 | 52 | ||||
| 53 | if out.String() != "abc" { | 53 | if out.String() != "abc" { | ||
| 54 | t.Fatalf("passthrough=%q want %q", out.String(), "abc") | 54 | t.Fatalf("passthrough=%q want %q", out.String(), "abc") | ||
| @@ -71,8 +71,8 @@ func TestScannerHandlesSplitSequences(t *testing.T) { | |||||
| 71 | onColor := func(kind int, rgb renderer.RGB) { got = append(got, rgb) } | 71 | onColor := func(kind int, rgb renderer.RGB) { got = append(got, rgb) } | ||
| 72 | onBytes := func(b []byte) { out.Write(b) } | 72 | onBytes := func(b []byte) { out.Write(b) } | ||
| 73 | 73 | ||||
| 74 | - | s.Scan([]byte("x\x1b]10;#12"), onColor, onBytes) | 74 | + | s.Scan([]byte("x\x1b]10;#12"), onColor, func(light bool) {}, onBytes) |
| 75 | - | s.Scan([]byte("3456\x07y"), onColor, onBytes) | 75 | + | s.Scan([]byte("3456\x07y"), onColor, func(light bool) {}, onBytes) |
| 76 | 76 | ||||
| 77 | if out.String() != "xy" { | 77 | if out.String() != "xy" { | ||
| 78 | t.Fatalf("passthrough=%q want xy", out.String()) | 78 | t.Fatalf("passthrough=%q want xy", out.String()) | ||
| @@ -82,12 +82,28 @@ func TestScannerHandlesSplitSequences(t *testing.T) { | |||||
| 82 | } | 82 | } | ||
| 83 | } | 83 | } | ||
| 84 | 84 | ||||
| 85 | + | func TestScannerHandlesSplitSchemeNotifications(t *testing.T) { | |||
| 86 | + | var s Scanner | |||
| 87 | + | var schemes []bool | |||
| 88 | + | var out bytes.Buffer | |||
| 89 | + | ||||
| 90 | + | s.Scan([]byte("a\x1b[?997"), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes = append(schemes, light) }, func(b []byte) { out.Write(b) }) | |||
| 91 | + | s.Scan([]byte(";1nb\x1b[?997;2nc"), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes = append(schemes, light) }, func(b []byte) { out.Write(b) }) | |||
| 92 | + | ||||
| 93 | + | if out.String() != "abc" { | |||
| 94 | + | t.Fatalf("passthrough=%q want abc", out.String()) | |||
| 95 | + | } | |||
| 96 | + | if len(schemes) != 2 || schemes[0] != false || schemes[1] != true { | |||
| 97 | + | t.Fatalf("schemes=%v want [false true]", schemes) | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 85 | func TestScannerForwardsInterleavedGarbageInOrder(t *testing.T) { | 101 | func TestScannerForwardsInterleavedGarbageInOrder(t *testing.T) { | ||
| 86 | var s Scanner | 102 | var s Scanner | ||
| 87 | var out bytes.Buffer | 103 | var out bytes.Buffer | ||
| 88 | colors := 0 | 104 | colors := 0 | ||
| 89 | 105 | ||||
| 90 | - | s.Scan([]byte("ab\x1b]12;#112233\x07cd\x1b]10;nope\x07ef"), func(kind int, rgb renderer.RGB) { colors++ }, func(b []byte) { out.Write(b) }) | 106 | + | s.Scan([]byte("ab\x1b]12;#112233\x07cd\x1b]10;nope\x07ef"), func(kind int, rgb renderer.RGB) { colors++ }, func(light bool) {}, func(b []byte) { out.Write(b) }) |
| 91 | 107 | ||||
| 92 | if colors != 0 { | 108 | if colors != 0 { | ||
| 93 | t.Fatalf("colors=%d want 0", colors) | 109 | t.Fatalf("colors=%d want 0", colors) | ||
| @@ -103,7 +119,7 @@ func TestScannerForwardsStandaloneEscapeImmediately(t *testing.T) { | |||||
| 103 | var out bytes.Buffer | 119 | var out bytes.Buffer | ||
| 104 | colors := 0 | 120 | colors := 0 | ||
| 105 | 121 | ||||
| 106 | - | s.Scan([]byte("\x1b"), func(kind int, rgb renderer.RGB) { colors++ }, func(b []byte) { out.Write(b) }) | 122 | + | s.Scan([]byte("\x1b"), func(kind int, rgb renderer.RGB) { colors++ }, func(light bool) {}, func(b []byte) { out.Write(b) }) |
| 107 | 123 | ||||
| 108 | if colors != 0 { | 124 | if colors != 0 { | ||
| 109 | t.Fatalf("colors=%d want 0", colors) | 125 | t.Fatalf("colors=%d want 0", colors) | ||
| @@ -119,7 +135,7 @@ func TestScannerDoesNotSplitSGRMouseReport(t *testing.T) { | |||||
| 119 | colors := 0 | 135 | colors := 0 | ||
| 120 | 136 | ||||
| 121 | in := []byte("\x1b[<0;1;1M") | 137 | in := []byte("\x1b[<0;1;1M") | ||
| 122 | - | s.Scan(in, func(kind int, rgb renderer.RGB) { colors++ }, func(b []byte) { | 138 | + | s.Scan(in, func(kind int, rgb renderer.RGB) { colors++ }, func(light bool) {}, func(b []byte) { |
| 123 | chunks = append(chunks, append([]byte(nil), b...)) | 139 | chunks = append(chunks, append([]byte(nil), b...)) | ||
| 124 | }) | 140 | }) | ||
| 125 | 141 | ||||
| @@ -139,7 +155,7 @@ func TestScannerDoesNotSplitArrowKey(t *testing.T) { | |||||
| 139 | var chunks [][]byte | 155 | var chunks [][]byte | ||
| 140 | 156 | ||||
| 141 | in := []byte("\x1b[A") | 157 | in := []byte("\x1b[A") | ||
| 142 | - | s.Scan(in, func(kind int, rgb renderer.RGB) {}, func(b []byte) { | 158 | + | s.Scan(in, func(kind int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { |
| 143 | chunks = append(chunks, append([]byte(nil), b...)) | 159 | chunks = append(chunks, append([]byte(nil), b...)) | ||
| 144 | }) | 160 | }) | ||
| 145 | 161 | ||||
| @@ -164,7 +180,7 @@ func TestScannerKeepsMouseReportContiguousAroundColorResponse(t *testing.T) { | |||||
| 164 | kind int | 180 | kind int | ||
| 165 | rgb renderer.RGB | 181 | rgb renderer.RGB | ||
| 166 | }{kind: kind, rgb: rgb}) | 182 | }{kind: kind, rgb: rgb}) | ||
| 167 | - | }, func(b []byte) { | 183 | + | }, func(light bool) {}, func(b []byte) { |
| 168 | chunks = append(chunks, append([]byte(nil), b...)) | 184 | chunks = append(chunks, append([]byte(nil), b...)) | ||
| 169 | }) | 185 | }) | ||
| 170 | 186 | ||||
| @@ -189,8 +205,8 @@ func TestScannerFlushesOverflowingPartialQueue(t *testing.T) { | |||||
| 189 | var s Scanner | 205 | var s Scanner | ||
| 190 | var out bytes.Buffer | 206 | var out bytes.Buffer | ||
| 191 | 207 | ||||
| 192 | - | s.Scan(append([]byte("\x1b]10;"), bytes.Repeat([]byte("a"), 70)...), func(kind int, rgb renderer.RGB) {}, func(b []byte) { out.Write(b) }) | 208 | + | s.Scan(append([]byte("\x1b]10;"), bytes.Repeat([]byte("a"), 70)...), func(kind int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) }) |
| 193 | - | s.Scan([]byte("Z"), func(kind int, rgb renderer.RGB) {}, func(b []byte) { out.Write(b) }) | 209 | + | s.Scan([]byte("Z"), func(kind int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) }) |
| 194 | 210 | ||||
| 195 | if out.Len() == 0 || !bytes.Contains(out.Bytes(), []byte("\x1b]10;")) || !bytes.HasSuffix(out.Bytes(), []byte("Z")) { | 211 | if out.Len() == 0 || !bytes.Contains(out.Bytes(), []byte("\x1b]10;")) || !bytes.HasSuffix(out.Bytes(), []byte("Z")) { | ||
| 196 | t.Fatalf("overflow passthrough=%q", out.String()) | 212 | t.Fatalf("overflow passthrough=%q", out.String()) | ||
M pkg/vt/query_test.go +55 -2
| @@ -23,6 +23,10 @@ func TestScreenQueryResponses(t *testing.T) { | |||
| 23 | 23 | {name: "private CPR", input: "\x1b[3;7H\x1b[?6n", want: "\x1b[?3;7R"}, | |
| 24 | 24 | {name: "DECRQM 2026 reset", input: "\x1b[?2026$p", want: "\x1b[?2026;2$y"}, | |
| 25 | 25 | {name: "DECRQM 2026 set", input: "\x1b[?2026h\x1b[?2026$p", want: "\x1b[?2026;1$y"}, | |
| 26 | + | {name: "DECRQM 2031 reset", input: "\x1b[?2031$p", want: "\x1b[?2031;2$y"}, | |
| 27 | + | {name: "DECRQM 2031 set", input: "\x1b[?2031h\x1b[?2031$p", want: "\x1b[?2031;1$y"}, | |
| 28 | + | {name: "color scheme DSR dark", input: "\x1b[?996n", want: "\x1b[?997;1n"}, | |
| 29 | + | {name: "color scheme DSR light", input: "\x1b[?996n", want: "\x1b[?997;2n"}, | |
| 26 | 30 | {name: "DECRQM unknown mode", input: "\x1b[?1337$p", want: "\x1b[?1337;0$y"}, | |
| 27 | 31 | {name: "kitty keyboard query unanswered", input: "\x1b[?u", want: ""}, | |
| 28 | 32 | {name: "XTVERSION unanswered", input: "\x1b[>0q", want: ""}, | |
| @@ -33,10 +37,17 @@ func TestScreenQueryResponses(t *testing.T) { | |||
| 33 | 37 | s := NewScreen(80, 24) | |
| 34 | 38 | var got bytes.Buffer | |
| 35 | 39 | s.OnResponse = func(b []byte) { got.Write(b) } | |
| 36 | - | if tc.name == "DA split across writes" { | |
| 40 | + | switch tc.name { | |
| 41 | + | case "DA split across writes": | |
| 37 | 42 | s.Write([]byte("\x1b[")) | |
| 38 | 43 | s.Write([]byte("0c")) | |
| 39 | - | } else { | |
| 44 | + | case "color scheme DSR light": | |
| 45 | + | s.SetColorScheme(true) | |
| 46 | + | s.Write([]byte(tc.input)) | |
| 47 | + | case "color scheme DSR dark": | |
| 48 | + | s.SetColorScheme(false) | |
| 49 | + | s.Write([]byte(tc.input)) | |
| 50 | + | default: | |
| 40 | 51 | s.Write([]byte(tc.input)) | |
| 41 | 52 | } | |
| 42 | 53 | if got.String() != tc.want { | |
| @@ -127,3 +138,45 @@ func TestCSIuDispatch(t *testing.T) { | |||
| 127 | 138 | }) | |
| 128 | 139 | } | |
| 129 | 140 | } | |
| 141 | + | ||
| 142 | + | func TestScreenColorSchemeQueriesUnknownAreSilent(t *testing.T) { | |
| 143 | + | s := NewScreen(80, 24) | |
| 144 | + | s.SetColorScheme(true) | |
| 145 | + | s.ClearColorScheme() | |
| 146 | + | var got bytes.Buffer | |
| 147 | + | s.OnResponse = func(b []byte) { got.Write(b) } | |
| 148 | + | s.Write([]byte("\x1b[?996n")) | |
| 149 | + | if got.Len() != 0 { | |
| 150 | + | t.Fatalf("response = %q, want silence", got.String()) | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func TestScreenColorSchemeUnsolicitedOnlySubscribedAndChanged(t *testing.T) { | |
| 155 | + | s := NewScreen(80, 24) | |
| 156 | + | var got bytes.Buffer | |
| 157 | + | s.OnResponse = func(b []byte) { got.Write(b) } | |
| 158 | + | ||
| 159 | + | s.SetColorScheme(true) | |
| 160 | + | if got.Len() != 0 { | |
| 161 | + | t.Fatalf("unsubscribed response = %q, want silence", got.String()) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | s.Write([]byte("\x1b[?2031h")) | |
| 165 | + | if !s.ColorSchemeMode() { | |
| 166 | + | t.Fatalf("ColorSchemeMode() = false, want true") | |
| 167 | + | } | |
| 168 | + | s.SetColorScheme(true) | |
| 169 | + | if got.Len() != 0 { | |
| 170 | + | t.Fatalf("unchanged response = %q, want silence", got.String()) | |
| 171 | + | } | |
| 172 | + | s.SetColorScheme(false) | |
| 173 | + | if got.String() != "\x1b[?997;1n" { | |
| 174 | + | t.Fatalf("changed response = %q, want dark notification", got.String()) | |
| 175 | + | } | |
| 176 | + | got.Reset() | |
| 177 | + | s.Write([]byte("\x1b[?2031l")) | |
| 178 | + | s.SetColorScheme(true) | |
| 179 | + | if got.Len() != 0 { | |
| 180 | + | t.Fatalf("disabled response = %q, want silence", got.String()) | |
| 181 | + | } | |
| 182 | + | } | |
| @@ -23,6 +23,10 @@ func TestScreenQueryResponses(t *testing.T) { | |||||
| 23 | {name: "private CPR", input: "\x1b[3;7H\x1b[?6n", want: "\x1b[?3;7R"}, | 23 | {name: "private CPR", input: "\x1b[3;7H\x1b[?6n", want: "\x1b[?3;7R"}, | ||
| 24 | {name: "DECRQM 2026 reset", input: "\x1b[?2026$p", want: "\x1b[?2026;2$y"}, | 24 | {name: "DECRQM 2026 reset", input: "\x1b[?2026$p", want: "\x1b[?2026;2$y"}, | ||
| 25 | {name: "DECRQM 2026 set", input: "\x1b[?2026h\x1b[?2026$p", want: "\x1b[?2026;1$y"}, | 25 | {name: "DECRQM 2026 set", input: "\x1b[?2026h\x1b[?2026$p", want: "\x1b[?2026;1$y"}, | ||
| 26 | + | {name: "DECRQM 2031 reset", input: "\x1b[?2031$p", want: "\x1b[?2031;2$y"}, | |||
| 27 | + | {name: "DECRQM 2031 set", input: "\x1b[?2031h\x1b[?2031$p", want: "\x1b[?2031;1$y"}, | |||
| 28 | + | {name: "color scheme DSR dark", input: "\x1b[?996n", want: "\x1b[?997;1n"}, | |||
| 29 | + | {name: "color scheme DSR light", input: "\x1b[?996n", want: "\x1b[?997;2n"}, | |||
| 26 | {name: "DECRQM unknown mode", input: "\x1b[?1337$p", want: "\x1b[?1337;0$y"}, | 30 | {name: "DECRQM unknown mode", input: "\x1b[?1337$p", want: "\x1b[?1337;0$y"}, | ||
| 27 | {name: "kitty keyboard query unanswered", input: "\x1b[?u", want: ""}, | 31 | {name: "kitty keyboard query unanswered", input: "\x1b[?u", want: ""}, | ||
| 28 | {name: "XTVERSION unanswered", input: "\x1b[>0q", want: ""}, | 32 | {name: "XTVERSION unanswered", input: "\x1b[>0q", want: ""}, | ||
| @@ -33,10 +37,17 @@ func TestScreenQueryResponses(t *testing.T) { | |||||
| 33 | s := NewScreen(80, 24) | 37 | s := NewScreen(80, 24) | ||
| 34 | var got bytes.Buffer | 38 | var got bytes.Buffer | ||
| 35 | s.OnResponse = func(b []byte) { got.Write(b) } | 39 | s.OnResponse = func(b []byte) { got.Write(b) } | ||
| 36 | - | if tc.name == "DA split across writes" { | 40 | + | switch tc.name { |
| 41 | + | case "DA split across writes": | |||
| 37 | s.Write([]byte("\x1b[")) | 42 | s.Write([]byte("\x1b[")) | ||
| 38 | s.Write([]byte("0c")) | 43 | s.Write([]byte("0c")) | ||
| 39 | - | } else { | 44 | + | case "color scheme DSR light": |
| 45 | + | s.SetColorScheme(true) | |||
| 46 | + | s.Write([]byte(tc.input)) | |||
| 47 | + | case "color scheme DSR dark": | |||
| 48 | + | s.SetColorScheme(false) | |||
| 49 | + | s.Write([]byte(tc.input)) | |||
| 50 | + | default: | |||
| 40 | s.Write([]byte(tc.input)) | 51 | s.Write([]byte(tc.input)) | ||
| 41 | } | 52 | } | ||
| 42 | if got.String() != tc.want { | 53 | if got.String() != tc.want { | ||
| @@ -127,3 +138,45 @@ func TestCSIuDispatch(t *testing.T) { | |||||
| 127 | }) | 138 | }) | ||
| 128 | } | 139 | } | ||
| 129 | } | 140 | } | ||
| 141 | + | ||||
| 142 | + | func TestScreenColorSchemeQueriesUnknownAreSilent(t *testing.T) { | |||
| 143 | + | s := NewScreen(80, 24) | |||
| 144 | + | s.SetColorScheme(true) | |||
| 145 | + | s.ClearColorScheme() | |||
| 146 | + | var got bytes.Buffer | |||
| 147 | + | s.OnResponse = func(b []byte) { got.Write(b) } | |||
| 148 | + | s.Write([]byte("\x1b[?996n")) | |||
| 149 | + | if got.Len() != 0 { | |||
| 150 | + | t.Fatalf("response = %q, want silence", got.String()) | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | func TestScreenColorSchemeUnsolicitedOnlySubscribedAndChanged(t *testing.T) { | |||
| 155 | + | s := NewScreen(80, 24) | |||
| 156 | + | var got bytes.Buffer | |||
| 157 | + | s.OnResponse = func(b []byte) { got.Write(b) } | |||
| 158 | + | ||||
| 159 | + | s.SetColorScheme(true) | |||
| 160 | + | if got.Len() != 0 { | |||
| 161 | + | t.Fatalf("unsubscribed response = %q, want silence", got.String()) | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | s.Write([]byte("\x1b[?2031h")) | |||
| 165 | + | if !s.ColorSchemeMode() { | |||
| 166 | + | t.Fatalf("ColorSchemeMode() = false, want true") | |||
| 167 | + | } | |||
| 168 | + | s.SetColorScheme(true) | |||
| 169 | + | if got.Len() != 0 { | |||
| 170 | + | t.Fatalf("unchanged response = %q, want silence", got.String()) | |||
| 171 | + | } | |||
| 172 | + | s.SetColorScheme(false) | |||
| 173 | + | if got.String() != "\x1b[?997;1n" { | |||
| 174 | + | t.Fatalf("changed response = %q, want dark notification", got.String()) | |||
| 175 | + | } | |||
| 176 | + | got.Reset() | |||
| 177 | + | s.Write([]byte("\x1b[?2031l")) | |||
| 178 | + | s.SetColorScheme(true) | |||
| 179 | + | if got.Len() != 0 { | |||
| 180 | + | t.Fatalf("disabled response = %q, want silence", got.String()) | |||
| 181 | + | } | |||
| 182 | + | } | |||
M pkg/vt/screen.go +49 -1
| @@ -89,6 +89,9 @@ type Screen struct { | |||
| 89 | 89 | mouseMode int | |
| 90 | 90 | mouseSGR bool | |
| 91 | 91 | bracketedPaste bool | |
| 92 | + | colorSchemeMode bool | |
| 93 | + | colorSchemeLight bool | |
| 94 | + | colorSchemeSet bool | |
| 92 | 95 | } | |
| 93 | 96 | ||
| 94 | 97 | func NewScreen(width, height int) *Screen { | |
| @@ -156,6 +159,27 @@ func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive } | |||
| 156 | 159 | // by the child process. | |
| 157 | 160 | func (s *Screen) BracketedPasteMode() bool { return s.bracketedPaste } | |
| 158 | 161 | ||
| 162 | + | // ColorSchemeMode reports whether DEC private mode 2031 is currently enabled. | |
| 163 | + | func (s *Screen) ColorSchemeMode() bool { return s.colorSchemeMode } | |
| 164 | + | ||
| 165 | + | // SetColorScheme updates the host color scheme and notifies subscribed child apps. | |
| 166 | + | func (s *Screen) SetColorScheme(light bool) { | |
| 167 | + | if s.colorSchemeSet && s.colorSchemeLight == light { | |
| 168 | + | return | |
| 169 | + | } | |
| 170 | + | s.colorSchemeSet = true | |
| 171 | + | s.colorSchemeLight = light | |
| 172 | + | if s.colorSchemeMode { | |
| 173 | + | s.respond(s.colorSchemeReport()) | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // ClearColorScheme marks the host color scheme as unknown. Future child color | |
| 178 | + | // scheme queries are silent until SetColorScheme supplies a known value again. | |
| 179 | + | func (s *Screen) ClearColorScheme() { | |
| 180 | + | s.colorSchemeSet = false | |
| 181 | + | } | |
| 182 | + | ||
| 159 | 183 | // ForceSyncEnd forcibly leaves DEC private mode 2026 (synchronized update). | |
| 160 | 184 | // Hosts use this as a safety valve if a child enters synchronized update mode | |
| 161 | 185 | // and never sends the matching end sequence. | |
| @@ -480,6 +504,16 @@ func (s *Screen) respond(b []byte) { | |||
| 480 | 504 | } | |
| 481 | 505 | } | |
| 482 | 506 | ||
| 507 | + | func (s *Screen) colorSchemeReport() []byte { | |
| 508 | + | if !s.colorSchemeSet { | |
| 509 | + | return nil | |
| 510 | + | } | |
| 511 | + | if s.colorSchemeLight { | |
| 512 | + | return []byte("\x1b[?997;2n") | |
| 513 | + | } | |
| 514 | + | return []byte("\x1b[?997;1n") | |
| 515 | + | } | |
| 516 | + | ||
| 483 | 517 | func (s *Screen) scrollDownRegion(top, bottom, n int) { | |
| 484 | 518 | if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 { | |
| 485 | 519 | return | |
| @@ -759,6 +793,12 @@ func (s *Screen) applyCSI(params string, cmd byte) { | |||
| 759 | 793 | if !private { | |
| 760 | 794 | s.respond([]byte("\x1b[0n")) | |
| 761 | 795 | } | |
| 796 | + | case 996: | |
| 797 | + | if private { | |
| 798 | + | if report := s.colorSchemeReport(); report != nil { | |
| 799 | + | s.respond(report) | |
| 800 | + | } | |
| 801 | + | } | |
| 762 | 802 | case 6: | |
| 763 | 803 | resp := make([]byte, 0, 16) | |
| 764 | 804 | resp = append(resp, "\x1b["...) | |
| @@ -778,11 +818,17 @@ func (s *Screen) applyCSI(params string, cmd byte) { | |||
| 778 | 818 | return | |
| 779 | 819 | } | |
| 780 | 820 | state := 0 | |
| 781 | - | if mode == 2026 { | |
| 821 | + | switch mode { | |
| 822 | + | case 2026: | |
| 782 | 823 | state = 2 | |
| 783 | 824 | if s.syncUpdateActive { | |
| 784 | 825 | state = 1 | |
| 785 | 826 | } | |
| 827 | + | case 2031: | |
| 828 | + | state = 2 | |
| 829 | + | if s.colorSchemeMode { | |
| 830 | + | state = 1 | |
| 831 | + | } | |
| 786 | 832 | } | |
| 787 | 833 | resp := make([]byte, 0, 16) | |
| 788 | 834 | resp = append(resp, "\x1b[?"...) | |
| @@ -1041,6 +1087,8 @@ func (s *Screen) setMode(private bool, parts []int, enabled bool) { | |||
| 1041 | 1087 | } | |
| 1042 | 1088 | case 2026: | |
| 1043 | 1089 | s.syncUpdateActive = enabled | |
| 1090 | + | case 2031: | |
| 1091 | + | s.colorSchemeMode = enabled | |
| 1044 | 1092 | case 25: | |
| 1045 | 1093 | s.cursorVisible = enabled | |
| 1046 | 1094 | case 1000, 1002, 1003: | |
| @@ -89,6 +89,9 @@ type Screen struct { | |||||
| 89 | mouseMode int | 89 | mouseMode int | ||
| 90 | mouseSGR bool | 90 | mouseSGR bool | ||
| 91 | bracketedPaste bool | 91 | bracketedPaste bool | ||
| 92 | + | colorSchemeMode bool | |||
| 93 | + | colorSchemeLight bool | |||
| 94 | + | colorSchemeSet bool | |||
| 92 | } | 95 | } | ||
| 93 | 96 | ||||
| 94 | func NewScreen(width, height int) *Screen { | 97 | func NewScreen(width, height int) *Screen { | ||
| @@ -156,6 +159,27 @@ func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive } | |||||
| 156 | // by the child process. | 159 | // by the child process. | ||
| 157 | func (s *Screen) BracketedPasteMode() bool { return s.bracketedPaste } | 160 | func (s *Screen) BracketedPasteMode() bool { return s.bracketedPaste } | ||
| 158 | 161 | ||||
| 162 | + | // ColorSchemeMode reports whether DEC private mode 2031 is currently enabled. | |||
| 163 | + | func (s *Screen) ColorSchemeMode() bool { return s.colorSchemeMode } | |||
| 164 | + | ||||
| 165 | + | // SetColorScheme updates the host color scheme and notifies subscribed child apps. | |||
| 166 | + | func (s *Screen) SetColorScheme(light bool) { | |||
| 167 | + | if s.colorSchemeSet && s.colorSchemeLight == light { | |||
| 168 | + | return | |||
| 169 | + | } | |||
| 170 | + | s.colorSchemeSet = true | |||
| 171 | + | s.colorSchemeLight = light | |||
| 172 | + | if s.colorSchemeMode { | |||
| 173 | + | s.respond(s.colorSchemeReport()) | |||
| 174 | + | } | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | // ClearColorScheme marks the host color scheme as unknown. Future child color | |||
| 178 | + | // scheme queries are silent until SetColorScheme supplies a known value again. | |||
| 179 | + | func (s *Screen) ClearColorScheme() { | |||
| 180 | + | s.colorSchemeSet = false | |||
| 181 | + | } | |||
| 182 | + | ||||
| 159 | // ForceSyncEnd forcibly leaves DEC private mode 2026 (synchronized update). | 183 | // ForceSyncEnd forcibly leaves DEC private mode 2026 (synchronized update). | ||
| 160 | // Hosts use this as a safety valve if a child enters synchronized update mode | 184 | // Hosts use this as a safety valve if a child enters synchronized update mode | ||
| 161 | // and never sends the matching end sequence. | 185 | // and never sends the matching end sequence. | ||
| @@ -480,6 +504,16 @@ func (s *Screen) respond(b []byte) { | |||||
| 480 | } | 504 | } | ||
| 481 | } | 505 | } | ||
| 482 | 506 | ||||
| 507 | + | func (s *Screen) colorSchemeReport() []byte { | |||
| 508 | + | if !s.colorSchemeSet { | |||
| 509 | + | return nil | |||
| 510 | + | } | |||
| 511 | + | if s.colorSchemeLight { | |||
| 512 | + | return []byte("\x1b[?997;2n") | |||
| 513 | + | } | |||
| 514 | + | return []byte("\x1b[?997;1n") | |||
| 515 | + | } | |||
| 516 | + | ||||
| 483 | func (s *Screen) scrollDownRegion(top, bottom, n int) { | 517 | func (s *Screen) scrollDownRegion(top, bottom, n int) { | ||
| 484 | if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 { | 518 | if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 { | ||
| 485 | return | 519 | return | ||
| @@ -759,6 +793,12 @@ func (s *Screen) applyCSI(params string, cmd byte) { | |||||
| 759 | if !private { | 793 | if !private { | ||
| 760 | s.respond([]byte("\x1b[0n")) | 794 | s.respond([]byte("\x1b[0n")) | ||
| 761 | } | 795 | } | ||
| 796 | + | case 996: | |||
| 797 | + | if private { | |||
| 798 | + | if report := s.colorSchemeReport(); report != nil { | |||
| 799 | + | s.respond(report) | |||
| 800 | + | } | |||
| 801 | + | } | |||
| 762 | case 6: | 802 | case 6: | ||
| 763 | resp := make([]byte, 0, 16) | 803 | resp := make([]byte, 0, 16) | ||
| 764 | resp = append(resp, "\x1b["...) | 804 | resp = append(resp, "\x1b["...) | ||
| @@ -778,11 +818,17 @@ func (s *Screen) applyCSI(params string, cmd byte) { | |||||
| 778 | return | 818 | return | ||
| 779 | } | 819 | } | ||
| 780 | state := 0 | 820 | state := 0 | ||
| 781 | - | if mode == 2026 { | 821 | + | switch mode { |
| 822 | + | case 2026: | |||
| 782 | state = 2 | 823 | state = 2 | ||
| 783 | if s.syncUpdateActive { | 824 | if s.syncUpdateActive { | ||
| 784 | state = 1 | 825 | state = 1 | ||
| 785 | } | 826 | } | ||
| 827 | + | case 2031: | |||
| 828 | + | state = 2 | |||
| 829 | + | if s.colorSchemeMode { | |||
| 830 | + | state = 1 | |||
| 831 | + | } | |||
| 786 | } | 832 | } | ||
| 787 | resp := make([]byte, 0, 16) | 833 | resp := make([]byte, 0, 16) | ||
| 788 | resp = append(resp, "\x1b[?"...) | 834 | resp = append(resp, "\x1b[?"...) | ||
| @@ -1041,6 +1087,8 @@ func (s *Screen) setMode(private bool, parts []int, enabled bool) { | |||||
| 1041 | } | 1087 | } | ||
| 1042 | case 2026: | 1088 | case 2026: | ||
| 1043 | s.syncUpdateActive = enabled | 1089 | s.syncUpdateActive = enabled | ||
| 1090 | + | case 2031: | |||
| 1091 | + | s.colorSchemeMode = enabled | |||
| 1044 | case 25: | 1092 | case 25: | ||
| 1045 | s.cursorVisible = enabled | 1093 | s.cursorVisible = enabled | ||
| 1046 | case 1000, 1002, 1003: | 1094 | case 1000, 1002, 1003: | ||