feat(config): add copy word separators
- commit
- 1e1b557e01834c43d0da8e42bed537c30a8bf10a
- author
- brice · · 2026-07-15 06:22 UTC
- committer
- brice · · 2026-07-15 09:45 UTC
- parent
- a3000d8
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +109 -0
M internal/adapters/config/config.go +29 -0
| @@ -31,6 +31,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 31 | 31 | var warnings []domain.Warning | |
| 32 | 32 | seenBindingKeys := make(map[string]bool) | |
| 33 | 33 | seenFloatingKeys := make(map[string]bool) | |
| 34 | + | seenCopyKeys := make(map[string]bool) | |
| 34 | 35 | seenPaletteKeys := make(map[string]bool) | |
| 35 | 36 | seenTabsKeys := make(map[string]bool) | |
| 36 | 37 | ||
| @@ -112,6 +113,14 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 112 | 113 | continue | |
| 113 | 114 | } | |
| 114 | 115 | cfg.Tabs.TerminalTitle = on | |
| 116 | + | case key == "copy.word-separators": | |
| 117 | + | warnings = warnDuplicateKey(warnings, seenCopyKeys, key, lineNo) | |
| 118 | + | separators, ok := parseConfigString(value) | |
| 119 | + | if !ok { | |
| 120 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid copy.word-separators %q", value)}) | |
| 121 | + | continue | |
| 122 | + | } | |
| 123 | + | cfg.Copy.WordSeparators = separators | |
| 115 | 124 | case key == "floating.command": | |
| 116 | 125 | warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo) | |
| 117 | 126 | command, ok := parseBarCommand(value) | |
| @@ -313,6 +322,26 @@ func parseBarCommand(value string) (string, bool) { | |||
| 313 | 322 | return value, true | |
| 314 | 323 | } | |
| 315 | 324 | ||
| 325 | + | func parseConfigString(value string) (string, bool) { | |
| 326 | + | value = strings.TrimSpace(value) | |
| 327 | + | if value == "" { | |
| 328 | + | return "", true | |
| 329 | + | } | |
| 330 | + | startsQuoted := strings.HasPrefix(value, `"`) | |
| 331 | + | endsQuoted := strings.HasSuffix(value, `"`) | |
| 332 | + | if startsQuoted != endsQuoted { | |
| 333 | + | return "", false | |
| 334 | + | } | |
| 335 | + | if !startsQuoted { | |
| 336 | + | return value, true | |
| 337 | + | } | |
| 338 | + | unquoted, err := strconv.Unquote(value) | |
| 339 | + | if err != nil { | |
| 340 | + | return "", false | |
| 341 | + | } | |
| 342 | + | return unquoted, true | |
| 343 | + | } | |
| 344 | + | ||
| 316 | 345 | func parseTheme(value string) (domain.ThemeMode, bool) { | |
| 317 | 346 | switch strings.ToLower(strings.TrimSpace(value)) { | |
| 318 | 347 | case "auto", "": | |
| @@ -31,6 +31,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||||
| 31 | var warnings []domain.Warning | 31 | var warnings []domain.Warning | ||
| 32 | seenBindingKeys := make(map[string]bool) | 32 | seenBindingKeys := make(map[string]bool) | ||
| 33 | seenFloatingKeys := make(map[string]bool) | 33 | seenFloatingKeys := make(map[string]bool) | ||
| 34 | + | seenCopyKeys := make(map[string]bool) | |||
| 34 | seenPaletteKeys := make(map[string]bool) | 35 | seenPaletteKeys := make(map[string]bool) | ||
| 35 | seenTabsKeys := make(map[string]bool) | 36 | seenTabsKeys := make(map[string]bool) | ||
| 36 | 37 | ||||
| @@ -112,6 +113,14 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||||
| 112 | continue | 113 | continue | ||
| 113 | } | 114 | } | ||
| 114 | cfg.Tabs.TerminalTitle = on | 115 | cfg.Tabs.TerminalTitle = on | ||
| 116 | + | case key == "copy.word-separators": | |||
| 117 | + | warnings = warnDuplicateKey(warnings, seenCopyKeys, key, lineNo) | |||
| 118 | + | separators, ok := parseConfigString(value) | |||
| 119 | + | if !ok { | |||
| 120 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid copy.word-separators %q", value)}) | |||
| 121 | + | continue | |||
| 122 | + | } | |||
| 123 | + | cfg.Copy.WordSeparators = separators | |||
| 115 | case key == "floating.command": | 124 | case key == "floating.command": | ||
| 116 | warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo) | 125 | warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo) | ||
| 117 | command, ok := parseBarCommand(value) | 126 | command, ok := parseBarCommand(value) | ||
| @@ -313,6 +322,26 @@ func parseBarCommand(value string) (string, bool) { | |||||
| 313 | return value, true | 322 | return value, true | ||
| 314 | } | 323 | } | ||
| 315 | 324 | ||||
| 325 | + | func parseConfigString(value string) (string, bool) { | |||
| 326 | + | value = strings.TrimSpace(value) | |||
| 327 | + | if value == "" { | |||
| 328 | + | return "", true | |||
| 329 | + | } | |||
| 330 | + | startsQuoted := strings.HasPrefix(value, `"`) | |||
| 331 | + | endsQuoted := strings.HasSuffix(value, `"`) | |||
| 332 | + | if startsQuoted != endsQuoted { | |||
| 333 | + | return "", false | |||
| 334 | + | } | |||
| 335 | + | if !startsQuoted { | |||
| 336 | + | return value, true | |||
| 337 | + | } | |||
| 338 | + | unquoted, err := strconv.Unquote(value) | |||
| 339 | + | if err != nil { | |||
| 340 | + | return "", false | |||
| 341 | + | } | |||
| 342 | + | return unquoted, true | |||
| 343 | + | } | |||
| 344 | + | ||||
| 316 | func parseTheme(value string) (domain.ThemeMode, bool) { | 345 | func parseTheme(value string) (domain.ThemeMode, bool) { | ||
| 317 | switch strings.ToLower(strings.TrimSpace(value)) { | 346 | switch strings.ToLower(strings.TrimSpace(value)) { | ||
| 318 | case "auto", "": | 347 | case "auto", "": | ||
M internal/adapters/config/config_test.go +68 -0
| @@ -344,6 +344,9 @@ func TestParse(t *testing.T) { | |||
| 344 | 344 | if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet { | |
| 345 | 345 | tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...) | |
| 346 | 346 | } | |
| 347 | + | if tt.want.Copy.WordSeparators == "" { | |
| 348 | + | tt.want.Copy = domain.Defaults().Copy | |
| 349 | + | } | |
| 347 | 350 | got, warnings, err := Parse(strings.NewReader(tt.input)) | |
| 348 | 351 | if err != nil { | |
| 349 | 352 | t.Fatalf("Parse() error = %v", err) | |
| @@ -419,6 +422,71 @@ func TestDefaultsCopiesSnapshotRestoreProcesses(t *testing.T) { | |||
| 419 | 422 | require.Equal(t, "vi", second.Snapshot.RestoreProcesses[0]) | |
| 420 | 423 | } | |
| 421 | 424 | ||
| 425 | + | func TestParseCopyWordSeparators(t *testing.T) { | |
| 426 | + | t.Parallel() | |
| 427 | + | ||
| 428 | + | tests := []struct { | |
| 429 | + | name string | |
| 430 | + | input string | |
| 431 | + | want string | |
| 432 | + | wantWarnings []domain.Warning | |
| 433 | + | }{ | |
| 434 | + | { | |
| 435 | + | name: "default", | |
| 436 | + | input: "", | |
| 437 | + | want: domain.DefaultWordSeparators, | |
| 438 | + | }, | |
| 439 | + | { | |
| 440 | + | name: "quoted preserves leading separator space", | |
| 441 | + | input: `copy.word-separators = " -_@/"`, | |
| 442 | + | want: " -_@/", | |
| 443 | + | }, | |
| 444 | + | { | |
| 445 | + | name: "quoted empty is valid", | |
| 446 | + | input: `copy.word-separators = ""`, | |
| 447 | + | want: "", | |
| 448 | + | }, | |
| 449 | + | { | |
| 450 | + | name: "unquoted empty is valid", | |
| 451 | + | input: "copy.word-separators =", | |
| 452 | + | want: "", | |
| 453 | + | }, | |
| 454 | + | { | |
| 455 | + | name: "malformed quote keeps default", | |
| 456 | + | input: `copy.word-separators = "unterminated`, | |
| 457 | + | want: domain.DefaultWordSeparators, | |
| 458 | + | wantWarnings: []domain.Warning{ | |
| 459 | + | {Line: 1, Msg: `invalid copy.word-separators "\"unterminated"`}, | |
| 460 | + | }, | |
| 461 | + | }, | |
| 462 | + | { | |
| 463 | + | name: "duplicate last value wins", | |
| 464 | + | input: "copy.word-separators = /\ncopy.word-separators = _", | |
| 465 | + | want: "_", | |
| 466 | + | wantWarnings: []domain.Warning{ | |
| 467 | + | {Line: 2, Msg: `duplicate key "copy.word-separators"`}, | |
| 468 | + | }, | |
| 469 | + | }, | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | for _, tt := range tests { | |
| 473 | + | t.Run(tt.name, func(t *testing.T) { | |
| 474 | + | t.Parallel() | |
| 475 | + | ||
| 476 | + | cfg, warnings, err := Parse(strings.NewReader(tt.input)) | |
| 477 | + | require.NoError(t, err) | |
| 478 | + | require.Equal(t, tt.want, cfg.Copy.WordSeparators) | |
| 479 | + | require.Equal(t, tt.wantWarnings, warnings) | |
| 480 | + | }) | |
| 481 | + | } | |
| 482 | + | } | |
| 483 | + | ||
| 484 | + | func TestDefaultsCopyWordSeparators(t *testing.T) { | |
| 485 | + | t.Parallel() | |
| 486 | + | ||
| 487 | + | require.Equal(t, " -_@", domain.Defaults().Copy.WordSeparators) | |
| 488 | + | } | |
| 489 | + | ||
| 422 | 490 | func TestParsePreservesBindingFileOrder(t *testing.T) { | |
| 423 | 491 | t.Parallel() | |
| 424 | 492 | ||
| @@ -344,6 +344,9 @@ func TestParse(t *testing.T) { | |||||
| 344 | if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet { | 344 | if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet { | ||
| 345 | tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...) | 345 | tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...) | ||
| 346 | } | 346 | } | ||
| 347 | + | if tt.want.Copy.WordSeparators == "" { | |||
| 348 | + | tt.want.Copy = domain.Defaults().Copy | |||
| 349 | + | } | |||
| 347 | got, warnings, err := Parse(strings.NewReader(tt.input)) | 350 | got, warnings, err := Parse(strings.NewReader(tt.input)) | ||
| 348 | if err != nil { | 351 | if err != nil { | ||
| 349 | t.Fatalf("Parse() error = %v", err) | 352 | t.Fatalf("Parse() error = %v", err) | ||
| @@ -419,6 +422,71 @@ func TestDefaultsCopiesSnapshotRestoreProcesses(t *testing.T) { | |||||
| 419 | require.Equal(t, "vi", second.Snapshot.RestoreProcesses[0]) | 422 | require.Equal(t, "vi", second.Snapshot.RestoreProcesses[0]) | ||
| 420 | } | 423 | } | ||
| 421 | 424 | ||||
| 425 | + | func TestParseCopyWordSeparators(t *testing.T) { | |||
| 426 | + | t.Parallel() | |||
| 427 | + | ||||
| 428 | + | tests := []struct { | |||
| 429 | + | name string | |||
| 430 | + | input string | |||
| 431 | + | want string | |||
| 432 | + | wantWarnings []domain.Warning | |||
| 433 | + | }{ | |||
| 434 | + | { | |||
| 435 | + | name: "default", | |||
| 436 | + | input: "", | |||
| 437 | + | want: domain.DefaultWordSeparators, | |||
| 438 | + | }, | |||
| 439 | + | { | |||
| 440 | + | name: "quoted preserves leading separator space", | |||
| 441 | + | input: `copy.word-separators = " -_@/"`, | |||
| 442 | + | want: " -_@/", | |||
| 443 | + | }, | |||
| 444 | + | { | |||
| 445 | + | name: "quoted empty is valid", | |||
| 446 | + | input: `copy.word-separators = ""`, | |||
| 447 | + | want: "", | |||
| 448 | + | }, | |||
| 449 | + | { | |||
| 450 | + | name: "unquoted empty is valid", | |||
| 451 | + | input: "copy.word-separators =", | |||
| 452 | + | want: "", | |||
| 453 | + | }, | |||
| 454 | + | { | |||
| 455 | + | name: "malformed quote keeps default", | |||
| 456 | + | input: `copy.word-separators = "unterminated`, | |||
| 457 | + | want: domain.DefaultWordSeparators, | |||
| 458 | + | wantWarnings: []domain.Warning{ | |||
| 459 | + | {Line: 1, Msg: `invalid copy.word-separators "\"unterminated"`}, | |||
| 460 | + | }, | |||
| 461 | + | }, | |||
| 462 | + | { | |||
| 463 | + | name: "duplicate last value wins", | |||
| 464 | + | input: "copy.word-separators = /\ncopy.word-separators = _", | |||
| 465 | + | want: "_", | |||
| 466 | + | wantWarnings: []domain.Warning{ | |||
| 467 | + | {Line: 2, Msg: `duplicate key "copy.word-separators"`}, | |||
| 468 | + | }, | |||
| 469 | + | }, | |||
| 470 | + | } | |||
| 471 | + | ||||
| 472 | + | for _, tt := range tests { | |||
| 473 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 474 | + | t.Parallel() | |||
| 475 | + | ||||
| 476 | + | cfg, warnings, err := Parse(strings.NewReader(tt.input)) | |||
| 477 | + | require.NoError(t, err) | |||
| 478 | + | require.Equal(t, tt.want, cfg.Copy.WordSeparators) | |||
| 479 | + | require.Equal(t, tt.wantWarnings, warnings) | |||
| 480 | + | }) | |||
| 481 | + | } | |||
| 482 | + | } | |||
| 483 | + | ||||
| 484 | + | func TestDefaultsCopyWordSeparators(t *testing.T) { | |||
| 485 | + | t.Parallel() | |||
| 486 | + | ||||
| 487 | + | require.Equal(t, " -_@", domain.Defaults().Copy.WordSeparators) | |||
| 488 | + | } | |||
| 489 | + | ||||
| 422 | func TestParsePreservesBindingFileOrder(t *testing.T) { | 490 | func TestParsePreservesBindingFileOrder(t *testing.T) { | ||
| 423 | t.Parallel() | 491 | t.Parallel() | ||
| 424 | 492 | ||||
M internal/domain/config.go +12 -0
| @@ -5,6 +5,9 @@ import "time" | |||
| 5 | 5 | // MinBarInterval is the minimum supported bar refresh interval. | |
| 6 | 6 | const MinBarInterval = time.Second | |
| 7 | 7 | ||
| 8 | + | // DefaultWordSeparators matches tmux's practical shell-oriented word boundaries. | |
| 9 | + | const DefaultWordSeparators = " -_@" | |
| 10 | + | ||
| 8 | 11 | // ThemeMode selects how vev derives its color scheme. | |
| 9 | 12 | type ThemeMode int | |
| 10 | 13 | ||
| @@ -47,6 +50,11 @@ type BarConfig struct { | |||
| 47 | 50 | Interval time.Duration | |
| 48 | 51 | } | |
| 49 | 52 | ||
| 53 | + | // CopyConfig contains copy-mode text selection settings. | |
| 54 | + | type CopyConfig struct { | |
| 55 | + | WordSeparators string | |
| 56 | + | } | |
| 57 | + | ||
| 50 | 58 | // PaletteConfig contains palette placement settings. A false AnchorSet uses the | |
| 51 | 59 | // adaptive automatic placement. | |
| 52 | 60 | type PaletteConfig struct { | |
| @@ -77,6 +85,7 @@ type Config struct { | |||
| 77 | 85 | BindingEntries []ConfigEntry | |
| 78 | 86 | Codes map[string]string | |
| 79 | 87 | Snapshot SnapshotConfig | |
| 88 | + | Copy CopyConfig | |
| 80 | 89 | Palette PaletteConfig | |
| 81 | 90 | Floating FloatingConfig | |
| 82 | 91 | Tabs TabsConfig | |
| @@ -103,6 +112,9 @@ func Defaults() Config { | |||
| 103 | 112 | Snapshot: SnapshotConfig{ | |
| 104 | 113 | RestoreProcesses: DefaultSnapshotRestoreProcesses(), | |
| 105 | 114 | }, | |
| 115 | + | Copy: CopyConfig{ | |
| 116 | + | WordSeparators: DefaultWordSeparators, | |
| 117 | + | }, | |
| 106 | 118 | Floating: FloatingConfig{ | |
| 107 | 119 | Width: 80, | |
| 108 | 120 | Height: 80, | |
| @@ -5,6 +5,9 @@ import "time" | |||||
| 5 | // MinBarInterval is the minimum supported bar refresh interval. | 5 | // MinBarInterval is the minimum supported bar refresh interval. | ||
| 6 | const MinBarInterval = time.Second | 6 | const MinBarInterval = time.Second | ||
| 7 | 7 | ||||
| 8 | + | // DefaultWordSeparators matches tmux's practical shell-oriented word boundaries. | |||
| 9 | + | const DefaultWordSeparators = " -_@" | |||
| 10 | + | ||||
| 8 | // ThemeMode selects how vev derives its color scheme. | 11 | // ThemeMode selects how vev derives its color scheme. | ||
| 9 | type ThemeMode int | 12 | type ThemeMode int | ||
| 10 | 13 | ||||
| @@ -47,6 +50,11 @@ type BarConfig struct { | |||||
| 47 | Interval time.Duration | 50 | Interval time.Duration | ||
| 48 | } | 51 | } | ||
| 49 | 52 | ||||
| 53 | + | // CopyConfig contains copy-mode text selection settings. | |||
| 54 | + | type CopyConfig struct { | |||
| 55 | + | WordSeparators string | |||
| 56 | + | } | |||
| 57 | + | ||||
| 50 | // PaletteConfig contains palette placement settings. A false AnchorSet uses the | 58 | // PaletteConfig contains palette placement settings. A false AnchorSet uses the | ||
| 51 | // adaptive automatic placement. | 59 | // adaptive automatic placement. | ||
| 52 | type PaletteConfig struct { | 60 | type PaletteConfig struct { | ||
| @@ -77,6 +85,7 @@ type Config struct { | |||||
| 77 | BindingEntries []ConfigEntry | 85 | BindingEntries []ConfigEntry | ||
| 78 | Codes map[string]string | 86 | Codes map[string]string | ||
| 79 | Snapshot SnapshotConfig | 87 | Snapshot SnapshotConfig | ||
| 88 | + | Copy CopyConfig | |||
| 80 | Palette PaletteConfig | 89 | Palette PaletteConfig | ||
| 81 | Floating FloatingConfig | 90 | Floating FloatingConfig | ||
| 82 | Tabs TabsConfig | 91 | Tabs TabsConfig | ||
| @@ -103,6 +112,9 @@ func Defaults() Config { | |||||
| 103 | Snapshot: SnapshotConfig{ | 112 | Snapshot: SnapshotConfig{ | ||
| 104 | RestoreProcesses: DefaultSnapshotRestoreProcesses(), | 113 | RestoreProcesses: DefaultSnapshotRestoreProcesses(), | ||
| 105 | }, | 114 | }, | ||
| 115 | + | Copy: CopyConfig{ | |||
| 116 | + | WordSeparators: DefaultWordSeparators, | |||
| 117 | + | }, | |||
| 106 | Floating: FloatingConfig{ | 118 | Floating: FloatingConfig{ | ||
| 107 | Width: 80, | 119 | Width: 80, | ||
| 108 | Height: 80, | 120 | Height: 80, | ||