fix(core): rank screen entry against current workspace and frame edges
- commit
- 6369b15c11f7b0f616250a1f8065f2d86577bacc
- author
- brice · · 2026-10-23 06:38 UTC
- parent
- 15f8339
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +114 -38
M internal/core/action.go +0 -31
| @@ -670,34 +670,3 @@ func moveFloat(cur, to *Workspace, id WindowID) { | |||
| 670 | 670 | to.Activate(id) | |
| 671 | 671 | to.SetFullscreen(id, true) | |
| 672 | 672 | } | |
| 673 | - | ||
| 674 | - | // focusEntry selects the visible window nearest the edge entered from d. | |
| 675 | - | // Layout order breaks ties; an empty viewport preserves the existing focus. | |
| 676 | - | func (m *Monitor) focusEntry(d direction) { | |
| 677 | - | view := m.viewport() | |
| 678 | - | var id WindowID | |
| 679 | - | best := 0 | |
| 680 | - | for _, p := range m.Layout() { | |
| 681 | - | if !view.shows(p) { | |
| 682 | - | continue | |
| 683 | - | } | |
| 684 | - | var distance int | |
| 685 | - | area := view.area(p) | |
| 686 | - | switch d { | |
| 687 | - | case dirLeft: | |
| 688 | - | distance = max(0, area.X+area.W-(p.Rect.X+p.Rect.W)) | |
| 689 | - | case dirRight: | |
| 690 | - | distance = max(0, p.Rect.X-area.X) | |
| 691 | - | case dirUp: | |
| 692 | - | distance = max(0, area.Y+area.H-(p.Rect.Y+p.Rect.H)) | |
| 693 | - | case dirDown: | |
| 694 | - | distance = max(0, p.Rect.Y-area.Y) | |
| 695 | - | } | |
| 696 | - | if id == 0 || distance < best { | |
| 697 | - | id, best = p.ID, distance | |
| 698 | - | } | |
| 699 | - | } | |
| 700 | - | if id != 0 { | |
| 701 | - | m.Current().FocusID(id) | |
| 702 | - | } | |
| 703 | - | } | |
| @@ -670,34 +670,3 @@ func moveFloat(cur, to *Workspace, id WindowID) { | |||||
| 670 | to.Activate(id) | 670 | to.Activate(id) | ||
| 671 | to.SetFullscreen(id, true) | 671 | to.SetFullscreen(id, true) | ||
| 672 | } | 672 | } | ||
| 673 | - | ||||
| 674 | - | // focusEntry selects the visible window nearest the edge entered from d. | |||
| 675 | - | // Layout order breaks ties; an empty viewport preserves the existing focus. | |||
| 676 | - | func (m *Monitor) focusEntry(d direction) { | |||
| 677 | - | view := m.viewport() | |||
| 678 | - | var id WindowID | |||
| 679 | - | best := 0 | |||
| 680 | - | for _, p := range m.Layout() { | |||
| 681 | - | if !view.shows(p) { | |||
| 682 | - | continue | |||
| 683 | - | } | |||
| 684 | - | var distance int | |||
| 685 | - | area := view.area(p) | |||
| 686 | - | switch d { | |||
| 687 | - | case dirLeft: | |||
| 688 | - | distance = max(0, area.X+area.W-(p.Rect.X+p.Rect.W)) | |||
| 689 | - | case dirRight: | |||
| 690 | - | distance = max(0, p.Rect.X-area.X) | |||
| 691 | - | case dirUp: | |||
| 692 | - | distance = max(0, area.Y+area.H-(p.Rect.Y+p.Rect.H)) | |||
| 693 | - | case dirDown: | |||
| 694 | - | distance = max(0, p.Rect.Y-area.Y) | |||
| 695 | - | } | |||
| 696 | - | if id == 0 || distance < best { | |||
| 697 | - | id, best = p.ID, distance | |||
| 698 | - | } | |||
| 699 | - | } | |||
| 700 | - | if id != 0 { | |||
| 701 | - | m.Current().FocusID(id) | |||
| 702 | - | } | |||
| 703 | - | } | |||
M internal/core/flash_test.go +10 -6
| @@ -606,12 +606,13 @@ func TestFlashCrossScreenActionsMarkDestinationWindow(t *testing.T) { | |||
| 606 | 606 | name string | |
| 607 | 607 | action Action | |
| 608 | 608 | occupied bool | |
| 609 | + | want WindowID | |
| 609 | 610 | }{ | |
| 610 | - | {"column focus to empty", ActionFocusColumnRight, false}, | |
| 611 | - | {"column focus to occupied", ActionFocusColumnRight, true}, | |
| 612 | - | {"monitor focus to empty", ActionFocusMonitorRight, false}, | |
| 613 | - | {"column move to empty", ActionMoveColumnRight, false}, | |
| 614 | - | {"column move to occupied", ActionMoveColumnRight, true}, | |
| 611 | + | {"column focus to empty", ActionFocusColumnRight, false, 0}, | |
| 612 | + | {"column focus to occupied", ActionFocusColumnRight, true, 4}, | |
| 613 | + | {"monitor focus to empty", ActionFocusMonitorRight, false, 0}, | |
| 614 | + | {"column move to empty", ActionMoveColumnRight, false, 3}, | |
| 615 | + | {"column move to occupied", ActionMoveColumnRight, true, 3}, | |
| 615 | 616 | } { | |
| 616 | 617 | t.Run(tc.name, func(t *testing.T) { | |
| 617 | 618 | c, clock := flashCore(t) | |
| @@ -627,7 +628,10 @@ func TestFlashCrossScreenActionsMarkDestinationWindow(t *testing.T) { | |||
| 627 | 628 | t.Fatal("action did not cross screens") | |
| 628 | 629 | } | |
| 629 | 630 | id, _ := b.mon.Focused() | |
| 630 | - | if id == 0 { | |
| 631 | + | if id != tc.want { | |
| 632 | + | t.Fatalf("focus %d want %d", id, tc.want) | |
| 633 | + | } | |
| 634 | + | if tc.want == 0 { | |
| 631 | 635 | if c.flash.pending != nil { | |
| 632 | 636 | t.Fatalf("empty destination request %+v", c.flash.pending) | |
| 633 | 637 | } | |
| @@ -606,12 +606,13 @@ func TestFlashCrossScreenActionsMarkDestinationWindow(t *testing.T) { | |||||
| 606 | name string | 606 | name string | ||
| 607 | action Action | 607 | action Action | ||
| 608 | occupied bool | 608 | occupied bool | ||
| 609 | + | want WindowID | |||
| 609 | }{ | 610 | }{ | ||
| 610 | - | {"column focus to empty", ActionFocusColumnRight, false}, | 611 | + | {"column focus to empty", ActionFocusColumnRight, false, 0}, |
| 611 | - | {"column focus to occupied", ActionFocusColumnRight, true}, | 612 | + | {"column focus to occupied", ActionFocusColumnRight, true, 4}, |
| 612 | - | {"monitor focus to empty", ActionFocusMonitorRight, false}, | 613 | + | {"monitor focus to empty", ActionFocusMonitorRight, false, 0}, |
| 613 | - | {"column move to empty", ActionMoveColumnRight, false}, | 614 | + | {"column move to empty", ActionMoveColumnRight, false, 3}, |
| 614 | - | {"column move to occupied", ActionMoveColumnRight, true}, | 615 | + | {"column move to occupied", ActionMoveColumnRight, true, 3}, |
| 615 | } { | 616 | } { | ||
| 616 | t.Run(tc.name, func(t *testing.T) { | 617 | t.Run(tc.name, func(t *testing.T) { | ||
| 617 | c, clock := flashCore(t) | 618 | c, clock := flashCore(t) | ||
| @@ -627,7 +628,10 @@ func TestFlashCrossScreenActionsMarkDestinationWindow(t *testing.T) { | |||||
| 627 | t.Fatal("action did not cross screens") | 628 | t.Fatal("action did not cross screens") | ||
| 628 | } | 629 | } | ||
| 629 | id, _ := b.mon.Focused() | 630 | id, _ := b.mon.Focused() | ||
| 630 | - | if id == 0 { | 631 | + | if id != tc.want { |
| 632 | + | t.Fatalf("focus %d want %d", id, tc.want) | |||
| 633 | + | } | |||
| 634 | + | if tc.want == 0 { | |||
| 631 | if c.flash.pending != nil { | 635 | if c.flash.pending != nil { | ||
| 632 | t.Fatalf("empty destination request %+v", c.flash.pending) | 636 | t.Fatalf("empty destination request %+v", c.flash.pending) | ||
| 633 | } | 637 | } | ||
M internal/core/monitor.go +36 -0
| @@ -448,6 +448,42 @@ func (w *Workspace) receive(col Column, at int) { | |||
| 448 | 448 | w.Activate(id) | |
| 449 | 449 | } | |
| 450 | 450 | ||
| 451 | + | // focusEntry selects the visible window nearest the edge entered from d. | |
| 452 | + | // Layout order breaks ties; an empty viewport preserves the existing focus. | |
| 453 | + | func (m *Monitor) focusEntry(d direction) { | |
| 454 | + | if m.ov.open || m.switcherShown() { | |
| 455 | + | return | |
| 456 | + | } | |
| 457 | + | w := m.Current() | |
| 458 | + | view := m.viewport() | |
| 459 | + | var id WindowID | |
| 460 | + | best := 0 | |
| 461 | + | for _, p := range m.Layout() { | |
| 462 | + | if p.Preview != 0 || !w.has(p.ID) || !view.shows(p) { | |
| 463 | + | continue | |
| 464 | + | } | |
| 465 | + | var distance int | |
| 466 | + | clip := intersect(p.Rect, view.area(p)) | |
| 467 | + | frame := view.frame | |
| 468 | + | switch d { | |
| 469 | + | case dirLeft: | |
| 470 | + | distance = frame.X + frame.W - (clip.X + clip.W) | |
| 471 | + | case dirRight: | |
| 472 | + | distance = clip.X - frame.X | |
| 473 | + | case dirUp: | |
| 474 | + | distance = frame.Y + frame.H - (clip.Y + clip.H) | |
| 475 | + | case dirDown: | |
| 476 | + | distance = clip.Y - frame.Y | |
| 477 | + | } | |
| 478 | + | if id == 0 || distance < best { | |
| 479 | + | id, best = p.ID, distance | |
| 480 | + | } | |
| 481 | + | } | |
| 482 | + | if id != 0 { | |
| 483 | + | w.FocusID(id) | |
| 484 | + | } | |
| 485 | + | } | |
| 486 | + | ||
| 451 | 487 | // Focused is the focused window of the workspace on screen; in the | |
| 452 | 488 | // overview, the selected stash card when it is one. | |
| 453 | 489 | func (m *Monitor) Focused() (WindowID, bool) { | |
| @@ -448,6 +448,42 @@ func (w *Workspace) receive(col Column, at int) { | |||||
| 448 | w.Activate(id) | 448 | w.Activate(id) | ||
| 449 | } | 449 | } | ||
| 450 | 450 | ||||
| 451 | + | // focusEntry selects the visible window nearest the edge entered from d. | |||
| 452 | + | // Layout order breaks ties; an empty viewport preserves the existing focus. | |||
| 453 | + | func (m *Monitor) focusEntry(d direction) { | |||
| 454 | + | if m.ov.open || m.switcherShown() { | |||
| 455 | + | return | |||
| 456 | + | } | |||
| 457 | + | w := m.Current() | |||
| 458 | + | view := m.viewport() | |||
| 459 | + | var id WindowID | |||
| 460 | + | best := 0 | |||
| 461 | + | for _, p := range m.Layout() { | |||
| 462 | + | if p.Preview != 0 || !w.has(p.ID) || !view.shows(p) { | |||
| 463 | + | continue | |||
| 464 | + | } | |||
| 465 | + | var distance int | |||
| 466 | + | clip := intersect(p.Rect, view.area(p)) | |||
| 467 | + | frame := view.frame | |||
| 468 | + | switch d { | |||
| 469 | + | case dirLeft: | |||
| 470 | + | distance = frame.X + frame.W - (clip.X + clip.W) | |||
| 471 | + | case dirRight: | |||
| 472 | + | distance = clip.X - frame.X | |||
| 473 | + | case dirUp: | |||
| 474 | + | distance = frame.Y + frame.H - (clip.Y + clip.H) | |||
| 475 | + | case dirDown: | |||
| 476 | + | distance = clip.Y - frame.Y | |||
| 477 | + | } | |||
| 478 | + | if id == 0 || distance < best { | |||
| 479 | + | id, best = p.ID, distance | |||
| 480 | + | } | |||
| 481 | + | } | |||
| 482 | + | if id != 0 { | |||
| 483 | + | w.FocusID(id) | |||
| 484 | + | } | |||
| 485 | + | } | |||
| 486 | + | ||||
| 451 | // Focused is the focused window of the workspace on screen; in the | 487 | // Focused is the focused window of the workspace on screen; in the | ||
| 452 | // overview, the selected stash card when it is one. | 488 | // overview, the selected stash card when it is one. | ||
| 453 | func (m *Monitor) Focused() (WindowID, bool) { | 489 | func (m *Monitor) Focused() (WindowID, bool) { | ||
M internal/core/screen_entry_test.go +68 -1
| @@ -31,7 +31,7 @@ func TestCrossScreenFocusEntry(t *testing.T) { | |||
| 31 | 31 | w.addFloat(Float{ID: id, W: 40, H: 40, free: true, cx: pos[0], cy: pos[1]}, true) | |
| 32 | 32 | } | |
| 33 | 33 | w.FocusID(4) | |
| 34 | - | if edge { | |
| 34 | + | if edge && (tc.d == dirLeft || tc.d == dirRight) { | |
| 35 | 35 | if !c.edgeFocus(horizontalAxis, 1, tc.d) { | |
| 36 | 36 | t.Fatal("did not cross") | |
| 37 | 37 | } | |
| @@ -103,3 +103,70 @@ func TestCrossScreenNoVisibleWindowPreservesFocus(t *testing.T) { | |||
| 103 | 103 | t.Fatalf("focus %d flash %+v", id, c.flash.pending) | |
| 104 | 104 | } | |
| 105 | 105 | } | |
| 106 | + | ||
| 107 | + | func TestFocusEntryPreservesOverview(t *testing.T) { | |
| 108 | + | m := overviewMonitor() | |
| 109 | + | m.ToggleOverview() | |
| 110 | + | m.OverviewMove(-1, 0) | |
| 111 | + | selected, at := m.ov.selected, m.ov.selectedAt | |
| 112 | + | focused, _ := m.Current().Focused() | |
| 113 | + | for _, d := range []direction{dirLeft, dirRight, dirUp, dirDown} { | |
| 114 | + | m.focusEntry(d) | |
| 115 | + | got, _ := m.Current().Focused() | |
| 116 | + | if !m.ov.open || m.ov.selected != selected || m.ov.selectedAt != at || got != focused { | |
| 117 | + | t.Fatalf("overview selection or focus changed: selected %d focus %d", m.ov.selected, got) | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func TestFocusEntryDuringWorkspaceSlide(t *testing.T) { | |
| 123 | + | m := overviewMonitor() | |
| 124 | + | m.Focus(1) | |
| 125 | + | m.AddWindow(5) | |
| 126 | + | m.Current().FocusID(5) | |
| 127 | + | m.switchAxis = verticalAxis | |
| 128 | + | m.switchView.off = -.5 | |
| 129 | + | view := m.viewport() | |
| 130 | + | outgoing := false | |
| 131 | + | for _, p := range m.Layout() { | |
| 132 | + | if !m.Current().has(p.ID) && view.shows(p) { | |
| 133 | + | outgoing = true | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | if !outgoing { | |
| 137 | + | t.Fatal("setup: outgoing workspace must be visible") | |
| 138 | + | } | |
| 139 | + | m.focusEntry(dirDown) | |
| 140 | + | id, _ := m.Current().Focused() | |
| 141 | + | if id != 4 { | |
| 142 | + | t.Fatalf("focus %d want current workspace window 4", id) | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func TestFocusEntryPanelUsesCommonFrameEdge(t *testing.T) { | |
| 147 | + | c, _ := flashCore(t) | |
| 148 | + | c.setLayers([]ports.LayerSurface{{ID: 90, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 50, ExclusiveZone: 50}}) | |
| 149 | + | m := c.cur().mon | |
| 150 | + | w := m.Current() | |
| 151 | + | // The tile reaches the bottom frame edge; the float stops above it. | |
| 152 | + | w.addFloat(Float{ID: 4, W: 40, H: 40, free: true, cx: .5, cy: .8}, true) | |
| 153 | + | view := m.viewport() | |
| 154 | + | if view.usable.H >= view.frame.H { | |
| 155 | + | t.Fatal("setup: missing exclusive panel") | |
| 156 | + | } | |
| 157 | + | var want WindowID | |
| 158 | + | for _, p := range m.Layout() { | |
| 159 | + | if !p.Floating && view.shows(p) { | |
| 160 | + | want = p.ID | |
| 161 | + | break | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | if want == 0 { | |
| 165 | + | t.Fatal("setup: no visible tile") | |
| 166 | + | } | |
| 167 | + | m.focusEntry(dirUp) | |
| 168 | + | id, _ := w.Focused() | |
| 169 | + | if id != want { | |
| 170 | + | t.Fatalf("focus %d want tile %d", id, want) | |
| 171 | + | } | |
| 172 | + | } | |
| @@ -31,7 +31,7 @@ func TestCrossScreenFocusEntry(t *testing.T) { | |||||
| 31 | w.addFloat(Float{ID: id, W: 40, H: 40, free: true, cx: pos[0], cy: pos[1]}, true) | 31 | w.addFloat(Float{ID: id, W: 40, H: 40, free: true, cx: pos[0], cy: pos[1]}, true) | ||
| 32 | } | 32 | } | ||
| 33 | w.FocusID(4) | 33 | w.FocusID(4) | ||
| 34 | - | if edge { | 34 | + | if edge && (tc.d == dirLeft || tc.d == dirRight) { |
| 35 | if !c.edgeFocus(horizontalAxis, 1, tc.d) { | 35 | if !c.edgeFocus(horizontalAxis, 1, tc.d) { | ||
| 36 | t.Fatal("did not cross") | 36 | t.Fatal("did not cross") | ||
| 37 | } | 37 | } | ||
| @@ -103,3 +103,70 @@ func TestCrossScreenNoVisibleWindowPreservesFocus(t *testing.T) { | |||||
| 103 | t.Fatalf("focus %d flash %+v", id, c.flash.pending) | 103 | t.Fatalf("focus %d flash %+v", id, c.flash.pending) | ||
| 104 | } | 104 | } | ||
| 105 | } | 105 | } | ||
| 106 | + | ||||
| 107 | + | func TestFocusEntryPreservesOverview(t *testing.T) { | |||
| 108 | + | m := overviewMonitor() | |||
| 109 | + | m.ToggleOverview() | |||
| 110 | + | m.OverviewMove(-1, 0) | |||
| 111 | + | selected, at := m.ov.selected, m.ov.selectedAt | |||
| 112 | + | focused, _ := m.Current().Focused() | |||
| 113 | + | for _, d := range []direction{dirLeft, dirRight, dirUp, dirDown} { | |||
| 114 | + | m.focusEntry(d) | |||
| 115 | + | got, _ := m.Current().Focused() | |||
| 116 | + | if !m.ov.open || m.ov.selected != selected || m.ov.selectedAt != at || got != focused { | |||
| 117 | + | t.Fatalf("overview selection or focus changed: selected %d focus %d", m.ov.selected, got) | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func TestFocusEntryDuringWorkspaceSlide(t *testing.T) { | |||
| 123 | + | m := overviewMonitor() | |||
| 124 | + | m.Focus(1) | |||
| 125 | + | m.AddWindow(5) | |||
| 126 | + | m.Current().FocusID(5) | |||
| 127 | + | m.switchAxis = verticalAxis | |||
| 128 | + | m.switchView.off = -.5 | |||
| 129 | + | view := m.viewport() | |||
| 130 | + | outgoing := false | |||
| 131 | + | for _, p := range m.Layout() { | |||
| 132 | + | if !m.Current().has(p.ID) && view.shows(p) { | |||
| 133 | + | outgoing = true | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 136 | + | if !outgoing { | |||
| 137 | + | t.Fatal("setup: outgoing workspace must be visible") | |||
| 138 | + | } | |||
| 139 | + | m.focusEntry(dirDown) | |||
| 140 | + | id, _ := m.Current().Focused() | |||
| 141 | + | if id != 4 { | |||
| 142 | + | t.Fatalf("focus %d want current workspace window 4", id) | |||
| 143 | + | } | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | func TestFocusEntryPanelUsesCommonFrameEdge(t *testing.T) { | |||
| 147 | + | c, _ := flashCore(t) | |||
| 148 | + | c.setLayers([]ports.LayerSurface{{ID: 90, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 50, ExclusiveZone: 50}}) | |||
| 149 | + | m := c.cur().mon | |||
| 150 | + | w := m.Current() | |||
| 151 | + | // The tile reaches the bottom frame edge; the float stops above it. | |||
| 152 | + | w.addFloat(Float{ID: 4, W: 40, H: 40, free: true, cx: .5, cy: .8}, true) | |||
| 153 | + | view := m.viewport() | |||
| 154 | + | if view.usable.H >= view.frame.H { | |||
| 155 | + | t.Fatal("setup: missing exclusive panel") | |||
| 156 | + | } | |||
| 157 | + | var want WindowID | |||
| 158 | + | for _, p := range m.Layout() { | |||
| 159 | + | if !p.Floating && view.shows(p) { | |||
| 160 | + | want = p.ID | |||
| 161 | + | break | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | if want == 0 { | |||
| 165 | + | t.Fatal("setup: no visible tile") | |||
| 166 | + | } | |||
| 167 | + | m.focusEntry(dirUp) | |||
| 168 | + | id, _ := w.Focused() | |||
| 169 | + | if id != want { | |||
| 170 | + | t.Fatalf("focus %d want tile %d", id, want) | |||
| 171 | + | } | |||
| 172 | + | } | |||