Merge pull request #131 from bnema/feat/navigation-overflow
feat(navigation): overflow focus across tabs and sessions
16 files · +1180 -56
M docs/configuration.md +10 -0
| @@ -19,6 +19,10 @@ floating.command = | |||
| 19 | 19 | floating.width = 80% | |
| 20 | 20 | floating.height = 80% | |
| 21 | 21 | ||
| 22 | + | # Let directional keyboard focus continue past a pane edge. Both default off. | |
| 23 | + | nav.overflow-tabs = off | |
| 24 | + | nav.overflow-sessions = off | |
| 25 | + | ||
| 22 | 26 | # Show the focused pane's terminal title in tab labels; off keeps the process name only. | |
| 23 | 27 | tabs.terminal-title = on | |
| 24 | 28 | ||
| @@ -84,6 +88,12 @@ Slots `0`, `7`, `8`, and `15` are valid explicit selections, but log a warning b | |||
| 84 | 88 | ||
| 85 | 89 | Key specs: `alt+<char>`, `alt+space`, `alt+left/right/up/down`, `alt+1` through `alt+9`. Configuring an action replaces all of its built-in aliases (set `focus-pane-left` and the Alt+Arrow alias is gone). Tab switching also accepts the top-row symbols of non-QWERTY layouts, so AZERTY works without extra config. | |
| 86 | 90 | ||
| 91 | + | ## Navigation overflow | |
| 92 | + | ||
| 93 | + | Both settings are independent and default to `off`. With `nav.overflow-tabs = on`, left/right keyboard focus (`Alt+H`/`Alt+L` by default) continues from a pane edge to the adjacent tab. With `nav.overflow-sessions = on`, up/down keyboard focus (`Alt+K`/`Alt+J` by default) continues to the adjacent live session in alphabetical order. Neither setting wraps at the first or last destination. | |
| 94 | + | ||
| 95 | + | Overflow applies only to keyboard focus actions; mouse navigation does not overflow, and floating panes never overflow. Hot reload applies either setting to subsequent navigation without restarting the daemon. | |
| 96 | + | ||
| 87 | 97 | ## Copy mode | |
| 88 | 98 | ||
| 89 | 99 | Scroll up with the mouse to enter copy mode. Use `h`, `j`, `k`, and `l` to move, `w`, `b`, and `e` for word motions, `v` or Space to start line selection, and `y` or Enter to copy. | |
| @@ -19,6 +19,10 @@ floating.command = | |||||
| 19 | floating.width = 80% | 19 | floating.width = 80% | ||
| 20 | floating.height = 80% | 20 | floating.height = 80% | ||
| 21 | 21 | ||||
| 22 | + | # Let directional keyboard focus continue past a pane edge. Both default off. | |||
| 23 | + | nav.overflow-tabs = off | |||
| 24 | + | nav.overflow-sessions = off | |||
| 25 | + | ||||
| 22 | # Show the focused pane's terminal title in tab labels; off keeps the process name only. | 26 | # Show the focused pane's terminal title in tab labels; off keeps the process name only. | ||
| 23 | tabs.terminal-title = on | 27 | tabs.terminal-title = on | ||
| 24 | 28 | ||||
| @@ -84,6 +88,12 @@ Slots `0`, `7`, `8`, and `15` are valid explicit selections, but log a warning b | |||||
| 84 | 88 | ||||
| 85 | Key specs: `alt+<char>`, `alt+space`, `alt+left/right/up/down`, `alt+1` through `alt+9`. Configuring an action replaces all of its built-in aliases (set `focus-pane-left` and the Alt+Arrow alias is gone). Tab switching also accepts the top-row symbols of non-QWERTY layouts, so AZERTY works without extra config. | 89 | Key specs: `alt+<char>`, `alt+space`, `alt+left/right/up/down`, `alt+1` through `alt+9`. Configuring an action replaces all of its built-in aliases (set `focus-pane-left` and the Alt+Arrow alias is gone). Tab switching also accepts the top-row symbols of non-QWERTY layouts, so AZERTY works without extra config. | ||
| 86 | 90 | ||||
| 91 | + | ## Navigation overflow | |||
| 92 | + | ||||
| 93 | + | Both settings are independent and default to `off`. With `nav.overflow-tabs = on`, left/right keyboard focus (`Alt+H`/`Alt+L` by default) continues from a pane edge to the adjacent tab. With `nav.overflow-sessions = on`, up/down keyboard focus (`Alt+K`/`Alt+J` by default) continues to the adjacent live session in alphabetical order. Neither setting wraps at the first or last destination. | |||
| 94 | + | ||||
| 95 | + | Overflow applies only to keyboard focus actions; mouse navigation does not overflow, and floating panes never overflow. Hot reload applies either setting to subsequent navigation without restarting the daemon. | |||
| 96 | + | ||||
| 87 | ## Copy mode | 97 | ## Copy mode | ||
| 88 | 98 | ||||
| 89 | Scroll up with the mouse to enter copy mode. Use `h`, `j`, `k`, and `l` to move, `w`, `b`, and `e` for word motions, `v` or Space to start line selection, and `y` or Enter to copy. | 99 | Scroll up with the mouse to enter copy mode. Use `h`, `j`, `k`, and `l` to move, `w`, `b`, and `e` for word motions, `v` or Space to start line selection, and `y` or Enter to copy. | ||
M internal/adapters/config/config.go +17 -0
| @@ -33,6 +33,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 33 | 33 | seenFloatingKeys := make(map[string]bool) | |
| 34 | 34 | seenCopyKeys := make(map[string]bool) | |
| 35 | 35 | seenPaletteKeys := make(map[string]bool) | |
| 36 | + | seenNavKeys := make(map[string]bool) | |
| 36 | 37 | seenTabsKeys := make(map[string]bool) | |
| 37 | 38 | ||
| 38 | 39 | scanner := bufio.NewScanner(r) | |
| @@ -118,6 +119,22 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 118 | 119 | continue | |
| 119 | 120 | } | |
| 120 | 121 | cfg.Palette = domain.PaletteConfig{Anchor: anchor, AnchorSet: true} | |
| 122 | + | case key == "nav.overflow-tabs": | |
| 123 | + | warnings = warnDuplicateKey(warnings, seenNavKeys, key, lineNo) | |
| 124 | + | on, ok := parseOnOff(value) | |
| 125 | + | if !ok { | |
| 126 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid nav.overflow-tabs %q", value)}) | |
| 127 | + | continue | |
| 128 | + | } | |
| 129 | + | cfg.Nav.OverflowTabs = on | |
| 130 | + | case key == "nav.overflow-sessions": | |
| 131 | + | warnings = warnDuplicateKey(warnings, seenNavKeys, key, lineNo) | |
| 132 | + | on, ok := parseOnOff(value) | |
| 133 | + | if !ok { | |
| 134 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid nav.overflow-sessions %q", value)}) | |
| 135 | + | continue | |
| 136 | + | } | |
| 137 | + | cfg.Nav.OverflowSessions = on | |
| 121 | 138 | case key == "tabs.terminal-title": | |
| 122 | 139 | warnings = warnDuplicateKey(warnings, seenTabsKeys, key, lineNo) | |
| 123 | 140 | on, ok := parseOnOff(value) | |
| @@ -33,6 +33,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||||
| 33 | seenFloatingKeys := make(map[string]bool) | 33 | seenFloatingKeys := make(map[string]bool) | ||
| 34 | seenCopyKeys := make(map[string]bool) | 34 | seenCopyKeys := make(map[string]bool) | ||
| 35 | seenPaletteKeys := make(map[string]bool) | 35 | seenPaletteKeys := make(map[string]bool) | ||
| 36 | + | seenNavKeys := make(map[string]bool) | |||
| 36 | seenTabsKeys := make(map[string]bool) | 37 | seenTabsKeys := make(map[string]bool) | ||
| 37 | 38 | ||||
| 38 | scanner := bufio.NewScanner(r) | 39 | scanner := bufio.NewScanner(r) | ||
| @@ -118,6 +119,22 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||||
| 118 | continue | 119 | continue | ||
| 119 | } | 120 | } | ||
| 120 | cfg.Palette = domain.PaletteConfig{Anchor: anchor, AnchorSet: true} | 121 | cfg.Palette = domain.PaletteConfig{Anchor: anchor, AnchorSet: true} | ||
| 122 | + | case key == "nav.overflow-tabs": | |||
| 123 | + | warnings = warnDuplicateKey(warnings, seenNavKeys, key, lineNo) | |||
| 124 | + | on, ok := parseOnOff(value) | |||
| 125 | + | if !ok { | |||
| 126 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid nav.overflow-tabs %q", value)}) | |||
| 127 | + | continue | |||
| 128 | + | } | |||
| 129 | + | cfg.Nav.OverflowTabs = on | |||
| 130 | + | case key == "nav.overflow-sessions": | |||
| 131 | + | warnings = warnDuplicateKey(warnings, seenNavKeys, key, lineNo) | |||
| 132 | + | on, ok := parseOnOff(value) | |||
| 133 | + | if !ok { | |||
| 134 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid nav.overflow-sessions %q", value)}) | |||
| 135 | + | continue | |||
| 136 | + | } | |||
| 137 | + | cfg.Nav.OverflowSessions = on | |||
| 121 | case key == "tabs.terminal-title": | 138 | case key == "tabs.terminal-title": | ||
| 122 | warnings = warnDuplicateKey(warnings, seenTabsKeys, key, lineNo) | 139 | warnings = warnDuplicateKey(warnings, seenTabsKeys, key, lineNo) | ||
| 123 | on, ok := parseOnOff(value) | 140 | on, ok := parseOnOff(value) | ||
M internal/adapters/config/config_test.go +77 -0
| @@ -476,6 +476,83 @@ func TestParseTabsTerminalTitle(t *testing.T) { | |||
| 476 | 476 | } | |
| 477 | 477 | } | |
| 478 | 478 | ||
| 479 | + | func TestParseNavigationOverflow(t *testing.T) { | |
| 480 | + | t.Parallel() | |
| 481 | + | ||
| 482 | + | tests := []struct { | |
| 483 | + | name string | |
| 484 | + | input string | |
| 485 | + | want domain.NavConfig | |
| 486 | + | wantWarnings []domain.Warning | |
| 487 | + | }{ | |
| 488 | + | { | |
| 489 | + | name: "absent defaults both settings off", | |
| 490 | + | }, | |
| 491 | + | { | |
| 492 | + | name: "tabs on enables only tab overflow", | |
| 493 | + | input: "nav.overflow-tabs = on\n", | |
| 494 | + | want: domain.NavConfig{OverflowTabs: true}, | |
| 495 | + | }, | |
| 496 | + | { | |
| 497 | + | name: "sessions on enables only session overflow", | |
| 498 | + | input: "nav.overflow-sessions = on\n", | |
| 499 | + | want: domain.NavConfig{OverflowSessions: true}, | |
| 500 | + | }, | |
| 501 | + | { | |
| 502 | + | name: "off disables both settings", | |
| 503 | + | input: "nav.overflow-tabs = off\nnav.overflow-sessions = off\n", | |
| 504 | + | }, | |
| 505 | + | { | |
| 506 | + | name: "settings are independent", | |
| 507 | + | input: "nav.overflow-tabs = on\nnav.overflow-sessions = off\n", | |
| 508 | + | want: domain.NavConfig{OverflowTabs: true}, | |
| 509 | + | }, | |
| 510 | + | { | |
| 511 | + | name: "invalid values warn and preserve defaults", | |
| 512 | + | input: "nav.overflow-tabs = maybe\nnav.overflow-sessions = always\n", | |
| 513 | + | wantWarnings: []domain.Warning{ | |
| 514 | + | {Line: 1, Msg: `invalid nav.overflow-tabs "maybe"`}, | |
| 515 | + | {Line: 2, Msg: `invalid nav.overflow-sessions "always"`}, | |
| 516 | + | }, | |
| 517 | + | }, | |
| 518 | + | { | |
| 519 | + | name: "duplicate keys warn and last valid value wins", | |
| 520 | + | input: "nav.overflow-tabs = off\nnav.overflow-tabs = on\nnav.overflow-sessions = on\nnav.overflow-sessions = off\n", | |
| 521 | + | want: domain.NavConfig{OverflowTabs: true}, | |
| 522 | + | wantWarnings: []domain.Warning{ | |
| 523 | + | {Line: 2, Msg: `duplicate key "nav.overflow-tabs"`}, | |
| 524 | + | {Line: 4, Msg: `duplicate key "nav.overflow-sessions"`}, | |
| 525 | + | }, | |
| 526 | + | }, | |
| 527 | + | { | |
| 528 | + | name: "invalid duplicate still warns and keeps previous value", | |
| 529 | + | input: "nav.overflow-tabs = on\nnav.overflow-tabs = maybe\n", | |
| 530 | + | want: domain.NavConfig{OverflowTabs: true}, | |
| 531 | + | wantWarnings: []domain.Warning{ | |
| 532 | + | {Line: 2, Msg: `duplicate key "nav.overflow-tabs"`}, | |
| 533 | + | {Line: 2, Msg: `invalid nav.overflow-tabs "maybe"`}, | |
| 534 | + | }, | |
| 535 | + | }, | |
| 536 | + | } | |
| 537 | + | ||
| 538 | + | for _, tt := range tests { | |
| 539 | + | t.Run(tt.name, func(t *testing.T) { | |
| 540 | + | t.Parallel() | |
| 541 | + | ||
| 542 | + | cfg, warnings, err := Parse(strings.NewReader(tt.input)) | |
| 543 | + | require.NoError(t, err) | |
| 544 | + | require.Equal(t, tt.want, cfg.Nav) | |
| 545 | + | require.Equal(t, tt.wantWarnings, warnings) | |
| 546 | + | }) | |
| 547 | + | } | |
| 548 | + | } | |
| 549 | + | ||
| 550 | + | func TestDefaultsDisableNavigationOverflow(t *testing.T) { | |
| 551 | + | t.Parallel() | |
| 552 | + | ||
| 553 | + | require.Equal(t, domain.NavConfig{}, domain.Defaults().Nav) | |
| 554 | + | } | |
| 555 | + | ||
| 479 | 556 | func TestDefaultsCopiesSnapshotRestoreProcesses(t *testing.T) { | |
| 480 | 557 | t.Parallel() | |
| 481 | 558 | ||
| @@ -476,6 +476,83 @@ func TestParseTabsTerminalTitle(t *testing.T) { | |||||
| 476 | } | 476 | } | ||
| 477 | } | 477 | } | ||
| 478 | 478 | ||||
| 479 | + | func TestParseNavigationOverflow(t *testing.T) { | |||
| 480 | + | t.Parallel() | |||
| 481 | + | ||||
| 482 | + | tests := []struct { | |||
| 483 | + | name string | |||
| 484 | + | input string | |||
| 485 | + | want domain.NavConfig | |||
| 486 | + | wantWarnings []domain.Warning | |||
| 487 | + | }{ | |||
| 488 | + | { | |||
| 489 | + | name: "absent defaults both settings off", | |||
| 490 | + | }, | |||
| 491 | + | { | |||
| 492 | + | name: "tabs on enables only tab overflow", | |||
| 493 | + | input: "nav.overflow-tabs = on\n", | |||
| 494 | + | want: domain.NavConfig{OverflowTabs: true}, | |||
| 495 | + | }, | |||
| 496 | + | { | |||
| 497 | + | name: "sessions on enables only session overflow", | |||
| 498 | + | input: "nav.overflow-sessions = on\n", | |||
| 499 | + | want: domain.NavConfig{OverflowSessions: true}, | |||
| 500 | + | }, | |||
| 501 | + | { | |||
| 502 | + | name: "off disables both settings", | |||
| 503 | + | input: "nav.overflow-tabs = off\nnav.overflow-sessions = off\n", | |||
| 504 | + | }, | |||
| 505 | + | { | |||
| 506 | + | name: "settings are independent", | |||
| 507 | + | input: "nav.overflow-tabs = on\nnav.overflow-sessions = off\n", | |||
| 508 | + | want: domain.NavConfig{OverflowTabs: true}, | |||
| 509 | + | }, | |||
| 510 | + | { | |||
| 511 | + | name: "invalid values warn and preserve defaults", | |||
| 512 | + | input: "nav.overflow-tabs = maybe\nnav.overflow-sessions = always\n", | |||
| 513 | + | wantWarnings: []domain.Warning{ | |||
| 514 | + | {Line: 1, Msg: `invalid nav.overflow-tabs "maybe"`}, | |||
| 515 | + | {Line: 2, Msg: `invalid nav.overflow-sessions "always"`}, | |||
| 516 | + | }, | |||
| 517 | + | }, | |||
| 518 | + | { | |||
| 519 | + | name: "duplicate keys warn and last valid value wins", | |||
| 520 | + | input: "nav.overflow-tabs = off\nnav.overflow-tabs = on\nnav.overflow-sessions = on\nnav.overflow-sessions = off\n", | |||
| 521 | + | want: domain.NavConfig{OverflowTabs: true}, | |||
| 522 | + | wantWarnings: []domain.Warning{ | |||
| 523 | + | {Line: 2, Msg: `duplicate key "nav.overflow-tabs"`}, | |||
| 524 | + | {Line: 4, Msg: `duplicate key "nav.overflow-sessions"`}, | |||
| 525 | + | }, | |||
| 526 | + | }, | |||
| 527 | + | { | |||
| 528 | + | name: "invalid duplicate still warns and keeps previous value", | |||
| 529 | + | input: "nav.overflow-tabs = on\nnav.overflow-tabs = maybe\n", | |||
| 530 | + | want: domain.NavConfig{OverflowTabs: true}, | |||
| 531 | + | wantWarnings: []domain.Warning{ | |||
| 532 | + | {Line: 2, Msg: `duplicate key "nav.overflow-tabs"`}, | |||
| 533 | + | {Line: 2, Msg: `invalid nav.overflow-tabs "maybe"`}, | |||
| 534 | + | }, | |||
| 535 | + | }, | |||
| 536 | + | } | |||
| 537 | + | ||||
| 538 | + | for _, tt := range tests { | |||
| 539 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 540 | + | t.Parallel() | |||
| 541 | + | ||||
| 542 | + | cfg, warnings, err := Parse(strings.NewReader(tt.input)) | |||
| 543 | + | require.NoError(t, err) | |||
| 544 | + | require.Equal(t, tt.want, cfg.Nav) | |||
| 545 | + | require.Equal(t, tt.wantWarnings, warnings) | |||
| 546 | + | }) | |||
| 547 | + | } | |||
| 548 | + | } | |||
| 549 | + | ||||
| 550 | + | func TestDefaultsDisableNavigationOverflow(t *testing.T) { | |||
| 551 | + | t.Parallel() | |||
| 552 | + | ||||
| 553 | + | require.Equal(t, domain.NavConfig{}, domain.Defaults().Nav) | |||
| 554 | + | } | |||
| 555 | + | ||||
| 479 | func TestDefaultsCopiesSnapshotRestoreProcesses(t *testing.T) { | 556 | func TestDefaultsCopiesSnapshotRestoreProcesses(t *testing.T) { | ||
| 480 | t.Parallel() | 557 | t.Parallel() | ||
| 481 | 558 | ||||
M internal/adapters/term/resize_test.go +3 -0
| @@ -58,9 +58,11 @@ func TestResizeLoop_GetSizeErrorSkipsEmission(t *testing.T) { | |||
| 58 | 58 | quit := make(chan struct{}) | |
| 59 | 59 | ||
| 60 | 60 | var calls int | |
| 61 | + | firstGetSize := make(chan struct{}) | |
| 61 | 62 | getSize := func() (domain.Size, error) { | |
| 62 | 63 | calls++ | |
| 63 | 64 | if calls == 1 { | |
| 65 | + | close(firstGetSize) | |
| 64 | 66 | return domain.Size{}, errors.New("boom") | |
| 65 | 67 | } | |
| 66 | 68 | return domain.Size{Cols: 10, Rows: 5}, nil | |
| @@ -69,6 +71,7 @@ func TestResizeLoop_GetSizeErrorSkipsEmission(t *testing.T) { | |||
| 69 | 71 | go resizeLoop(sig, out, quit, getSize) | |
| 70 | 72 | ||
| 71 | 73 | sig <- syscall.SIGWINCH // errors; no emission | |
| 74 | + | <-firstGetSize // wait until the failed signal has been handled | |
| 72 | 75 | sig <- syscall.SIGWINCH // succeeds | |
| 73 | 76 | ||
| 74 | 77 | got := <-out | |
| @@ -58,9 +58,11 @@ func TestResizeLoop_GetSizeErrorSkipsEmission(t *testing.T) { | |||||
| 58 | quit := make(chan struct{}) | 58 | quit := make(chan struct{}) | ||
| 59 | 59 | ||||
| 60 | var calls int | 60 | var calls int | ||
| 61 | + | firstGetSize := make(chan struct{}) | |||
| 61 | getSize := func() (domain.Size, error) { | 62 | getSize := func() (domain.Size, error) { | ||
| 62 | calls++ | 63 | calls++ | ||
| 63 | if calls == 1 { | 64 | if calls == 1 { | ||
| 65 | + | close(firstGetSize) | |||
| 64 | return domain.Size{}, errors.New("boom") | 66 | return domain.Size{}, errors.New("boom") | ||
| 65 | } | 67 | } | ||
| 66 | return domain.Size{Cols: 10, Rows: 5}, nil | 68 | return domain.Size{Cols: 10, Rows: 5}, nil | ||
| @@ -69,6 +71,7 @@ func TestResizeLoop_GetSizeErrorSkipsEmission(t *testing.T) { | |||||
| 69 | go resizeLoop(sig, out, quit, getSize) | 71 | go resizeLoop(sig, out, quit, getSize) | ||
| 70 | 72 | ||||
| 71 | sig <- syscall.SIGWINCH // errors; no emission | 73 | sig <- syscall.SIGWINCH // errors; no emission | ||
| 74 | + | <-firstGetSize // wait until the failed signal has been handled | |||
| 72 | sig <- syscall.SIGWINCH // succeeds | 75 | sig <- syscall.SIGWINCH // succeeds | ||
| 73 | 76 | ||||
| 74 | got := <-out | 77 | got := <-out | ||
M internal/domain/config.go +7 -0
| @@ -69,6 +69,12 @@ type FloatingConfig struct { | |||
| 69 | 69 | Height int | |
| 70 | 70 | } | |
| 71 | 71 | ||
| 72 | + | // NavConfig contains directional navigation overflow settings. | |
| 73 | + | type NavConfig struct { | |
| 74 | + | OverflowTabs bool | |
| 75 | + | OverflowSessions bool | |
| 76 | + | } | |
| 77 | + | ||
| 72 | 78 | // TabsConfig contains tab label display settings. | |
| 73 | 79 | type TabsConfig struct { | |
| 74 | 80 | // TerminalTitle includes the focused pane's OSC terminal title in tab | |
| @@ -90,6 +96,7 @@ type Config struct { | |||
| 90 | 96 | Copy CopyConfig | |
| 91 | 97 | Palette PaletteConfig | |
| 92 | 98 | Floating FloatingConfig | |
| 99 | + | Nav NavConfig | |
| 93 | 100 | Tabs TabsConfig | |
| 94 | 101 | } | |
| 95 | 102 | ||
| @@ -69,6 +69,12 @@ type FloatingConfig struct { | |||||
| 69 | Height int | 69 | Height int | ||
| 70 | } | 70 | } | ||
| 71 | 71 | ||||
| 72 | + | // NavConfig contains directional navigation overflow settings. | |||
| 73 | + | type NavConfig struct { | |||
| 74 | + | OverflowTabs bool | |||
| 75 | + | OverflowSessions bool | |||
| 76 | + | } | |||
| 77 | + | ||||
| 72 | // TabsConfig contains tab label display settings. | 78 | // TabsConfig contains tab label display settings. | ||
| 73 | type TabsConfig struct { | 79 | type TabsConfig struct { | ||
| 74 | // TerminalTitle includes the focused pane's OSC terminal title in tab | 80 | // TerminalTitle includes the focused pane's OSC terminal title in tab | ||
| @@ -90,6 +96,7 @@ type Config struct { | |||||
| 90 | Copy CopyConfig | 96 | Copy CopyConfig | ||
| 91 | Palette PaletteConfig | 97 | Palette PaletteConfig | ||
| 92 | Floating FloatingConfig | 98 | Floating FloatingConfig | ||
| 99 | + | Nav NavConfig | |||
| 93 | Tabs TabsConfig | 100 | Tabs TabsConfig | ||
| 94 | } | 101 | } | ||
| 95 | 102 | ||||
M internal/usecase/daemon/config.go +9 -0
| @@ -56,6 +56,8 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) { | |||
| 56 | 56 | d.copyConfig.Store(©Config) | |
| 57 | 57 | palette := cfg.Palette | |
| 58 | 58 | d.paletteConfig.Store(&palette) | |
| 59 | + | nav := cfg.Nav | |
| 60 | + | d.navConfig.Store(&nav) | |
| 59 | 61 | tabs := cfg.Tabs | |
| 60 | 62 | d.tabsConfig.Store(&tabs) | |
| 61 | 63 | d.storeThemeConfig(cfg) | |
| @@ -262,6 +264,13 @@ func (d *Daemon) currentPaletteConfig() domain.PaletteConfig { | |||
| 262 | 264 | return domain.Defaults().Palette | |
| 263 | 265 | } | |
| 264 | 266 | ||
| 267 | + | func (d *Daemon) currentNavConfig() domain.NavConfig { | |
| 268 | + | if cfg := d.navConfig.Load(); cfg != nil { | |
| 269 | + | return *cfg | |
| 270 | + | } | |
| 271 | + | return domain.Defaults().Nav | |
| 272 | + | } | |
| 273 | + | ||
| 265 | 274 | func (d *Daemon) currentTabsConfig() domain.TabsConfig { | |
| 266 | 275 | if cfg := d.tabsConfig.Load(); cfg != nil { | |
| 267 | 276 | return *cfg | |
| @@ -56,6 +56,8 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) { | |||||
| 56 | d.copyConfig.Store(©Config) | 56 | d.copyConfig.Store(©Config) | ||
| 57 | palette := cfg.Palette | 57 | palette := cfg.Palette | ||
| 58 | d.paletteConfig.Store(&palette) | 58 | d.paletteConfig.Store(&palette) | ||
| 59 | + | nav := cfg.Nav | |||
| 60 | + | d.navConfig.Store(&nav) | |||
| 59 | tabs := cfg.Tabs | 61 | tabs := cfg.Tabs | ||
| 60 | d.tabsConfig.Store(&tabs) | 62 | d.tabsConfig.Store(&tabs) | ||
| 61 | d.storeThemeConfig(cfg) | 63 | d.storeThemeConfig(cfg) | ||
| @@ -262,6 +264,13 @@ func (d *Daemon) currentPaletteConfig() domain.PaletteConfig { | |||||
| 262 | return domain.Defaults().Palette | 264 | return domain.Defaults().Palette | ||
| 263 | } | 265 | } | ||
| 264 | 266 | ||||
| 267 | + | func (d *Daemon) currentNavConfig() domain.NavConfig { | |||
| 268 | + | if cfg := d.navConfig.Load(); cfg != nil { | |||
| 269 | + | return *cfg | |||
| 270 | + | } | |||
| 271 | + | return domain.Defaults().Nav | |||
| 272 | + | } | |||
| 273 | + | ||||
| 265 | func (d *Daemon) currentTabsConfig() domain.TabsConfig { | 274 | func (d *Daemon) currentTabsConfig() domain.TabsConfig { | ||
| 266 | if cfg := d.tabsConfig.Load(); cfg != nil { | 275 | if cfg := d.tabsConfig.Load(); cfg != nil { | ||
| 267 | return *cfg | 276 | return *cfg | ||
M internal/usecase/daemon/config_test.go +18 -0
| @@ -365,6 +365,24 @@ func TestApplyConfigPublishesImmutableFloatingSnapshot(t *testing.T) { | |||
| 365 | 365 | require.Equal(t, domain.FloatingConfig{Command: "lazygit", Width: 90, Height: 85}, second) | |
| 366 | 366 | } | |
| 367 | 367 | ||
| 368 | + | func TestApplyConfigPublishesImmutableNavSnapshot(t *testing.T) { | |
| 369 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 370 | + | require.Equal(t, domain.NavConfig{}, d.currentNavConfig()) | |
| 371 | + | ||
| 372 | + | firstConfig := domain.Defaults() | |
| 373 | + | firstConfig.Nav = domain.NavConfig{OverflowTabs: true} | |
| 374 | + | d.ApplyConfig(firstConfig) | |
| 375 | + | first := d.currentNavConfig() | |
| 376 | + | ||
| 377 | + | secondConfig := domain.Defaults() | |
| 378 | + | secondConfig.Nav = domain.NavConfig{OverflowSessions: true} | |
| 379 | + | d.ApplyConfig(secondConfig) | |
| 380 | + | second := d.currentNavConfig() | |
| 381 | + | ||
| 382 | + | require.Equal(t, domain.NavConfig{OverflowTabs: true}, first) | |
| 383 | + | require.Equal(t, domain.NavConfig{OverflowSessions: true}, second) | |
| 384 | + | } | |
| 385 | + | ||
| 368 | 386 | func TestApplyConfigPublishesImmutableTabsSnapshot(t *testing.T) { | |
| 369 | 387 | d := newTestDaemon(t, nil, stubClock{}) | |
| 370 | 388 | require.Equal(t, domain.Defaults().Tabs, d.currentTabsConfig()) | |
| @@ -365,6 +365,24 @@ func TestApplyConfigPublishesImmutableFloatingSnapshot(t *testing.T) { | |||||
| 365 | require.Equal(t, domain.FloatingConfig{Command: "lazygit", Width: 90, Height: 85}, second) | 365 | require.Equal(t, domain.FloatingConfig{Command: "lazygit", Width: 90, Height: 85}, second) | ||
| 366 | } | 366 | } | ||
| 367 | 367 | ||||
| 368 | + | func TestApplyConfigPublishesImmutableNavSnapshot(t *testing.T) { | |||
| 369 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 370 | + | require.Equal(t, domain.NavConfig{}, d.currentNavConfig()) | |||
| 371 | + | ||||
| 372 | + | firstConfig := domain.Defaults() | |||
| 373 | + | firstConfig.Nav = domain.NavConfig{OverflowTabs: true} | |||
| 374 | + | d.ApplyConfig(firstConfig) | |||
| 375 | + | first := d.currentNavConfig() | |||
| 376 | + | ||||
| 377 | + | secondConfig := domain.Defaults() | |||
| 378 | + | secondConfig.Nav = domain.NavConfig{OverflowSessions: true} | |||
| 379 | + | d.ApplyConfig(secondConfig) | |||
| 380 | + | second := d.currentNavConfig() | |||
| 381 | + | ||||
| 382 | + | require.Equal(t, domain.NavConfig{OverflowTabs: true}, first) | |||
| 383 | + | require.Equal(t, domain.NavConfig{OverflowSessions: true}, second) | |||
| 384 | + | } | |||
| 385 | + | ||||
| 368 | func TestApplyConfigPublishesImmutableTabsSnapshot(t *testing.T) { | 386 | func TestApplyConfigPublishesImmutableTabsSnapshot(t *testing.T) { | ||
| 369 | d := newTestDaemon(t, nil, stubClock{}) | 387 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 370 | require.Equal(t, domain.Defaults().Tabs, d.currentTabsConfig()) | 388 | require.Equal(t, domain.Defaults().Tabs, d.currentTabsConfig()) | ||
M internal/usecase/daemon/control.go +4 -12
| @@ -265,7 +265,8 @@ func (a daemonActions) Run(request daemonActionRequest) error { | |||
| 265 | 265 | } | |
| 266 | 266 | return nil | |
| 267 | 267 | case daemonActionFocusPane: | |
| 268 | - | return a.d.focusDirAt(target.session, target.tab, target.pane, request.direction) | |
| 268 | + | _, err := a.d.focusDirAt(target.session, target.tab, target.pane, request.direction) | |
| 269 | + | return err | |
| 269 | 270 | case daemonActionNextTab: | |
| 270 | 271 | return a.switchRelative(target.session, 1) | |
| 271 | 272 | case daemonActionPreviousTab: | |
| @@ -349,17 +350,8 @@ func finishDaemonActionForClient(d *Daemon, request daemonActionRequest, ac *att | |||
| 349 | 350 | return | |
| 350 | 351 | } | |
| 351 | 352 | if request.kind == daemonActionFocusPane && request.target.tab != nil && request.target.pane != nil { | |
| 352 | - | tb := request.target.tab | |
| 353 | - | tb.mu.Lock() | |
| 354 | - | newFocus := tb.tree.Focus | |
| 355 | - | pl, hasPlacement := focusedPlacementLocked(tb) | |
| 356 | - | tb.mu.Unlock() | |
| 357 | - | if newFocus != request.target.pane.id { | |
| 358 | - | d.exitCopyMode(ac) | |
| 359 | - | if hasPlacement && pl.InStack { | |
| 360 | - | d.refreshPaneTitleOnFocus(request.target.session, newFocus) | |
| 361 | - | } | |
| 362 | - | } | |
| 353 | + | d.finishPaneFocusForClient(request.target.session, ac, request.target.tab, request.target.pane.id, producer) | |
| 354 | + | return | |
| 363 | 355 | } | |
| 364 | 356 | d.invalidateRender(request.target.session, ac, true, producer) | |
| 365 | 357 | } | |
| @@ -265,7 +265,8 @@ func (a daemonActions) Run(request daemonActionRequest) error { | |||||
| 265 | } | 265 | } | ||
| 266 | return nil | 266 | return nil | ||
| 267 | case daemonActionFocusPane: | 267 | case daemonActionFocusPane: | ||
| 268 | - | return a.d.focusDirAt(target.session, target.tab, target.pane, request.direction) | 268 | + | _, err := a.d.focusDirAt(target.session, target.tab, target.pane, request.direction) |
| 269 | + | return err | |||
| 269 | case daemonActionNextTab: | 270 | case daemonActionNextTab: | ||
| 270 | return a.switchRelative(target.session, 1) | 271 | return a.switchRelative(target.session, 1) | ||
| 271 | case daemonActionPreviousTab: | 272 | case daemonActionPreviousTab: | ||
| @@ -349,17 +350,8 @@ func finishDaemonActionForClient(d *Daemon, request daemonActionRequest, ac *att | |||||
| 349 | return | 350 | return | ||
| 350 | } | 351 | } | ||
| 351 | if request.kind == daemonActionFocusPane && request.target.tab != nil && request.target.pane != nil { | 352 | if request.kind == daemonActionFocusPane && request.target.tab != nil && request.target.pane != nil { | ||
| 352 | - | tb := request.target.tab | 353 | + | d.finishPaneFocusForClient(request.target.session, ac, request.target.tab, request.target.pane.id, producer) |
| 353 | - | tb.mu.Lock() | 354 | + | return |
| 354 | - | newFocus := tb.tree.Focus | |||
| 355 | - | pl, hasPlacement := focusedPlacementLocked(tb) | |||
| 356 | - | tb.mu.Unlock() | |||
| 357 | - | if newFocus != request.target.pane.id { | |||
| 358 | - | d.exitCopyMode(ac) | |||
| 359 | - | if hasPlacement && pl.InStack { | |||
| 360 | - | d.refreshPaneTitleOnFocus(request.target.session, newFocus) | |||
| 361 | - | } | |||
| 362 | - | } | |||
| 363 | } | 355 | } | ||
| 364 | d.invalidateRender(request.target.session, ac, true, producer) | 356 | d.invalidateRender(request.target.session, ac, true, producer) | ||
| 365 | } | 357 | } | ||
M internal/usecase/daemon/daemon.go +1 -0
| @@ -162,6 +162,7 @@ type Daemon struct { | |||
| 162 | 162 | floatingConfig atomic.Pointer[domain.FloatingConfig] | |
| 163 | 163 | copyConfig atomic.Pointer[domain.CopyConfig] | |
| 164 | 164 | paletteConfig atomic.Pointer[domain.PaletteConfig] | |
| 165 | + | navConfig atomic.Pointer[domain.NavConfig] | |
| 165 | 166 | tabsConfig atomic.Pointer[domain.TabsConfig] | |
| 166 | 167 | themeConfig atomic.Pointer[themeConfigSnapshot] | |
| 167 | 168 | barScripts *barScriptState | |
| @@ -162,6 +162,7 @@ type Daemon struct { | |||||
| 162 | floatingConfig atomic.Pointer[domain.FloatingConfig] | 162 | floatingConfig atomic.Pointer[domain.FloatingConfig] | ||
| 163 | copyConfig atomic.Pointer[domain.CopyConfig] | 163 | copyConfig atomic.Pointer[domain.CopyConfig] | ||
| 164 | paletteConfig atomic.Pointer[domain.PaletteConfig] | 164 | paletteConfig atomic.Pointer[domain.PaletteConfig] | ||
| 165 | + | navConfig atomic.Pointer[domain.NavConfig] | |||
| 165 | tabsConfig atomic.Pointer[domain.TabsConfig] | 166 | tabsConfig atomic.Pointer[domain.TabsConfig] | ||
| 166 | themeConfig atomic.Pointer[themeConfigSnapshot] | 167 | themeConfig atomic.Pointer[themeConfigSnapshot] | ||
| 167 | barScripts *barScriptState | 168 | barScripts *barScriptState | ||
A internal/usecase/daemon/navigation_overflow.go +244 -0
| @@ -0,0 +1,244 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sort" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 8 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | type overflowKind uint8 | |
| 12 | + | ||
| 13 | + | const ( | |
| 14 | + | overflowNone overflowKind = iota | |
| 15 | + | overflowTabs | |
| 16 | + | overflowSessions | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | type overflowStep struct { | |
| 20 | + | kind overflowKind | |
| 21 | + | delta int | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // resolveOverflow is the pure escalation policy for directional navigation. | |
| 25 | + | // The caller supplies its position on the selected axis, so disabled axes and | |
| 26 | + | // either hard wall all resolve to the zero step. | |
| 27 | + | func resolveOverflow(dir layout.Direction, cfg domain.NavConfig, position, count int) overflowStep { | |
| 28 | + | var step overflowStep | |
| 29 | + | switch dir { | |
| 30 | + | case layout.Left: | |
| 31 | + | if cfg.OverflowTabs { | |
| 32 | + | step = overflowStep{kind: overflowTabs, delta: -1} | |
| 33 | + | } | |
| 34 | + | case layout.Right: | |
| 35 | + | if cfg.OverflowTabs { | |
| 36 | + | step = overflowStep{kind: overflowTabs, delta: 1} | |
| 37 | + | } | |
| 38 | + | case layout.Up: | |
| 39 | + | if cfg.OverflowSessions { | |
| 40 | + | step = overflowStep{kind: overflowSessions, delta: -1} | |
| 41 | + | } | |
| 42 | + | case layout.Down: | |
| 43 | + | if cfg.OverflowSessions { | |
| 44 | + | step = overflowStep{kind: overflowSessions, delta: 1} | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | if step.kind == overflowNone || count < 2 || position < 0 || position >= count || position+step.delta < 0 || position+step.delta >= count { | |
| 48 | + | return overflowStep{} | |
| 49 | + | } | |
| 50 | + | return step | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // overflowSourceEligible validates that directional overflow still originates | |
| 54 | + | // from the active tiled tab. The session-to-tab lock order matches tab overflow | |
| 55 | + | // commit and leaves no lock held across a session handoff. | |
| 56 | + | func overflowSourceEligible(sess *session, expectedSource *tab) bool { | |
| 57 | + | if sess == nil || expectedSource == nil { | |
| 58 | + | return false | |
| 59 | + | } | |
| 60 | + | sess.mu.Lock() | |
| 61 | + | defer sess.mu.Unlock() | |
| 62 | + | if sess.active < 0 || sess.active >= len(sess.tabs) || sess.tabs[sess.active] != expectedSource { | |
| 63 | + | return false | |
| 64 | + | } | |
| 65 | + | expectedSource.mu.Lock() | |
| 66 | + | defer expectedSource.mu.Unlock() | |
| 67 | + | return expectedSource.floating.state != floatingVisible | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | type sessionOverflowSnapshot struct { | |
| 71 | + | session *session | |
| 72 | + | id domain.SessionID | |
| 73 | + | name string | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // prepareSessionOverflow snapshots only the live registry entries, then sorts | |
| 77 | + | // immutable names exactly like the picker. No daemon or session lock survives | |
| 78 | + | // the snapshot, sorting, or the later switchToTarget call. | |
| 79 | + | func (d *Daemon) prepareSessionOverflow(current *session, dir layout.Direction, cfg domain.NavConfig) (picker.Target, bool) { | |
| 80 | + | if current == nil || !cfg.OverflowSessions || (dir != layout.Up && dir != layout.Down) { | |
| 81 | + | return picker.Target{}, false | |
| 82 | + | } | |
| 83 | + | current.mu.Lock() | |
| 84 | + | currentID := current.id | |
| 85 | + | current.mu.Unlock() | |
| 86 | + | ||
| 87 | + | d.mu.Lock() | |
| 88 | + | live := make([]*session, 0, len(d.sessions)) | |
| 89 | + | for _, sess := range d.sessions { | |
| 90 | + | live = append(live, sess) | |
| 91 | + | } | |
| 92 | + | d.mu.Unlock() | |
| 93 | + | ||
| 94 | + | snapshots := make([]sessionOverflowSnapshot, 0, len(live)) | |
| 95 | + | for _, sess := range live { | |
| 96 | + | sess.mu.Lock() | |
| 97 | + | snapshot := sessionOverflowSnapshot{session: sess, id: sess.id, name: sess.name} | |
| 98 | + | sess.mu.Unlock() | |
| 99 | + | snapshots = append(snapshots, snapshot) | |
| 100 | + | } | |
| 101 | + | sort.Slice(snapshots, func(i, j int) bool { return snapshots[i].name < snapshots[j].name }) | |
| 102 | + | ||
| 103 | + | position := -1 | |
| 104 | + | for i, snapshot := range snapshots { | |
| 105 | + | if snapshot.session == current && snapshot.id == currentID { | |
| 106 | + | position = i | |
| 107 | + | break | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | step := resolveOverflow(dir, cfg, position, len(snapshots)) | |
| 111 | + | if step.kind != overflowSessions { | |
| 112 | + | return picker.Target{}, false | |
| 113 | + | } | |
| 114 | + | target := snapshots[position+step.delta] | |
| 115 | + | return picker.Target{Session: target.id, TabIndex: -1}, true | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // commitSessionOverflow carries its source constraint into the handoff so tab | |
| 119 | + | // identity and floating visibility are revalidated while source ownership is | |
| 120 | + | // transferred under the daemon -> routing -> session -> tab lock order. | |
| 121 | + | func (d *Daemon) commitSessionOverflow(from *session, ac *attachedClient, expectedSource *tab, target picker.Target) error { | |
| 122 | + | if from == nil || ac == nil || expectedSource == nil { | |
| 123 | + | return errNoNeighbor | |
| 124 | + | } | |
| 125 | + | return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{expectedSource: expectedSource}) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | type tabOverflowCandidate struct { | |
| 129 | + | source *tab | |
| 130 | + | target *tab | |
| 131 | + | sourceIndex int | |
| 132 | + | delta int | |
| 133 | + | direction layout.Direction | |
| 134 | + | span domain.Rect | |
| 135 | + | entry layout.PaneID | |
| 136 | + | entryPane *pane | |
| 137 | + | targetOldFocus layout.PaneID | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // prepareTabOverflow captures tab identities under the session lock and uses | |
| 141 | + | // EntryPane's pure lookup before any active-tab state is changed. | |
| 142 | + | func (d *Daemon) prepareTabOverflow(sess *session, expectedSource *tab, dir layout.Direction, span domain.Rect, delta int) (tabOverflowCandidate, bool) { | |
| 143 | + | if sess == nil || expectedSource == nil || (delta != -1 && delta != 1) { | |
| 144 | + | return tabOverflowCandidate{}, false | |
| 145 | + | } | |
| 146 | + | sess.mu.Lock() | |
| 147 | + | if sess.active < 0 || sess.active >= len(sess.tabs) || sess.tabs[sess.active] != expectedSource { | |
| 148 | + | sess.mu.Unlock() | |
| 149 | + | return tabOverflowCandidate{}, false | |
| 150 | + | } | |
| 151 | + | sourceIndex := sess.active | |
| 152 | + | targetIndex := sourceIndex + delta | |
| 153 | + | if targetIndex < 0 || targetIndex >= len(sess.tabs) { | |
| 154 | + | sess.mu.Unlock() | |
| 155 | + | return tabOverflowCandidate{}, false | |
| 156 | + | } | |
| 157 | + | source := sess.tabs[sourceIndex] | |
| 158 | + | target := sess.tabs[targetIndex] | |
| 159 | + | sess.mu.Unlock() | |
| 160 | + | ||
| 161 | + | source.mu.Lock() | |
| 162 | + | floatingVisible := source.floating.state == floatingVisible | |
| 163 | + | source.mu.Unlock() | |
| 164 | + | if floatingVisible { | |
| 165 | + | return tabOverflowCandidate{}, false | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | target.mu.Lock() | |
| 169 | + | if target.tree == nil { | |
| 170 | + | target.mu.Unlock() | |
| 171 | + | return tabOverflowCandidate{}, false | |
| 172 | + | } | |
| 173 | + | area := domain.Rect{Width: target.size.Cols, Height: target.size.Rows} | |
| 174 | + | entry, err := target.tree.EntryPane(dir, span, area) | |
| 175 | + | entryPane := target.panes[entry] | |
| 176 | + | oldFocus := target.tree.Focus | |
| 177 | + | valid := err == nil && entryPane != nil && layout.ContainsLeaf(target.tree.Root, entry) | |
| 178 | + | target.mu.Unlock() | |
| 179 | + | if !valid { | |
| 180 | + | return tabOverflowCandidate{}, false | |
| 181 | + | } | |
| 182 | + | return tabOverflowCandidate{ | |
| 183 | + | source: source, | |
| 184 | + | target: target, | |
| 185 | + | sourceIndex: sourceIndex, | |
| 186 | + | delta: delta, | |
| 187 | + | direction: dir, | |
| 188 | + | span: span, | |
| 189 | + | entry: entry, | |
| 190 | + | entryPane: entryPane, | |
| 191 | + | targetOldFocus: oldFocus, | |
| 192 | + | }, true | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | // commitTabOverflow atomically revalidates the source and target tab entries. | |
| 196 | + | // The target layout and selected pane are revalidated while locked before the | |
| 197 | + | // focus and active-tab mutations become visible together. | |
| 198 | + | func (d *Daemon) commitTabOverflow(sess *session, candidate tabOverflowCandidate) bool { | |
| 199 | + | if sess == nil || candidate.source == nil || candidate.target == nil { | |
| 200 | + | return false | |
| 201 | + | } | |
| 202 | + | sess.mu.Lock() | |
| 203 | + | targetIndex := candidate.sourceIndex + candidate.delta | |
| 204 | + | if candidate.sourceIndex < 0 || candidate.sourceIndex >= len(sess.tabs) || targetIndex < 0 || targetIndex >= len(sess.tabs) || | |
| 205 | + | sess.active != candidate.sourceIndex || sess.tabs[candidate.sourceIndex] != candidate.source || sess.tabs[targetIndex] != candidate.target { | |
| 206 | + | sess.mu.Unlock() | |
| 207 | + | return false | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | candidate.source.mu.Lock() | |
| 211 | + | if candidate.source.floating.state == floatingVisible { | |
| 212 | + | candidate.source.mu.Unlock() | |
| 213 | + | sess.mu.Unlock() | |
| 214 | + | return false | |
| 215 | + | } | |
| 216 | + | candidate.target.mu.Lock() | |
| 217 | + | if candidate.target.tree == nil { | |
| 218 | + | candidate.target.mu.Unlock() | |
| 219 | + | candidate.source.mu.Unlock() | |
| 220 | + | sess.mu.Unlock() | |
| 221 | + | return false | |
| 222 | + | } | |
| 223 | + | area := domain.Rect{Width: candidate.target.size.Cols, Height: candidate.target.size.Rows} | |
| 224 | + | entry, err := candidate.target.tree.EntryPane(candidate.direction, candidate.span, area) | |
| 225 | + | if err != nil || entry != candidate.entry || candidate.target.panes[entry] != candidate.entryPane || !layout.ContainsLeaf(candidate.target.tree.Root, entry) { | |
| 226 | + | candidate.target.mu.Unlock() | |
| 227 | + | candidate.source.mu.Unlock() | |
| 228 | + | sess.mu.Unlock() | |
| 229 | + | return false | |
| 230 | + | } | |
| 231 | + | if err := candidate.target.tree.FocusEnter(candidate.direction, candidate.span, area); err != nil { | |
| 232 | + | candidate.target.mu.Unlock() | |
| 233 | + | candidate.source.mu.Unlock() | |
| 234 | + | sess.mu.Unlock() | |
| 235 | + | return false | |
| 236 | + | } | |
| 237 | + | d.applyLayoutLocked(candidate.target) | |
| 238 | + | sess.active = targetIndex | |
| 239 | + | candidate.target.mu.Unlock() | |
| 240 | + | candidate.source.mu.Unlock() | |
| 241 | + | sess.mu.Unlock() | |
| 242 | + | markSnapshotDirty(sess) | |
| 243 | + | return true | |
| 244 | + | } | |
| @@ -0,0 +1,244 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "sort" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 8 | + | "github.com/bnema/vev/internal/usecase/picker" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | type overflowKind uint8 | |||
| 12 | + | ||||
| 13 | + | const ( | |||
| 14 | + | overflowNone overflowKind = iota | |||
| 15 | + | overflowTabs | |||
| 16 | + | overflowSessions | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | type overflowStep struct { | |||
| 20 | + | kind overflowKind | |||
| 21 | + | delta int | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // resolveOverflow is the pure escalation policy for directional navigation. | |||
| 25 | + | // The caller supplies its position on the selected axis, so disabled axes and | |||
| 26 | + | // either hard wall all resolve to the zero step. | |||
| 27 | + | func resolveOverflow(dir layout.Direction, cfg domain.NavConfig, position, count int) overflowStep { | |||
| 28 | + | var step overflowStep | |||
| 29 | + | switch dir { | |||
| 30 | + | case layout.Left: | |||
| 31 | + | if cfg.OverflowTabs { | |||
| 32 | + | step = overflowStep{kind: overflowTabs, delta: -1} | |||
| 33 | + | } | |||
| 34 | + | case layout.Right: | |||
| 35 | + | if cfg.OverflowTabs { | |||
| 36 | + | step = overflowStep{kind: overflowTabs, delta: 1} | |||
| 37 | + | } | |||
| 38 | + | case layout.Up: | |||
| 39 | + | if cfg.OverflowSessions { | |||
| 40 | + | step = overflowStep{kind: overflowSessions, delta: -1} | |||
| 41 | + | } | |||
| 42 | + | case layout.Down: | |||
| 43 | + | if cfg.OverflowSessions { | |||
| 44 | + | step = overflowStep{kind: overflowSessions, delta: 1} | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | if step.kind == overflowNone || count < 2 || position < 0 || position >= count || position+step.delta < 0 || position+step.delta >= count { | |||
| 48 | + | return overflowStep{} | |||
| 49 | + | } | |||
| 50 | + | return step | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // overflowSourceEligible validates that directional overflow still originates | |||
| 54 | + | // from the active tiled tab. The session-to-tab lock order matches tab overflow | |||
| 55 | + | // commit and leaves no lock held across a session handoff. | |||
| 56 | + | func overflowSourceEligible(sess *session, expectedSource *tab) bool { | |||
| 57 | + | if sess == nil || expectedSource == nil { | |||
| 58 | + | return false | |||
| 59 | + | } | |||
| 60 | + | sess.mu.Lock() | |||
| 61 | + | defer sess.mu.Unlock() | |||
| 62 | + | if sess.active < 0 || sess.active >= len(sess.tabs) || sess.tabs[sess.active] != expectedSource { | |||
| 63 | + | return false | |||
| 64 | + | } | |||
| 65 | + | expectedSource.mu.Lock() | |||
| 66 | + | defer expectedSource.mu.Unlock() | |||
| 67 | + | return expectedSource.floating.state != floatingVisible | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | type sessionOverflowSnapshot struct { | |||
| 71 | + | session *session | |||
| 72 | + | id domain.SessionID | |||
| 73 | + | name string | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // prepareSessionOverflow snapshots only the live registry entries, then sorts | |||
| 77 | + | // immutable names exactly like the picker. No daemon or session lock survives | |||
| 78 | + | // the snapshot, sorting, or the later switchToTarget call. | |||
| 79 | + | func (d *Daemon) prepareSessionOverflow(current *session, dir layout.Direction, cfg domain.NavConfig) (picker.Target, bool) { | |||
| 80 | + | if current == nil || !cfg.OverflowSessions || (dir != layout.Up && dir != layout.Down) { | |||
| 81 | + | return picker.Target{}, false | |||
| 82 | + | } | |||
| 83 | + | current.mu.Lock() | |||
| 84 | + | currentID := current.id | |||
| 85 | + | current.mu.Unlock() | |||
| 86 | + | ||||
| 87 | + | d.mu.Lock() | |||
| 88 | + | live := make([]*session, 0, len(d.sessions)) | |||
| 89 | + | for _, sess := range d.sessions { | |||
| 90 | + | live = append(live, sess) | |||
| 91 | + | } | |||
| 92 | + | d.mu.Unlock() | |||
| 93 | + | ||||
| 94 | + | snapshots := make([]sessionOverflowSnapshot, 0, len(live)) | |||
| 95 | + | for _, sess := range live { | |||
| 96 | + | sess.mu.Lock() | |||
| 97 | + | snapshot := sessionOverflowSnapshot{session: sess, id: sess.id, name: sess.name} | |||
| 98 | + | sess.mu.Unlock() | |||
| 99 | + | snapshots = append(snapshots, snapshot) | |||
| 100 | + | } | |||
| 101 | + | sort.Slice(snapshots, func(i, j int) bool { return snapshots[i].name < snapshots[j].name }) | |||
| 102 | + | ||||
| 103 | + | position := -1 | |||
| 104 | + | for i, snapshot := range snapshots { | |||
| 105 | + | if snapshot.session == current && snapshot.id == currentID { | |||
| 106 | + | position = i | |||
| 107 | + | break | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | step := resolveOverflow(dir, cfg, position, len(snapshots)) | |||
| 111 | + | if step.kind != overflowSessions { | |||
| 112 | + | return picker.Target{}, false | |||
| 113 | + | } | |||
| 114 | + | target := snapshots[position+step.delta] | |||
| 115 | + | return picker.Target{Session: target.id, TabIndex: -1}, true | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // commitSessionOverflow carries its source constraint into the handoff so tab | |||
| 119 | + | // identity and floating visibility are revalidated while source ownership is | |||
| 120 | + | // transferred under the daemon -> routing -> session -> tab lock order. | |||
| 121 | + | func (d *Daemon) commitSessionOverflow(from *session, ac *attachedClient, expectedSource *tab, target picker.Target) error { | |||
| 122 | + | if from == nil || ac == nil || expectedSource == nil { | |||
| 123 | + | return errNoNeighbor | |||
| 124 | + | } | |||
| 125 | + | return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{expectedSource: expectedSource}) | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | type tabOverflowCandidate struct { | |||
| 129 | + | source *tab | |||
| 130 | + | target *tab | |||
| 131 | + | sourceIndex int | |||
| 132 | + | delta int | |||
| 133 | + | direction layout.Direction | |||
| 134 | + | span domain.Rect | |||
| 135 | + | entry layout.PaneID | |||
| 136 | + | entryPane *pane | |||
| 137 | + | targetOldFocus layout.PaneID | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | // prepareTabOverflow captures tab identities under the session lock and uses | |||
| 141 | + | // EntryPane's pure lookup before any active-tab state is changed. | |||
| 142 | + | func (d *Daemon) prepareTabOverflow(sess *session, expectedSource *tab, dir layout.Direction, span domain.Rect, delta int) (tabOverflowCandidate, bool) { | |||
| 143 | + | if sess == nil || expectedSource == nil || (delta != -1 && delta != 1) { | |||
| 144 | + | return tabOverflowCandidate{}, false | |||
| 145 | + | } | |||
| 146 | + | sess.mu.Lock() | |||
| 147 | + | if sess.active < 0 || sess.active >= len(sess.tabs) || sess.tabs[sess.active] != expectedSource { | |||
| 148 | + | sess.mu.Unlock() | |||
| 149 | + | return tabOverflowCandidate{}, false | |||
| 150 | + | } | |||
| 151 | + | sourceIndex := sess.active | |||
| 152 | + | targetIndex := sourceIndex + delta | |||
| 153 | + | if targetIndex < 0 || targetIndex >= len(sess.tabs) { | |||
| 154 | + | sess.mu.Unlock() | |||
| 155 | + | return tabOverflowCandidate{}, false | |||
| 156 | + | } | |||
| 157 | + | source := sess.tabs[sourceIndex] | |||
| 158 | + | target := sess.tabs[targetIndex] | |||
| 159 | + | sess.mu.Unlock() | |||
| 160 | + | ||||
| 161 | + | source.mu.Lock() | |||
| 162 | + | floatingVisible := source.floating.state == floatingVisible | |||
| 163 | + | source.mu.Unlock() | |||
| 164 | + | if floatingVisible { | |||
| 165 | + | return tabOverflowCandidate{}, false | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | target.mu.Lock() | |||
| 169 | + | if target.tree == nil { | |||
| 170 | + | target.mu.Unlock() | |||
| 171 | + | return tabOverflowCandidate{}, false | |||
| 172 | + | } | |||
| 173 | + | area := domain.Rect{Width: target.size.Cols, Height: target.size.Rows} | |||
| 174 | + | entry, err := target.tree.EntryPane(dir, span, area) | |||
| 175 | + | entryPane := target.panes[entry] | |||
| 176 | + | oldFocus := target.tree.Focus | |||
| 177 | + | valid := err == nil && entryPane != nil && layout.ContainsLeaf(target.tree.Root, entry) | |||
| 178 | + | target.mu.Unlock() | |||
| 179 | + | if !valid { | |||
| 180 | + | return tabOverflowCandidate{}, false | |||
| 181 | + | } | |||
| 182 | + | return tabOverflowCandidate{ | |||
| 183 | + | source: source, | |||
| 184 | + | target: target, | |||
| 185 | + | sourceIndex: sourceIndex, | |||
| 186 | + | delta: delta, | |||
| 187 | + | direction: dir, | |||
| 188 | + | span: span, | |||
| 189 | + | entry: entry, | |||
| 190 | + | entryPane: entryPane, | |||
| 191 | + | targetOldFocus: oldFocus, | |||
| 192 | + | }, true | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | // commitTabOverflow atomically revalidates the source and target tab entries. | |||
| 196 | + | // The target layout and selected pane are revalidated while locked before the | |||
| 197 | + | // focus and active-tab mutations become visible together. | |||
| 198 | + | func (d *Daemon) commitTabOverflow(sess *session, candidate tabOverflowCandidate) bool { | |||
| 199 | + | if sess == nil || candidate.source == nil || candidate.target == nil { | |||
| 200 | + | return false | |||
| 201 | + | } | |||
| 202 | + | sess.mu.Lock() | |||
| 203 | + | targetIndex := candidate.sourceIndex + candidate.delta | |||
| 204 | + | if candidate.sourceIndex < 0 || candidate.sourceIndex >= len(sess.tabs) || targetIndex < 0 || targetIndex >= len(sess.tabs) || | |||
| 205 | + | sess.active != candidate.sourceIndex || sess.tabs[candidate.sourceIndex] != candidate.source || sess.tabs[targetIndex] != candidate.target { | |||
| 206 | + | sess.mu.Unlock() | |||
| 207 | + | return false | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | candidate.source.mu.Lock() | |||
| 211 | + | if candidate.source.floating.state == floatingVisible { | |||
| 212 | + | candidate.source.mu.Unlock() | |||
| 213 | + | sess.mu.Unlock() | |||
| 214 | + | return false | |||
| 215 | + | } | |||
| 216 | + | candidate.target.mu.Lock() | |||
| 217 | + | if candidate.target.tree == nil { | |||
| 218 | + | candidate.target.mu.Unlock() | |||
| 219 | + | candidate.source.mu.Unlock() | |||
| 220 | + | sess.mu.Unlock() | |||
| 221 | + | return false | |||
| 222 | + | } | |||
| 223 | + | area := domain.Rect{Width: candidate.target.size.Cols, Height: candidate.target.size.Rows} | |||
| 224 | + | entry, err := candidate.target.tree.EntryPane(candidate.direction, candidate.span, area) | |||
| 225 | + | if err != nil || entry != candidate.entry || candidate.target.panes[entry] != candidate.entryPane || !layout.ContainsLeaf(candidate.target.tree.Root, entry) { | |||
| 226 | + | candidate.target.mu.Unlock() | |||
| 227 | + | candidate.source.mu.Unlock() | |||
| 228 | + | sess.mu.Unlock() | |||
| 229 | + | return false | |||
| 230 | + | } | |||
| 231 | + | if err := candidate.target.tree.FocusEnter(candidate.direction, candidate.span, area); err != nil { | |||
| 232 | + | candidate.target.mu.Unlock() | |||
| 233 | + | candidate.source.mu.Unlock() | |||
| 234 | + | sess.mu.Unlock() | |||
| 235 | + | return false | |||
| 236 | + | } | |||
| 237 | + | d.applyLayoutLocked(candidate.target) | |||
| 238 | + | sess.active = targetIndex | |||
| 239 | + | candidate.target.mu.Unlock() | |||
| 240 | + | candidate.source.mu.Unlock() | |||
| 241 | + | sess.mu.Unlock() | |||
| 242 | + | markSnapshotDirty(sess) | |||
| 243 | + | return true | |||
| 244 | + | } | |||
A internal/usecase/daemon/navigation_overflow_test.go +383 -0
| @@ -0,0 +1,383 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/usecase/keys" | |
| 11 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestResolveOverflowObeysAxisConfigurationAndWalls(t *testing.T) { | |
| 16 | + | tests := []struct { | |
| 17 | + | name string | |
| 18 | + | dir layout.Direction | |
| 19 | + | cfg domain.NavConfig | |
| 20 | + | position int | |
| 21 | + | count int | |
| 22 | + | want overflowStep | |
| 23 | + | }{ | |
| 24 | + | {name: "tabs disabled", dir: layout.Right, cfg: domain.NavConfig{}, position: 0, count: 2}, | |
| 25 | + | {name: "sessions alone do not enable tabs", dir: layout.Right, cfg: domain.NavConfig{OverflowSessions: true}, position: 0, count: 2}, | |
| 26 | + | {name: "right tab", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 2, want: overflowStep{kind: overflowTabs, delta: 1}}, | |
| 27 | + | {name: "left tab", dir: layout.Left, cfg: domain.NavConfig{OverflowTabs: true}, position: 1, count: 2, want: overflowStep{kind: overflowTabs, delta: -1}}, | |
| 28 | + | {name: "left wall", dir: layout.Left, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 2}, | |
| 29 | + | {name: "right wall", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: 1, count: 2}, | |
| 30 | + | {name: "up session", dir: layout.Up, cfg: domain.NavConfig{OverflowSessions: true}, position: 1, count: 2, want: overflowStep{kind: overflowSessions, delta: -1}}, | |
| 31 | + | {name: "down session", dir: layout.Down, cfg: domain.NavConfig{OverflowSessions: true}, position: 0, count: 2, want: overflowStep{kind: overflowSessions, delta: 1}}, | |
| 32 | + | {name: "tabs alone do not enable sessions", dir: layout.Down, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 2}, | |
| 33 | + | {name: "single destination is a wall", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 1}, | |
| 34 | + | {name: "negative position is out of range", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: -1, count: 2}, | |
| 35 | + | {name: "position at count is out of range", dir: layout.Down, cfg: domain.NavConfig{OverflowSessions: true}, position: 2, count: 2}, | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | for _, tt := range tests { | |
| 39 | + | t.Run(tt.name, func(t *testing.T) { | |
| 40 | + | require.Equal(t, tt.want, resolveOverflow(tt.dir, tt.cfg, tt.position, tt.count)) | |
| 41 | + | }) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestKeyboardHorizontalOverflowLandsOnFacingEdge(t *testing.T) { | |
| 46 | + | tests := []struct { | |
| 47 | + | name string | |
| 48 | + | start int | |
| 49 | + | action keys.Action | |
| 50 | + | wantActive int | |
| 51 | + | wantFocus layout.PaneID | |
| 52 | + | }{ | |
| 53 | + | {name: "right enters target left edge", start: 0, action: keys.ActionFocusPaneRight, wantActive: 1, wantFocus: "pane-1"}, | |
| 54 | + | {name: "left enters target right edge", start: 1, action: keys.ActionFocusPaneLeft, wantActive: 0, wantFocus: "pane-2"}, | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | for _, tt := range tests { | |
| 58 | + | t.Run(tt.name, func(t *testing.T) { | |
| 59 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil) | |
| 60 | + | sess.mu.Lock() | |
| 61 | + | sess.active = tt.start | |
| 62 | + | target := sess.tabs[tt.wantActive] | |
| 63 | + | source := sess.tabs[tt.start] | |
| 64 | + | sess.mu.Unlock() | |
| 65 | + | target.mu.Lock() | |
| 66 | + | target.size = domain.Size{Cols: 41, Rows: 10} | |
| 67 | + | target.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"} | |
| 68 | + | target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10}) | |
| 69 | + | target.mu.Unlock() | |
| 70 | + | d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}}) | |
| 71 | + | ||
| 72 | + | daemonKeyHandler{d: d, ac: ac}.Action(tt.action) | |
| 73 | + | ||
| 74 | + | require.Equal(t, tt.wantActive, activeTabIndex(sess)) | |
| 75 | + | target.mu.Lock() | |
| 76 | + | require.Equal(t, tt.wantFocus, target.tree.Focus) | |
| 77 | + | target.mu.Unlock() | |
| 78 | + | require.NotSame(t, source, sess.activeTab()) | |
| 79 | + | }) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *testing.T) { | |
| 84 | + | d, alpha, ac, _ := newManualSessionWithPTYs(t, nil, nil) | |
| 85 | + | alpha.mu.Lock() | |
| 86 | + | alpha.name = "alpha" | |
| 87 | + | alpha.mu.Unlock() | |
| 88 | + | ||
| 89 | + | newSession := func(id, name string, active int, focus layout.PaneID) *session { | |
| 90 | + | tabs := []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10}), newTab(nil, domain.Size{Cols: 41, Rows: 10})} | |
| 91 | + | target := tabs[active] | |
| 92 | + | target.mu.Lock() | |
| 93 | + | target.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: focus} | |
| 94 | + | target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10}) | |
| 95 | + | target.mu.Unlock() | |
| 96 | + | return &session{id: domain.SessionID(id), name: name, ctx: t.Context(), cancel: func() {}, tabs: tabs, active: active} | |
| 97 | + | } | |
| 98 | + | charlie := newSession("live-charlie", "charlie", 1, "pane-2") | |
| 99 | + | echo := newSession("live-echo", "echo", 1, "pane-2") | |
| 100 | + | ||
| 101 | + | // Deliberately register the live sessions out of alphabetical order. The | |
| 102 | + | // stopped name sorts between alpha and charlie but must never be resumed. | |
| 103 | + | d.mu.Lock() | |
| 104 | + | delete(d.sessions, alpha.id) | |
| 105 | + | d.sessions[echo.id] = echo | |
| 106 | + | d.sessions[alpha.id] = alpha | |
| 107 | + | d.sessions[charlie.id] = charlie | |
| 108 | + | d.stopped["bravo"] = stoppedSession{name: "bravo", cwd: "/tmp/bravo"} | |
| 109 | + | d.mu.Unlock() | |
| 110 | + | d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}}) | |
| 111 | + | handler := daemonKeyHandler{d: d, ac: ac} | |
| 112 | + | ||
| 113 | + | moves := []struct { | |
| 114 | + | name string | |
| 115 | + | action keys.Action | |
| 116 | + | want *session | |
| 117 | + | }{ | |
| 118 | + | {name: "down to charlie", action: keys.ActionFocusPaneDown, want: charlie}, | |
| 119 | + | {name: "down to echo", action: keys.ActionFocusPaneDown, want: echo}, | |
| 120 | + | {name: "down wall", action: keys.ActionFocusPaneDown, want: echo}, | |
| 121 | + | {name: "up to charlie", action: keys.ActionFocusPaneUp, want: charlie}, | |
| 122 | + | {name: "up to alpha", action: keys.ActionFocusPaneUp, want: alpha}, | |
| 123 | + | {name: "up wall", action: keys.ActionFocusPaneUp, want: alpha}, | |
| 124 | + | } | |
| 125 | + | for _, move := range moves { | |
| 126 | + | t.Run(move.name, func(t *testing.T) { | |
| 127 | + | handler.Action(move.action) | |
| 128 | + | require.Same(t, move.want, ac.currentSession()) | |
| 129 | + | }) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | for _, target := range []*session{charlie, echo} { | |
| 133 | + | require.Equal(t, 1, activeTabIndex(target), "switch preserves the target active tab") | |
| 134 | + | target.tabs[1].mu.Lock() | |
| 135 | + | require.Equal(t, layout.PaneID("pane-2"), target.tabs[1].tree.Focus, "switch preserves the target pane focus") | |
| 136 | + | target.tabs[1].mu.Unlock() | |
| 137 | + | } | |
| 138 | + | d.mu.Lock() | |
| 139 | + | _, stopped := d.stopped["bravo"] | |
| 140 | + | var bravoLive bool | |
| 141 | + | for _, candidate := range d.sessions { | |
| 142 | + | candidate.mu.Lock() | |
| 143 | + | bravoLive = bravoLive || candidate.name == "bravo" | |
| 144 | + | candidate.mu.Unlock() | |
| 145 | + | } | |
| 146 | + | d.mu.Unlock() | |
| 147 | + | alpha.mu.Lock() | |
| 148 | + | attached := alpha.client | |
| 149 | + | alpha.mu.Unlock() | |
| 150 | + | require.True(t, stopped, "vertical overflow leaves the stopped session stopped") | |
| 151 | + | require.False(t, bravoLive, "the stopped session is absent from the live registry") | |
| 152 | + | require.Same(t, ac, attached, "the source session owns the genuinely attached client after returning") | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func TestKeyboardVerticalOverflowRefusesVisibleFloatingSource(t *testing.T) { | |
| 156 | + | d, alpha, ac, _ := newManualSessionWithPTYs(t, nil) | |
| 157 | + | alpha.mu.Lock() | |
| 158 | + | alpha.name = "alpha" | |
| 159 | + | alpha.mu.Unlock() | |
| 160 | + | installTestFloating(alpha.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true) | |
| 161 | + | charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | |
| 162 | + | d.mu.Lock() | |
| 163 | + | d.sessions[charlie.id] = charlie | |
| 164 | + | d.mu.Unlock() | |
| 165 | + | d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}}) | |
| 166 | + | ||
| 167 | + | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneDown) | |
| 168 | + | ||
| 169 | + | require.Same(t, alpha, ac.currentSession()) | |
| 170 | + | alpha.mu.Lock() | |
| 171 | + | require.Same(t, ac, alpha.client) | |
| 172 | + | alpha.mu.Unlock() | |
| 173 | + | charlie.mu.Lock() | |
| 174 | + | require.Nil(t, charlie.client) | |
| 175 | + | charlie.mu.Unlock() | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func TestVerticalOverflowRevalidatesFloatingSourceBeforeHandoff(t *testing.T) { | |
| 179 | + | d, alpha, ac, _ := newManualSessionWithPTYs(t, nil) | |
| 180 | + | alpha.mu.Lock() | |
| 181 | + | alpha.name = "alpha" | |
| 182 | + | alpha.mu.Unlock() | |
| 183 | + | charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | |
| 184 | + | d.mu.Lock() | |
| 185 | + | d.sessions[charlie.id] = charlie | |
| 186 | + | d.mu.Unlock() | |
| 187 | + | ||
| 188 | + | target, ok := d.prepareSessionOverflow(alpha, layout.Down, domain.NavConfig{OverflowSessions: true}) | |
| 189 | + | require.True(t, ok) | |
| 190 | + | installTestFloating(alpha.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true) | |
| 191 | + | ||
| 192 | + | require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, alpha.tabs[0], target), errNoNeighbor) | |
| 193 | + | require.Same(t, alpha, ac.currentSession()) | |
| 194 | + | alpha.mu.Lock() | |
| 195 | + | require.Same(t, ac, alpha.client) | |
| 196 | + | alpha.mu.Unlock() | |
| 197 | + | charlie.mu.Lock() | |
| 198 | + | require.Nil(t, charlie.client) | |
| 199 | + | charlie.mu.Unlock() | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func TestVerticalOverflowRejectsStaleSourceTab(t *testing.T) { | |
| 203 | + | d, alpha, ac, _ := newManualSessionWithPTYs(t, nil, nil) | |
| 204 | + | alpha.mu.Lock() | |
| 205 | + | alpha.name = "alpha" | |
| 206 | + | expectedSource := alpha.tabs[0] | |
| 207 | + | alpha.active = 1 | |
| 208 | + | alpha.mu.Unlock() | |
| 209 | + | charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | |
| 210 | + | d.mu.Lock() | |
| 211 | + | d.sessions[charlie.id] = charlie | |
| 212 | + | d.mu.Unlock() | |
| 213 | + | ||
| 214 | + | require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, expectedSource, picker.Target{Session: charlie.id, TabIndex: -1}), errNoNeighbor) | |
| 215 | + | require.Same(t, alpha, ac.currentSession()) | |
| 216 | + | alpha.mu.Lock() | |
| 217 | + | require.Same(t, ac, alpha.client) | |
| 218 | + | alpha.mu.Unlock() | |
| 219 | + | charlie.mu.Lock() | |
| 220 | + | require.Nil(t, charlie.client) | |
| 221 | + | charlie.mu.Unlock() | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | func TestOrdinarySessionSwitchDoesNotApplyOverflowGuard(t *testing.T) { | |
| 225 | + | d, alpha, ac, _ := newManualSessionWithPTYs(t, nil) | |
| 226 | + | installTestFloating(alpha.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true) | |
| 227 | + | charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | |
| 228 | + | d.mu.Lock() | |
| 229 | + | d.sessions[charlie.id] = charlie | |
| 230 | + | d.mu.Unlock() | |
| 231 | + | ||
| 232 | + | require.NoError(t, d.switchToTarget(alpha, ac, picker.Target{Session: charlie.id, TabIndex: -1})) | |
| 233 | + | require.Same(t, charlie, ac.currentSession()) | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | func TestVerticalOverflowTreatsDisplacedSourceClientAsNoNeighbor(t *testing.T) { | |
| 237 | + | d, alpha, ac, _ := newManualSessionWithPTYs(t, nil) | |
| 238 | + | alpha.mu.Lock() | |
| 239 | + | alpha.name = "alpha" | |
| 240 | + | alpha.mu.Unlock() | |
| 241 | + | charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | |
| 242 | + | d.mu.Lock() | |
| 243 | + | d.sessions[charlie.id] = charlie | |
| 244 | + | d.mu.Unlock() | |
| 245 | + | ||
| 246 | + | target, ok := d.prepareSessionOverflow(alpha, layout.Down, domain.NavConfig{OverflowSessions: true}) | |
| 247 | + | require.True(t, ok) | |
| 248 | + | replacement := &attachedClient{} | |
| 249 | + | alpha.mu.Lock() | |
| 250 | + | alpha.client = replacement | |
| 251 | + | alpha.mu.Unlock() | |
| 252 | + | ||
| 253 | + | require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, alpha.tabs[0], target), errNoNeighbor) | |
| 254 | + | require.Same(t, alpha, ac.currentSession()) | |
| 255 | + | alpha.mu.Lock() | |
| 256 | + | require.Same(t, replacement, alpha.client) | |
| 257 | + | alpha.mu.Unlock() | |
| 258 | + | charlie.mu.Lock() | |
| 259 | + | require.Nil(t, charlie.client) | |
| 260 | + | charlie.mu.Unlock() | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) { | |
| 264 | + | d, alpha, ac, _ := newManualSessionWithPTYs(t, nil) | |
| 265 | + | alpha.mu.Lock() | |
| 266 | + | alpha.name = "alpha" | |
| 267 | + | alpha.mu.Unlock() | |
| 268 | + | charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | |
| 269 | + | echo := &session{id: "live-echo", name: "echo", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | |
| 270 | + | d.mu.Lock() | |
| 271 | + | d.sessions[echo.id] = echo | |
| 272 | + | d.sessions[charlie.id] = charlie | |
| 273 | + | d.mu.Unlock() | |
| 274 | + | d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}}) | |
| 275 | + | ||
| 276 | + | start := make(chan struct{}) | |
| 277 | + | renameErrors := make(chan error, 40) | |
| 278 | + | var wg sync.WaitGroup | |
| 279 | + | wg.Go(func() { | |
| 280 | + | <-start | |
| 281 | + | for i := range 40 { | |
| 282 | + | name := "charlie" | |
| 283 | + | if i%2 == 0 { | |
| 284 | + | name = "delta" | |
| 285 | + | } | |
| 286 | + | if err := d.renameSession(charlie, name); err != nil { | |
| 287 | + | renameErrors <- err | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | }) | |
| 291 | + | ||
| 292 | + | close(start) | |
| 293 | + | handler := daemonKeyHandler{d: d, ac: ac} | |
| 294 | + | for range 20 { | |
| 295 | + | handler.Action(keys.ActionFocusPaneDown) | |
| 296 | + | handler.Action(keys.ActionFocusPaneUp) | |
| 297 | + | } | |
| 298 | + | wg.Wait() | |
| 299 | + | close(renameErrors) | |
| 300 | + | for err := range renameErrors { | |
| 301 | + | require.NoError(t, err) | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | current := ac.currentSession() | |
| 305 | + | owners := make([]*session, 0, 1) | |
| 306 | + | d.mu.Lock() | |
| 307 | + | for _, candidate := range d.sessions { | |
| 308 | + | candidate.mu.Lock() | |
| 309 | + | if candidate.client == ac { | |
| 310 | + | owners = append(owners, candidate) | |
| 311 | + | } | |
| 312 | + | candidate.mu.Unlock() | |
| 313 | + | } | |
| 314 | + | d.mu.Unlock() | |
| 315 | + | require.Len(t, owners, 1) | |
| 316 | + | require.Same(t, current, owners[0]) | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | func TestKeyboardHorizontalOverflowRespectsDefaultsWallsFailedEntryAndFloating(t *testing.T) { | |
| 320 | + | tests := []struct { | |
| 321 | + | name string | |
| 322 | + | setup func(d *Daemon, sess *session) | |
| 323 | + | dir keys.Action | |
| 324 | + | }{ | |
| 325 | + | {name: "defaults off", setup: func(_ *Daemon, _ *session) {}, dir: keys.ActionFocusPaneRight}, | |
| 326 | + | {name: "left wall", setup: func(d *Daemon, _ *session) { d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}}) }, dir: keys.ActionFocusPaneLeft}, | |
| 327 | + | {name: "failed target entry", setup: func(d *Daemon, sess *session) { | |
| 328 | + | d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}}) | |
| 329 | + | sess.tabs[1].mu.Lock() | |
| 330 | + | sess.tabs[1].tree = nil | |
| 331 | + | sess.tabs[1].mu.Unlock() | |
| 332 | + | }, dir: keys.ActionFocusPaneRight}, | |
| 333 | + | {name: "visible floating pane", setup: func(d *Daemon, sess *session) { | |
| 334 | + | d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}}) | |
| 335 | + | installTestFloating(sess.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true) | |
| 336 | + | }, dir: keys.ActionFocusPaneRight}, | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | for _, tt := range tests { | |
| 340 | + | t.Run(tt.name, func(t *testing.T) { | |
| 341 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil) | |
| 342 | + | tt.setup(d, sess) | |
| 343 | + | ||
| 344 | + | daemonKeyHandler{d: d, ac: ac}.Action(tt.dir) | |
| 345 | + | ||
| 346 | + | require.Equal(t, 0, activeTabIndex(sess)) | |
| 347 | + | }) | |
| 348 | + | } | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | func TestCommitTabOverflowRevalidatesTabPointerIdentities(t *testing.T) { | |
| 352 | + | tests := []struct { | |
| 353 | + | name string | |
| 354 | + | mutate func(sess *session, candidate tabOverflowCandidate) | |
| 355 | + | }{ | |
| 356 | + | {name: "source no longer active", mutate: func(sess *session, _ tabOverflowCandidate) { | |
| 357 | + | sess.mu.Lock() | |
| 358 | + | sess.active = 1 | |
| 359 | + | sess.mu.Unlock() | |
| 360 | + | }}, | |
| 361 | + | {name: "target entry replaced", mutate: func(sess *session, candidate tabOverflowCandidate) { | |
| 362 | + | sess.mu.Lock() | |
| 363 | + | sess.tabs[1] = newTab(nil, candidate.target.size) | |
| 364 | + | sess.mu.Unlock() | |
| 365 | + | }}, | |
| 366 | + | {name: "source gains visible floating pane", mutate: func(_ *session, candidate tabOverflowCandidate) { | |
| 367 | + | installTestFloating(candidate.source, newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true) | |
| 368 | + | }}, | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | for _, tt := range tests { | |
| 372 | + | t.Run(tt.name, func(t *testing.T) { | |
| 373 | + | d, sess, _, _ := newManualSessionWithPTYs(t, nil, nil) | |
| 374 | + | candidate, ok := d.prepareTabOverflow(sess, sess.tabs[0], layout.Right, domain.Rect{Width: 80, Height: 23}, 1) | |
| 375 | + | require.True(t, ok) | |
| 376 | + | tt.mutate(sess, candidate) | |
| 377 | + | activeBeforeCommit := activeTabIndex(sess) | |
| 378 | + | ||
| 379 | + | require.False(t, d.commitTabOverflow(sess, candidate)) | |
| 380 | + | require.Equal(t, activeBeforeCommit, activeTabIndex(sess)) | |
| 381 | + | }) | |
| 382 | + | } | |
| 383 | + | } | |
| @@ -0,0 +1,383 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "sync" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/domain" | |||
| 10 | + | "github.com/bnema/vev/internal/usecase/keys" | |||
| 11 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 12 | + | "github.com/bnema/vev/internal/usecase/picker" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestResolveOverflowObeysAxisConfigurationAndWalls(t *testing.T) { | |||
| 16 | + | tests := []struct { | |||
| 17 | + | name string | |||
| 18 | + | dir layout.Direction | |||
| 19 | + | cfg domain.NavConfig | |||
| 20 | + | position int | |||
| 21 | + | count int | |||
| 22 | + | want overflowStep | |||
| 23 | + | }{ | |||
| 24 | + | {name: "tabs disabled", dir: layout.Right, cfg: domain.NavConfig{}, position: 0, count: 2}, | |||
| 25 | + | {name: "sessions alone do not enable tabs", dir: layout.Right, cfg: domain.NavConfig{OverflowSessions: true}, position: 0, count: 2}, | |||
| 26 | + | {name: "right tab", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 2, want: overflowStep{kind: overflowTabs, delta: 1}}, | |||
| 27 | + | {name: "left tab", dir: layout.Left, cfg: domain.NavConfig{OverflowTabs: true}, position: 1, count: 2, want: overflowStep{kind: overflowTabs, delta: -1}}, | |||
| 28 | + | {name: "left wall", dir: layout.Left, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 2}, | |||
| 29 | + | {name: "right wall", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: 1, count: 2}, | |||
| 30 | + | {name: "up session", dir: layout.Up, cfg: domain.NavConfig{OverflowSessions: true}, position: 1, count: 2, want: overflowStep{kind: overflowSessions, delta: -1}}, | |||
| 31 | + | {name: "down session", dir: layout.Down, cfg: domain.NavConfig{OverflowSessions: true}, position: 0, count: 2, want: overflowStep{kind: overflowSessions, delta: 1}}, | |||
| 32 | + | {name: "tabs alone do not enable sessions", dir: layout.Down, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 2}, | |||
| 33 | + | {name: "single destination is a wall", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 1}, | |||
| 34 | + | {name: "negative position is out of range", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: -1, count: 2}, | |||
| 35 | + | {name: "position at count is out of range", dir: layout.Down, cfg: domain.NavConfig{OverflowSessions: true}, position: 2, count: 2}, | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | for _, tt := range tests { | |||
| 39 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 40 | + | require.Equal(t, tt.want, resolveOverflow(tt.dir, tt.cfg, tt.position, tt.count)) | |||
| 41 | + | }) | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func TestKeyboardHorizontalOverflowLandsOnFacingEdge(t *testing.T) { | |||
| 46 | + | tests := []struct { | |||
| 47 | + | name string | |||
| 48 | + | start int | |||
| 49 | + | action keys.Action | |||
| 50 | + | wantActive int | |||
| 51 | + | wantFocus layout.PaneID | |||
| 52 | + | }{ | |||
| 53 | + | {name: "right enters target left edge", start: 0, action: keys.ActionFocusPaneRight, wantActive: 1, wantFocus: "pane-1"}, | |||
| 54 | + | {name: "left enters target right edge", start: 1, action: keys.ActionFocusPaneLeft, wantActive: 0, wantFocus: "pane-2"}, | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | for _, tt := range tests { | |||
| 58 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 59 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil) | |||
| 60 | + | sess.mu.Lock() | |||
| 61 | + | sess.active = tt.start | |||
| 62 | + | target := sess.tabs[tt.wantActive] | |||
| 63 | + | source := sess.tabs[tt.start] | |||
| 64 | + | sess.mu.Unlock() | |||
| 65 | + | target.mu.Lock() | |||
| 66 | + | target.size = domain.Size{Cols: 41, Rows: 10} | |||
| 67 | + | target.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"} | |||
| 68 | + | target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10}) | |||
| 69 | + | target.mu.Unlock() | |||
| 70 | + | d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}}) | |||
| 71 | + | ||||
| 72 | + | daemonKeyHandler{d: d, ac: ac}.Action(tt.action) | |||
| 73 | + | ||||
| 74 | + | require.Equal(t, tt.wantActive, activeTabIndex(sess)) | |||
| 75 | + | target.mu.Lock() | |||
| 76 | + | require.Equal(t, tt.wantFocus, target.tree.Focus) | |||
| 77 | + | target.mu.Unlock() | |||
| 78 | + | require.NotSame(t, source, sess.activeTab()) | |||
| 79 | + | }) | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *testing.T) { | |||
| 84 | + | d, alpha, ac, _ := newManualSessionWithPTYs(t, nil, nil) | |||
| 85 | + | alpha.mu.Lock() | |||
| 86 | + | alpha.name = "alpha" | |||
| 87 | + | alpha.mu.Unlock() | |||
| 88 | + | ||||
| 89 | + | newSession := func(id, name string, active int, focus layout.PaneID) *session { | |||
| 90 | + | tabs := []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10}), newTab(nil, domain.Size{Cols: 41, Rows: 10})} | |||
| 91 | + | target := tabs[active] | |||
| 92 | + | target.mu.Lock() | |||
| 93 | + | target.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: focus} | |||
| 94 | + | target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10}) | |||
| 95 | + | target.mu.Unlock() | |||
| 96 | + | return &session{id: domain.SessionID(id), name: name, ctx: t.Context(), cancel: func() {}, tabs: tabs, active: active} | |||
| 97 | + | } | |||
| 98 | + | charlie := newSession("live-charlie", "charlie", 1, "pane-2") | |||
| 99 | + | echo := newSession("live-echo", "echo", 1, "pane-2") | |||
| 100 | + | ||||
| 101 | + | // Deliberately register the live sessions out of alphabetical order. The | |||
| 102 | + | // stopped name sorts between alpha and charlie but must never be resumed. | |||
| 103 | + | d.mu.Lock() | |||
| 104 | + | delete(d.sessions, alpha.id) | |||
| 105 | + | d.sessions[echo.id] = echo | |||
| 106 | + | d.sessions[alpha.id] = alpha | |||
| 107 | + | d.sessions[charlie.id] = charlie | |||
| 108 | + | d.stopped["bravo"] = stoppedSession{name: "bravo", cwd: "/tmp/bravo"} | |||
| 109 | + | d.mu.Unlock() | |||
| 110 | + | d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}}) | |||
| 111 | + | handler := daemonKeyHandler{d: d, ac: ac} | |||
| 112 | + | ||||
| 113 | + | moves := []struct { | |||
| 114 | + | name string | |||
| 115 | + | action keys.Action | |||
| 116 | + | want *session | |||
| 117 | + | }{ | |||
| 118 | + | {name: "down to charlie", action: keys.ActionFocusPaneDown, want: charlie}, | |||
| 119 | + | {name: "down to echo", action: keys.ActionFocusPaneDown, want: echo}, | |||
| 120 | + | {name: "down wall", action: keys.ActionFocusPaneDown, want: echo}, | |||
| 121 | + | {name: "up to charlie", action: keys.ActionFocusPaneUp, want: charlie}, | |||
| 122 | + | {name: "up to alpha", action: keys.ActionFocusPaneUp, want: alpha}, | |||
| 123 | + | {name: "up wall", action: keys.ActionFocusPaneUp, want: alpha}, | |||
| 124 | + | } | |||
| 125 | + | for _, move := range moves { | |||
| 126 | + | t.Run(move.name, func(t *testing.T) { | |||
| 127 | + | handler.Action(move.action) | |||
| 128 | + | require.Same(t, move.want, ac.currentSession()) | |||
| 129 | + | }) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | for _, target := range []*session{charlie, echo} { | |||
| 133 | + | require.Equal(t, 1, activeTabIndex(target), "switch preserves the target active tab") | |||
| 134 | + | target.tabs[1].mu.Lock() | |||
| 135 | + | require.Equal(t, layout.PaneID("pane-2"), target.tabs[1].tree.Focus, "switch preserves the target pane focus") | |||
| 136 | + | target.tabs[1].mu.Unlock() | |||
| 137 | + | } | |||
| 138 | + | d.mu.Lock() | |||
| 139 | + | _, stopped := d.stopped["bravo"] | |||
| 140 | + | var bravoLive bool | |||
| 141 | + | for _, candidate := range d.sessions { | |||
| 142 | + | candidate.mu.Lock() | |||
| 143 | + | bravoLive = bravoLive || candidate.name == "bravo" | |||
| 144 | + | candidate.mu.Unlock() | |||
| 145 | + | } | |||
| 146 | + | d.mu.Unlock() | |||
| 147 | + | alpha.mu.Lock() | |||
| 148 | + | attached := alpha.client | |||
| 149 | + | alpha.mu.Unlock() | |||
| 150 | + | require.True(t, stopped, "vertical overflow leaves the stopped session stopped") | |||
| 151 | + | require.False(t, bravoLive, "the stopped session is absent from the live registry") | |||
| 152 | + | require.Same(t, ac, attached, "the source session owns the genuinely attached client after returning") | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | func TestKeyboardVerticalOverflowRefusesVisibleFloatingSource(t *testing.T) { | |||
| 156 | + | d, alpha, ac, _ := newManualSessionWithPTYs(t, nil) | |||
| 157 | + | alpha.mu.Lock() | |||
| 158 | + | alpha.name = "alpha" | |||
| 159 | + | alpha.mu.Unlock() | |||
| 160 | + | installTestFloating(alpha.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true) | |||
| 161 | + | charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | |||
| 162 | + | d.mu.Lock() | |||
| 163 | + | d.sessions[charlie.id] = charlie | |||
| 164 | + | d.mu.Unlock() | |||
| 165 | + | d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}}) | |||
| 166 | + | ||||
| 167 | + | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneDown) | |||
| 168 | + | ||||
| 169 | + | require.Same(t, alpha, ac.currentSession()) | |||
| 170 | + | alpha.mu.Lock() | |||
| 171 | + | require.Same(t, ac, alpha.client) | |||
| 172 | + | alpha.mu.Unlock() | |||
| 173 | + | charlie.mu.Lock() | |||
| 174 | + | require.Nil(t, charlie.client) | |||
| 175 | + | charlie.mu.Unlock() | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func TestVerticalOverflowRevalidatesFloatingSourceBeforeHandoff(t *testing.T) { | |||
| 179 | + | d, alpha, ac, _ := newManualSessionWithPTYs(t, nil) | |||
| 180 | + | alpha.mu.Lock() | |||
| 181 | + | alpha.name = "alpha" | |||
| 182 | + | alpha.mu.Unlock() | |||
| 183 | + | charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | |||
| 184 | + | d.mu.Lock() | |||
| 185 | + | d.sessions[charlie.id] = charlie | |||
| 186 | + | d.mu.Unlock() | |||
| 187 | + | ||||
| 188 | + | target, ok := d.prepareSessionOverflow(alpha, layout.Down, domain.NavConfig{OverflowSessions: true}) | |||
| 189 | + | require.True(t, ok) | |||
| 190 | + | installTestFloating(alpha.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true) | |||
| 191 | + | ||||
| 192 | + | require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, alpha.tabs[0], target), errNoNeighbor) | |||
| 193 | + | require.Same(t, alpha, ac.currentSession()) | |||
| 194 | + | alpha.mu.Lock() | |||
| 195 | + | require.Same(t, ac, alpha.client) | |||
| 196 | + | alpha.mu.Unlock() | |||
| 197 | + | charlie.mu.Lock() | |||
| 198 | + | require.Nil(t, charlie.client) | |||
| 199 | + | charlie.mu.Unlock() | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | func TestVerticalOverflowRejectsStaleSourceTab(t *testing.T) { | |||
| 203 | + | d, alpha, ac, _ := newManualSessionWithPTYs(t, nil, nil) | |||
| 204 | + | alpha.mu.Lock() | |||
| 205 | + | alpha.name = "alpha" | |||
| 206 | + | expectedSource := alpha.tabs[0] | |||
| 207 | + | alpha.active = 1 | |||
| 208 | + | alpha.mu.Unlock() | |||
| 209 | + | charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | |||
| 210 | + | d.mu.Lock() | |||
| 211 | + | d.sessions[charlie.id] = charlie | |||
| 212 | + | d.mu.Unlock() | |||
| 213 | + | ||||
| 214 | + | require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, expectedSource, picker.Target{Session: charlie.id, TabIndex: -1}), errNoNeighbor) | |||
| 215 | + | require.Same(t, alpha, ac.currentSession()) | |||
| 216 | + | alpha.mu.Lock() | |||
| 217 | + | require.Same(t, ac, alpha.client) | |||
| 218 | + | alpha.mu.Unlock() | |||
| 219 | + | charlie.mu.Lock() | |||
| 220 | + | require.Nil(t, charlie.client) | |||
| 221 | + | charlie.mu.Unlock() | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | func TestOrdinarySessionSwitchDoesNotApplyOverflowGuard(t *testing.T) { | |||
| 225 | + | d, alpha, ac, _ := newManualSessionWithPTYs(t, nil) | |||
| 226 | + | installTestFloating(alpha.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true) | |||
| 227 | + | charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | |||
| 228 | + | d.mu.Lock() | |||
| 229 | + | d.sessions[charlie.id] = charlie | |||
| 230 | + | d.mu.Unlock() | |||
| 231 | + | ||||
| 232 | + | require.NoError(t, d.switchToTarget(alpha, ac, picker.Target{Session: charlie.id, TabIndex: -1})) | |||
| 233 | + | require.Same(t, charlie, ac.currentSession()) | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | func TestVerticalOverflowTreatsDisplacedSourceClientAsNoNeighbor(t *testing.T) { | |||
| 237 | + | d, alpha, ac, _ := newManualSessionWithPTYs(t, nil) | |||
| 238 | + | alpha.mu.Lock() | |||
| 239 | + | alpha.name = "alpha" | |||
| 240 | + | alpha.mu.Unlock() | |||
| 241 | + | charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | |||
| 242 | + | d.mu.Lock() | |||
| 243 | + | d.sessions[charlie.id] = charlie | |||
| 244 | + | d.mu.Unlock() | |||
| 245 | + | ||||
| 246 | + | target, ok := d.prepareSessionOverflow(alpha, layout.Down, domain.NavConfig{OverflowSessions: true}) | |||
| 247 | + | require.True(t, ok) | |||
| 248 | + | replacement := &attachedClient{} | |||
| 249 | + | alpha.mu.Lock() | |||
| 250 | + | alpha.client = replacement | |||
| 251 | + | alpha.mu.Unlock() | |||
| 252 | + | ||||
| 253 | + | require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, alpha.tabs[0], target), errNoNeighbor) | |||
| 254 | + | require.Same(t, alpha, ac.currentSession()) | |||
| 255 | + | alpha.mu.Lock() | |||
| 256 | + | require.Same(t, replacement, alpha.client) | |||
| 257 | + | alpha.mu.Unlock() | |||
| 258 | + | charlie.mu.Lock() | |||
| 259 | + | require.Nil(t, charlie.client) | |||
| 260 | + | charlie.mu.Unlock() | |||
| 261 | + | } | |||
| 262 | + | ||||
| 263 | + | func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) { | |||
| 264 | + | d, alpha, ac, _ := newManualSessionWithPTYs(t, nil) | |||
| 265 | + | alpha.mu.Lock() | |||
| 266 | + | alpha.name = "alpha" | |||
| 267 | + | alpha.mu.Unlock() | |||
| 268 | + | charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | |||
| 269 | + | echo := &session{id: "live-echo", name: "echo", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | |||
| 270 | + | d.mu.Lock() | |||
| 271 | + | d.sessions[echo.id] = echo | |||
| 272 | + | d.sessions[charlie.id] = charlie | |||
| 273 | + | d.mu.Unlock() | |||
| 274 | + | d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}}) | |||
| 275 | + | ||||
| 276 | + | start := make(chan struct{}) | |||
| 277 | + | renameErrors := make(chan error, 40) | |||
| 278 | + | var wg sync.WaitGroup | |||
| 279 | + | wg.Go(func() { | |||
| 280 | + | <-start | |||
| 281 | + | for i := range 40 { | |||
| 282 | + | name := "charlie" | |||
| 283 | + | if i%2 == 0 { | |||
| 284 | + | name = "delta" | |||
| 285 | + | } | |||
| 286 | + | if err := d.renameSession(charlie, name); err != nil { | |||
| 287 | + | renameErrors <- err | |||
| 288 | + | } | |||
| 289 | + | } | |||
| 290 | + | }) | |||
| 291 | + | ||||
| 292 | + | close(start) | |||
| 293 | + | handler := daemonKeyHandler{d: d, ac: ac} | |||
| 294 | + | for range 20 { | |||
| 295 | + | handler.Action(keys.ActionFocusPaneDown) | |||
| 296 | + | handler.Action(keys.ActionFocusPaneUp) | |||
| 297 | + | } | |||
| 298 | + | wg.Wait() | |||
| 299 | + | close(renameErrors) | |||
| 300 | + | for err := range renameErrors { | |||
| 301 | + | require.NoError(t, err) | |||
| 302 | + | } | |||
| 303 | + | ||||
| 304 | + | current := ac.currentSession() | |||
| 305 | + | owners := make([]*session, 0, 1) | |||
| 306 | + | d.mu.Lock() | |||
| 307 | + | for _, candidate := range d.sessions { | |||
| 308 | + | candidate.mu.Lock() | |||
| 309 | + | if candidate.client == ac { | |||
| 310 | + | owners = append(owners, candidate) | |||
| 311 | + | } | |||
| 312 | + | candidate.mu.Unlock() | |||
| 313 | + | } | |||
| 314 | + | d.mu.Unlock() | |||
| 315 | + | require.Len(t, owners, 1) | |||
| 316 | + | require.Same(t, current, owners[0]) | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | func TestKeyboardHorizontalOverflowRespectsDefaultsWallsFailedEntryAndFloating(t *testing.T) { | |||
| 320 | + | tests := []struct { | |||
| 321 | + | name string | |||
| 322 | + | setup func(d *Daemon, sess *session) | |||
| 323 | + | dir keys.Action | |||
| 324 | + | }{ | |||
| 325 | + | {name: "defaults off", setup: func(_ *Daemon, _ *session) {}, dir: keys.ActionFocusPaneRight}, | |||
| 326 | + | {name: "left wall", setup: func(d *Daemon, _ *session) { d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}}) }, dir: keys.ActionFocusPaneLeft}, | |||
| 327 | + | {name: "failed target entry", setup: func(d *Daemon, sess *session) { | |||
| 328 | + | d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}}) | |||
| 329 | + | sess.tabs[1].mu.Lock() | |||
| 330 | + | sess.tabs[1].tree = nil | |||
| 331 | + | sess.tabs[1].mu.Unlock() | |||
| 332 | + | }, dir: keys.ActionFocusPaneRight}, | |||
| 333 | + | {name: "visible floating pane", setup: func(d *Daemon, sess *session) { | |||
| 334 | + | d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}}) | |||
| 335 | + | installTestFloating(sess.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true) | |||
| 336 | + | }, dir: keys.ActionFocusPaneRight}, | |||
| 337 | + | } | |||
| 338 | + | ||||
| 339 | + | for _, tt := range tests { | |||
| 340 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 341 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil) | |||
| 342 | + | tt.setup(d, sess) | |||
| 343 | + | ||||
| 344 | + | daemonKeyHandler{d: d, ac: ac}.Action(tt.dir) | |||
| 345 | + | ||||
| 346 | + | require.Equal(t, 0, activeTabIndex(sess)) | |||
| 347 | + | }) | |||
| 348 | + | } | |||
| 349 | + | } | |||
| 350 | + | ||||
| 351 | + | func TestCommitTabOverflowRevalidatesTabPointerIdentities(t *testing.T) { | |||
| 352 | + | tests := []struct { | |||
| 353 | + | name string | |||
| 354 | + | mutate func(sess *session, candidate tabOverflowCandidate) | |||
| 355 | + | }{ | |||
| 356 | + | {name: "source no longer active", mutate: func(sess *session, _ tabOverflowCandidate) { | |||
| 357 | + | sess.mu.Lock() | |||
| 358 | + | sess.active = 1 | |||
| 359 | + | sess.mu.Unlock() | |||
| 360 | + | }}, | |||
| 361 | + | {name: "target entry replaced", mutate: func(sess *session, candidate tabOverflowCandidate) { | |||
| 362 | + | sess.mu.Lock() | |||
| 363 | + | sess.tabs[1] = newTab(nil, candidate.target.size) | |||
| 364 | + | sess.mu.Unlock() | |||
| 365 | + | }}, | |||
| 366 | + | {name: "source gains visible floating pane", mutate: func(_ *session, candidate tabOverflowCandidate) { | |||
| 367 | + | installTestFloating(candidate.source, newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true) | |||
| 368 | + | }}, | |||
| 369 | + | } | |||
| 370 | + | ||||
| 371 | + | for _, tt := range tests { | |||
| 372 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 373 | + | d, sess, _, _ := newManualSessionWithPTYs(t, nil, nil) | |||
| 374 | + | candidate, ok := d.prepareTabOverflow(sess, sess.tabs[0], layout.Right, domain.Rect{Width: 80, Height: 23}, 1) | |||
| 375 | + | require.True(t, ok) | |||
| 376 | + | tt.mutate(sess, candidate) | |||
| 377 | + | activeBeforeCommit := activeTabIndex(sess) | |||
| 378 | + | ||||
| 379 | + | require.False(t, d.commitTabOverflow(sess, candidate)) | |||
| 380 | + | require.Equal(t, activeBeforeCommit, activeTabIndex(sess)) | |||
| 381 | + | }) | |||
| 382 | + | } | |||
| 383 | + | } | |||
M internal/usecase/daemon/pane_actions.go +61 -19
| @@ -309,35 +309,77 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio | |||
| 309 | 309 | if target.pane != nil { | |
| 310 | 310 | oldFocus = target.pane.id | |
| 311 | 311 | } | |
| 312 | - | err := d.focusDirAt(sess, target.tab, target.pane, dir) | |
| 313 | - | if err == nil && ac != nil { | |
| 314 | - | target.tab.mu.Lock() | |
| 315 | - | newFocus := target.tab.tree.Focus | |
| 316 | - | pl, hasPlacement := focusedPlacementLocked(target.tab) | |
| 317 | - | target.tab.mu.Unlock() | |
| 318 | - | if newFocus != oldFocus { | |
| 319 | - | d.exitCopyMode(ac) | |
| 320 | - | if hasPlacement && pl.InStack { | |
| 321 | - | d.refreshPaneTitleOnFocus(sess, newFocus) | |
| 322 | - | } | |
| 312 | + | span, err := d.focusDirAt(sess, target.tab, target.pane, dir) | |
| 313 | + | if err == nil { | |
| 314 | + | if ac != nil { | |
| 315 | + | d.finishPaneFocusForClient(sess, ac, target.tab, oldFocus, "pane_actions.go") | |
| 323 | 316 | } | |
| 324 | - | d.invalidateRender(sess, ac, true, "pane_actions.go") | |
| 317 | + | return nil | |
| 325 | 318 | } | |
| 326 | - | return err | |
| 319 | + | if !errors.Is(err, errNoNeighbor) || target.tab == nil { | |
| 320 | + | return err | |
| 321 | + | } | |
| 322 | + | if !overflowSourceEligible(sess, target.tab) { | |
| 323 | + | return errNoNeighbor | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | cfg := d.currentNavConfig() | |
| 327 | + | if sessionTarget, ok := d.prepareSessionOverflow(sess, dir, cfg); ok { | |
| 328 | + | if ac == nil { | |
| 329 | + | return errNoNeighbor | |
| 330 | + | } | |
| 331 | + | return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget) | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | sess.mu.Lock() | |
| 335 | + | position, count := sess.active, len(sess.tabs) | |
| 336 | + | sess.mu.Unlock() | |
| 337 | + | step := resolveOverflow(dir, cfg, position, count) | |
| 338 | + | if step.kind != overflowTabs { | |
| 339 | + | return err | |
| 340 | + | } | |
| 341 | + | candidate, ok := d.prepareTabOverflow(sess, target.tab, dir, span, step.delta) | |
| 342 | + | if !ok || !d.commitTabOverflow(sess, candidate) { | |
| 343 | + | return errNoNeighbor | |
| 344 | + | } | |
| 345 | + | d.activateTab(sess, candidate.target) | |
| 346 | + | if ac != nil { | |
| 347 | + | d.finishPaneFocusForClient(sess, ac, candidate.target, candidate.targetOldFocus, "pane_actions.go") | |
| 348 | + | } | |
| 349 | + | return nil | |
| 327 | 350 | } | |
| 328 | 351 | ||
| 329 | - | func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Direction) error { | |
| 352 | + | // finishPaneFocusForClient applies the attachment lifecycle shared by every | |
| 353 | + | // successful directional focus move. Keep copy-mode exit before the optional | |
| 354 | + | // stack title refresh, and both before render invalidation. | |
| 355 | + | func (d *Daemon) finishPaneFocusForClient(sess *session, ac *attachedClient, tb *tab, oldFocus layout.PaneID, producer string) { | |
| 356 | + | tb.mu.Lock() | |
| 357 | + | newFocus := tb.tree.Focus | |
| 358 | + | pl, hasPlacement := focusedPlacementLocked(tb) | |
| 359 | + | tb.mu.Unlock() | |
| 360 | + | if newFocus != oldFocus { | |
| 361 | + | d.exitCopyMode(ac) | |
| 362 | + | if hasPlacement && pl.InStack { | |
| 363 | + | d.refreshPaneTitleOnFocus(sess, newFocus) | |
| 364 | + | } | |
| 365 | + | } | |
| 366 | + | d.invalidateRender(sess, ac, true, producer) | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Direction) (domain.Rect, error) { | |
| 330 | 370 | if tb == nil || target == nil { | |
| 331 | - | return layout.ErrNotFound | |
| 371 | + | return domain.Rect{}, layout.ErrNotFound | |
| 332 | 372 | } | |
| 333 | 373 | tb.mu.Lock() | |
| 334 | 374 | if tb.tree == nil || tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) { | |
| 335 | 375 | tb.mu.Unlock() | |
| 336 | - | return layout.ErrNotFound | |
| 376 | + | return domain.Rect{}, layout.ErrNotFound | |
| 337 | 377 | } | |
| 338 | 378 | oldFocus := target.id | |
| 339 | 379 | tb.tree.Focus = oldFocus | |
| 340 | - | err := tb.tree.FocusDir(dir, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |
| 380 | + | area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | |
| 381 | + | span, _ := tb.tree.FocusSpan(area) | |
| 382 | + | err := tb.tree.FocusDir(dir, area) | |
| 341 | 383 | newFocus := tb.tree.Focus | |
| 342 | 384 | if err == nil { | |
| 343 | 385 | d.applyLayoutLocked(tb) | |
| @@ -347,7 +389,7 @@ func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Dir | |||
| 347 | 389 | markSnapshotDirty(sess) | |
| 348 | 390 | } | |
| 349 | 391 | if errors.Is(err, layout.ErrNoPane) { | |
| 350 | - | return errNoNeighbor | |
| 392 | + | return span, errNoNeighbor | |
| 351 | 393 | } | |
| 352 | - | return err | |
| 394 | + | return span, err | |
| 353 | 395 | } | |
| @@ -309,35 +309,77 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio | |||||
| 309 | if target.pane != nil { | 309 | if target.pane != nil { | ||
| 310 | oldFocus = target.pane.id | 310 | oldFocus = target.pane.id | ||
| 311 | } | 311 | } | ||
| 312 | - | err := d.focusDirAt(sess, target.tab, target.pane, dir) | 312 | + | span, err := d.focusDirAt(sess, target.tab, target.pane, dir) |
| 313 | - | if err == nil && ac != nil { | 313 | + | if err == nil { |
| 314 | - | target.tab.mu.Lock() | 314 | + | if ac != nil { |
| 315 | - | newFocus := target.tab.tree.Focus | 315 | + | d.finishPaneFocusForClient(sess, ac, target.tab, oldFocus, "pane_actions.go") |
| 316 | - | pl, hasPlacement := focusedPlacementLocked(target.tab) | |||
| 317 | - | target.tab.mu.Unlock() | |||
| 318 | - | if newFocus != oldFocus { | |||
| 319 | - | d.exitCopyMode(ac) | |||
| 320 | - | if hasPlacement && pl.InStack { | |||
| 321 | - | d.refreshPaneTitleOnFocus(sess, newFocus) | |||
| 322 | - | } | |||
| 323 | } | 316 | } | ||
| 324 | - | d.invalidateRender(sess, ac, true, "pane_actions.go") | 317 | + | return nil |
| 325 | } | 318 | } | ||
| 326 | - | return err | 319 | + | if !errors.Is(err, errNoNeighbor) || target.tab == nil { |
| 320 | + | return err | |||
| 321 | + | } | |||
| 322 | + | if !overflowSourceEligible(sess, target.tab) { | |||
| 323 | + | return errNoNeighbor | |||
| 324 | + | } | |||
| 325 | + | ||||
| 326 | + | cfg := d.currentNavConfig() | |||
| 327 | + | if sessionTarget, ok := d.prepareSessionOverflow(sess, dir, cfg); ok { | |||
| 328 | + | if ac == nil { | |||
| 329 | + | return errNoNeighbor | |||
| 330 | + | } | |||
| 331 | + | return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget) | |||
| 332 | + | } | |||
| 333 | + | ||||
| 334 | + | sess.mu.Lock() | |||
| 335 | + | position, count := sess.active, len(sess.tabs) | |||
| 336 | + | sess.mu.Unlock() | |||
| 337 | + | step := resolveOverflow(dir, cfg, position, count) | |||
| 338 | + | if step.kind != overflowTabs { | |||
| 339 | + | return err | |||
| 340 | + | } | |||
| 341 | + | candidate, ok := d.prepareTabOverflow(sess, target.tab, dir, span, step.delta) | |||
| 342 | + | if !ok || !d.commitTabOverflow(sess, candidate) { | |||
| 343 | + | return errNoNeighbor | |||
| 344 | + | } | |||
| 345 | + | d.activateTab(sess, candidate.target) | |||
| 346 | + | if ac != nil { | |||
| 347 | + | d.finishPaneFocusForClient(sess, ac, candidate.target, candidate.targetOldFocus, "pane_actions.go") | |||
| 348 | + | } | |||
| 349 | + | return nil | |||
| 327 | } | 350 | } | ||
| 328 | 351 | ||||
| 329 | - | func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Direction) error { | 352 | + | // finishPaneFocusForClient applies the attachment lifecycle shared by every |
| 353 | + | // successful directional focus move. Keep copy-mode exit before the optional | |||
| 354 | + | // stack title refresh, and both before render invalidation. | |||
| 355 | + | func (d *Daemon) finishPaneFocusForClient(sess *session, ac *attachedClient, tb *tab, oldFocus layout.PaneID, producer string) { | |||
| 356 | + | tb.mu.Lock() | |||
| 357 | + | newFocus := tb.tree.Focus | |||
| 358 | + | pl, hasPlacement := focusedPlacementLocked(tb) | |||
| 359 | + | tb.mu.Unlock() | |||
| 360 | + | if newFocus != oldFocus { | |||
| 361 | + | d.exitCopyMode(ac) | |||
| 362 | + | if hasPlacement && pl.InStack { | |||
| 363 | + | d.refreshPaneTitleOnFocus(sess, newFocus) | |||
| 364 | + | } | |||
| 365 | + | } | |||
| 366 | + | d.invalidateRender(sess, ac, true, producer) | |||
| 367 | + | } | |||
| 368 | + | ||||
| 369 | + | func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Direction) (domain.Rect, error) { | |||
| 330 | if tb == nil || target == nil { | 370 | if tb == nil || target == nil { | ||
| 331 | - | return layout.ErrNotFound | 371 | + | return domain.Rect{}, layout.ErrNotFound |
| 332 | } | 372 | } | ||
| 333 | tb.mu.Lock() | 373 | tb.mu.Lock() | ||
| 334 | if tb.tree == nil || tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) { | 374 | if tb.tree == nil || tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) { | ||
| 335 | tb.mu.Unlock() | 375 | tb.mu.Unlock() | ||
| 336 | - | return layout.ErrNotFound | 376 | + | return domain.Rect{}, layout.ErrNotFound |
| 337 | } | 377 | } | ||
| 338 | oldFocus := target.id | 378 | oldFocus := target.id | ||
| 339 | tb.tree.Focus = oldFocus | 379 | tb.tree.Focus = oldFocus | ||
| 340 | - | err := tb.tree.FocusDir(dir, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | 380 | + | area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} |
| 381 | + | span, _ := tb.tree.FocusSpan(area) | |||
| 382 | + | err := tb.tree.FocusDir(dir, area) | |||
| 341 | newFocus := tb.tree.Focus | 383 | newFocus := tb.tree.Focus | ||
| 342 | if err == nil { | 384 | if err == nil { | ||
| 343 | d.applyLayoutLocked(tb) | 385 | d.applyLayoutLocked(tb) | ||
| @@ -347,7 +389,7 @@ func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Dir | |||||
| 347 | markSnapshotDirty(sess) | 389 | markSnapshotDirty(sess) | ||
| 348 | } | 390 | } | ||
| 349 | if errors.Is(err, layout.ErrNoPane) { | 391 | if errors.Is(err, layout.ErrNoPane) { | ||
| 350 | - | return errNoNeighbor | 392 | + | return span, errNoNeighbor |
| 351 | } | 393 | } | ||
| 352 | - | return err | 394 | + | return span, err |
| 353 | } | 395 | } | ||
M internal/usecase/daemon/pane_actions_test.go +30 -0
| @@ -152,6 +152,36 @@ func TestResizeReflowsAllPanes(t *testing.T) { | |||
| 152 | 152 | require.Equal(t, 29, tb.panes["pane-2"].screen.Frame.Width) | |
| 153 | 153 | } | |
| 154 | 154 | ||
| 155 | + | func TestFocusDirAtReturnsDepartingSpanAndMapsNoNeighbor(t *testing.T) { | |
| 156 | + | tests := []struct { | |
| 157 | + | name string | |
| 158 | + | direction layout.Direction | |
| 159 | + | wantErr error | |
| 160 | + | wantFocus layout.PaneID | |
| 161 | + | }{ | |
| 162 | + | {name: "moves right", direction: layout.Right, wantFocus: "pane-2"}, | |
| 163 | + | {name: "maps missing left neighbor", direction: layout.Left, wantErr: errNoNeighbor, wantFocus: "pane-1"}, | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | for _, tt := range tests { | |
| 167 | + | t.Run(tt.name, func(t *testing.T) { | |
| 168 | + | d, sess, _, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |
| 169 | + | tb := sess.activeTab() | |
| 170 | + | target := tb.panes["pane-1"] | |
| 171 | + | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"} | |
| 172 | + | tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10} | |
| 173 | + | tb.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10}) | |
| 174 | + | tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10} | |
| 175 | + | ||
| 176 | + | span, err := d.focusDirAt(sess, tb, target, tt.direction) | |
| 177 | + | ||
| 178 | + | require.ErrorIs(t, err, tt.wantErr) | |
| 179 | + | require.Equal(t, domain.Rect{Width: 20, Height: 10}, span) | |
| 180 | + | require.Equal(t, tt.wantFocus, tb.tree.Focus) | |
| 181 | + | }) | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | ||
| 155 | 185 | func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) { | |
| 156 | 186 | d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |
| 157 | 187 | tb := sess.activeTab() | |
| @@ -152,6 +152,36 @@ func TestResizeReflowsAllPanes(t *testing.T) { | |||||
| 152 | require.Equal(t, 29, tb.panes["pane-2"].screen.Frame.Width) | 152 | require.Equal(t, 29, tb.panes["pane-2"].screen.Frame.Width) | ||
| 153 | } | 153 | } | ||
| 154 | 154 | ||||
| 155 | + | func TestFocusDirAtReturnsDepartingSpanAndMapsNoNeighbor(t *testing.T) { | |||
| 156 | + | tests := []struct { | |||
| 157 | + | name string | |||
| 158 | + | direction layout.Direction | |||
| 159 | + | wantErr error | |||
| 160 | + | wantFocus layout.PaneID | |||
| 161 | + | }{ | |||
| 162 | + | {name: "moves right", direction: layout.Right, wantFocus: "pane-2"}, | |||
| 163 | + | {name: "maps missing left neighbor", direction: layout.Left, wantErr: errNoNeighbor, wantFocus: "pane-1"}, | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | for _, tt := range tests { | |||
| 167 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 168 | + | d, sess, _, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |||
| 169 | + | tb := sess.activeTab() | |||
| 170 | + | target := tb.panes["pane-1"] | |||
| 171 | + | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"} | |||
| 172 | + | tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10} | |||
| 173 | + | tb.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10}) | |||
| 174 | + | tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10} | |||
| 175 | + | ||||
| 176 | + | span, err := d.focusDirAt(sess, tb, target, tt.direction) | |||
| 177 | + | ||||
| 178 | + | require.ErrorIs(t, err, tt.wantErr) | |||
| 179 | + | require.Equal(t, domain.Rect{Width: 20, Height: 10}, span) | |||
| 180 | + | require.Equal(t, tt.wantFocus, tb.tree.Focus) | |||
| 181 | + | }) | |||
| 182 | + | } | |||
| 183 | + | } | |||
| 184 | + | ||||
| 155 | func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) { | 185 | func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) { | ||
| 156 | d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | 186 | d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | ||
| 157 | tb := sess.activeTab() | 187 | tb := sess.activeTab() | ||
M internal/usecase/daemon/picker.go +39 -4
| @@ -341,10 +341,23 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session { | |||
| 341 | 341 | return d.sessions[id] | |
| 342 | 342 | } | |
| 343 | 343 | ||
| 344 | + | // sessionHandoffGuard optionally constrains an active-session handoff to the | |
| 345 | + | // tab that initiated navigation. Its zero value preserves ordinary picker and | |
| 346 | + | // command switching behavior. | |
| 347 | + | type sessionHandoffGuard struct { | |
| 348 | + | expectedSource *tab | |
| 349 | + | } | |
| 350 | + | ||
| 344 | 351 | // switchToTarget resolves named lifecycle targets and commits their transition | |
| 345 | 352 | // while d.mu is held. A named palette result is allowed to cross an | |
| 346 | 353 | // active/stopped transition, but it never follows a same-name replacement. | |
| 347 | 354 | func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker.Target) error { | |
| 355 | + | return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{}) | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | // switchToTargetGuarded is the navigation-only variant whose guard is checked | |
| 359 | + | // as part of the source ownership transfer. | |
| 360 | + | func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error { | |
| 348 | 361 | d.mu.Lock() | |
| 349 | 362 | var ( | |
| 350 | 363 | targetSess *session | |
| @@ -363,15 +376,18 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker | |||
| 363 | 376 | // locked name/lifecycle resolver chose this active incarnation. | |
| 364 | 377 | resolvedTarget := target | |
| 365 | 378 | resolvedTarget.Session = active.id | |
| 366 | - | targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget) | |
| 379 | + | targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget, guard) | |
| 367 | 380 | } | |
| 368 | 381 | } | |
| 369 | 382 | } else if active := d.sessions[target.Session]; active != nil { | |
| 370 | - | targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, target) | |
| 383 | + | targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, target, guard) | |
| 371 | 384 | } | |
| 372 | 385 | d.mu.Unlock() | |
| 373 | 386 | ||
| 374 | 387 | if !switched { | |
| 388 | + | if guard.expectedSource != nil { | |
| 389 | + | return errNoNeighbor | |
| 390 | + | } | |
| 375 | 391 | d.invalidateRender(from, ac, true, "picker.go") | |
| 376 | 392 | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause) | |
| 377 | 393 | } | |
| @@ -413,7 +429,7 @@ func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*sessi | |||
| 413 | 429 | ||
| 414 | 430 | // switchToActiveTargetLocked commits an active target handoff. Caller holds | |
| 415 | 431 | // d.mu and releases it before completing render cleanup or first paint. | |
| 416 | - | func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target) (*session, *attachedClient, []renderLifecycleCleanup, bool) { | |
| 432 | + | func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target, guard sessionHandoffGuard) (*session, *attachedClient, []renderLifecycleCleanup, bool) { | |
| 417 | 433 | // The caller already holds d.mu. Keep handoff ownership atomic with global | |
| 418 | 434 | // routing so it cannot select an attachment midway between sessions. | |
| 419 | 435 | d.notices.routingMu.Lock() | |
| @@ -443,10 +459,29 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t | |||
| 443 | 459 | targetSess.mu.Unlock() | |
| 444 | 460 | return nil, nil, nil, false | |
| 445 | 461 | } | |
| 462 | + | var guardedSource *tab | |
| 463 | + | if guard.expectedSource != nil { | |
| 464 | + | if from.active < 0 || from.active >= len(from.tabs) || from.tabs[from.active] != guard.expectedSource { | |
| 465 | + | from.mu.Unlock() | |
| 466 | + | targetSess.mu.Unlock() | |
| 467 | + | return nil, nil, nil, false | |
| 468 | + | } | |
| 469 | + | guardedSource = guard.expectedSource | |
| 470 | + | guardedSource.mu.Lock() | |
| 471 | + | if guardedSource.floating.state == floatingVisible { | |
| 472 | + | guardedSource.mu.Unlock() | |
| 473 | + | from.mu.Unlock() | |
| 474 | + | targetSess.mu.Unlock() | |
| 475 | + | return nil, nil, nil, false | |
| 476 | + | } | |
| 477 | + | } | |
| 446 | 478 | term := from.terminal | |
| 447 | 479 | env := copyEnvironment(from.env) | |
| 448 | 480 | from.client = nil | |
| 449 | 481 | ac.setSession(nil) | |
| 482 | + | if guardedSource != nil { | |
| 483 | + | guardedSource.mu.Unlock() | |
| 484 | + | } | |
| 450 | 485 | from.mu.Unlock() | |
| 451 | 486 | ||
| 452 | 487 | old := targetSess.client | |
| @@ -513,7 +548,7 @@ func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) | |||
| 513 | 548 | // targets must use switchToTarget so resolution and commit share d.mu. | |
| 514 | 549 | func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient { | |
| 515 | 550 | d.mu.Lock() | |
| 516 | - | _, old, cleanups, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target) | |
| 551 | + | _, old, cleanups, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target, sessionHandoffGuard{}) | |
| 517 | 552 | d.mu.Unlock() | |
| 518 | 553 | if !switched { | |
| 519 | 554 | return nil | |
| @@ -341,10 +341,23 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session { | |||||
| 341 | return d.sessions[id] | 341 | return d.sessions[id] | ||
| 342 | } | 342 | } | ||
| 343 | 343 | ||||
| 344 | + | // sessionHandoffGuard optionally constrains an active-session handoff to the | |||
| 345 | + | // tab that initiated navigation. Its zero value preserves ordinary picker and | |||
| 346 | + | // command switching behavior. | |||
| 347 | + | type sessionHandoffGuard struct { | |||
| 348 | + | expectedSource *tab | |||
| 349 | + | } | |||
| 350 | + | ||||
| 344 | // switchToTarget resolves named lifecycle targets and commits their transition | 351 | // switchToTarget resolves named lifecycle targets and commits their transition | ||
| 345 | // while d.mu is held. A named palette result is allowed to cross an | 352 | // while d.mu is held. A named palette result is allowed to cross an | ||
| 346 | // active/stopped transition, but it never follows a same-name replacement. | 353 | // active/stopped transition, but it never follows a same-name replacement. | ||
| 347 | func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker.Target) error { | 354 | func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker.Target) error { | ||
| 355 | + | return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{}) | |||
| 356 | + | } | |||
| 357 | + | ||||
| 358 | + | // switchToTargetGuarded is the navigation-only variant whose guard is checked | |||
| 359 | + | // as part of the source ownership transfer. | |||
| 360 | + | func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error { | |||
| 348 | d.mu.Lock() | 361 | d.mu.Lock() | ||
| 349 | var ( | 362 | var ( | ||
| 350 | targetSess *session | 363 | targetSess *session | ||
| @@ -363,15 +376,18 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker | |||||
| 363 | // locked name/lifecycle resolver chose this active incarnation. | 376 | // locked name/lifecycle resolver chose this active incarnation. | ||
| 364 | resolvedTarget := target | 377 | resolvedTarget := target | ||
| 365 | resolvedTarget.Session = active.id | 378 | resolvedTarget.Session = active.id | ||
| 366 | - | targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget) | 379 | + | targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget, guard) |
| 367 | } | 380 | } | ||
| 368 | } | 381 | } | ||
| 369 | } else if active := d.sessions[target.Session]; active != nil { | 382 | } else if active := d.sessions[target.Session]; active != nil { | ||
| 370 | - | targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, target) | 383 | + | targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, target, guard) |
| 371 | } | 384 | } | ||
| 372 | d.mu.Unlock() | 385 | d.mu.Unlock() | ||
| 373 | 386 | ||||
| 374 | if !switched { | 387 | if !switched { | ||
| 388 | + | if guard.expectedSource != nil { | |||
| 389 | + | return errNoNeighbor | |||
| 390 | + | } | |||
| 375 | d.invalidateRender(from, ac, true, "picker.go") | 391 | d.invalidateRender(from, ac, true, "picker.go") | ||
| 376 | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause) | 392 | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause) | ||
| 377 | } | 393 | } | ||
| @@ -413,7 +429,7 @@ func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*sessi | |||||
| 413 | 429 | ||||
| 414 | // switchToActiveTargetLocked commits an active target handoff. Caller holds | 430 | // switchToActiveTargetLocked commits an active target handoff. Caller holds | ||
| 415 | // d.mu and releases it before completing render cleanup or first paint. | 431 | // d.mu and releases it before completing render cleanup or first paint. | ||
| 416 | - | func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target) (*session, *attachedClient, []renderLifecycleCleanup, bool) { | 432 | + | func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target, guard sessionHandoffGuard) (*session, *attachedClient, []renderLifecycleCleanup, bool) { |
| 417 | // The caller already holds d.mu. Keep handoff ownership atomic with global | 433 | // The caller already holds d.mu. Keep handoff ownership atomic with global | ||
| 418 | // routing so it cannot select an attachment midway between sessions. | 434 | // routing so it cannot select an attachment midway between sessions. | ||
| 419 | d.notices.routingMu.Lock() | 435 | d.notices.routingMu.Lock() | ||
| @@ -443,10 +459,29 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t | |||||
| 443 | targetSess.mu.Unlock() | 459 | targetSess.mu.Unlock() | ||
| 444 | return nil, nil, nil, false | 460 | return nil, nil, nil, false | ||
| 445 | } | 461 | } | ||
| 462 | + | var guardedSource *tab | |||
| 463 | + | if guard.expectedSource != nil { | |||
| 464 | + | if from.active < 0 || from.active >= len(from.tabs) || from.tabs[from.active] != guard.expectedSource { | |||
| 465 | + | from.mu.Unlock() | |||
| 466 | + | targetSess.mu.Unlock() | |||
| 467 | + | return nil, nil, nil, false | |||
| 468 | + | } | |||
| 469 | + | guardedSource = guard.expectedSource | |||
| 470 | + | guardedSource.mu.Lock() | |||
| 471 | + | if guardedSource.floating.state == floatingVisible { | |||
| 472 | + | guardedSource.mu.Unlock() | |||
| 473 | + | from.mu.Unlock() | |||
| 474 | + | targetSess.mu.Unlock() | |||
| 475 | + | return nil, nil, nil, false | |||
| 476 | + | } | |||
| 477 | + | } | |||
| 446 | term := from.terminal | 478 | term := from.terminal | ||
| 447 | env := copyEnvironment(from.env) | 479 | env := copyEnvironment(from.env) | ||
| 448 | from.client = nil | 480 | from.client = nil | ||
| 449 | ac.setSession(nil) | 481 | ac.setSession(nil) | ||
| 482 | + | if guardedSource != nil { | |||
| 483 | + | guardedSource.mu.Unlock() | |||
| 484 | + | } | |||
| 450 | from.mu.Unlock() | 485 | from.mu.Unlock() | ||
| 451 | 486 | ||||
| 452 | old := targetSess.client | 487 | old := targetSess.client | ||
| @@ -513,7 +548,7 @@ func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) | |||||
| 513 | // targets must use switchToTarget so resolution and commit share d.mu. | 548 | // targets must use switchToTarget so resolution and commit share d.mu. | ||
| 514 | func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient { | 549 | func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient { | ||
| 515 | d.mu.Lock() | 550 | d.mu.Lock() | ||
| 516 | - | _, old, cleanups, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target) | 551 | + | _, old, cleanups, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target, sessionHandoffGuard{}) |
| 517 | d.mu.Unlock() | 552 | d.mu.Unlock() | ||
| 518 | if !switched { | 553 | if !switched { | ||
| 519 | return nil | 554 | return nil | ||
A internal/usecase/layout/cross_focus_test.go +178 -0
| @@ -0,0 +1,178 @@ | |||
| 1 | + | package layout | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestFocusSpan(t *testing.T) { | |
| 11 | + | t.Parallel() | |
| 12 | + | ||
| 13 | + | area := domain.Rect{X: 5, Y: 7, Width: 41, Height: 5} | |
| 14 | + | tests := []struct { | |
| 15 | + | name string | |
| 16 | + | tree *Tree | |
| 17 | + | area domain.Rect | |
| 18 | + | want domain.Rect | |
| 19 | + | wantErr error | |
| 20 | + | }{ | |
| 21 | + | { | |
| 22 | + | name: "nil root", | |
| 23 | + | tree: &Tree{}, | |
| 24 | + | area: area, | |
| 25 | + | wantErr: ErrNotFound, | |
| 26 | + | }, | |
| 27 | + | { | |
| 28 | + | name: "layout too small", | |
| 29 | + | tree: NewTree("a"), | |
| 30 | + | area: domain.Rect{Width: MinPaneCols - 1, Height: MinPaneRows}, | |
| 31 | + | wantErr: ErrTooSmall, | |
| 32 | + | }, | |
| 33 | + | { | |
| 34 | + | name: "focused pane unknown", | |
| 35 | + | tree: &Tree{Root: NewLeaf("a"), Focus: "missing"}, | |
| 36 | + | area: area, | |
| 37 | + | wantErr: ErrNotFound, | |
| 38 | + | }, | |
| 39 | + | { | |
| 40 | + | name: "returns collapsed stack title bar", | |
| 41 | + | tree: &Tree{ | |
| 42 | + | Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b")}, Expanded: "a"}, | |
| 43 | + | Focus: "b", | |
| 44 | + | }, | |
| 45 | + | area: area, | |
| 46 | + | want: domain.Rect{X: 5, Y: 11, Width: 41, Height: 1}, | |
| 47 | + | }, | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | for _, tt := range tests { | |
| 51 | + | t.Run(tt.name, func(t *testing.T) { | |
| 52 | + | got, err := tt.tree.FocusSpan(tt.area) | |
| 53 | + | require.ErrorIs(t, err, tt.wantErr) | |
| 54 | + | require.Equal(t, tt.want, got) | |
| 55 | + | }) | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestEntryPane(t *testing.T) { | |
| 60 | + | t.Parallel() | |
| 61 | + | ||
| 62 | + | area := domain.Rect{Width: 41, Height: 5} | |
| 63 | + | tests := []struct { | |
| 64 | + | name string | |
| 65 | + | tree *Tree | |
| 66 | + | dir Direction | |
| 67 | + | span domain.Rect | |
| 68 | + | area domain.Rect | |
| 69 | + | want PaneID | |
| 70 | + | wantErr error | |
| 71 | + | }{ | |
| 72 | + | { | |
| 73 | + | name: "nil root", | |
| 74 | + | tree: &Tree{}, | |
| 75 | + | dir: Right, | |
| 76 | + | area: area, | |
| 77 | + | wantErr: ErrNotFound, | |
| 78 | + | }, | |
| 79 | + | { | |
| 80 | + | name: "layout too small", | |
| 81 | + | tree: NewTree("a"), | |
| 82 | + | dir: Right, | |
| 83 | + | area: domain.Rect{Width: MinPaneCols - 1, Height: MinPaneRows}, | |
| 84 | + | wantErr: ErrTooSmall, | |
| 85 | + | }, | |
| 86 | + | { | |
| 87 | + | name: "unknown direction has no pane", | |
| 88 | + | tree: NewTree("a"), | |
| 89 | + | dir: Direction(99), | |
| 90 | + | area: area, | |
| 91 | + | wantErr: ErrNoPane, | |
| 92 | + | }, | |
| 93 | + | { | |
| 94 | + | name: "right enters left edge", | |
| 95 | + | tree: &Tree{Root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{NewLeaf("left"), NewLeaf("right")}}, Focus: "right"}, | |
| 96 | + | dir: Right, | |
| 97 | + | span: domain.Rect{Y: 1, Width: 20, Height: 2}, | |
| 98 | + | area: area, | |
| 99 | + | want: "left", | |
| 100 | + | }, | |
| 101 | + | { | |
| 102 | + | name: "left enters right edge", | |
| 103 | + | tree: &Tree{Root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{NewLeaf("left"), NewLeaf("right")}}, Focus: "left"}, | |
| 104 | + | dir: Left, | |
| 105 | + | span: domain.Rect{Y: 1, Width: 20, Height: 2}, | |
| 106 | + | area: area, | |
| 107 | + | want: "right", | |
| 108 | + | }, | |
| 109 | + | { | |
| 110 | + | name: "down enters top edge", | |
| 111 | + | tree: &Tree{Root: &Node{Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("top"), NewLeaf("bottom")}}, Focus: "bottom"}, | |
| 112 | + | dir: Down, | |
| 113 | + | span: domain.Rect{X: 5, Width: 10, Height: 2}, | |
| 114 | + | area: area, | |
| 115 | + | want: "top", | |
| 116 | + | }, | |
| 117 | + | { | |
| 118 | + | name: "up enters bottom edge", | |
| 119 | + | tree: &Tree{Root: &Node{Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("top"), NewLeaf("bottom")}}, Focus: "top"}, | |
| 120 | + | dir: Up, | |
| 121 | + | span: domain.Rect{X: 5, Width: 10, Height: 2}, | |
| 122 | + | area: area, | |
| 123 | + | want: "bottom", | |
| 124 | + | }, | |
| 125 | + | { | |
| 126 | + | name: "minimum edge gap precedes overlap", | |
| 127 | + | tree: &Tree{Root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{ | |
| 128 | + | {Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("edge-top"), NewLeaf("edge-bottom")}}, | |
| 129 | + | NewLeaf("far"), | |
| 130 | + | }}}, | |
| 131 | + | dir: Right, | |
| 132 | + | span: domain.Rect{Y: 1, Width: 20, Height: 4}, | |
| 133 | + | area: domain.Rect{Width: 62, Height: 5}, | |
| 134 | + | want: "edge-bottom", | |
| 135 | + | }, | |
| 136 | + | { | |
| 137 | + | name: "maximum perpendicular overlap breaks equal gap", | |
| 138 | + | tree: &Tree{Root: &Node{Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("top"), NewLeaf("bottom")}}}, | |
| 139 | + | dir: Right, | |
| 140 | + | span: domain.Rect{Y: 3, Width: 20, Height: 2}, | |
| 141 | + | area: domain.Rect{Width: 20, Height: 5}, | |
| 142 | + | want: "bottom", | |
| 143 | + | }, | |
| 144 | + | { | |
| 145 | + | name: "pane id deterministically breaks full tie", | |
| 146 | + | tree: &Tree{Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("z"), NewLeaf("a")}, Expanded: "z"}}, | |
| 147 | + | dir: Right, | |
| 148 | + | span: domain.Rect{Y: 10, Width: 20, Height: 1}, | |
| 149 | + | area: domain.Rect{Width: 20, Height: 4}, | |
| 150 | + | want: "a", | |
| 151 | + | }, | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | for _, tt := range tests { | |
| 155 | + | t.Run(tt.name, func(t *testing.T) { | |
| 156 | + | before := tt.tree.Clone() | |
| 157 | + | got, err := tt.tree.EntryPane(tt.dir, tt.span, tt.area) | |
| 158 | + | require.ErrorIs(t, err, tt.wantErr) | |
| 159 | + | require.Equal(t, tt.want, got) | |
| 160 | + | require.Equal(t, before, tt.tree, "EntryPane must be pure") | |
| 161 | + | }) | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func TestFocusEnterExpandsCollapsedStackMember(t *testing.T) { | |
| 166 | + | t.Parallel() | |
| 167 | + | ||
| 168 | + | area := domain.Rect{Width: 20, Height: 4} | |
| 169 | + | tr := &Tree{ | |
| 170 | + | Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("expanded"), NewLeaf("collapsed")}, Expanded: "expanded"}, | |
| 171 | + | Focus: "expanded", | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | err := tr.FocusEnter(Right, domain.Rect{Y: 3, Width: 20, Height: 1}, area) | |
| 175 | + | require.NoError(t, err) | |
| 176 | + | require.Equal(t, PaneID("collapsed"), tr.Focus) | |
| 177 | + | require.Equal(t, PaneID("collapsed"), tr.Root.Expanded) | |
| 178 | + | } | |
| @@ -0,0 +1,178 @@ | |||||
| 1 | + | package layout | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestFocusSpan(t *testing.T) { | |||
| 11 | + | t.Parallel() | |||
| 12 | + | ||||
| 13 | + | area := domain.Rect{X: 5, Y: 7, Width: 41, Height: 5} | |||
| 14 | + | tests := []struct { | |||
| 15 | + | name string | |||
| 16 | + | tree *Tree | |||
| 17 | + | area domain.Rect | |||
| 18 | + | want domain.Rect | |||
| 19 | + | wantErr error | |||
| 20 | + | }{ | |||
| 21 | + | { | |||
| 22 | + | name: "nil root", | |||
| 23 | + | tree: &Tree{}, | |||
| 24 | + | area: area, | |||
| 25 | + | wantErr: ErrNotFound, | |||
| 26 | + | }, | |||
| 27 | + | { | |||
| 28 | + | name: "layout too small", | |||
| 29 | + | tree: NewTree("a"), | |||
| 30 | + | area: domain.Rect{Width: MinPaneCols - 1, Height: MinPaneRows}, | |||
| 31 | + | wantErr: ErrTooSmall, | |||
| 32 | + | }, | |||
| 33 | + | { | |||
| 34 | + | name: "focused pane unknown", | |||
| 35 | + | tree: &Tree{Root: NewLeaf("a"), Focus: "missing"}, | |||
| 36 | + | area: area, | |||
| 37 | + | wantErr: ErrNotFound, | |||
| 38 | + | }, | |||
| 39 | + | { | |||
| 40 | + | name: "returns collapsed stack title bar", | |||
| 41 | + | tree: &Tree{ | |||
| 42 | + | Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b")}, Expanded: "a"}, | |||
| 43 | + | Focus: "b", | |||
| 44 | + | }, | |||
| 45 | + | area: area, | |||
| 46 | + | want: domain.Rect{X: 5, Y: 11, Width: 41, Height: 1}, | |||
| 47 | + | }, | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | for _, tt := range tests { | |||
| 51 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 52 | + | got, err := tt.tree.FocusSpan(tt.area) | |||
| 53 | + | require.ErrorIs(t, err, tt.wantErr) | |||
| 54 | + | require.Equal(t, tt.want, got) | |||
| 55 | + | }) | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func TestEntryPane(t *testing.T) { | |||
| 60 | + | t.Parallel() | |||
| 61 | + | ||||
| 62 | + | area := domain.Rect{Width: 41, Height: 5} | |||
| 63 | + | tests := []struct { | |||
| 64 | + | name string | |||
| 65 | + | tree *Tree | |||
| 66 | + | dir Direction | |||
| 67 | + | span domain.Rect | |||
| 68 | + | area domain.Rect | |||
| 69 | + | want PaneID | |||
| 70 | + | wantErr error | |||
| 71 | + | }{ | |||
| 72 | + | { | |||
| 73 | + | name: "nil root", | |||
| 74 | + | tree: &Tree{}, | |||
| 75 | + | dir: Right, | |||
| 76 | + | area: area, | |||
| 77 | + | wantErr: ErrNotFound, | |||
| 78 | + | }, | |||
| 79 | + | { | |||
| 80 | + | name: "layout too small", | |||
| 81 | + | tree: NewTree("a"), | |||
| 82 | + | dir: Right, | |||
| 83 | + | area: domain.Rect{Width: MinPaneCols - 1, Height: MinPaneRows}, | |||
| 84 | + | wantErr: ErrTooSmall, | |||
| 85 | + | }, | |||
| 86 | + | { | |||
| 87 | + | name: "unknown direction has no pane", | |||
| 88 | + | tree: NewTree("a"), | |||
| 89 | + | dir: Direction(99), | |||
| 90 | + | area: area, | |||
| 91 | + | wantErr: ErrNoPane, | |||
| 92 | + | }, | |||
| 93 | + | { | |||
| 94 | + | name: "right enters left edge", | |||
| 95 | + | tree: &Tree{Root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{NewLeaf("left"), NewLeaf("right")}}, Focus: "right"}, | |||
| 96 | + | dir: Right, | |||
| 97 | + | span: domain.Rect{Y: 1, Width: 20, Height: 2}, | |||
| 98 | + | area: area, | |||
| 99 | + | want: "left", | |||
| 100 | + | }, | |||
| 101 | + | { | |||
| 102 | + | name: "left enters right edge", | |||
| 103 | + | tree: &Tree{Root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{NewLeaf("left"), NewLeaf("right")}}, Focus: "left"}, | |||
| 104 | + | dir: Left, | |||
| 105 | + | span: domain.Rect{Y: 1, Width: 20, Height: 2}, | |||
| 106 | + | area: area, | |||
| 107 | + | want: "right", | |||
| 108 | + | }, | |||
| 109 | + | { | |||
| 110 | + | name: "down enters top edge", | |||
| 111 | + | tree: &Tree{Root: &Node{Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("top"), NewLeaf("bottom")}}, Focus: "bottom"}, | |||
| 112 | + | dir: Down, | |||
| 113 | + | span: domain.Rect{X: 5, Width: 10, Height: 2}, | |||
| 114 | + | area: area, | |||
| 115 | + | want: "top", | |||
| 116 | + | }, | |||
| 117 | + | { | |||
| 118 | + | name: "up enters bottom edge", | |||
| 119 | + | tree: &Tree{Root: &Node{Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("top"), NewLeaf("bottom")}}, Focus: "top"}, | |||
| 120 | + | dir: Up, | |||
| 121 | + | span: domain.Rect{X: 5, Width: 10, Height: 2}, | |||
| 122 | + | area: area, | |||
| 123 | + | want: "bottom", | |||
| 124 | + | }, | |||
| 125 | + | { | |||
| 126 | + | name: "minimum edge gap precedes overlap", | |||
| 127 | + | tree: &Tree{Root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{ | |||
| 128 | + | {Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("edge-top"), NewLeaf("edge-bottom")}}, | |||
| 129 | + | NewLeaf("far"), | |||
| 130 | + | }}}, | |||
| 131 | + | dir: Right, | |||
| 132 | + | span: domain.Rect{Y: 1, Width: 20, Height: 4}, | |||
| 133 | + | area: domain.Rect{Width: 62, Height: 5}, | |||
| 134 | + | want: "edge-bottom", | |||
| 135 | + | }, | |||
| 136 | + | { | |||
| 137 | + | name: "maximum perpendicular overlap breaks equal gap", | |||
| 138 | + | tree: &Tree{Root: &Node{Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("top"), NewLeaf("bottom")}}}, | |||
| 139 | + | dir: Right, | |||
| 140 | + | span: domain.Rect{Y: 3, Width: 20, Height: 2}, | |||
| 141 | + | area: domain.Rect{Width: 20, Height: 5}, | |||
| 142 | + | want: "bottom", | |||
| 143 | + | }, | |||
| 144 | + | { | |||
| 145 | + | name: "pane id deterministically breaks full tie", | |||
| 146 | + | tree: &Tree{Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("z"), NewLeaf("a")}, Expanded: "z"}}, | |||
| 147 | + | dir: Right, | |||
| 148 | + | span: domain.Rect{Y: 10, Width: 20, Height: 1}, | |||
| 149 | + | area: domain.Rect{Width: 20, Height: 4}, | |||
| 150 | + | want: "a", | |||
| 151 | + | }, | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | for _, tt := range tests { | |||
| 155 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 156 | + | before := tt.tree.Clone() | |||
| 157 | + | got, err := tt.tree.EntryPane(tt.dir, tt.span, tt.area) | |||
| 158 | + | require.ErrorIs(t, err, tt.wantErr) | |||
| 159 | + | require.Equal(t, tt.want, got) | |||
| 160 | + | require.Equal(t, before, tt.tree, "EntryPane must be pure") | |||
| 161 | + | }) | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func TestFocusEnterExpandsCollapsedStackMember(t *testing.T) { | |||
| 166 | + | t.Parallel() | |||
| 167 | + | ||||
| 168 | + | area := domain.Rect{Width: 20, Height: 4} | |||
| 169 | + | tr := &Tree{ | |||
| 170 | + | Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("expanded"), NewLeaf("collapsed")}, Expanded: "expanded"}, | |||
| 171 | + | Focus: "expanded", | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | err := tr.FocusEnter(Right, domain.Rect{Y: 3, Width: 20, Height: 1}, area) | |||
| 175 | + | require.NoError(t, err) | |||
| 176 | + | require.Equal(t, PaneID("collapsed"), tr.Focus) | |||
| 177 | + | require.Equal(t, PaneID("collapsed"), tr.Root.Expanded) | |||
| 178 | + | } | |||
M internal/usecase/layout/ops.go +99 -21
| @@ -372,6 +372,63 @@ func leafIDs(n *Node) []PaneID { | |||
| 372 | 372 | return ids | |
| 373 | 373 | } | |
| 374 | 374 | ||
| 375 | + | // FocusSpan returns the solved focus rectangle of the focused pane. | |
| 376 | + | func (t *Tree) FocusSpan(area domain.Rect) (domain.Rect, error) { | |
| 377 | + | if t == nil || t.Root == nil { | |
| 378 | + | return domain.Rect{}, ErrNotFound | |
| 379 | + | } | |
| 380 | + | placements, ok := Solve(t.Root, area) | |
| 381 | + | if !ok { | |
| 382 | + | return domain.Rect{}, ErrTooSmall | |
| 383 | + | } | |
| 384 | + | for _, placement := range placements { | |
| 385 | + | if placement.ID == t.Focus { | |
| 386 | + | return focusRect(placement), nil | |
| 387 | + | } | |
| 388 | + | } | |
| 389 | + | return domain.Rect{}, ErrNotFound | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | // EntryPane returns the pane on the facing edge that best preserves span on | |
| 393 | + | // the perpendicular axis. It does not mutate the tree. | |
| 394 | + | func (t *Tree) EntryPane(dir Direction, span, area domain.Rect) (PaneID, error) { | |
| 395 | + | if t == nil || t.Root == nil { | |
| 396 | + | return "", ErrNotFound | |
| 397 | + | } | |
| 398 | + | placements, ok := Solve(t.Root, area) | |
| 399 | + | if !ok { | |
| 400 | + | return "", ErrTooSmall | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | outside := span | |
| 404 | + | switch dir { | |
| 405 | + | case Left: | |
| 406 | + | outside.X = area.X + area.Width | |
| 407 | + | case Right: | |
| 408 | + | outside.X = area.X - span.Width | |
| 409 | + | case Up: | |
| 410 | + | outside.Y = area.Y + area.Height | |
| 411 | + | case Down: | |
| 412 | + | outside.Y = area.Y - span.Height | |
| 413 | + | default: | |
| 414 | + | return "", ErrNoPane | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | return selectFacingCandidate(placements, outside, dir, facingGapFirst) | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | // FocusEnter focuses the best pane on the facing edge and expands it when it | |
| 421 | + | // is a collapsed stack member. | |
| 422 | + | func (t *Tree) FocusEnter(dir Direction, span, area domain.Rect) error { | |
| 423 | + | id, err := t.EntryPane(dir, span, area) | |
| 424 | + | if err != nil { | |
| 425 | + | return err | |
| 426 | + | } | |
| 427 | + | t.Focus = id | |
| 428 | + | setExpanded(t.Root, id) | |
| 429 | + | return nil | |
| 430 | + | } | |
| 431 | + | ||
| 375 | 432 | // FocusDir moves focus in dir. Vertical movement within a stack walks stack | |
| 376 | 433 | // members before falling back to geometric focus. | |
| 377 | 434 | func (t *Tree) FocusDir(dir Direction, area domain.Rect) error { | |
| @@ -396,36 +453,57 @@ func (t *Tree) FocusDir(dir Direction, area domain.Rect) error { | |||
| 396 | 453 | return ErrNotFound | |
| 397 | 454 | } | |
| 398 | 455 | current := focusRect(placements[currentIdx]) | |
| 399 | - | type candidate struct { | |
| 400 | - | id PaneID | |
| 401 | - | overlap int | |
| 402 | - | gap int | |
| 456 | + | candidate, err := selectFacingCandidate(placements, current, dir, facingOverlapFirst) | |
| 457 | + | if err != nil { | |
| 458 | + | return err | |
| 403 | 459 | } | |
| 404 | - | var candidates []candidate | |
| 405 | - | for _, p := range placements { | |
| 406 | - | if p.ID == t.Focus { | |
| 407 | - | continue | |
| 408 | - | } | |
| 409 | - | r := focusRect(p) | |
| 410 | - | if ok, gap, overlap := sideGapOverlap(current, r, dir); ok { | |
| 411 | - | candidates = append(candidates, candidate{id: p.ID, gap: gap, overlap: overlap}) | |
| 460 | + | t.Focus = candidate | |
| 461 | + | setExpanded(t.Root, t.Focus) | |
| 462 | + | return nil | |
| 463 | + | } | |
| 464 | + | ||
| 465 | + | type facingMetricPriority uint8 | |
| 466 | + | ||
| 467 | + | const ( | |
| 468 | + | facingGapFirst facingMetricPriority = iota | |
| 469 | + | facingOverlapFirst | |
| 470 | + | ) | |
| 471 | + | ||
| 472 | + | type facingCandidate struct { | |
| 473 | + | id PaneID | |
| 474 | + | gap int | |
| 475 | + | overlap int | |
| 476 | + | } | |
| 477 | + | ||
| 478 | + | func selectFacingCandidate(placements []Placement, from domain.Rect, dir Direction, priority facingMetricPriority) (PaneID, error) { | |
| 479 | + | candidates := make([]facingCandidate, 0, len(placements)) | |
| 480 | + | for _, placement := range placements { | |
| 481 | + | if facing, gap, perpendicularOverlap := sideGapOverlap(from, focusRect(placement), dir); facing { | |
| 482 | + | candidates = append(candidates, facingCandidate{id: placement.ID, gap: gap, overlap: perpendicularOverlap}) | |
| 412 | 483 | } | |
| 413 | 484 | } | |
| 414 | 485 | if len(candidates) == 0 { | |
| 415 | - | return ErrNoPane | |
| 486 | + | return "", ErrNoPane | |
| 416 | 487 | } | |
| 417 | 488 | sort.Slice(candidates, func(i, j int) bool { | |
| 418 | - | if candidates[i].overlap != candidates[j].overlap { | |
| 419 | - | return candidates[i].overlap > candidates[j].overlap | |
| 420 | - | } | |
| 421 | - | if candidates[i].gap != candidates[j].gap { | |
| 422 | - | return candidates[i].gap < candidates[j].gap | |
| 489 | + | if priority == facingGapFirst { | |
| 490 | + | if candidates[i].gap != candidates[j].gap { | |
| 491 | + | return candidates[i].gap < candidates[j].gap | |
| 492 | + | } | |
| 493 | + | if candidates[i].overlap != candidates[j].overlap { | |
| 494 | + | return candidates[i].overlap > candidates[j].overlap | |
| 495 | + | } | |
| 496 | + | } else { | |
| 497 | + | if candidates[i].overlap != candidates[j].overlap { | |
| 498 | + | return candidates[i].overlap > candidates[j].overlap | |
| 499 | + | } | |
| 500 | + | if candidates[i].gap != candidates[j].gap { | |
| 501 | + | return candidates[i].gap < candidates[j].gap | |
| 502 | + | } | |
| 423 | 503 | } | |
| 424 | 504 | return candidates[i].id < candidates[j].id | |
| 425 | 505 | }) | |
| 426 | - | t.Focus = candidates[0].id | |
| 427 | - | setExpanded(t.Root, t.Focus) | |
| 428 | - | return nil | |
| 506 | + | return candidates[0].id, nil | |
| 429 | 507 | } | |
| 430 | 508 | ||
| 431 | 509 | func focusStackLocal(n *Node, id PaneID, dir Direction, focus *PaneID) bool { | |
| @@ -372,6 +372,63 @@ func leafIDs(n *Node) []PaneID { | |||||
| 372 | return ids | 372 | return ids | ||
| 373 | } | 373 | } | ||
| 374 | 374 | ||||
| 375 | + | // FocusSpan returns the solved focus rectangle of the focused pane. | |||
| 376 | + | func (t *Tree) FocusSpan(area domain.Rect) (domain.Rect, error) { | |||
| 377 | + | if t == nil || t.Root == nil { | |||
| 378 | + | return domain.Rect{}, ErrNotFound | |||
| 379 | + | } | |||
| 380 | + | placements, ok := Solve(t.Root, area) | |||
| 381 | + | if !ok { | |||
| 382 | + | return domain.Rect{}, ErrTooSmall | |||
| 383 | + | } | |||
| 384 | + | for _, placement := range placements { | |||
| 385 | + | if placement.ID == t.Focus { | |||
| 386 | + | return focusRect(placement), nil | |||
| 387 | + | } | |||
| 388 | + | } | |||
| 389 | + | return domain.Rect{}, ErrNotFound | |||
| 390 | + | } | |||
| 391 | + | ||||
| 392 | + | // EntryPane returns the pane on the facing edge that best preserves span on | |||
| 393 | + | // the perpendicular axis. It does not mutate the tree. | |||
| 394 | + | func (t *Tree) EntryPane(dir Direction, span, area domain.Rect) (PaneID, error) { | |||
| 395 | + | if t == nil || t.Root == nil { | |||
| 396 | + | return "", ErrNotFound | |||
| 397 | + | } | |||
| 398 | + | placements, ok := Solve(t.Root, area) | |||
| 399 | + | if !ok { | |||
| 400 | + | return "", ErrTooSmall | |||
| 401 | + | } | |||
| 402 | + | ||||
| 403 | + | outside := span | |||
| 404 | + | switch dir { | |||
| 405 | + | case Left: | |||
| 406 | + | outside.X = area.X + area.Width | |||
| 407 | + | case Right: | |||
| 408 | + | outside.X = area.X - span.Width | |||
| 409 | + | case Up: | |||
| 410 | + | outside.Y = area.Y + area.Height | |||
| 411 | + | case Down: | |||
| 412 | + | outside.Y = area.Y - span.Height | |||
| 413 | + | default: | |||
| 414 | + | return "", ErrNoPane | |||
| 415 | + | } | |||
| 416 | + | ||||
| 417 | + | return selectFacingCandidate(placements, outside, dir, facingGapFirst) | |||
| 418 | + | } | |||
| 419 | + | ||||
| 420 | + | // FocusEnter focuses the best pane on the facing edge and expands it when it | |||
| 421 | + | // is a collapsed stack member. | |||
| 422 | + | func (t *Tree) FocusEnter(dir Direction, span, area domain.Rect) error { | |||
| 423 | + | id, err := t.EntryPane(dir, span, area) | |||
| 424 | + | if err != nil { | |||
| 425 | + | return err | |||
| 426 | + | } | |||
| 427 | + | t.Focus = id | |||
| 428 | + | setExpanded(t.Root, id) | |||
| 429 | + | return nil | |||
| 430 | + | } | |||
| 431 | + | ||||
| 375 | // FocusDir moves focus in dir. Vertical movement within a stack walks stack | 432 | // FocusDir moves focus in dir. Vertical movement within a stack walks stack | ||
| 376 | // members before falling back to geometric focus. | 433 | // members before falling back to geometric focus. | ||
| 377 | func (t *Tree) FocusDir(dir Direction, area domain.Rect) error { | 434 | func (t *Tree) FocusDir(dir Direction, area domain.Rect) error { | ||
| @@ -396,36 +453,57 @@ func (t *Tree) FocusDir(dir Direction, area domain.Rect) error { | |||||
| 396 | return ErrNotFound | 453 | return ErrNotFound | ||
| 397 | } | 454 | } | ||
| 398 | current := focusRect(placements[currentIdx]) | 455 | current := focusRect(placements[currentIdx]) | ||
| 399 | - | type candidate struct { | 456 | + | candidate, err := selectFacingCandidate(placements, current, dir, facingOverlapFirst) |
| 400 | - | id PaneID | 457 | + | if err != nil { |
| 401 | - | overlap int | 458 | + | return err |
| 402 | - | gap int | |||
| 403 | } | 459 | } | ||
| 404 | - | var candidates []candidate | 460 | + | t.Focus = candidate |
| 405 | - | for _, p := range placements { | 461 | + | setExpanded(t.Root, t.Focus) |
| 406 | - | if p.ID == t.Focus { | 462 | + | return nil |
| 407 | - | continue | 463 | + | } |
| 408 | - | } | 464 | + | |
| 409 | - | r := focusRect(p) | 465 | + | type facingMetricPriority uint8 |
| 410 | - | if ok, gap, overlap := sideGapOverlap(current, r, dir); ok { | 466 | + | |
| 411 | - | candidates = append(candidates, candidate{id: p.ID, gap: gap, overlap: overlap}) | 467 | + | const ( |
| 468 | + | facingGapFirst facingMetricPriority = iota | |||
| 469 | + | facingOverlapFirst | |||
| 470 | + | ) | |||
| 471 | + | ||||
| 472 | + | type facingCandidate struct { | |||
| 473 | + | id PaneID | |||
| 474 | + | gap int | |||
| 475 | + | overlap int | |||
| 476 | + | } | |||
| 477 | + | ||||
| 478 | + | func selectFacingCandidate(placements []Placement, from domain.Rect, dir Direction, priority facingMetricPriority) (PaneID, error) { | |||
| 479 | + | candidates := make([]facingCandidate, 0, len(placements)) | |||
| 480 | + | for _, placement := range placements { | |||
| 481 | + | if facing, gap, perpendicularOverlap := sideGapOverlap(from, focusRect(placement), dir); facing { | |||
| 482 | + | candidates = append(candidates, facingCandidate{id: placement.ID, gap: gap, overlap: perpendicularOverlap}) | |||
| 412 | } | 483 | } | ||
| 413 | } | 484 | } | ||
| 414 | if len(candidates) == 0 { | 485 | if len(candidates) == 0 { | ||
| 415 | - | return ErrNoPane | 486 | + | return "", ErrNoPane |
| 416 | } | 487 | } | ||
| 417 | sort.Slice(candidates, func(i, j int) bool { | 488 | sort.Slice(candidates, func(i, j int) bool { | ||
| 418 | - | if candidates[i].overlap != candidates[j].overlap { | 489 | + | if priority == facingGapFirst { |
| 419 | - | return candidates[i].overlap > candidates[j].overlap | 490 | + | if candidates[i].gap != candidates[j].gap { |
| 420 | - | } | 491 | + | return candidates[i].gap < candidates[j].gap |
| 421 | - | if candidates[i].gap != candidates[j].gap { | 492 | + | } |
| 422 | - | return candidates[i].gap < candidates[j].gap | 493 | + | if candidates[i].overlap != candidates[j].overlap { |
| 494 | + | return candidates[i].overlap > candidates[j].overlap | |||
| 495 | + | } | |||
| 496 | + | } else { | |||
| 497 | + | if candidates[i].overlap != candidates[j].overlap { | |||
| 498 | + | return candidates[i].overlap > candidates[j].overlap | |||
| 499 | + | } | |||
| 500 | + | if candidates[i].gap != candidates[j].gap { | |||
| 501 | + | return candidates[i].gap < candidates[j].gap | |||
| 502 | + | } | |||
| 423 | } | 503 | } | ||
| 424 | return candidates[i].id < candidates[j].id | 504 | return candidates[i].id < candidates[j].id | ||
| 425 | }) | 505 | }) | ||
| 426 | - | t.Focus = candidates[0].id | 506 | + | return candidates[0].id, nil |
| 427 | - | setExpanded(t.Root, t.Focus) | |||
| 428 | - | return nil | |||
| 429 | } | 507 | } | ||
| 430 | 508 | ||||
| 431 | func focusStackLocal(n *Node, id PaneID, dir Direction, focus *PaneID) bool { | 509 | func focusStackLocal(n *Node, id PaneID, dir Direction, focus *PaneID) bool { | ||