fix(core): no clicks on hidden windows, focus stays on client fullscreen
A workspace switch clears pointer focus when the window under the cursor is hidden, and clicks only focus windows on the active workspace. Client fullscreen requests no longer move focus. Bound keys are tracked by keycode so releasing Shift first still swallows the release; the shifted-level fallback is limited to digits. A lone column with a preset keeps its width. Default workspace binds go through the combo parser, and layout.default-width warns that max-columns replaced it.
- commit
- 3d0a029bb932afe3048a871e6b08433290ca8c39
- author
- brice · · 2026-09-24 14:12 UTC
- parent
- 267e7d1
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +158 -24
M internal/adapters/config/config.go +6 -2
| @@ -87,8 +87,10 @@ func Defaults() ports.Config { | |||
| 87 | 87 | c.Binds[combo] = b.action | |
| 88 | 88 | } | |
| 89 | 89 | for n := 1; n <= 9; n++ { | |
| 90 | - | c.Binds[fmt.Sprintf("Cmd+%d", n)] = fmt.Sprintf("focus-workspace %d", n) | |
| 91 | - | c.Binds[fmt.Sprintf("Cmd+Shift+%d", n)] = fmt.Sprintf("move-to-workspace %d", n) | |
| 90 | + | focus, _ := parseCombo(fmt.Sprintf("cmd+%d", n)) | |
| 91 | + | move, _ := parseCombo(fmt.Sprintf("cmd+shift+%d", n)) | |
| 92 | + | c.Binds[focus] = fmt.Sprintf("focus-workspace %d", n) | |
| 93 | + | c.Binds[move] = fmt.Sprintf("move-to-workspace %d", n) | |
| 92 | 94 | } | |
| 93 | 95 | c.Render.DirectScanout = true | |
| 94 | 96 | c.Log.Level = "info" | |
| @@ -317,6 +319,8 @@ func set(c *ports.Config, key, v string) error { | |||
| 317 | 319 | return fmt.Errorf("must be between 0 and 200") | |
| 318 | 320 | } | |
| 319 | 321 | c.Layout.Gaps = n | |
| 322 | + | case "layout.default-width": | |
| 323 | + | return fmt.Errorf("replaced by layout.max-columns (columns share the width equally)") | |
| 320 | 324 | case "layout.max-columns": | |
| 321 | 325 | return positive(&c.Layout.MaxColumns, v, 16) | |
| 322 | 326 | case "layout.presets": | |
| @@ -87,8 +87,10 @@ func Defaults() ports.Config { | |||||
| 87 | c.Binds[combo] = b.action | 87 | c.Binds[combo] = b.action | ||
| 88 | } | 88 | } | ||
| 89 | for n := 1; n <= 9; n++ { | 89 | for n := 1; n <= 9; n++ { | ||
| 90 | - | c.Binds[fmt.Sprintf("Cmd+%d", n)] = fmt.Sprintf("focus-workspace %d", n) | 90 | + | focus, _ := parseCombo(fmt.Sprintf("cmd+%d", n)) |
| 91 | - | c.Binds[fmt.Sprintf("Cmd+Shift+%d", n)] = fmt.Sprintf("move-to-workspace %d", n) | 91 | + | move, _ := parseCombo(fmt.Sprintf("cmd+shift+%d", n)) |
| 92 | + | c.Binds[focus] = fmt.Sprintf("focus-workspace %d", n) | |||
| 93 | + | c.Binds[move] = fmt.Sprintf("move-to-workspace %d", n) | |||
| 92 | } | 94 | } | ||
| 93 | c.Render.DirectScanout = true | 95 | c.Render.DirectScanout = true | ||
| 94 | c.Log.Level = "info" | 96 | c.Log.Level = "info" | ||
| @@ -317,6 +319,8 @@ func set(c *ports.Config, key, v string) error { | |||||
| 317 | return fmt.Errorf("must be between 0 and 200") | 319 | return fmt.Errorf("must be between 0 and 200") | ||
| 318 | } | 320 | } | ||
| 319 | c.Layout.Gaps = n | 321 | c.Layout.Gaps = n | ||
| 322 | + | case "layout.default-width": | |||
| 323 | + | return fmt.Errorf("replaced by layout.max-columns (columns share the width equally)") | |||
| 320 | case "layout.max-columns": | 324 | case "layout.max-columns": | ||
| 321 | return positive(&c.Layout.MaxColumns, v, 16) | 325 | return positive(&c.Layout.MaxColumns, v, 16) | ||
| 322 | case "layout.presets": | 326 | case "layout.presets": | ||
M internal/core/core.go +39 -13
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "github.com/bnema/nefertty/internal/ports" | |
| 8 | + | "strconv" | |
| 8 | 9 | "strings" | |
| 9 | 10 | "unicode" | |
| 10 | 11 | ) | |
| @@ -164,6 +165,16 @@ func (c *Core) clientRect(p Placement) Rect { | |||
| 164 | 165 | return Rect{X: r.X + b, Y: r.Y + b, W: r.W - 2*b, H: r.H - 2*b} | |
| 165 | 166 | } | |
| 166 | 167 | ||
| 168 | + | // visible reports whether the window is on screen on the active workspace. | |
| 169 | + | func (c *Core) visible(id WindowID) bool { | |
| 170 | + | for _, p := range c.ws.Layout() { | |
| 171 | + | if p.ID == id { | |
| 172 | + | return !p.Hidden | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | return false | |
| 176 | + | } | |
| 177 | + | ||
| 167 | 178 | // keyboardFocus is the mapped top/overlay layer with exclusive keyboard | |
| 168 | 179 | // interactivity and the highest ID, else the focused window. On-demand layers | |
| 169 | 180 | // never take focus automatically. When the layer unmaps, focus returns to the window. | |
| @@ -213,6 +224,16 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 213 | 224 | delete(c.sent, id) | |
| 214 | 225 | } | |
| 215 | 226 | } | |
| 227 | + | // A workspace switch can hide the window under the pointer; it must not get | |
| 228 | + | // clicks. The next motion re-runs hit-testing. | |
| 229 | + | if c.pointer != 0 && !c.visible(c.pointer) { | |
| 230 | + | c.pointer = 0 | |
| 231 | + | if c.grab == 0 { | |
| 232 | + | if err := c.command(ctx, ports.PointerFocus{}); err != nil { | |
| 233 | + | return err | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | } | |
| 216 | 237 | if focus != c.focus { | |
| 217 | 238 | if err := c.command(ctx, ports.FocusWindow{ID: focus}); err != nil { | |
| 218 | 239 | return err | |
| @@ -357,7 +378,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 357 | 378 | if err := c.command(ctx, ports.PointerButtonTo{ID: id, Button: v.Button, Pressed: v.Pressed, TimeMsec: v.TimeMsec}); err != nil { | |
| 358 | 379 | return nil | |
| 359 | 380 | } | |
| 360 | - | if v.Pressed && c.focus != id && c.ws.FocusID(id) { | |
| 381 | + | if v.Pressed && c.focus != id && c.ws.Current().FocusID(id) { | |
| 361 | 382 | if err := c.publish(ctx); err != nil { | |
| 362 | 383 | return nil | |
| 363 | 384 | } | |
| @@ -374,25 +395,30 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 374 | 395 | } | |
| 375 | 396 | name := keyName(key.Keysym) | |
| 376 | 397 | action, bound := c.binds[binding{key: name, mods: key.Mods}] | |
| 377 | - | // Layouts like AZERTY put digits on the shifted level: Cmd+1 there | |
| 378 | - | // is Super+ampersand, so also try the key's other levels. | |
| 379 | - | for _, alt := range []string{key.Base, key.Shifted} { | |
| 380 | - | if !bound && alt != "" { | |
| 381 | - | if a, ok := c.binds[binding{key: keyName(alt), mods: key.Mods}]; ok { | |
| 382 | - | action, bound = a, true | |
| 383 | - | } | |
| 384 | - | } | |
| 398 | + | // Cmd+Shift+1 on US prints exclam: match the unshifted keysym. | |
| 399 | + | if !bound && key.Base != "" { | |
| 400 | + | action, bound = c.binds[binding{key: keyName(key.Base), mods: key.Mods}] | |
| 401 | + | } | |
| 402 | + | // AZERTY puts digits on the shifted level: Cmd+1 is Super+ampersand. | |
| 403 | + | // Only digits fall back this way, so cmd+plus stays off Cmd+=. | |
| 404 | + | if !bound && len(key.Shifted) == 1 && key.Shifted[0] >= '0' && key.Shifted[0] <= '9' { | |
| 405 | + | action, bound = c.binds[binding{key: key.Shifted, mods: key.Mods}] | |
| 406 | + | } | |
| 407 | + | // Track presses by physical key: Shift may be released before the key. | |
| 408 | + | held := name | |
| 409 | + | if key.Keycode != 0 { | |
| 410 | + | held = "#" + strconv.FormatUint(uint64(key.Keycode), 10) | |
| 385 | 411 | } | |
| 386 | 412 | if !key.Pressed { | |
| 387 | - | consumed := c.pressed[name] | |
| 388 | - | delete(c.pressed, name) | |
| 413 | + | consumed := c.pressed[held] | |
| 414 | + | delete(c.pressed, held) | |
| 389 | 415 | if consumed { | |
| 390 | 416 | continue | |
| 391 | 417 | } | |
| 392 | 418 | } | |
| 393 | 419 | if key.Pressed { | |
| 394 | 420 | if bound { | |
| 395 | - | c.pressed[name] = true | |
| 421 | + | c.pressed[held] = true | |
| 396 | 422 | effect := c.ws.Apply(action) | |
| 397 | 423 | if effect.Quit { | |
| 398 | 424 | return ErrQuit | |
| @@ -418,7 +444,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 418 | 444 | } | |
| 419 | 445 | continue | |
| 420 | 446 | } | |
| 421 | - | c.pressed[name] = false | |
| 447 | + | c.pressed[held] = false | |
| 422 | 448 | } | |
| 423 | 449 | if id := c.keyboardFocus(); id != 0 { | |
| 424 | 450 | if err := c.command(ctx, ports.ForwardKey{ID: id, Key: key}); err != nil { | |
| @@ -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 | + | "strconv" | |||
| 8 | "strings" | 9 | "strings" | ||
| 9 | "unicode" | 10 | "unicode" | ||
| 10 | ) | 11 | ) | ||
| @@ -164,6 +165,16 @@ func (c *Core) clientRect(p Placement) Rect { | |||||
| 164 | return Rect{X: r.X + b, Y: r.Y + b, W: r.W - 2*b, H: r.H - 2*b} | 165 | return Rect{X: r.X + b, Y: r.Y + b, W: r.W - 2*b, H: r.H - 2*b} | ||
| 165 | } | 166 | } | ||
| 166 | 167 | ||||
| 168 | + | // visible reports whether the window is on screen on the active workspace. | |||
| 169 | + | func (c *Core) visible(id WindowID) bool { | |||
| 170 | + | for _, p := range c.ws.Layout() { | |||
| 171 | + | if p.ID == id { | |||
| 172 | + | return !p.Hidden | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | return false | |||
| 176 | + | } | |||
| 177 | + | ||||
| 167 | // keyboardFocus is the mapped top/overlay layer with exclusive keyboard | 178 | // keyboardFocus is the mapped top/overlay layer with exclusive keyboard | ||
| 168 | // interactivity and the highest ID, else the focused window. On-demand layers | 179 | // interactivity and the highest ID, else the focused window. On-demand layers | ||
| 169 | // never take focus automatically. When the layer unmaps, focus returns to the window. | 180 | // never take focus automatically. When the layer unmaps, focus returns to the window. | ||
| @@ -213,6 +224,16 @@ func (c *Core) publish(ctx context.Context) error { | |||||
| 213 | delete(c.sent, id) | 224 | delete(c.sent, id) | ||
| 214 | } | 225 | } | ||
| 215 | } | 226 | } | ||
| 227 | + | // A workspace switch can hide the window under the pointer; it must not get | |||
| 228 | + | // clicks. The next motion re-runs hit-testing. | |||
| 229 | + | if c.pointer != 0 && !c.visible(c.pointer) { | |||
| 230 | + | c.pointer = 0 | |||
| 231 | + | if c.grab == 0 { | |||
| 232 | + | if err := c.command(ctx, ports.PointerFocus{}); err != nil { | |||
| 233 | + | return err | |||
| 234 | + | } | |||
| 235 | + | } | |||
| 236 | + | } | |||
| 216 | if focus != c.focus { | 237 | if focus != c.focus { | ||
| 217 | if err := c.command(ctx, ports.FocusWindow{ID: focus}); err != nil { | 238 | if err := c.command(ctx, ports.FocusWindow{ID: focus}); err != nil { | ||
| 218 | return err | 239 | return err | ||
| @@ -357,7 +378,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 357 | if err := c.command(ctx, ports.PointerButtonTo{ID: id, Button: v.Button, Pressed: v.Pressed, TimeMsec: v.TimeMsec}); err != nil { | 378 | if err := c.command(ctx, ports.PointerButtonTo{ID: id, Button: v.Button, Pressed: v.Pressed, TimeMsec: v.TimeMsec}); err != nil { | ||
| 358 | return nil | 379 | return nil | ||
| 359 | } | 380 | } | ||
| 360 | - | if v.Pressed && c.focus != id && c.ws.FocusID(id) { | 381 | + | if v.Pressed && c.focus != id && c.ws.Current().FocusID(id) { |
| 361 | if err := c.publish(ctx); err != nil { | 382 | if err := c.publish(ctx); err != nil { | ||
| 362 | return nil | 383 | return nil | ||
| 363 | } | 384 | } | ||
| @@ -374,25 +395,30 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 374 | } | 395 | } | ||
| 375 | name := keyName(key.Keysym) | 396 | name := keyName(key.Keysym) | ||
| 376 | action, bound := c.binds[binding{key: name, mods: key.Mods}] | 397 | action, bound := c.binds[binding{key: name, mods: key.Mods}] | ||
| 377 | - | // Layouts like AZERTY put digits on the shifted level: Cmd+1 there | 398 | + | // Cmd+Shift+1 on US prints exclam: match the unshifted keysym. |
| 378 | - | // is Super+ampersand, so also try the key's other levels. | 399 | + | if !bound && key.Base != "" { |
| 379 | - | for _, alt := range []string{key.Base, key.Shifted} { | 400 | + | action, bound = c.binds[binding{key: keyName(key.Base), mods: key.Mods}] |
| 380 | - | if !bound && alt != "" { | 401 | + | } |
| 381 | - | if a, ok := c.binds[binding{key: keyName(alt), mods: key.Mods}]; ok { | 402 | + | // AZERTY puts digits on the shifted level: Cmd+1 is Super+ampersand. |
| 382 | - | action, bound = a, true | 403 | + | // Only digits fall back this way, so cmd+plus stays off Cmd+=. |
| 383 | - | } | 404 | + | if !bound && len(key.Shifted) == 1 && key.Shifted[0] >= '0' && key.Shifted[0] <= '9' { |
| 384 | - | } | 405 | + | action, bound = c.binds[binding{key: key.Shifted, mods: key.Mods}] |
| 406 | + | } | |||
| 407 | + | // Track presses by physical key: Shift may be released before the key. | |||
| 408 | + | held := name | |||
| 409 | + | if key.Keycode != 0 { | |||
| 410 | + | held = "#" + strconv.FormatUint(uint64(key.Keycode), 10) | |||
| 385 | } | 411 | } | ||
| 386 | if !key.Pressed { | 412 | if !key.Pressed { | ||
| 387 | - | consumed := c.pressed[name] | 413 | + | consumed := c.pressed[held] |
| 388 | - | delete(c.pressed, name) | 414 | + | delete(c.pressed, held) |
| 389 | if consumed { | 415 | if consumed { | ||
| 390 | continue | 416 | continue | ||
| 391 | } | 417 | } | ||
| 392 | } | 418 | } | ||
| 393 | if key.Pressed { | 419 | if key.Pressed { | ||
| 394 | if bound { | 420 | if bound { | ||
| 395 | - | c.pressed[name] = true | 421 | + | c.pressed[held] = true |
| 396 | effect := c.ws.Apply(action) | 422 | effect := c.ws.Apply(action) | ||
| 397 | if effect.Quit { | 423 | if effect.Quit { | ||
| 398 | return ErrQuit | 424 | return ErrQuit | ||
| @@ -418,7 +444,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 418 | } | 444 | } | ||
| 419 | continue | 445 | continue | ||
| 420 | } | 446 | } | ||
| 421 | - | c.pressed[name] = false | 447 | + | c.pressed[held] = false |
| 422 | } | 448 | } | ||
| 423 | if id := c.keyboardFocus(); id != 0 { | 449 | if id := c.keyboardFocus(); id != 0 { | ||
| 424 | if err := c.command(ctx, ports.ForwardKey{ID: id, Key: key}); err != nil { | 450 | if err := c.command(ctx, ports.ForwardKey{ID: id, Key: key}); err != nil { | ||
M internal/core/core_test.go +81 -0
| @@ -399,3 +399,84 @@ func TestWorkspaceSwitch(t *testing.T) { | |||
| 399 | 399 | t.Fatal(s) | |
| 400 | 400 | } | |
| 401 | 401 | } | |
| 402 | + | ||
| 403 | + | func TestClickAfterWorkspaceSwitch(t *testing.T) { | |
| 404 | + | cfg := config.Defaults() | |
| 405 | + | client := make(chan ports.ClientEvent, 8) | |
| 406 | + | input := make(chan ports.InputEvent, 8) | |
| 407 | + | output := make(chan ports.OutputEvent, 8) | |
| 408 | + | commands := make(chan ports.ClientCommand, 64) | |
| 409 | + | scenes := make(chan ports.Scene, 1) | |
| 410 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |
| 411 | + | if err != nil { | |
| 412 | + | t.Fatal(err) | |
| 413 | + | } | |
| 414 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 415 | + | defer cancel() | |
| 416 | + | go c.Run(ctx) | |
| 417 | + | output <- ports.OutputMode{Width: 100, Height: 80} | |
| 418 | + | receive(t, scenes) | |
| 419 | + | client <- ports.WindowMapped{ID: 1} | |
| 420 | + | receive(t, scenes) | |
| 421 | + | input <- ports.PointerMotion{X: 50, Y: 40} | |
| 422 | + | for len(commands) < 3 { | |
| 423 | + | time.Sleep(time.Millisecond) | |
| 424 | + | } | |
| 425 | + | for len(commands) > 0 { | |
| 426 | + | <-commands | |
| 427 | + | } | |
| 428 | + | // Cmd+2 hides window 1; the pointer must leave it. | |
| 429 | + | input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModSuper, Pressed: true} | |
| 430 | + | receive(t, scenes) | |
| 431 | + | var cleared bool | |
| 432 | + | for len(commands) > 0 { | |
| 433 | + | if v, ok := (<-commands).(ports.PointerFocus); ok && v.ID == 0 { | |
| 434 | + | cleared = true | |
| 435 | + | } | |
| 436 | + | } | |
| 437 | + | if !cleared { | |
| 438 | + | t.Fatal("pointer focus kept on hidden window") | |
| 439 | + | } | |
| 440 | + | // A click without moving does not reach window 1 nor switch back. | |
| 441 | + | input <- ports.PointerButton{Button: 0x110, Pressed: true} | |
| 442 | + | input <- ports.PointerButton{Button: 0x110} | |
| 443 | + | client <- ports.WindowMapped{ID: 2} | |
| 444 | + | s := receive(t, scenes) | |
| 445 | + | if s.Windows[0].ID != 1 || !s.Windows[0].Hidden || s.Windows[1].Hidden { | |
| 446 | + | t.Fatal(s) | |
| 447 | + | } | |
| 448 | + | for len(commands) > 0 { | |
| 449 | + | if v, ok := (<-commands).(ports.PointerButtonTo); ok { | |
| 450 | + | t.Fatal(v) | |
| 451 | + | } | |
| 452 | + | } | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | func TestShiftReleasedFirst(t *testing.T) { | |
| 456 | + | cfg := config.Defaults() | |
| 457 | + | input := make(chan ports.InputEvent, 8) | |
| 458 | + | client := make(chan ports.ClientEvent, 2) | |
| 459 | + | commands := make(chan ports.ClientCommand, 16) | |
| 460 | + | scenes := make(chan ports.Scene, 1) | |
| 461 | + | c, err := core.New(cfg, core.Channels{Input: input, Client: client, Commands: commands, Scenes: scenes}) | |
| 462 | + | if err != nil { | |
| 463 | + | t.Fatal(err) | |
| 464 | + | } | |
| 465 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 466 | + | defer cancel() | |
| 467 | + | go c.Run(ctx) | |
| 468 | + | client <- ports.WindowMapped{ID: 1} | |
| 469 | + | receive(t, scenes) | |
| 470 | + | for len(commands) > 0 { | |
| 471 | + | <-commands | |
| 472 | + | } | |
| 473 | + | // US Cmd+Shift+1 prints exclam; its base level 1 matches move-to-workspace 1. | |
| 474 | + | input <- ports.KeyEvent{Keysym: "exclam", Base: "1", Shifted: "exclam", Keycode: 2, Mods: ports.ModSuper | ports.ModShift, Pressed: true} | |
| 475 | + | receive(t, scenes) | |
| 476 | + | // Shift goes up first, so the release reports "1": still swallowed. | |
| 477 | + | input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModSuper} | |
| 478 | + | input <- ports.KeyEvent{Keysym: "x", Keycode: 45, Pressed: true} | |
| 479 | + | if v := receive(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "x", Keycode: 45, Pressed: true}}) { | |
| 480 | + | t.Fatal(v) | |
| 481 | + | } | |
| 482 | + | } | |
| @@ -399,3 +399,84 @@ func TestWorkspaceSwitch(t *testing.T) { | |||||
| 399 | t.Fatal(s) | 399 | t.Fatal(s) | ||
| 400 | } | 400 | } | ||
| 401 | } | 401 | } | ||
| 402 | + | ||||
| 403 | + | func TestClickAfterWorkspaceSwitch(t *testing.T) { | |||
| 404 | + | cfg := config.Defaults() | |||
| 405 | + | client := make(chan ports.ClientEvent, 8) | |||
| 406 | + | input := make(chan ports.InputEvent, 8) | |||
| 407 | + | output := make(chan ports.OutputEvent, 8) | |||
| 408 | + | commands := make(chan ports.ClientCommand, 64) | |||
| 409 | + | scenes := make(chan ports.Scene, 1) | |||
| 410 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |||
| 411 | + | if err != nil { | |||
| 412 | + | t.Fatal(err) | |||
| 413 | + | } | |||
| 414 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 415 | + | defer cancel() | |||
| 416 | + | go c.Run(ctx) | |||
| 417 | + | output <- ports.OutputMode{Width: 100, Height: 80} | |||
| 418 | + | receive(t, scenes) | |||
| 419 | + | client <- ports.WindowMapped{ID: 1} | |||
| 420 | + | receive(t, scenes) | |||
| 421 | + | input <- ports.PointerMotion{X: 50, Y: 40} | |||
| 422 | + | for len(commands) < 3 { | |||
| 423 | + | time.Sleep(time.Millisecond) | |||
| 424 | + | } | |||
| 425 | + | for len(commands) > 0 { | |||
| 426 | + | <-commands | |||
| 427 | + | } | |||
| 428 | + | // Cmd+2 hides window 1; the pointer must leave it. | |||
| 429 | + | input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModSuper, Pressed: true} | |||
| 430 | + | receive(t, scenes) | |||
| 431 | + | var cleared bool | |||
| 432 | + | for len(commands) > 0 { | |||
| 433 | + | if v, ok := (<-commands).(ports.PointerFocus); ok && v.ID == 0 { | |||
| 434 | + | cleared = true | |||
| 435 | + | } | |||
| 436 | + | } | |||
| 437 | + | if !cleared { | |||
| 438 | + | t.Fatal("pointer focus kept on hidden window") | |||
| 439 | + | } | |||
| 440 | + | // A click without moving does not reach window 1 nor switch back. | |||
| 441 | + | input <- ports.PointerButton{Button: 0x110, Pressed: true} | |||
| 442 | + | input <- ports.PointerButton{Button: 0x110} | |||
| 443 | + | client <- ports.WindowMapped{ID: 2} | |||
| 444 | + | s := receive(t, scenes) | |||
| 445 | + | if s.Windows[0].ID != 1 || !s.Windows[0].Hidden || s.Windows[1].Hidden { | |||
| 446 | + | t.Fatal(s) | |||
| 447 | + | } | |||
| 448 | + | for len(commands) > 0 { | |||
| 449 | + | if v, ok := (<-commands).(ports.PointerButtonTo); ok { | |||
| 450 | + | t.Fatal(v) | |||
| 451 | + | } | |||
| 452 | + | } | |||
| 453 | + | } | |||
| 454 | + | ||||
| 455 | + | func TestShiftReleasedFirst(t *testing.T) { | |||
| 456 | + | cfg := config.Defaults() | |||
| 457 | + | input := make(chan ports.InputEvent, 8) | |||
| 458 | + | client := make(chan ports.ClientEvent, 2) | |||
| 459 | + | commands := make(chan ports.ClientCommand, 16) | |||
| 460 | + | scenes := make(chan ports.Scene, 1) | |||
| 461 | + | c, err := core.New(cfg, core.Channels{Input: input, Client: client, Commands: commands, Scenes: scenes}) | |||
| 462 | + | if err != nil { | |||
| 463 | + | t.Fatal(err) | |||
| 464 | + | } | |||
| 465 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 466 | + | defer cancel() | |||
| 467 | + | go c.Run(ctx) | |||
| 468 | + | client <- ports.WindowMapped{ID: 1} | |||
| 469 | + | receive(t, scenes) | |||
| 470 | + | for len(commands) > 0 { | |||
| 471 | + | <-commands | |||
| 472 | + | } | |||
| 473 | + | // US Cmd+Shift+1 prints exclam; its base level 1 matches move-to-workspace 1. | |||
| 474 | + | input <- ports.KeyEvent{Keysym: "exclam", Base: "1", Shifted: "exclam", Keycode: 2, Mods: ports.ModSuper | ports.ModShift, Pressed: true} | |||
| 475 | + | receive(t, scenes) | |||
| 476 | + | // Shift goes up first, so the release reports "1": still swallowed. | |||
| 477 | + | input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModSuper} | |||
| 478 | + | input <- ports.KeyEvent{Keysym: "x", Keycode: 45, Pressed: true} | |||
| 479 | + | if v := receive(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "x", Keycode: 45, Pressed: true}}) { | |||
| 480 | + | t.Fatal(v) | |||
| 481 | + | } | |||
| 482 | + | } | |||
M internal/core/layout.go +6 -7
| @@ -243,13 +243,12 @@ func (w *Workspace) ToggleFullscreen() { | |||
| 243 | 243 | w.scroll() | |
| 244 | 244 | } | |
| 245 | 245 | ||
| 246 | - | // SetFullscreen focuses an existing window and applies the requested state. | |
| 246 | + | // SetFullscreen applies a client request. It never moves focus: client | |
| 247 | + | // requests are automatic events (ADR 011 golden rule). | |
| 247 | 248 | func (w *Workspace) SetFullscreen(id WindowID, on bool) { | |
| 248 | 249 | for i := range w.Columns { | |
| 249 | - | for j, v := range w.Columns[i].Windows { | |
| 250 | + | for _, v := range w.Columns[i].Windows { | |
| 250 | 251 | if v == id { | |
| 251 | - | w.Focus = i | |
| 252 | - | w.Columns[i].Focus = j | |
| 253 | 252 | if on { | |
| 254 | 253 | w.fullscreen = id | |
| 255 | 254 | } else if w.fullscreen == id { | |
| @@ -312,10 +311,10 @@ func (w *Workspace) columnWidth(i int) int { | |||
| 312 | 311 | return w.Output.W | |
| 313 | 312 | } | |
| 314 | 313 | g := w.gap() | |
| 315 | - | if len(w.Columns) == 1 { | |
| 316 | - | return max(w.Usable.W-2*g, 0) | |
| 317 | - | } | |
| 318 | 314 | if w.Columns[i].Width == (Width{}) { | |
| 315 | + | if len(w.Columns) == 1 { | |
| 316 | + | return max(w.Usable.W-2*g, 0) | |
| 317 | + | } | |
| 319 | 318 | // Equal shares of the width left after gaps; a remainder under k pixels stays empty. | |
| 320 | 319 | k := min(len(w.Columns), max(w.MaxColumns, 1)) | |
| 321 | 320 | return max((w.Usable.W-g*(k+1))/k, 0) | |
| @@ -243,13 +243,12 @@ func (w *Workspace) ToggleFullscreen() { | |||||
| 243 | w.scroll() | 243 | w.scroll() | ||
| 244 | } | 244 | } | ||
| 245 | 245 | ||||
| 246 | - | // SetFullscreen focuses an existing window and applies the requested state. | 246 | + | // SetFullscreen applies a client request. It never moves focus: client |
| 247 | + | // requests are automatic events (ADR 011 golden rule). | |||
| 247 | func (w *Workspace) SetFullscreen(id WindowID, on bool) { | 248 | func (w *Workspace) SetFullscreen(id WindowID, on bool) { | ||
| 248 | for i := range w.Columns { | 249 | for i := range w.Columns { | ||
| 249 | - | for j, v := range w.Columns[i].Windows { | 250 | + | for _, v := range w.Columns[i].Windows { |
| 250 | if v == id { | 251 | if v == id { | ||
| 251 | - | w.Focus = i | |||
| 252 | - | w.Columns[i].Focus = j | |||
| 253 | if on { | 252 | if on { | ||
| 254 | w.fullscreen = id | 253 | w.fullscreen = id | ||
| 255 | } else if w.fullscreen == id { | 254 | } else if w.fullscreen == id { | ||
| @@ -312,10 +311,10 @@ func (w *Workspace) columnWidth(i int) int { | |||||
| 312 | return w.Output.W | 311 | return w.Output.W | ||
| 313 | } | 312 | } | ||
| 314 | g := w.gap() | 313 | g := w.gap() | ||
| 315 | - | if len(w.Columns) == 1 { | |||
| 316 | - | return max(w.Usable.W-2*g, 0) | |||
| 317 | - | } | |||
| 318 | if w.Columns[i].Width == (Width{}) { | 314 | if w.Columns[i].Width == (Width{}) { | ||
| 315 | + | if len(w.Columns) == 1 { | |||
| 316 | + | return max(w.Usable.W-2*g, 0) | |||
| 317 | + | } | |||
| 319 | // Equal shares of the width left after gaps; a remainder under k pixels stays empty. | 318 | // Equal shares of the width left after gaps; a remainder under k pixels stays empty. | ||
| 320 | k := min(len(w.Columns), max(w.MaxColumns, 1)) | 319 | k := min(len(w.Columns), max(w.MaxColumns, 1)) | ||
| 321 | return max((w.Usable.W-g*(k+1))/k, 0) | 320 | return max((w.Usable.W-g*(k+1))/k, 0) | ||
M internal/core/layout_test.go +3 -2
| @@ -73,7 +73,7 @@ func TestOperations(t *testing.T) { | |||
| 73 | 73 | } | |
| 74 | 74 | func TestStackAndLayout(t *testing.T) { | |
| 75 | 75 | w := workspace() | |
| 76 | - | w.Columns = []Column{{Windows: []WindowID{1, 2, 3}, Width: Width{Num: 1, Den: 2}, Focus: 1}} | |
| 76 | + | w.Columns = []Column{{Windows: []WindowID{1, 2, 3}, Focus: 1}} | |
| 77 | 77 | w.RemoveWindow(2) | |
| 78 | 78 | if id, _ := w.Focused(); id != 1 { | |
| 79 | 79 | t.Fatal(id) | |
| @@ -339,9 +339,10 @@ func TestFullscreenPersistsAndScrolls(t *testing.T) { | |||
| 339 | 339 | func TestSetFullscreenStackDeactivation(t *testing.T) { | |
| 340 | 340 | w := workspace() | |
| 341 | 341 | w.Columns = []Column{{Windows: []WindowID{1, 2}, Focus: 0}} | |
| 342 | + | // A client request does not take focus; window 1 keeps it but is covered. | |
| 342 | 343 | w.SetFullscreen(2, true) | |
| 343 | 344 | p := w.Layout() | |
| 344 | - | if !p[0].Hidden || !p[1].Fullscreen || !p[1].Focused { | |
| 345 | + | if !p[0].Hidden || !p[1].Fullscreen || p[1].Focused || !p[0].Focused { | |
| 345 | 346 | t.Fatal(p) | |
| 346 | 347 | } | |
| 347 | 348 | w.SetFullscreen(2, false) | |
| @@ -73,7 +73,7 @@ func TestOperations(t *testing.T) { | |||||
| 73 | } | 73 | } | ||
| 74 | func TestStackAndLayout(t *testing.T) { | 74 | func TestStackAndLayout(t *testing.T) { | ||
| 75 | w := workspace() | 75 | w := workspace() | ||
| 76 | - | w.Columns = []Column{{Windows: []WindowID{1, 2, 3}, Width: Width{Num: 1, Den: 2}, Focus: 1}} | 76 | + | w.Columns = []Column{{Windows: []WindowID{1, 2, 3}, Focus: 1}} |
| 77 | w.RemoveWindow(2) | 77 | w.RemoveWindow(2) | ||
| 78 | if id, _ := w.Focused(); id != 1 { | 78 | if id, _ := w.Focused(); id != 1 { | ||
| 79 | t.Fatal(id) | 79 | t.Fatal(id) | ||
| @@ -339,9 +339,10 @@ func TestFullscreenPersistsAndScrolls(t *testing.T) { | |||||
| 339 | func TestSetFullscreenStackDeactivation(t *testing.T) { | 339 | func TestSetFullscreenStackDeactivation(t *testing.T) { | ||
| 340 | w := workspace() | 340 | w := workspace() | ||
| 341 | w.Columns = []Column{{Windows: []WindowID{1, 2}, Focus: 0}} | 341 | w.Columns = []Column{{Windows: []WindowID{1, 2}, Focus: 0}} | ||
| 342 | + | // A client request does not take focus; window 1 keeps it but is covered. | |||
| 342 | w.SetFullscreen(2, true) | 343 | w.SetFullscreen(2, true) | ||
| 343 | p := w.Layout() | 344 | p := w.Layout() | ||
| 344 | - | if !p[0].Hidden || !p[1].Fullscreen || !p[1].Focused { | 345 | + | if !p[0].Hidden || !p[1].Fullscreen || p[1].Focused || !p[0].Focused { |
| 345 | t.Fatal(p) | 346 | t.Fatal(p) | ||
| 346 | } | 347 | } | ||
| 347 | w.SetFullscreen(2, false) | 348 | w.SetFullscreen(2, false) | ||
M internal/core/monitor_test.go +23 -0
| @@ -216,3 +216,26 @@ func TestWorkspaceArg(t *testing.T) { | |||
| 216 | 216 | } | |
| 217 | 217 | } | |
| 218 | 218 | } | |
| 219 | + | ||
| 220 | + | func TestFullscreenRequestKeepsFocus(t *testing.T) { | |
| 221 | + | m := monitor() | |
| 222 | + | m.AddWindow(1) | |
| 223 | + | m.AddWindow(2) | |
| 224 | + | m.SetFullscreen(1, true) | |
| 225 | + | if id, _ := m.Focused(); id != 2 { | |
| 226 | + | t.Fatal(id) | |
| 227 | + | } | |
| 228 | + | if m.Current().fullscreen != 1 { | |
| 229 | + | t.Fatal("not fullscreen") | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | func TestLonePresetColumnKeepsWidth(t *testing.T) { | |
| 234 | + | m := monitor() | |
| 235 | + | m.SetPresets([]Width{{Num: 1, Den: 2}}) | |
| 236 | + | m.AddWindow(1) | |
| 237 | + | m.Apply(ActionCycleColumnWidth) | |
| 238 | + | if p := m.Layout()[0]; p.Rect.W != 50 || p.Borderless { | |
| 239 | + | t.Fatal(p) | |
| 240 | + | } | |
| 241 | + | } | |
| @@ -216,3 +216,26 @@ func TestWorkspaceArg(t *testing.T) { | |||||
| 216 | } | 216 | } | ||
| 217 | } | 217 | } | ||
| 218 | } | 218 | } | ||
| 219 | + | ||||
| 220 | + | func TestFullscreenRequestKeepsFocus(t *testing.T) { | |||
| 221 | + | m := monitor() | |||
| 222 | + | m.AddWindow(1) | |||
| 223 | + | m.AddWindow(2) | |||
| 224 | + | m.SetFullscreen(1, true) | |||
| 225 | + | if id, _ := m.Focused(); id != 2 { | |||
| 226 | + | t.Fatal(id) | |||
| 227 | + | } | |||
| 228 | + | if m.Current().fullscreen != 1 { | |||
| 229 | + | t.Fatal("not fullscreen") | |||
| 230 | + | } | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | func TestLonePresetColumnKeepsWidth(t *testing.T) { | |||
| 234 | + | m := monitor() | |||
| 235 | + | m.SetPresets([]Width{{Num: 1, Den: 2}}) | |||
| 236 | + | m.AddWindow(1) | |||
| 237 | + | m.Apply(ActionCycleColumnWidth) | |||
| 238 | + | if p := m.Layout()[0]; p.Rect.W != 50 || p.Borderless { | |||
| 239 | + | t.Fatal(p) | |||
| 240 | + | } | |||
| 241 | + | } | |||