fix(layer): on-demand focus ends on any output's window focus change or a bind
- commit
- ee631b3356d645e0624b54aa8ccd78fdf082eeb7
- author
- brice · · 2026-09-26 06:04 UTC
- committer
- brice · · 2026-09-26 06:05 UTC
- parent
- 16be8cb
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +29 -14
M internal/core/core.go +19 -14
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "github.com/bnema/nefertty/internal/ports" | |
| 8 | + | "maps" | |
| 8 | 9 | "slices" | |
| 9 | 10 | "strconv" | |
| 10 | 11 | "strings" | |
| @@ -82,14 +83,12 @@ type Core struct { | |||
| 82 | 83 | popups map[WindowID]*popupState | |
| 83 | 84 | popupOrder []WindowID | |
| 84 | 85 | // layerFocus is the on-demand layer surface the user clicked; it keeps | |
| 85 | - | // the keyboard while it stays mapped on-demand and the window focus | |
| 86 | - | // (layerOver) does not change: any click elsewhere, bind, activation or | |
| 87 | - | // new window takes the keyboard back. | |
| 86 | + | // the keyboard while it stays mapped on-demand and no window focus | |
| 87 | + | // changes on any output (layerOver, taken at the click): any click | |
| 88 | + | // elsewhere, bind, activation or new window takes the keyboard back. The | |
| 89 | + | // pointer moving to another output does not. | |
| 88 | 90 | layerFocus WindowID | |
| 89 | - | layerOver WindowID | |
| 90 | - | // layerScreen is the output focused at the click: the pointer moving to | |
| 91 | - | // another output does not end the layer's focus. | |
| 92 | - | layerScreen *screen | |
| 91 | + | layerOver map[*screen]WindowID | |
| 93 | 92 | } | |
| 94 | 93 | ||
| 95 | 94 | func keyName(s string) string { | |
| @@ -309,6 +308,15 @@ func (c *Core) visible(id WindowID) bool { | |||
| 309 | 308 | // interactivity and the highest ID on any output, else the focused window of | |
| 310 | 309 | // the focused output. An on-demand layer takes focus only when clicked, until | |
| 311 | 310 | // a window is clicked. When the layer unmaps, focus returns to the window. | |
| 311 | + | // windowFocus is the focused window of each output. | |
| 312 | + | func (c *Core) windowFocus() map[*screen]WindowID { | |
| 313 | + | m := make(map[*screen]WindowID, len(c.screens)) | |
| 314 | + | for _, sc := range c.screens { | |
| 315 | + | m[sc], _ = sc.mon.Focused() | |
| 316 | + | } | |
| 317 | + | return m | |
| 318 | + | } | |
| 319 | + | ||
| 312 | 320 | func (c *Core) keyboardFocus() WindowID { | |
| 313 | 321 | var layer WindowID | |
| 314 | 322 | for _, sc := range c.screens { | |
| @@ -325,11 +333,8 @@ func (c *Core) keyboardFocus() WindowID { | |||
| 325 | 333 | if g := c.grabFocus(); g != 0 { | |
| 326 | 334 | return g | |
| 327 | 335 | } | |
| 328 | - | if c.layerFocus != 0 { | |
| 329 | - | over, _ := c.layerScreen.mon.Focused() | |
| 330 | - | if !c.onDemand(c.layerFocus) || !slices.Contains(c.screens, c.layerScreen) || over != c.layerOver { | |
| 331 | - | c.layerFocus, c.layerScreen = 0, nil | |
| 332 | - | } | |
| 336 | + | if c.layerFocus != 0 && (!c.onDemand(c.layerFocus) || !maps.Equal(c.layerOver, c.windowFocus())) { | |
| 337 | + | c.layerFocus, c.layerOver = 0, nil | |
| 333 | 338 | } | |
| 334 | 339 | if c.layerFocus != 0 { | |
| 335 | 340 | return c.layerFocus | |
| @@ -753,8 +758,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 753 | 758 | // A click on an on-demand layer gives it the keyboard. | |
| 754 | 759 | if v.Pressed && c.onDemand(id) && c.layerFocus != id { | |
| 755 | 760 | c.layerFocus = id | |
| 756 | - | c.layerScreen = c.cur() | |
| 757 | - | c.layerOver, _ = c.layerScreen.mon.Focused() | |
| 761 | + | c.layerOver = c.windowFocus() | |
| 758 | 762 | if err := c.publish(ctx); err != nil { | |
| 759 | 763 | return nil | |
| 760 | 764 | } | |
| @@ -832,6 +836,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 832 | 836 | continue | |
| 833 | 837 | } | |
| 834 | 838 | before := c.cur().mon.Current() | |
| 839 | + | c.layerFocus = 0 // a bind acts on the windows | |
| 835 | 840 | effect := c.applyAction(action) | |
| 836 | 841 | if effect.Quit { | |
| 837 | 842 | return ErrQuit | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "github.com/bnema/nefertty/internal/ports" | 7 | "github.com/bnema/nefertty/internal/ports" | ||
| 8 | + | "maps" | |||
| 8 | "slices" | 9 | "slices" | ||
| 9 | "strconv" | 10 | "strconv" | ||
| 10 | "strings" | 11 | "strings" | ||
| @@ -82,14 +83,12 @@ type Core struct { | |||||
| 82 | popups map[WindowID]*popupState | 83 | popups map[WindowID]*popupState | ||
| 83 | popupOrder []WindowID | 84 | popupOrder []WindowID | ||
| 84 | // layerFocus is the on-demand layer surface the user clicked; it keeps | 85 | // layerFocus is the on-demand layer surface the user clicked; it keeps | ||
| 85 | - | // the keyboard while it stays mapped on-demand and the window focus | 86 | + | // the keyboard while it stays mapped on-demand and no window focus |
| 86 | - | // (layerOver) does not change: any click elsewhere, bind, activation or | 87 | + | // changes on any output (layerOver, taken at the click): any click |
| 87 | - | // new window takes the keyboard back. | 88 | + | // elsewhere, bind, activation or new window takes the keyboard back. The |
| 89 | + | // pointer moving to another output does not. | |||
| 88 | layerFocus WindowID | 90 | layerFocus WindowID | ||
| 89 | - | layerOver WindowID | 91 | + | layerOver map[*screen]WindowID |
| 90 | - | // layerScreen is the output focused at the click: the pointer moving to | |||
| 91 | - | // another output does not end the layer's focus. | |||
| 92 | - | layerScreen *screen | |||
| 93 | } | 92 | } | ||
| 94 | 93 | ||||
| 95 | func keyName(s string) string { | 94 | func keyName(s string) string { | ||
| @@ -309,6 +308,15 @@ func (c *Core) visible(id WindowID) bool { | |||||
| 309 | // interactivity and the highest ID on any output, else the focused window of | 308 | // interactivity and the highest ID on any output, else the focused window of | ||
| 310 | // the focused output. An on-demand layer takes focus only when clicked, until | 309 | // the focused output. An on-demand layer takes focus only when clicked, until | ||
| 311 | // a window is clicked. When the layer unmaps, focus returns to the window. | 310 | // a window is clicked. When the layer unmaps, focus returns to the window. | ||
| 311 | + | // windowFocus is the focused window of each output. | |||
| 312 | + | func (c *Core) windowFocus() map[*screen]WindowID { | |||
| 313 | + | m := make(map[*screen]WindowID, len(c.screens)) | |||
| 314 | + | for _, sc := range c.screens { | |||
| 315 | + | m[sc], _ = sc.mon.Focused() | |||
| 316 | + | } | |||
| 317 | + | return m | |||
| 318 | + | } | |||
| 319 | + | ||||
| 312 | func (c *Core) keyboardFocus() WindowID { | 320 | func (c *Core) keyboardFocus() WindowID { | ||
| 313 | var layer WindowID | 321 | var layer WindowID | ||
| 314 | for _, sc := range c.screens { | 322 | for _, sc := range c.screens { | ||
| @@ -325,11 +333,8 @@ func (c *Core) keyboardFocus() WindowID { | |||||
| 325 | if g := c.grabFocus(); g != 0 { | 333 | if g := c.grabFocus(); g != 0 { | ||
| 326 | return g | 334 | return g | ||
| 327 | } | 335 | } | ||
| 328 | - | if c.layerFocus != 0 { | 336 | + | if c.layerFocus != 0 && (!c.onDemand(c.layerFocus) || !maps.Equal(c.layerOver, c.windowFocus())) { |
| 329 | - | over, _ := c.layerScreen.mon.Focused() | 337 | + | c.layerFocus, c.layerOver = 0, nil |
| 330 | - | if !c.onDemand(c.layerFocus) || !slices.Contains(c.screens, c.layerScreen) || over != c.layerOver { | |||
| 331 | - | c.layerFocus, c.layerScreen = 0, nil | |||
| 332 | - | } | |||
| 333 | } | 338 | } | ||
| 334 | if c.layerFocus != 0 { | 339 | if c.layerFocus != 0 { | ||
| 335 | return c.layerFocus | 340 | return c.layerFocus | ||
| @@ -753,8 +758,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 753 | // A click on an on-demand layer gives it the keyboard. | 758 | // A click on an on-demand layer gives it the keyboard. | ||
| 754 | if v.Pressed && c.onDemand(id) && c.layerFocus != id { | 759 | if v.Pressed && c.onDemand(id) && c.layerFocus != id { | ||
| 755 | c.layerFocus = id | 760 | c.layerFocus = id | ||
| 756 | - | c.layerScreen = c.cur() | 761 | + | c.layerOver = c.windowFocus() |
| 757 | - | c.layerOver, _ = c.layerScreen.mon.Focused() | |||
| 758 | if err := c.publish(ctx); err != nil { | 762 | if err := c.publish(ctx); err != nil { | ||
| 759 | return nil | 763 | return nil | ||
| 760 | } | 764 | } | ||
| @@ -832,6 +836,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 832 | continue | 836 | continue | ||
| 833 | } | 837 | } | ||
| 834 | before := c.cur().mon.Current() | 838 | before := c.cur().mon.Current() | ||
| 839 | + | c.layerFocus = 0 // a bind acts on the windows | |||
| 835 | effect := c.applyAction(action) | 840 | effect := c.applyAction(action) | ||
| 836 | if effect.Quit { | 841 | if effect.Quit { | ||
| 837 | return ErrQuit | 842 | return ErrQuit | ||
M internal/core/layer_input_test.go +10 -0
| @@ -258,4 +258,14 @@ func TestLayerFocusAcrossOutputs(t *testing.T) { | |||
| 258 | 258 | if v := next(t, commands, anyOf[ports.ForwardKey]); v.ID != 6 { | |
| 259 | 259 | t.Fatalf("key to %d, want the bar", v.ID) | |
| 260 | 260 | } | |
| 261 | + | input <- ports.KeyEvent{Keysym: "a"} | |
| 262 | + | next(t, commands, anyOf[ports.ForwardKey]) | |
| 263 | + | ||
| 264 | + | // A window mapped on OUT-2 (focused there) takes the keyboard back. | |
| 265 | + | client <- ports.WindowMapped{ID: 2} | |
| 266 | + | next(t, commands, func(v ports.FocusWindow) bool { return v.ID == 2 }) | |
| 267 | + | input <- ports.KeyEvent{Keysym: "b", Pressed: true} | |
| 268 | + | if v := next(t, commands, anyOf[ports.ForwardKey]); v.ID != 2 { | |
| 269 | + | t.Fatalf("key to %d, want the new window", v.ID) | |
| 270 | + | } | |
| 261 | 271 | } | |
| @@ -258,4 +258,14 @@ func TestLayerFocusAcrossOutputs(t *testing.T) { | |||||
| 258 | if v := next(t, commands, anyOf[ports.ForwardKey]); v.ID != 6 { | 258 | if v := next(t, commands, anyOf[ports.ForwardKey]); v.ID != 6 { | ||
| 259 | t.Fatalf("key to %d, want the bar", v.ID) | 259 | t.Fatalf("key to %d, want the bar", v.ID) | ||
| 260 | } | 260 | } | ||
| 261 | + | input <- ports.KeyEvent{Keysym: "a"} | |||
| 262 | + | next(t, commands, anyOf[ports.ForwardKey]) | |||
| 263 | + | ||||
| 264 | + | // A window mapped on OUT-2 (focused there) takes the keyboard back. | |||
| 265 | + | client <- ports.WindowMapped{ID: 2} | |||
| 266 | + | next(t, commands, func(v ports.FocusWindow) bool { return v.ID == 2 }) | |||
| 267 | + | input <- ports.KeyEvent{Keysym: "b", Pressed: true} | |||
| 268 | + | if v := next(t, commands, anyOf[ports.ForwardKey]); v.ID != 2 { | |||
| 269 | + | t.Fatalf("key to %d, want the new window", v.ID) | |||
| 270 | + | } | |||
| 261 | } | 271 | } | ||