test(navigation): strengthen overflow invariants
- commit
- f9e1be422bf2c89fc320ca3d6996fc51a63f548c
- author
- brice · · 2026-07-25 07:36 UTC
- parent
- 05d4687
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +85 -54
M internal/usecase/daemon/navigation_overflow.go +11 -4
| @@ -166,13 +166,14 @@ func (d *Daemon) prepareTabOverflow(sess *session, expectedSource *tab, dir layo | |||
| 166 | 166 | } | |
| 167 | 167 | ||
| 168 | 168 | target.mu.Lock() | |
| 169 | + | if target.tree == nil { | |
| 170 | + | target.mu.Unlock() | |
| 171 | + | return tabOverflowCandidate{}, false | |
| 172 | + | } | |
| 169 | 173 | area := domain.Rect{Width: target.size.Cols, Height: target.size.Rows} | |
| 170 | 174 | entry, err := target.tree.EntryPane(dir, span, area) | |
| 171 | 175 | entryPane := target.panes[entry] | |
| 172 | - | oldFocus := layout.PaneID("") | |
| 173 | - | if target.tree != nil { | |
| 174 | - | oldFocus = target.tree.Focus | |
| 175 | - | } | |
| 176 | + | oldFocus := target.tree.Focus | |
| 176 | 177 | valid := err == nil && entryPane != nil && layout.ContainsLeaf(target.tree.Root, entry) | |
| 177 | 178 | target.mu.Unlock() | |
| 178 | 179 | if !valid { | |
| @@ -213,6 +214,12 @@ func (d *Daemon) commitTabOverflow(sess *session, candidate tabOverflowCandidate | |||
| 213 | 214 | return false | |
| 214 | 215 | } | |
| 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 | + | } | |
| 216 | 223 | area := domain.Rect{Width: candidate.target.size.Cols, Height: candidate.target.size.Rows} | |
| 217 | 224 | entry, err := candidate.target.tree.EntryPane(candidate.direction, candidate.span, area) | |
| 218 | 225 | if err != nil || entry != candidate.entry || candidate.target.panes[entry] != candidate.entryPane || !layout.ContainsLeaf(candidate.target.tree.Root, entry) { | |
| @@ -166,13 +166,14 @@ func (d *Daemon) prepareTabOverflow(sess *session, expectedSource *tab, dir layo | |||||
| 166 | } | 166 | } | ||
| 167 | 167 | ||||
| 168 | target.mu.Lock() | 168 | target.mu.Lock() | ||
| 169 | + | if target.tree == nil { | |||
| 170 | + | target.mu.Unlock() | |||
| 171 | + | return tabOverflowCandidate{}, false | |||
| 172 | + | } | |||
| 169 | area := domain.Rect{Width: target.size.Cols, Height: target.size.Rows} | 173 | area := domain.Rect{Width: target.size.Cols, Height: target.size.Rows} | ||
| 170 | entry, err := target.tree.EntryPane(dir, span, area) | 174 | entry, err := target.tree.EntryPane(dir, span, area) | ||
| 171 | entryPane := target.panes[entry] | 175 | entryPane := target.panes[entry] | ||
| 172 | - | oldFocus := layout.PaneID("") | 176 | + | oldFocus := target.tree.Focus |
| 173 | - | if target.tree != nil { | |||
| 174 | - | oldFocus = target.tree.Focus | |||
| 175 | - | } | |||
| 176 | valid := err == nil && entryPane != nil && layout.ContainsLeaf(target.tree.Root, entry) | 177 | valid := err == nil && entryPane != nil && layout.ContainsLeaf(target.tree.Root, entry) | ||
| 177 | target.mu.Unlock() | 178 | target.mu.Unlock() | ||
| 178 | if !valid { | 179 | if !valid { | ||
| @@ -213,6 +214,12 @@ func (d *Daemon) commitTabOverflow(sess *session, candidate tabOverflowCandidate | |||||
| 213 | return false | 214 | return false | ||
| 214 | } | 215 | } | ||
| 215 | candidate.target.mu.Lock() | 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 | + | } | |||
| 216 | area := domain.Rect{Width: candidate.target.size.Cols, Height: candidate.target.size.Rows} | 223 | area := domain.Rect{Width: candidate.target.size.Cols, Height: candidate.target.size.Rows} | ||
| 217 | entry, err := candidate.target.tree.EntryPane(candidate.direction, candidate.span, area) | 224 | entry, err := candidate.target.tree.EntryPane(candidate.direction, candidate.span, area) | ||
| 218 | if err != nil || entry != candidate.entry || candidate.target.panes[entry] != candidate.entryPane || !layout.ContainsLeaf(candidate.target.tree.Root, entry) { | 225 | if err != nil || entry != candidate.entry || candidate.target.panes[entry] != candidate.entryPane || !layout.ContainsLeaf(candidate.target.tree.Root, entry) { | ||
M internal/usecase/daemon/navigation_overflow_test.go +31 -5
| @@ -31,6 +31,8 @@ func TestResolveOverflowObeysAxisConfigurationAndWalls(t *testing.T) { | |||
| 31 | 31 | {name: "down session", dir: layout.Down, cfg: domain.NavConfig{OverflowSessions: true}, position: 0, count: 2, want: overflowStep{kind: overflowSessions, delta: 1}}, | |
| 32 | 32 | {name: "tabs alone do not enable sessions", dir: layout.Down, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 2}, | |
| 33 | 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}, | |
| 34 | 36 | } | |
| 35 | 37 | ||
| 36 | 38 | for _, tt := range tests { | |
| @@ -298,7 +300,20 @@ func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) { | |||
| 298 | 300 | for err := range renameErrors { | |
| 299 | 301 | require.NoError(t, err) | |
| 300 | 302 | } | |
| 301 | - | require.NotNil(t, ac.currentSession()) | |
| 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]) | |
| 302 | 317 | } | |
| 303 | 318 | ||
| 304 | 319 | func TestKeyboardHorizontalOverflowRespectsDefaultsWallsFailedEntryAndFloating(t *testing.T) { | |
| @@ -338,8 +353,19 @@ func TestCommitTabOverflowRevalidatesTabPointerIdentities(t *testing.T) { | |||
| 338 | 353 | name string | |
| 339 | 354 | mutate func(sess *session, candidate tabOverflowCandidate) | |
| 340 | 355 | }{ | |
| 341 | - | {name: "source no longer active", mutate: func(sess *session, _ tabOverflowCandidate) { sess.active = 1 }}, | |
| 342 | - | {name: "target entry replaced", mutate: func(sess *session, candidate tabOverflowCandidate) { sess.tabs[1] = newTab(nil, candidate.target.size) }}, | |
| 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 | + | }}, | |
| 343 | 369 | } | |
| 344 | 370 | ||
| 345 | 371 | for _, tt := range tests { | |
| @@ -347,11 +373,11 @@ func TestCommitTabOverflowRevalidatesTabPointerIdentities(t *testing.T) { | |||
| 347 | 373 | d, sess, _, _ := newManualSessionWithPTYs(t, nil, nil) | |
| 348 | 374 | candidate, ok := d.prepareTabOverflow(sess, sess.tabs[0], layout.Right, domain.Rect{Width: 80, Height: 23}, 1) | |
| 349 | 375 | require.True(t, ok) | |
| 350 | - | sess.mu.Lock() | |
| 351 | 376 | tt.mutate(sess, candidate) | |
| 352 | - | sess.mu.Unlock() | |
| 377 | + | activeBeforeCommit := activeTabIndex(sess) | |
| 353 | 378 | ||
| 354 | 379 | require.False(t, d.commitTabOverflow(sess, candidate)) | |
| 380 | + | require.Equal(t, activeBeforeCommit, activeTabIndex(sess)) | |
| 355 | 381 | }) | |
| 356 | 382 | } | |
| 357 | 383 | } | |
| @@ -31,6 +31,8 @@ func TestResolveOverflowObeysAxisConfigurationAndWalls(t *testing.T) { | |||||
| 31 | {name: "down session", dir: layout.Down, cfg: domain.NavConfig{OverflowSessions: true}, position: 0, 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}, | 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}, | 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}, | |||
| 34 | } | 36 | } | ||
| 35 | 37 | ||||
| 36 | for _, tt := range tests { | 38 | for _, tt := range tests { | ||
| @@ -298,7 +300,20 @@ func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) { | |||||
| 298 | for err := range renameErrors { | 300 | for err := range renameErrors { | ||
| 299 | require.NoError(t, err) | 301 | require.NoError(t, err) | ||
| 300 | } | 302 | } | ||
| 301 | - | require.NotNil(t, ac.currentSession()) | 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]) | |||
| 302 | } | 317 | } | ||
| 303 | 318 | ||||
| 304 | func TestKeyboardHorizontalOverflowRespectsDefaultsWallsFailedEntryAndFloating(t *testing.T) { | 319 | func TestKeyboardHorizontalOverflowRespectsDefaultsWallsFailedEntryAndFloating(t *testing.T) { | ||
| @@ -338,8 +353,19 @@ func TestCommitTabOverflowRevalidatesTabPointerIdentities(t *testing.T) { | |||||
| 338 | name string | 353 | name string | ||
| 339 | mutate func(sess *session, candidate tabOverflowCandidate) | 354 | mutate func(sess *session, candidate tabOverflowCandidate) | ||
| 340 | }{ | 355 | }{ | ||
| 341 | - | {name: "source no longer active", mutate: func(sess *session, _ tabOverflowCandidate) { sess.active = 1 }}, | 356 | + | {name: "source no longer active", mutate: func(sess *session, _ tabOverflowCandidate) { |
| 342 | - | {name: "target entry replaced", mutate: func(sess *session, candidate tabOverflowCandidate) { sess.tabs[1] = newTab(nil, candidate.target.size) }}, | 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 | + | }}, | |||
| 343 | } | 369 | } | ||
| 344 | 370 | ||||
| 345 | for _, tt := range tests { | 371 | for _, tt := range tests { | ||
| @@ -347,11 +373,11 @@ func TestCommitTabOverflowRevalidatesTabPointerIdentities(t *testing.T) { | |||||
| 347 | d, sess, _, _ := newManualSessionWithPTYs(t, nil, nil) | 373 | d, sess, _, _ := newManualSessionWithPTYs(t, nil, nil) | ||
| 348 | candidate, ok := d.prepareTabOverflow(sess, sess.tabs[0], layout.Right, domain.Rect{Width: 80, Height: 23}, 1) | 374 | candidate, ok := d.prepareTabOverflow(sess, sess.tabs[0], layout.Right, domain.Rect{Width: 80, Height: 23}, 1) | ||
| 349 | require.True(t, ok) | 375 | require.True(t, ok) | ||
| 350 | - | sess.mu.Lock() | |||
| 351 | tt.mutate(sess, candidate) | 376 | tt.mutate(sess, candidate) | ||
| 352 | - | sess.mu.Unlock() | 377 | + | activeBeforeCommit := activeTabIndex(sess) |
| 353 | 378 | ||||
| 354 | require.False(t, d.commitTabOverflow(sess, candidate)) | 379 | require.False(t, d.commitTabOverflow(sess, candidate)) | ||
| 380 | + | require.Equal(t, activeBeforeCommit, activeTabIndex(sess)) | |||
| 355 | }) | 381 | }) | ||
| 356 | } | 382 | } | ||
| 357 | } | 383 | } | ||
M internal/usecase/layout/ops.go +43 -45
| @@ -414,30 +414,7 @@ func (t *Tree) EntryPane(dir Direction, span, area domain.Rect) (PaneID, error) | |||
| 414 | 414 | return "", ErrNoPane | |
| 415 | 415 | } | |
| 416 | 416 | ||
| 417 | - | type candidate struct { | |
| 418 | - | id PaneID | |
| 419 | - | gap int | |
| 420 | - | overlap int | |
| 421 | - | } | |
| 422 | - | candidates := make([]candidate, 0, len(placements)) | |
| 423 | - | for _, placement := range placements { | |
| 424 | - | if facing, gap, perpendicularOverlap := sideGapOverlap(outside, focusRect(placement), dir); facing { | |
| 425 | - | candidates = append(candidates, candidate{id: placement.ID, gap: gap, overlap: perpendicularOverlap}) | |
| 426 | - | } | |
| 427 | - | } | |
| 428 | - | if len(candidates) == 0 { | |
| 429 | - | return "", ErrNoPane | |
| 430 | - | } | |
| 431 | - | sort.Slice(candidates, func(i, j int) bool { | |
| 432 | - | if candidates[i].gap != candidates[j].gap { | |
| 433 | - | return candidates[i].gap < candidates[j].gap | |
| 434 | - | } | |
| 435 | - | if candidates[i].overlap != candidates[j].overlap { | |
| 436 | - | return candidates[i].overlap > candidates[j].overlap | |
| 437 | - | } | |
| 438 | - | return candidates[i].id < candidates[j].id | |
| 439 | - | }) | |
| 440 | - | return candidates[0].id, nil | |
| 417 | + | return selectFacingCandidate(placements, outside, dir, facingGapFirst) | |
| 441 | 418 | } | |
| 442 | 419 | ||
| 443 | 420 | // FocusEnter focuses the best pane on the facing edge and expands it when it | |
| @@ -476,36 +453,57 @@ func (t *Tree) FocusDir(dir Direction, area domain.Rect) error { | |||
| 476 | 453 | return ErrNotFound | |
| 477 | 454 | } | |
| 478 | 455 | current := focusRect(placements[currentIdx]) | |
| 479 | - | type candidate struct { | |
| 480 | - | id PaneID | |
| 481 | - | overlap int | |
| 482 | - | gap int | |
| 456 | + | candidate, err := selectFacingCandidate(placements, current, dir, facingOverlapFirst) | |
| 457 | + | if err != nil { | |
| 458 | + | return err | |
| 483 | 459 | } | |
| 484 | - | var candidates []candidate | |
| 485 | - | for _, p := range placements { | |
| 486 | - | if p.ID == t.Focus { | |
| 487 | - | continue | |
| 488 | - | } | |
| 489 | - | r := focusRect(p) | |
| 490 | - | if ok, gap, overlap := sideGapOverlap(current, r, dir); ok { | |
| 491 | - | 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}) | |
| 492 | 483 | } | |
| 493 | 484 | } | |
| 494 | 485 | if len(candidates) == 0 { | |
| 495 | - | return ErrNoPane | |
| 486 | + | return "", ErrNoPane | |
| 496 | 487 | } | |
| 497 | 488 | sort.Slice(candidates, func(i, j int) bool { | |
| 498 | - | if candidates[i].overlap != candidates[j].overlap { | |
| 499 | - | return candidates[i].overlap > candidates[j].overlap | |
| 500 | - | } | |
| 501 | - | if candidates[i].gap != candidates[j].gap { | |
| 502 | - | 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 | + | } | |
| 503 | 503 | } | |
| 504 | 504 | return candidates[i].id < candidates[j].id | |
| 505 | 505 | }) | |
| 506 | - | t.Focus = candidates[0].id | |
| 507 | - | setExpanded(t.Root, t.Focus) | |
| 508 | - | return nil | |
| 506 | + | return candidates[0].id, nil | |
| 509 | 507 | } | |
| 510 | 508 | ||
| 511 | 509 | func focusStackLocal(n *Node, id PaneID, dir Direction, focus *PaneID) bool { | |
| @@ -414,30 +414,7 @@ func (t *Tree) EntryPane(dir Direction, span, area domain.Rect) (PaneID, error) | |||||
| 414 | return "", ErrNoPane | 414 | return "", ErrNoPane | ||
| 415 | } | 415 | } | ||
| 416 | 416 | ||||
| 417 | - | type candidate struct { | 417 | + | return selectFacingCandidate(placements, outside, dir, facingGapFirst) |
| 418 | - | id PaneID | |||
| 419 | - | gap int | |||
| 420 | - | overlap int | |||
| 421 | - | } | |||
| 422 | - | candidates := make([]candidate, 0, len(placements)) | |||
| 423 | - | for _, placement := range placements { | |||
| 424 | - | if facing, gap, perpendicularOverlap := sideGapOverlap(outside, focusRect(placement), dir); facing { | |||
| 425 | - | candidates = append(candidates, candidate{id: placement.ID, gap: gap, overlap: perpendicularOverlap}) | |||
| 426 | - | } | |||
| 427 | - | } | |||
| 428 | - | if len(candidates) == 0 { | |||
| 429 | - | return "", ErrNoPane | |||
| 430 | - | } | |||
| 431 | - | sort.Slice(candidates, func(i, j int) bool { | |||
| 432 | - | if candidates[i].gap != candidates[j].gap { | |||
| 433 | - | return candidates[i].gap < candidates[j].gap | |||
| 434 | - | } | |||
| 435 | - | if candidates[i].overlap != candidates[j].overlap { | |||
| 436 | - | return candidates[i].overlap > candidates[j].overlap | |||
| 437 | - | } | |||
| 438 | - | return candidates[i].id < candidates[j].id | |||
| 439 | - | }) | |||
| 440 | - | return candidates[0].id, nil | |||
| 441 | } | 418 | } | ||
| 442 | 419 | ||||
| 443 | // FocusEnter focuses the best pane on the facing edge and expands it when it | 420 | // FocusEnter focuses the best pane on the facing edge and expands it when it | ||
| @@ -476,36 +453,57 @@ func (t *Tree) FocusDir(dir Direction, area domain.Rect) error { | |||||
| 476 | return ErrNotFound | 453 | return ErrNotFound | ||
| 477 | } | 454 | } | ||
| 478 | current := focusRect(placements[currentIdx]) | 455 | current := focusRect(placements[currentIdx]) | ||
| 479 | - | type candidate struct { | 456 | + | candidate, err := selectFacingCandidate(placements, current, dir, facingOverlapFirst) |
| 480 | - | id PaneID | 457 | + | if err != nil { |
| 481 | - | overlap int | 458 | + | return err |
| 482 | - | gap int | |||
| 483 | } | 459 | } | ||
| 484 | - | var candidates []candidate | 460 | + | t.Focus = candidate |
| 485 | - | for _, p := range placements { | 461 | + | setExpanded(t.Root, t.Focus) |
| 486 | - | if p.ID == t.Focus { | 462 | + | return nil |
| 487 | - | continue | 463 | + | } |
| 488 | - | } | 464 | + | |
| 489 | - | r := focusRect(p) | 465 | + | type facingMetricPriority uint8 |
| 490 | - | if ok, gap, overlap := sideGapOverlap(current, r, dir); ok { | 466 | + | |
| 491 | - | 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}) | |||
| 492 | } | 483 | } | ||
| 493 | } | 484 | } | ||
| 494 | if len(candidates) == 0 { | 485 | if len(candidates) == 0 { | ||
| 495 | - | return ErrNoPane | 486 | + | return "", ErrNoPane |
| 496 | } | 487 | } | ||
| 497 | sort.Slice(candidates, func(i, j int) bool { | 488 | sort.Slice(candidates, func(i, j int) bool { | ||
| 498 | - | if candidates[i].overlap != candidates[j].overlap { | 489 | + | if priority == facingGapFirst { |
| 499 | - | return candidates[i].overlap > candidates[j].overlap | 490 | + | if candidates[i].gap != candidates[j].gap { |
| 500 | - | } | 491 | + | return candidates[i].gap < candidates[j].gap |
| 501 | - | if candidates[i].gap != candidates[j].gap { | 492 | + | } |
| 502 | - | 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 | + | } | |||
| 503 | } | 503 | } | ||
| 504 | return candidates[i].id < candidates[j].id | 504 | return candidates[i].id < candidates[j].id | ||
| 505 | }) | 505 | }) | ||
| 506 | - | t.Focus = candidates[0].id | 506 | + | return candidates[0].id, nil |
| 507 | - | setExpanded(t.Root, t.Focus) | |||
| 508 | - | return nil | |||
| 509 | } | 507 | } | ||
| 510 | 508 | ||||
| 511 | func focusStackLocal(n *Node, id PaneID, dir Direction, focus *PaneID) bool { | 509 | func focusStackLocal(n *Node, id PaneID, dir Direction, focus *PaneID) bool { | ||