Merge c64260b018f2a81c31292054ab41102b41d60410 into ec21f3234eec62047f4cd53d14e21a5b02c00735
4 files · +131 -9
M internal/app/session_lock_test.go +4 -1
| @@ -3,6 +3,7 @@ package app | |||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | + | "errors" | |
| 6 | 7 | "io" | |
| 7 | 8 | "os" | |
| 8 | 9 | "path/filepath" | |
| @@ -52,7 +53,9 @@ func TestHeadlessSessionLockEndToEnd(t *testing.T) { | |||
| 52 | 53 | } | |
| 53 | 54 | select { | |
| 54 | 55 | case err := <-done: | |
| 55 | - | if err != nil { | |
| 56 | + | // We canceled Run above. Workers may report that cancellation | |
| 57 | + | // during shutdown; it is not a failed session-lock assertion. | |
| 58 | + | if err != nil && !errors.Is(err, context.Canceled) { | |
| 56 | 59 | t.Errorf("Run: %v", err) | |
| 57 | 60 | } | |
| 58 | 61 | case <-time.After(3 * time.Second): | |
| @@ -3,6 +3,7 @@ package app | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | + | "errors" | |||
| 6 | "io" | 7 | "io" | ||
| 7 | "os" | 8 | "os" | ||
| 8 | "path/filepath" | 9 | "path/filepath" | ||
| @@ -52,7 +53,9 @@ func TestHeadlessSessionLockEndToEnd(t *testing.T) { | |||||
| 52 | } | 53 | } | ||
| 53 | select { | 54 | select { | ||
| 54 | case err := <-done: | 55 | case err := <-done: | ||
| 55 | - | if err != nil { | 56 | + | // We canceled Run above. Workers may report that cancellation |
| 57 | + | // during shutdown; it is not a failed session-lock assertion. | |||
| 58 | + | if err != nil && !errors.Is(err, context.Canceled) { | |||
| 56 | t.Errorf("Run: %v", err) | 59 | t.Errorf("Run: %v", err) | ||
| 57 | } | 60 | } | ||
| 58 | case <-time.After(3 * time.Second): | 61 | case <-time.After(3 * time.Second): | ||
M internal/core/drag.go +1 -2
| @@ -154,14 +154,13 @@ func (c *Core) beginDrag(ctx context.Context, id WindowID, button uint32, resize | |||
| 154 | 154 | return nil | |
| 155 | 155 | } | |
| 156 | 156 | // Click raises a float above the columns: Escape keeps that. | |
| 157 | - | w.Click(id) | |
| 157 | + | c.focusClick(s, w, id) | |
| 158 | 158 | d := &dragState{id: id, button: button, resize: resize, edges: edges, start: [2]float64{c.cursorX, c.cursorY}} | |
| 159 | 159 | if i := w.floatIndex(id); i >= 0 { | |
| 160 | 160 | d.float, d.from, d.rect = true, w.Floats[i], w.floatRect(w.Floats[i]) | |
| 161 | 161 | } | |
| 162 | 162 | c.drag = d | |
| 163 | 163 | c.buttons[button] = true | |
| 164 | - | c.focusScreen = c.screenIndex(s.name()) | |
| 165 | 164 | c.grab = 0 | |
| 166 | 165 | if c.pointer != 0 { | |
| 167 | 166 | c.pointer = 0 | |
| @@ -154,14 +154,13 @@ func (c *Core) beginDrag(ctx context.Context, id WindowID, button uint32, resize | |||||
| 154 | return nil | 154 | return nil | ||
| 155 | } | 155 | } | ||
| 156 | // Click raises a float above the columns: Escape keeps that. | 156 | // Click raises a float above the columns: Escape keeps that. | ||
| 157 | - | w.Click(id) | 157 | + | c.focusClick(s, w, id) |
| 158 | d := &dragState{id: id, button: button, resize: resize, edges: edges, start: [2]float64{c.cursorX, c.cursorY}} | 158 | d := &dragState{id: id, button: button, resize: resize, edges: edges, start: [2]float64{c.cursorX, c.cursorY}} | ||
| 159 | if i := w.floatIndex(id); i >= 0 { | 159 | if i := w.floatIndex(id); i >= 0 { | ||
| 160 | d.float, d.from, d.rect = true, w.Floats[i], w.floatRect(w.Floats[i]) | 160 | d.float, d.from, d.rect = true, w.Floats[i], w.floatRect(w.Floats[i]) | ||
| 161 | } | 161 | } | ||
| 162 | c.drag = d | 162 | c.drag = d | ||
| 163 | c.buttons[button] = true | 163 | c.buttons[button] = true | ||
| 164 | - | c.focusScreen = c.screenIndex(s.name()) | |||
| 165 | c.grab = 0 | 164 | c.grab = 0 | ||
| 166 | if c.pointer != 0 { | 165 | if c.pointer != 0 { | ||
| 167 | c.pointer = 0 | 166 | c.pointer = 0 | ||
M internal/core/flash_test.go +113 -4
| @@ -389,11 +389,8 @@ func TestFlashNoImplicitFocusTriggers(t *testing.T) { | |||
| 389 | 389 | if err := c.pointerMotion(ctx, ports.PointerMotion{X: 50, Y: 50}); err != nil { | |
| 390 | 390 | t.Fatal(err) | |
| 391 | 391 | } | |
| 392 | - | if err := c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Pressed: true}); err != nil { | |
| 393 | - | t.Fatal(err) | |
| 394 | - | } | |
| 395 | 392 | if c.flash.pending != nil { | |
| 396 | - | t.Fatal("pointer focus flashed") | |
| 393 | + | t.Fatal("hover focus flashed") | |
| 397 | 394 | } | |
| 398 | 395 | } | |
| 399 | 396 | ||
| @@ -774,3 +771,115 @@ func TestFlashFollowsInnerBorderAndRadius(t *testing.T) { | |||
| 774 | 771 | }) | |
| 775 | 772 | } | |
| 776 | 773 | } | |
| 774 | + | ||
| 775 | + | func TestFlashClickFocus(t *testing.T) { | |
| 776 | + | for _, test := range []struct { | |
| 777 | + | name string | |
| 778 | + | target WindowID | |
| 779 | + | enabled bool | |
| 780 | + | want bool | |
| 781 | + | }{ | |
| 782 | + | {"different window", 1, true, true}, | |
| 783 | + | {"already focused", 3, true, false}, | |
| 784 | + | {"disabled", 1, false, false}, | |
| 785 | + | } { | |
| 786 | + | t.Run(test.name, func(t *testing.T) { | |
| 787 | + | c, clock := flashCore(t) | |
| 788 | + | c.cfg.Focus.Flash = test.enabled | |
| 789 | + | indicatorScene(t, c) | |
| 790 | + | if c.keyboardFocus() != 3 { | |
| 791 | + | t.Fatal("setup: expected window 3 focused") | |
| 792 | + | } | |
| 793 | + | c.pointer = test.target | |
| 794 | + | if err := c.pointerButton(context.Background(), ports.PointerButton{Button: btnLeft, Pressed: true}); err != nil { | |
| 795 | + | t.Fatal(err) | |
| 796 | + | } | |
| 797 | + | if c.keyboardFocus() != test.target { | |
| 798 | + | t.Fatalf("focus = %d, want %d", c.keyboardFocus(), test.target) | |
| 799 | + | } | |
| 800 | + | if (c.flash.pending != nil) != test.want { | |
| 801 | + | t.Fatalf("flash request = %+v, want flash %v", c.flash.pending, test.want) | |
| 802 | + | } | |
| 803 | + | // Let the click's viewport transition settle before the flash. | |
| 804 | + | clock.now = clock.now.Add(time.Second) | |
| 805 | + | stepScenes(t, c, c.cur()) | |
| 806 | + | if c.flashTick() != test.want { | |
| 807 | + | t.Fatalf("flash start: want %v", test.want) | |
| 808 | + | } | |
| 809 | + | if test.want && (c.flash.running.window != test.target || c.flash.running.level != flashWindow || c.flashFor(c.cur()) == nil) { | |
| 810 | + | t.Fatalf("wrong running flash: %+v", c.flash.running) | |
| 811 | + | } | |
| 812 | + | }) | |
| 813 | + | } | |
| 814 | + | } | |
| 815 | + | ||
| 816 | + | func TestFlashClickModalFocus(t *testing.T) { | |
| 817 | + | for _, unchanged := range []bool{false, true} { | |
| 818 | + | t.Run(map[bool]string{false: "changed", true: "unchanged"}[unchanged], func(t *testing.T) { | |
| 819 | + | c, _ := flashCore(t) | |
| 820 | + | w := c.cur().mon.Current() | |
| 821 | + | c.windows.mapped(ports.WindowMapped{ID: 4}, c.now()) | |
| 822 | + | w.AddDialog(4, 1, 100, 80) | |
| 823 | + | w.SetModal(4, true) | |
| 824 | + | if !unchanged { | |
| 825 | + | w.Click(2) | |
| 826 | + | } | |
| 827 | + | indicatorScene(t, c) | |
| 828 | + | c.pointer = 1 | |
| 829 | + | if err := c.pointerButton(context.Background(), ports.PointerButton{Button: btnLeft, Pressed: true}); err != nil { | |
| 830 | + | t.Fatal(err) | |
| 831 | + | } | |
| 832 | + | if c.keyboardFocus() != 4 { | |
| 833 | + | t.Fatalf("focus = %d, want modal 4", c.keyboardFocus()) | |
| 834 | + | } | |
| 835 | + | if unchanged { | |
| 836 | + | if c.flash.pending != nil { | |
| 837 | + | t.Fatalf("unchanged modal flashed: %+v", c.flash.pending) | |
| 838 | + | } | |
| 839 | + | } else if c.flash.pending == nil || c.flash.pending.window != 4 { | |
| 840 | + | t.Fatalf("want modal flash, got %+v", c.flash.pending) | |
| 841 | + | } | |
| 842 | + | }) | |
| 843 | + | } | |
| 844 | + | } | |
| 845 | + | ||
| 846 | + | func TestFlashModifierClickDrag(t *testing.T) { | |
| 847 | + | for _, resize := range []bool{false, true} { | |
| 848 | + | for _, mode := range []string{"changed", "unchanged", "disabled"} { | |
| 849 | + | t.Run(map[bool]string{false: "move", true: "resize"}[resize]+"/"+mode, func(t *testing.T) { | |
| 850 | + | c, clock := flashCore(t) | |
| 851 | + | w := c.cur().mon.Current() | |
| 852 | + | w.Click(1) | |
| 853 | + | if resize { | |
| 854 | + | w.Apply(ActionToggleFloating) | |
| 855 | + | } | |
| 856 | + | if mode != "unchanged" { | |
| 857 | + | w.Click(3) | |
| 858 | + | } | |
| 859 | + | c.cfg.Focus.Flash = mode != "disabled" | |
| 860 | + | indicatorScene(t, c) | |
| 861 | + | c.pointer = 1 | |
| 862 | + | c.mods = c.cmdMod | |
| 863 | + | button := uint32(btnLeft) | |
| 864 | + | if resize { | |
| 865 | + | button = btnRight | |
| 866 | + | } | |
| 867 | + | if err := c.pointerButton(context.Background(), ports.PointerButton{Button: button, Pressed: true}); err != nil { | |
| 868 | + | t.Fatal(err) | |
| 869 | + | } | |
| 870 | + | if c.drag == nil || c.drag.id != 1 || c.drag.resize != resize { | |
| 871 | + | t.Fatalf("drag not started: %+v", c.drag) | |
| 872 | + | } | |
| 873 | + | want := mode == "changed" | |
| 874 | + | if (c.flash.pending != nil) != want { | |
| 875 | + | t.Fatalf("flash = %+v, want %v", c.flash.pending, want) | |
| 876 | + | } | |
| 877 | + | clock.now = clock.now.Add(time.Second) | |
| 878 | + | stepScenes(t, c, c.cur()) | |
| 879 | + | if c.flashTick() != want { | |
| 880 | + | t.Fatalf("flash start: want %v", want) | |
| 881 | + | } | |
| 882 | + | }) | |
| 883 | + | } | |
| 884 | + | } | |
| 885 | + | } | |
| @@ -389,11 +389,8 @@ func TestFlashNoImplicitFocusTriggers(t *testing.T) { | |||||
| 389 | if err := c.pointerMotion(ctx, ports.PointerMotion{X: 50, Y: 50}); err != nil { | 389 | if err := c.pointerMotion(ctx, ports.PointerMotion{X: 50, Y: 50}); err != nil { | ||
| 390 | t.Fatal(err) | 390 | t.Fatal(err) | ||
| 391 | } | 391 | } | ||
| 392 | - | if err := c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Pressed: true}); err != nil { | |||
| 393 | - | t.Fatal(err) | |||
| 394 | - | } | |||
| 395 | if c.flash.pending != nil { | 392 | if c.flash.pending != nil { | ||
| 396 | - | t.Fatal("pointer focus flashed") | 393 | + | t.Fatal("hover focus flashed") |
| 397 | } | 394 | } | ||
| 398 | } | 395 | } | ||
| 399 | 396 | ||||
| @@ -774,3 +771,115 @@ func TestFlashFollowsInnerBorderAndRadius(t *testing.T) { | |||||
| 774 | }) | 771 | }) | ||
| 775 | } | 772 | } | ||
| 776 | } | 773 | } | ||
| 774 | + | ||||
| 775 | + | func TestFlashClickFocus(t *testing.T) { | |||
| 776 | + | for _, test := range []struct { | |||
| 777 | + | name string | |||
| 778 | + | target WindowID | |||
| 779 | + | enabled bool | |||
| 780 | + | want bool | |||
| 781 | + | }{ | |||
| 782 | + | {"different window", 1, true, true}, | |||
| 783 | + | {"already focused", 3, true, false}, | |||
| 784 | + | {"disabled", 1, false, false}, | |||
| 785 | + | } { | |||
| 786 | + | t.Run(test.name, func(t *testing.T) { | |||
| 787 | + | c, clock := flashCore(t) | |||
| 788 | + | c.cfg.Focus.Flash = test.enabled | |||
| 789 | + | indicatorScene(t, c) | |||
| 790 | + | if c.keyboardFocus() != 3 { | |||
| 791 | + | t.Fatal("setup: expected window 3 focused") | |||
| 792 | + | } | |||
| 793 | + | c.pointer = test.target | |||
| 794 | + | if err := c.pointerButton(context.Background(), ports.PointerButton{Button: btnLeft, Pressed: true}); err != nil { | |||
| 795 | + | t.Fatal(err) | |||
| 796 | + | } | |||
| 797 | + | if c.keyboardFocus() != test.target { | |||
| 798 | + | t.Fatalf("focus = %d, want %d", c.keyboardFocus(), test.target) | |||
| 799 | + | } | |||
| 800 | + | if (c.flash.pending != nil) != test.want { | |||
| 801 | + | t.Fatalf("flash request = %+v, want flash %v", c.flash.pending, test.want) | |||
| 802 | + | } | |||
| 803 | + | // Let the click's viewport transition settle before the flash. | |||
| 804 | + | clock.now = clock.now.Add(time.Second) | |||
| 805 | + | stepScenes(t, c, c.cur()) | |||
| 806 | + | if c.flashTick() != test.want { | |||
| 807 | + | t.Fatalf("flash start: want %v", test.want) | |||
| 808 | + | } | |||
| 809 | + | if test.want && (c.flash.running.window != test.target || c.flash.running.level != flashWindow || c.flashFor(c.cur()) == nil) { | |||
| 810 | + | t.Fatalf("wrong running flash: %+v", c.flash.running) | |||
| 811 | + | } | |||
| 812 | + | }) | |||
| 813 | + | } | |||
| 814 | + | } | |||
| 815 | + | ||||
| 816 | + | func TestFlashClickModalFocus(t *testing.T) { | |||
| 817 | + | for _, unchanged := range []bool{false, true} { | |||
| 818 | + | t.Run(map[bool]string{false: "changed", true: "unchanged"}[unchanged], func(t *testing.T) { | |||
| 819 | + | c, _ := flashCore(t) | |||
| 820 | + | w := c.cur().mon.Current() | |||
| 821 | + | c.windows.mapped(ports.WindowMapped{ID: 4}, c.now()) | |||
| 822 | + | w.AddDialog(4, 1, 100, 80) | |||
| 823 | + | w.SetModal(4, true) | |||
| 824 | + | if !unchanged { | |||
| 825 | + | w.Click(2) | |||
| 826 | + | } | |||
| 827 | + | indicatorScene(t, c) | |||
| 828 | + | c.pointer = 1 | |||
| 829 | + | if err := c.pointerButton(context.Background(), ports.PointerButton{Button: btnLeft, Pressed: true}); err != nil { | |||
| 830 | + | t.Fatal(err) | |||
| 831 | + | } | |||
| 832 | + | if c.keyboardFocus() != 4 { | |||
| 833 | + | t.Fatalf("focus = %d, want modal 4", c.keyboardFocus()) | |||
| 834 | + | } | |||
| 835 | + | if unchanged { | |||
| 836 | + | if c.flash.pending != nil { | |||
| 837 | + | t.Fatalf("unchanged modal flashed: %+v", c.flash.pending) | |||
| 838 | + | } | |||
| 839 | + | } else if c.flash.pending == nil || c.flash.pending.window != 4 { | |||
| 840 | + | t.Fatalf("want modal flash, got %+v", c.flash.pending) | |||
| 841 | + | } | |||
| 842 | + | }) | |||
| 843 | + | } | |||
| 844 | + | } | |||
| 845 | + | ||||
| 846 | + | func TestFlashModifierClickDrag(t *testing.T) { | |||
| 847 | + | for _, resize := range []bool{false, true} { | |||
| 848 | + | for _, mode := range []string{"changed", "unchanged", "disabled"} { | |||
| 849 | + | t.Run(map[bool]string{false: "move", true: "resize"}[resize]+"/"+mode, func(t *testing.T) { | |||
| 850 | + | c, clock := flashCore(t) | |||
| 851 | + | w := c.cur().mon.Current() | |||
| 852 | + | w.Click(1) | |||
| 853 | + | if resize { | |||
| 854 | + | w.Apply(ActionToggleFloating) | |||
| 855 | + | } | |||
| 856 | + | if mode != "unchanged" { | |||
| 857 | + | w.Click(3) | |||
| 858 | + | } | |||
| 859 | + | c.cfg.Focus.Flash = mode != "disabled" | |||
| 860 | + | indicatorScene(t, c) | |||
| 861 | + | c.pointer = 1 | |||
| 862 | + | c.mods = c.cmdMod | |||
| 863 | + | button := uint32(btnLeft) | |||
| 864 | + | if resize { | |||
| 865 | + | button = btnRight | |||
| 866 | + | } | |||
| 867 | + | if err := c.pointerButton(context.Background(), ports.PointerButton{Button: button, Pressed: true}); err != nil { | |||
| 868 | + | t.Fatal(err) | |||
| 869 | + | } | |||
| 870 | + | if c.drag == nil || c.drag.id != 1 || c.drag.resize != resize { | |||
| 871 | + | t.Fatalf("drag not started: %+v", c.drag) | |||
| 872 | + | } | |||
| 873 | + | want := mode == "changed" | |||
| 874 | + | if (c.flash.pending != nil) != want { | |||
| 875 | + | t.Fatalf("flash = %+v, want %v", c.flash.pending, want) | |||
| 876 | + | } | |||
| 877 | + | clock.now = clock.now.Add(time.Second) | |||
| 878 | + | stepScenes(t, c, c.cur()) | |||
| 879 | + | if c.flashTick() != want { | |||
| 880 | + | t.Fatalf("flash start: want %v", want) | |||
| 881 | + | } | |||
| 882 | + | }) | |||
| 883 | + | } | |||
| 884 | + | } | |||
| 885 | + | } | |||
M internal/core/input.go +13 -2
| @@ -136,12 +136,23 @@ func (c *Core) clickFocus(ctx context.Context, id WindowID) error { | |||
| 136 | 136 | } | |
| 137 | 137 | now := c.now() | |
| 138 | 138 | before := c.snapshot(now) | |
| 139 | - | w.Click(id) | |
| 140 | - | c.focusScreen = c.screenIndex(s.name()) | |
| 139 | + | c.focusClick(s, w, id) | |
| 141 | 140 | c.transition(before, now) | |
| 142 | 141 | return c.publish(ctx) | |
| 143 | 142 | } | |
| 144 | 143 | ||
| 144 | + | // focusClick raises the clicked window and focuses its output. Modal dialogs | |
| 145 | + | // can take the parent's focus, so flash the resolved focus, not the click target. | |
| 146 | + | func (c *Core) focusClick(s *screen, w *Workspace, id WindowID) { | |
| 147 | + | before, _ := c.cur().mon.Focused() | |
| 148 | + | w.Click(id) | |
| 149 | + | c.focusScreen = c.screenIndex(s.name()) | |
| 150 | + | after, _ := c.cur().mon.Focused() | |
| 151 | + | if after != 0 && after != before { | |
| 152 | + | c.requestFlash(flashWindow, after) | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 145 | 156 | func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error { | |
| 146 | 157 | // Buttons a drag took stay in c.buttons until released, so the | |
| 147 | 158 | // security gate admits their release; no client sees it. | |
| @@ -136,12 +136,23 @@ func (c *Core) clickFocus(ctx context.Context, id WindowID) error { | |||||
| 136 | } | 136 | } | ||
| 137 | now := c.now() | 137 | now := c.now() | ||
| 138 | before := c.snapshot(now) | 138 | before := c.snapshot(now) | ||
| 139 | - | w.Click(id) | 139 | + | c.focusClick(s, w, id) |
| 140 | - | c.focusScreen = c.screenIndex(s.name()) | |||
| 141 | c.transition(before, now) | 140 | c.transition(before, now) | ||
| 142 | return c.publish(ctx) | 141 | return c.publish(ctx) | ||
| 143 | } | 142 | } | ||
| 144 | 143 | ||||
| 144 | + | // focusClick raises the clicked window and focuses its output. Modal dialogs | |||
| 145 | + | // can take the parent's focus, so flash the resolved focus, not the click target. | |||
| 146 | + | func (c *Core) focusClick(s *screen, w *Workspace, id WindowID) { | |||
| 147 | + | before, _ := c.cur().mon.Focused() | |||
| 148 | + | w.Click(id) | |||
| 149 | + | c.focusScreen = c.screenIndex(s.name()) | |||
| 150 | + | after, _ := c.cur().mon.Focused() | |||
| 151 | + | if after != 0 && after != before { | |||
| 152 | + | c.requestFlash(flashWindow, after) | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | ||||
| 145 | func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error { | 156 | func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error { | ||
| 146 | // Buttons a drag took stay in c.buttons until released, so the | 157 | // Buttons a drag took stay in c.buttons until released, so the | ||
| 147 | // security gate admits their release; no client sees it. | 158 | // security gate admits their release; no client sees it. | ||