fix(core): ignore fullscreen requests in a window's first second
Wine asks for fullscreen whenever a window has the monitor's size, so a launcher that remembered a fullscreen size opened fullscreen and hid the windows it opened next. Requests within a second of mapping are ignored; later ones are honoured. Core reads the time through the Clock port.
- commit
- f94690a5aa776013b50b19f667c42c2e63ea1e1b
- author
- brice · · 2026-09-28 15:28 UTC
- parent
- 7b60e95
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +104 -7
M docs/config.md +1 -1
| @@ -89,7 +89,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||
| 89 | 89 | | `spawn <command>` | Run a program | | |
| 90 | 90 | | `spawn-terminal` | Run `terminal` | | |
| 91 | 91 | | `close-window` | Close the focused window | | |
| 92 | - | | `toggle-fullscreen` | Fullscreen the focused window. Fullscreen is exclusive, whether from this bind or the app: nothing is drawn above it but a surface taking the keyboard (a locker). Bars, notifications and other windows, dialogs included, wait hidden until it leaves; activating one of them leaves fullscreen | | |
| 92 | + | | `toggle-fullscreen` | Fullscreen the focused window. An app that asks for fullscreen in its first second (e.g. a Wine launcher that remembered a monitor-sized window) opens in a column instead; later requests are honoured. Fullscreen is exclusive, whether from this bind or the app: nothing is drawn above it but a surface taking the keyboard (a locker). Bars, notifications and other windows, dialogs included, wait hidden until it leaves; activating one of them leaves fullscreen | | |
| 93 | 93 | | `toggle-window-floating` | Float a tiled window at 80% of usable area; toggle back to its former column, width and maximized or expanded state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first | | |
| 94 | 94 | | `toggle-floating-visible` | Hide all floats on this workspace when a float has the focus, or show hidden ones again; a new float shows them too. Does nothing under a fullscreen window | | |
| 95 | 95 | | `maximize-column` | Toggle full usable width for the focused column, preserving gaps and its saved width; in fixed overflow, other columns are hidden until focus moves or it is toggled off. On a window that made itself fullscreen (e.g. a Wine app at monitor size), it first returns the window to its column | | |
| @@ -89,7 +89,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||||
| 89 | | `spawn <command>` | Run a program | | 89 | | `spawn <command>` | Run a program | | ||
| 90 | | `spawn-terminal` | Run `terminal` | | 90 | | `spawn-terminal` | Run `terminal` | | ||
| 91 | | `close-window` | Close the focused window | | 91 | | `close-window` | Close the focused window | | ||
| 92 | - | | `toggle-fullscreen` | Fullscreen the focused window. Fullscreen is exclusive, whether from this bind or the app: nothing is drawn above it but a surface taking the keyboard (a locker). Bars, notifications and other windows, dialogs included, wait hidden until it leaves; activating one of them leaves fullscreen | | 92 | + | | `toggle-fullscreen` | Fullscreen the focused window. An app that asks for fullscreen in its first second (e.g. a Wine launcher that remembered a monitor-sized window) opens in a column instead; later requests are honoured. Fullscreen is exclusive, whether from this bind or the app: nothing is drawn above it but a surface taking the keyboard (a locker). Bars, notifications and other windows, dialogs included, wait hidden until it leaves; activating one of them leaves fullscreen | |
| 93 | | `toggle-window-floating` | Float a tiled window at 80% of usable area; toggle back to its former column, width and maximized or expanded state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first | | 93 | | `toggle-window-floating` | Float a tiled window at 80% of usable area; toggle back to its former column, width and maximized or expanded state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first | | ||
| 94 | | `toggle-floating-visible` | Hide all floats on this workspace when a float has the focus, or show hidden ones again; a new float shows them too. Does nothing under a fullscreen window | | 94 | | `toggle-floating-visible` | Hide all floats on this workspace when a float has the focus, or show hidden ones again; a new float shows them too. Does nothing under a fullscreen window | | ||
| 95 | | `maximize-column` | Toggle full usable width for the focused column, preserving gaps and its saved width; in fixed overflow, other columns are hidden until focus moves or it is toggled off. On a window that made itself fullscreen (e.g. a Wine app at monitor size), it first returns the window to its column | | 95 | | `maximize-column` | Toggle full usable width for the focused column, preserving gaps and its saved width; in fixed overflow, other columns are hidden until focus moves or it is toggled off. On a window that made itself fullscreen (e.g. a Wine app at monitor size), it first returns the window to its column | | ||
M internal/app/app.go +1 -1
| @@ -103,7 +103,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 103 | 103 | // Only real sessions save scales: headless runs never touch the config file. | |
| 104 | 104 | scales = make(chan ports.ScaleChanged, 8) | |
| 105 | 105 | } | |
| 106 | - | ch := core.Channels{Scales: scales, Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, Workspaces: workspaces, ConfigErrors: configErrors, Terminal: !opts.NoTerminal} | |
| 106 | + | ch := core.Channels{Scales: scales, Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, Workspaces: workspaces, ConfigErrors: configErrors, Terminal: !opts.NoTerminal, Clock: clock.System{}} | |
| 107 | 107 | c, err := core.New(opts.Config, ch) | |
| 108 | 108 | if err != nil { | |
| 109 | 109 | return err | |
| @@ -103,7 +103,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 103 | // Only real sessions save scales: headless runs never touch the config file. | 103 | // Only real sessions save scales: headless runs never touch the config file. | ||
| 104 | scales = make(chan ports.ScaleChanged, 8) | 104 | scales = make(chan ports.ScaleChanged, 8) | ||
| 105 | } | 105 | } | ||
| 106 | - | ch := core.Channels{Scales: scales, Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, Workspaces: workspaces, ConfigErrors: configErrors, Terminal: !opts.NoTerminal} | 106 | + | ch := core.Channels{Scales: scales, Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, Workspaces: workspaces, ConfigErrors: configErrors, Terminal: !opts.NoTerminal, Clock: clock.System{}} |
| 107 | c, err := core.New(opts.Config, ch) | 107 | c, err := core.New(opts.Config, ch) | ||
| 108 | if err != nil { | 108 | if err != nil { | ||
| 109 | return err | 109 | return err | ||
M internal/core/core.go +23 -1
| @@ -43,7 +43,16 @@ type Channels struct { | |||
| 43 | 43 | Scales chan<- ports.ScaleChanged | |
| 44 | 44 | // Terminal enables automatic terminal opening according to terminal.auto-open. | |
| 45 | 45 | Terminal bool | |
| 46 | + | // Clock tells the time for fullscreenGrace; nil is the system clock. | |
| 47 | + | Clock ports.Clock | |
| 46 | 48 | } | |
| 49 | + | ||
| 50 | + | // fullscreenGrace is how long after mapping a window's fullscreen request | |
| 51 | + | // is ignored. Wine asks for fullscreen whenever a window has the monitor's | |
| 52 | + | // size, so a launcher that remembered a fullscreen size would open | |
| 53 | + | // fullscreen and hide the windows it opens next. The user can still | |
| 54 | + | // fullscreen it, and an app can ask again later. | |
| 55 | + | const fullscreenGrace = time.Second | |
| 47 | 56 | type binding struct { | |
| 48 | 57 | mods ports.Mods | |
| 49 | 58 | key string | |
| @@ -81,6 +90,7 @@ type Core struct { | |||
| 81 | 90 | firstTerminalResolved bool | |
| 82 | 91 | // clients holds the app ID and PID of mapped windows, for State. | |
| 83 | 92 | clients map[WindowID]ports.WindowMapped | |
| 93 | + | mappedAt map[WindowID]time.Time | |
| 84 | 94 | inputRegions map[WindowID]ports.InputRegionChanged | |
| 85 | 95 | sentState ports.State | |
| 86 | 96 | sentWorkspaces ports.Workspaces | |
| @@ -292,13 +302,20 @@ func New(cfg ports.Config, ch Channels) (*Core, error) { | |||
| 292 | 302 | return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1") | |
| 293 | 303 | } | |
| 294 | 304 | // A placeholder screen holds windows until the first output arrives. | |
| 295 | - | c := &Core{slots: map[slotKey]*slotState{}, placement: newSpawnPlacement(), clients: map[WindowID]ports.WindowMapped{}, inputRegions: map[WindowID]ports.InputRegionChanged{}, popups: map[WindowID]*popupState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}, inhibitors: map[WindowID]bool{}, idle: map[WindowID]bool{}} | |
| 305 | + | c := &Core{slots: map[slotKey]*slotState{}, placement: newSpawnPlacement(), clients: map[WindowID]ports.WindowMapped{}, mappedAt: map[WindowID]time.Time{}, inputRegions: map[WindowID]ports.InputRegionChanged{}, popups: map[WindowID]*popupState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}, inhibitors: map[WindowID]bool{}, idle: map[WindowID]bool{}} | |
| 296 | 306 | c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}} | |
| 297 | 307 | if err := c.apply(cfg); err != nil { | |
| 298 | 308 | return nil, err | |
| 299 | 309 | } | |
| 300 | 310 | return c, nil | |
| 301 | 311 | } | |
| 312 | + | func (c *Core) now() time.Time { | |
| 313 | + | if c.ch.Clock != nil { | |
| 314 | + | return c.ch.Clock.Now() | |
| 315 | + | } | |
| 316 | + | return time.Now() | |
| 317 | + | } | |
| 318 | + | ||
| 302 | 319 | func (c *Core) command(ctx context.Context, v ports.ClientCommand) error { | |
| 303 | 320 | select { | |
| 304 | 321 | case <-ctx.Done(): | |
| @@ -703,6 +720,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 703 | 720 | c.inputRegions[v.ID] = v | |
| 704 | 721 | case ports.WindowMapped: | |
| 705 | 722 | c.clients[v.ID] = v | |
| 723 | + | c.mappedAt[v.ID] = c.now() | |
| 706 | 724 | c.placement.place(c, v) | |
| 707 | 725 | case ports.WindowResized: | |
| 708 | 726 | if _, w := c.screenOf(v.ID); w != nil { | |
| @@ -753,6 +771,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 753 | 771 | return nil | |
| 754 | 772 | } | |
| 755 | 773 | delete(c.clients, v.ID) | |
| 774 | + | delete(c.mappedAt, v.ID) | |
| 756 | 775 | delete(c.inputRegions, v.ID) | |
| 757 | 776 | delete(c.inhibitors, v.ID) | |
| 758 | 777 | delete(c.idle, v.ID) | |
| @@ -769,6 +788,9 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 769 | 788 | case ports.PointerConstrained: | |
| 770 | 789 | c.constrained = v | |
| 771 | 790 | case ports.WindowFullscreenRequest: | |
| 791 | + | if v.Fullscreen && c.now().Sub(c.mappedAt[v.ID]) < fullscreenGrace { | |
| 792 | + | continue | |
| 793 | + | } | |
| 772 | 794 | if s, _ := c.screenOf(v.ID); s != nil { | |
| 773 | 795 | s.mon.SetFullscreen(v.ID, v.Fullscreen) | |
| 774 | 796 | } | |
| @@ -43,7 +43,16 @@ type Channels struct { | |||||
| 43 | Scales chan<- ports.ScaleChanged | 43 | Scales chan<- ports.ScaleChanged | ||
| 44 | // Terminal enables automatic terminal opening according to terminal.auto-open. | 44 | // Terminal enables automatic terminal opening according to terminal.auto-open. | ||
| 45 | Terminal bool | 45 | Terminal bool | ||
| 46 | + | // Clock tells the time for fullscreenGrace; nil is the system clock. | |||
| 47 | + | Clock ports.Clock | |||
| 46 | } | 48 | } | ||
| 49 | + | ||||
| 50 | + | // fullscreenGrace is how long after mapping a window's fullscreen request | |||
| 51 | + | // is ignored. Wine asks for fullscreen whenever a window has the monitor's | |||
| 52 | + | // size, so a launcher that remembered a fullscreen size would open | |||
| 53 | + | // fullscreen and hide the windows it opens next. The user can still | |||
| 54 | + | // fullscreen it, and an app can ask again later. | |||
| 55 | + | const fullscreenGrace = time.Second | |||
| 47 | type binding struct { | 56 | type binding struct { | ||
| 48 | mods ports.Mods | 57 | mods ports.Mods | ||
| 49 | key string | 58 | key string | ||
| @@ -81,6 +90,7 @@ type Core struct { | |||||
| 81 | firstTerminalResolved bool | 90 | firstTerminalResolved bool | ||
| 82 | // clients holds the app ID and PID of mapped windows, for State. | 91 | // clients holds the app ID and PID of mapped windows, for State. | ||
| 83 | clients map[WindowID]ports.WindowMapped | 92 | clients map[WindowID]ports.WindowMapped | ||
| 93 | + | mappedAt map[WindowID]time.Time | |||
| 84 | inputRegions map[WindowID]ports.InputRegionChanged | 94 | inputRegions map[WindowID]ports.InputRegionChanged | ||
| 85 | sentState ports.State | 95 | sentState ports.State | ||
| 86 | sentWorkspaces ports.Workspaces | 96 | sentWorkspaces ports.Workspaces | ||
| @@ -292,13 +302,20 @@ func New(cfg ports.Config, ch Channels) (*Core, error) { | |||||
| 292 | return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1") | 302 | return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1") | ||
| 293 | } | 303 | } | ||
| 294 | // A placeholder screen holds windows until the first output arrives. | 304 | // A placeholder screen holds windows until the first output arrives. | ||
| 295 | - | c := &Core{slots: map[slotKey]*slotState{}, placement: newSpawnPlacement(), clients: map[WindowID]ports.WindowMapped{}, inputRegions: map[WindowID]ports.InputRegionChanged{}, popups: map[WindowID]*popupState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}, inhibitors: map[WindowID]bool{}, idle: map[WindowID]bool{}} | 305 | + | c := &Core{slots: map[slotKey]*slotState{}, placement: newSpawnPlacement(), clients: map[WindowID]ports.WindowMapped{}, mappedAt: map[WindowID]time.Time{}, inputRegions: map[WindowID]ports.InputRegionChanged{}, popups: map[WindowID]*popupState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}, inhibitors: map[WindowID]bool{}, idle: map[WindowID]bool{}} |
| 296 | c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}} | 306 | c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}} | ||
| 297 | if err := c.apply(cfg); err != nil { | 307 | if err := c.apply(cfg); err != nil { | ||
| 298 | return nil, err | 308 | return nil, err | ||
| 299 | } | 309 | } | ||
| 300 | return c, nil | 310 | return c, nil | ||
| 301 | } | 311 | } | ||
| 312 | + | func (c *Core) now() time.Time { | |||
| 313 | + | if c.ch.Clock != nil { | |||
| 314 | + | return c.ch.Clock.Now() | |||
| 315 | + | } | |||
| 316 | + | return time.Now() | |||
| 317 | + | } | |||
| 318 | + | ||||
| 302 | func (c *Core) command(ctx context.Context, v ports.ClientCommand) error { | 319 | func (c *Core) command(ctx context.Context, v ports.ClientCommand) error { | ||
| 303 | select { | 320 | select { | ||
| 304 | case <-ctx.Done(): | 321 | case <-ctx.Done(): | ||
| @@ -703,6 +720,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 703 | c.inputRegions[v.ID] = v | 720 | c.inputRegions[v.ID] = v | ||
| 704 | case ports.WindowMapped: | 721 | case ports.WindowMapped: | ||
| 705 | c.clients[v.ID] = v | 722 | c.clients[v.ID] = v | ||
| 723 | + | c.mappedAt[v.ID] = c.now() | |||
| 706 | c.placement.place(c, v) | 724 | c.placement.place(c, v) | ||
| 707 | case ports.WindowResized: | 725 | case ports.WindowResized: | ||
| 708 | if _, w := c.screenOf(v.ID); w != nil { | 726 | if _, w := c.screenOf(v.ID); w != nil { | ||
| @@ -753,6 +771,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 753 | return nil | 771 | return nil | ||
| 754 | } | 772 | } | ||
| 755 | delete(c.clients, v.ID) | 773 | delete(c.clients, v.ID) | ||
| 774 | + | delete(c.mappedAt, v.ID) | |||
| 756 | delete(c.inputRegions, v.ID) | 775 | delete(c.inputRegions, v.ID) | ||
| 757 | delete(c.inhibitors, v.ID) | 776 | delete(c.inhibitors, v.ID) | ||
| 758 | delete(c.idle, v.ID) | 777 | delete(c.idle, v.ID) | ||
| @@ -769,6 +788,9 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 769 | case ports.PointerConstrained: | 788 | case ports.PointerConstrained: | ||
| 770 | c.constrained = v | 789 | c.constrained = v | ||
| 771 | case ports.WindowFullscreenRequest: | 790 | case ports.WindowFullscreenRequest: | ||
| 791 | + | if v.Fullscreen && c.now().Sub(c.mappedAt[v.ID]) < fullscreenGrace { | |||
| 792 | + | continue | |||
| 793 | + | } | |||
| 772 | if s, _ := c.screenOf(v.ID); s != nil { | 794 | if s, _ := c.screenOf(v.ID); s != nil { | ||
| 773 | s.mon.SetFullscreen(v.ID, v.Fullscreen) | 795 | s.mon.SetFullscreen(v.ID, v.Fullscreen) | ||
| 774 | } | 796 | } | ||
M internal/core/core_test.go +49 -1
| @@ -5,7 +5,10 @@ import ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "github.com/bnema/neferwl/internal/adapters/config" | |
| 7 | 7 | "github.com/bnema/neferwl/internal/core" | |
| 8 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 8 | 9 | "github.com/bnema/neferwl/internal/ports" | |
| 10 | + | "slices" | |
| 11 | + | "sync/atomic" | |
| 9 | 12 | "testing" | |
| 10 | 13 | "time" | |
| 11 | 14 | ) | |
| @@ -708,7 +711,7 @@ func TestFloatOverFullscreenHit(t *testing.T) { | |||
| 708 | 711 | output := make(chan ports.OutputEvent, 8) | |
| 709 | 712 | commands := make(chan ports.ClientCommand, 64) | |
| 710 | 713 | scenes := make(chan []ports.Scene, 1) | |
| 711 | - | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |
| 714 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes, Clock: steppingClock(t)}) | |
| 712 | 715 | if err != nil { | |
| 713 | 716 | t.Fatal(err) | |
| 714 | 717 | } | |
| @@ -741,6 +744,51 @@ func TestFloatOverFullscreenHit(t *testing.T) { | |||
| 741 | 744 | } | |
| 742 | 745 | } | |
| 743 | 746 | ||
| 747 | + | // A window that asks for fullscreen as it maps (Wine at a remembered | |
| 748 | + | // monitor size) stays in its column: the windows it opens next are seen. | |
| 749 | + | // A later request is honoured. | |
| 750 | + | func TestFullscreenAtMapIgnored(t *testing.T) { | |
| 751 | + | cfg := config.Defaults() | |
| 752 | + | cfg.Layout.Overflow = "fixed" | |
| 753 | + | client := make(chan ports.ClientEvent, 8) | |
| 754 | + | output := make(chan ports.OutputEvent, 8) | |
| 755 | + | commands := make(chan ports.ClientCommand, 64) | |
| 756 | + | scenes := make(chan []ports.Scene, 1) | |
| 757 | + | // Core reads the time on its goroutine. | |
| 758 | + | var now atomic.Int64 | |
| 759 | + | clock := portsmocks.NewMockClock(t) | |
| 760 | + | clock.EXPECT().Now().RunAndReturn(func() time.Time { return time.Unix(now.Load(), 0) }) | |
| 761 | + | c, err := core.New(cfg, core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes, Clock: clock}) | |
| 762 | + | if err != nil { | |
| 763 | + | t.Fatal(err) | |
| 764 | + | } | |
| 765 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 766 | + | defer cancel() | |
| 767 | + | go c.Run(ctx) | |
| 768 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |
| 769 | + | scene(t, scenes) | |
| 770 | + | client <- ports.WindowMapped{ID: 1} | |
| 771 | + | scene(t, scenes) | |
| 772 | + | client <- ports.WindowMapped{ID: 2} | |
| 773 | + | scene(t, scenes) | |
| 774 | + | client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true} | |
| 775 | + | client <- ports.WindowMapped{ID: 3, Floating: true, Width: 20, Height: 10} | |
| 776 | + | s := scene(t, scenes) | |
| 777 | + | for _, w := range s.Windows { | |
| 778 | + | if w.Fullscreen || w.Hidden { | |
| 779 | + | t.Fatalf("startup fullscreen applied: %+v", s.Windows) | |
| 780 | + | } | |
| 781 | + | } | |
| 782 | + | now.Add(1) | |
| 783 | + | client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true} | |
| 784 | + | s = scene(t, scenes) | |
| 785 | + | // Honoured: under fixed overflow it gets its own workspace, and the view | |
| 786 | + | // stays here since it has no focus (ADR 011). | |
| 787 | + | if !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 && w.Hidden }) { | |
| 788 | + | t.Fatalf("later fullscreen ignored: %+v", s.Windows) | |
| 789 | + | } | |
| 790 | + | } | |
| 791 | + | ||
| 744 | 792 | // An activated window on another workspace comes on screen with focus. | |
| 745 | 793 | func TestWindowActivateShowsAndFocuses(t *testing.T) { | |
| 746 | 794 | cfg := config.Defaults() | |
| @@ -5,7 +5,10 @@ import ( | |||||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "github.com/bnema/neferwl/internal/adapters/config" | 6 | "github.com/bnema/neferwl/internal/adapters/config" | ||
| 7 | "github.com/bnema/neferwl/internal/core" | 7 | "github.com/bnema/neferwl/internal/core" | ||
| 8 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 8 | "github.com/bnema/neferwl/internal/ports" | 9 | "github.com/bnema/neferwl/internal/ports" | ||
| 10 | + | "slices" | |||
| 11 | + | "sync/atomic" | |||
| 9 | "testing" | 12 | "testing" | ||
| 10 | "time" | 13 | "time" | ||
| 11 | ) | 14 | ) | ||
| @@ -708,7 +711,7 @@ func TestFloatOverFullscreenHit(t *testing.T) { | |||||
| 708 | output := make(chan ports.OutputEvent, 8) | 711 | output := make(chan ports.OutputEvent, 8) | ||
| 709 | commands := make(chan ports.ClientCommand, 64) | 712 | commands := make(chan ports.ClientCommand, 64) | ||
| 710 | scenes := make(chan []ports.Scene, 1) | 713 | scenes := make(chan []ports.Scene, 1) | ||
| 711 | - | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | 714 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes, Clock: steppingClock(t)}) |
| 712 | if err != nil { | 715 | if err != nil { | ||
| 713 | t.Fatal(err) | 716 | t.Fatal(err) | ||
| 714 | } | 717 | } | ||
| @@ -741,6 +744,51 @@ func TestFloatOverFullscreenHit(t *testing.T) { | |||||
| 741 | } | 744 | } | ||
| 742 | } | 745 | } | ||
| 743 | 746 | ||||
| 747 | + | // A window that asks for fullscreen as it maps (Wine at a remembered | |||
| 748 | + | // monitor size) stays in its column: the windows it opens next are seen. | |||
| 749 | + | // A later request is honoured. | |||
| 750 | + | func TestFullscreenAtMapIgnored(t *testing.T) { | |||
| 751 | + | cfg := config.Defaults() | |||
| 752 | + | cfg.Layout.Overflow = "fixed" | |||
| 753 | + | client := make(chan ports.ClientEvent, 8) | |||
| 754 | + | output := make(chan ports.OutputEvent, 8) | |||
| 755 | + | commands := make(chan ports.ClientCommand, 64) | |||
| 756 | + | scenes := make(chan []ports.Scene, 1) | |||
| 757 | + | // Core reads the time on its goroutine. | |||
| 758 | + | var now atomic.Int64 | |||
| 759 | + | clock := portsmocks.NewMockClock(t) | |||
| 760 | + | clock.EXPECT().Now().RunAndReturn(func() time.Time { return time.Unix(now.Load(), 0) }) | |||
| 761 | + | c, err := core.New(cfg, core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes, Clock: clock}) | |||
| 762 | + | if err != nil { | |||
| 763 | + | t.Fatal(err) | |||
| 764 | + | } | |||
| 765 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 766 | + | defer cancel() | |||
| 767 | + | go c.Run(ctx) | |||
| 768 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |||
| 769 | + | scene(t, scenes) | |||
| 770 | + | client <- ports.WindowMapped{ID: 1} | |||
| 771 | + | scene(t, scenes) | |||
| 772 | + | client <- ports.WindowMapped{ID: 2} | |||
| 773 | + | scene(t, scenes) | |||
| 774 | + | client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true} | |||
| 775 | + | client <- ports.WindowMapped{ID: 3, Floating: true, Width: 20, Height: 10} | |||
| 776 | + | s := scene(t, scenes) | |||
| 777 | + | for _, w := range s.Windows { | |||
| 778 | + | if w.Fullscreen || w.Hidden { | |||
| 779 | + | t.Fatalf("startup fullscreen applied: %+v", s.Windows) | |||
| 780 | + | } | |||
| 781 | + | } | |||
| 782 | + | now.Add(1) | |||
| 783 | + | client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true} | |||
| 784 | + | s = scene(t, scenes) | |||
| 785 | + | // Honoured: under fixed overflow it gets its own workspace, and the view | |||
| 786 | + | // stays here since it has no focus (ADR 011). | |||
| 787 | + | if !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 && w.Hidden }) { | |||
| 788 | + | t.Fatalf("later fullscreen ignored: %+v", s.Windows) | |||
| 789 | + | } | |||
| 790 | + | } | |||
| 791 | + | ||||
| 744 | // An activated window on another workspace comes on screen with focus. | 792 | // An activated window on another workspace comes on screen with focus. | ||
| 745 | func TestWindowActivateShowsAndFocuses(t *testing.T) { | 793 | func TestWindowActivateShowsAndFocuses(t *testing.T) { | ||
| 746 | cfg := config.Defaults() | 794 | cfg := config.Defaults() | ||
M internal/core/layer_input_test.go +2 -2
| @@ -186,7 +186,7 @@ func TestLayerHitOrder(t *testing.T) { | |||
| 186 | 186 | output := make(chan ports.OutputEvent, 8) | |
| 187 | 187 | commands := make(chan ports.ClientCommand, 256) | |
| 188 | 188 | scenes := make(chan []ports.Scene, 1) | |
| 189 | - | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |
| 189 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes, Clock: steppingClock(t)}) | |
| 190 | 190 | if err != nil { | |
| 191 | 191 | t.Fatal(err) | |
| 192 | 192 | } | |
| @@ -404,7 +404,7 @@ func TestHiddenGrabPopupLosesKeyboard(t *testing.T) { | |||
| 404 | 404 | output := make(chan ports.OutputEvent, 8) | |
| 405 | 405 | commands := make(chan ports.ClientCommand, 64) | |
| 406 | 406 | scenes := make(chan []ports.Scene, 1) | |
| 407 | - | c, err := core.New(cfg, core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes}) | |
| 407 | + | c, err := core.New(cfg, core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes, Clock: steppingClock(t)}) | |
| 408 | 408 | if err != nil { | |
| 409 | 409 | t.Fatal(err) | |
| 410 | 410 | } | |
| @@ -186,7 +186,7 @@ func TestLayerHitOrder(t *testing.T) { | |||||
| 186 | output := make(chan ports.OutputEvent, 8) | 186 | output := make(chan ports.OutputEvent, 8) | ||
| 187 | commands := make(chan ports.ClientCommand, 256) | 187 | commands := make(chan ports.ClientCommand, 256) | ||
| 188 | scenes := make(chan []ports.Scene, 1) | 188 | scenes := make(chan []ports.Scene, 1) | ||
| 189 | - | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | 189 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes, Clock: steppingClock(t)}) |
| 190 | if err != nil { | 190 | if err != nil { | ||
| 191 | t.Fatal(err) | 191 | t.Fatal(err) | ||
| 192 | } | 192 | } | ||
| @@ -404,7 +404,7 @@ func TestHiddenGrabPopupLosesKeyboard(t *testing.T) { | |||||
| 404 | output := make(chan ports.OutputEvent, 8) | 404 | output := make(chan ports.OutputEvent, 8) | ||
| 405 | commands := make(chan ports.ClientCommand, 64) | 405 | commands := make(chan ports.ClientCommand, 64) | ||
| 406 | scenes := make(chan []ports.Scene, 1) | 406 | scenes := make(chan []ports.Scene, 1) | ||
| 407 | - | c, err := core.New(cfg, core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes}) | 407 | + | c, err := core.New(cfg, core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes, Clock: steppingClock(t)}) |
| 408 | if err != nil { | 408 | if err != nil { | ||
| 409 | t.Fatal(err) | 409 | t.Fatal(err) | ||
| 410 | } | 410 | } | ||
M internal/core/layers_test.go +15 -1
| @@ -5,9 +5,23 @@ import ( | |||
| 5 | 5 | "testing" | |
| 6 | 6 | "time" | |
| 7 | 7 | ||
| 8 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 8 | 9 | "github.com/bnema/neferwl/internal/ports" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| 12 | + | // laterClock moves fullscreenGrace forward on each Now: fullscreen | |
| 13 | + | // requests are past the grace. | |
| 14 | + | func laterClock(t *testing.T) *portsmocks.MockClock { | |
| 15 | + | t.Helper() | |
| 16 | + | now := time.Unix(0, 0) | |
| 17 | + | clock := portsmocks.NewMockClock(t) | |
| 18 | + | clock.EXPECT().Now().RunAndReturn(func() time.Time { | |
| 19 | + | now = now.Add(fullscreenGrace) | |
| 20 | + | return now | |
| 21 | + | }).Maybe() | |
| 22 | + | return clock | |
| 23 | + | } | |
| 24 | + | ||
| 11 | 25 | func TestArrangeLayers(t *testing.T) { | |
| 12 | 26 | bar := ports.LayerSurface{ID: 1, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Width: 1920, Height: 30, ExclusiveZone: 30} | |
| 13 | 27 | tests := []struct { | |
| @@ -139,7 +153,7 @@ func TestFullscreenSceneLayers(t *testing.T) { | |||
| 139 | 153 | cfg := ports.Config{} | |
| 140 | 154 | cfg.Keyboard.CmdKey = "super" | |
| 141 | 155 | cfg.Layout.MaxColumns = 2 | |
| 142 | - | c, err := New(cfg, Channels{Client: client, Output: output, Commands: commands, Scenes: scenes}) | |
| 156 | + | c, err := New(cfg, Channels{Client: client, Output: output, Commands: commands, Scenes: scenes, Clock: laterClock(t)}) | |
| 143 | 157 | if err != nil { | |
| 144 | 158 | t.Fatal(err) | |
| 145 | 159 | } | |
| @@ -5,9 +5,23 @@ import ( | |||||
| 5 | "testing" | 5 | "testing" | ||
| 6 | "time" | 6 | "time" | ||
| 7 | 7 | ||||
| 8 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 8 | "github.com/bnema/neferwl/internal/ports" | 9 | "github.com/bnema/neferwl/internal/ports" | ||
| 9 | ) | 10 | ) | ||
| 10 | 11 | ||||
| 12 | + | // laterClock moves fullscreenGrace forward on each Now: fullscreen | |||
| 13 | + | // requests are past the grace. | |||
| 14 | + | func laterClock(t *testing.T) *portsmocks.MockClock { | |||
| 15 | + | t.Helper() | |||
| 16 | + | now := time.Unix(0, 0) | |||
| 17 | + | clock := portsmocks.NewMockClock(t) | |||
| 18 | + | clock.EXPECT().Now().RunAndReturn(func() time.Time { | |||
| 19 | + | now = now.Add(fullscreenGrace) | |||
| 20 | + | return now | |||
| 21 | + | }).Maybe() | |||
| 22 | + | return clock | |||
| 23 | + | } | |||
| 24 | + | ||||
| 11 | func TestArrangeLayers(t *testing.T) { | 25 | func TestArrangeLayers(t *testing.T) { | ||
| 12 | bar := ports.LayerSurface{ID: 1, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Width: 1920, Height: 30, ExclusiveZone: 30} | 26 | bar := ports.LayerSurface{ID: 1, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Width: 1920, Height: 30, ExclusiveZone: 30} | ||
| 13 | tests := []struct { | 27 | tests := []struct { | ||
| @@ -139,7 +153,7 @@ func TestFullscreenSceneLayers(t *testing.T) { | |||||
| 139 | cfg := ports.Config{} | 153 | cfg := ports.Config{} | ||
| 140 | cfg.Keyboard.CmdKey = "super" | 154 | cfg.Keyboard.CmdKey = "super" | ||
| 141 | cfg.Layout.MaxColumns = 2 | 155 | cfg.Layout.MaxColumns = 2 | ||
| 142 | - | c, err := New(cfg, Channels{Client: client, Output: output, Commands: commands, Scenes: scenes}) | 156 | + | c, err := New(cfg, Channels{Client: client, Output: output, Commands: commands, Scenes: scenes, Clock: laterClock(t)}) |
| 143 | if err != nil { | 157 | if err != nil { | ||
| 144 | t.Fatal(err) | 158 | t.Fatal(err) | ||
| 145 | } | 159 | } | ||
M internal/core/persist_test.go +13 -0
| @@ -12,6 +12,19 @@ import ( | |||
| 12 | 12 | "github.com/bnema/neferwl/internal/ports" | |
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | + | // steppingClock returns a clock that moves a second forward on each Now: | |
| 16 | + | // a fullscreen request is never within fullscreenGrace of the mapping. | |
| 17 | + | func steppingClock(t *testing.T) *portsmocks.MockClock { | |
| 18 | + | t.Helper() | |
| 19 | + | now := time.Unix(0, 0) | |
| 20 | + | clock := portsmocks.NewMockClock(t) | |
| 21 | + | clock.EXPECT().Now().RunAndReturn(func() time.Time { | |
| 22 | + | now = now.Add(time.Second) | |
| 23 | + | return now | |
| 24 | + | }).Maybe() | |
| 25 | + | return clock | |
| 26 | + | } | |
| 27 | + | ||
| 15 | 28 | // persistClock returns a clock whose debounce timer fires when the test | |
| 16 | 29 | // sends on the returned channel. Each change restarts the timer. | |
| 17 | 30 | func persistClock(t *testing.T, debounce time.Duration, changes int) (*portsmocks.MockClock, chan time.Time) { | |
| @@ -12,6 +12,19 @@ import ( | |||||
| 12 | "github.com/bnema/neferwl/internal/ports" | 12 | "github.com/bnema/neferwl/internal/ports" | ||
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
| 15 | + | // steppingClock returns a clock that moves a second forward on each Now: | |||
| 16 | + | // a fullscreen request is never within fullscreenGrace of the mapping. | |||
| 17 | + | func steppingClock(t *testing.T) *portsmocks.MockClock { | |||
| 18 | + | t.Helper() | |||
| 19 | + | now := time.Unix(0, 0) | |||
| 20 | + | clock := portsmocks.NewMockClock(t) | |||
| 21 | + | clock.EXPECT().Now().RunAndReturn(func() time.Time { | |||
| 22 | + | now = now.Add(time.Second) | |||
| 23 | + | return now | |||
| 24 | + | }).Maybe() | |||
| 25 | + | return clock | |||
| 26 | + | } | |||
| 27 | + | ||||
| 15 | // persistClock returns a clock whose debounce timer fires when the test | 28 | // persistClock returns a clock whose debounce timer fires when the test | ||
| 16 | // sends on the returned channel. Each change restarts the timer. | 29 | // sends on the returned channel. Each change restarts the timer. | ||
| 17 | func persistClock(t *testing.T, debounce time.Duration, changes int) (*portsmocks.MockClock, chan time.Time) { | 30 | func persistClock(t *testing.T, debounce time.Duration, changes int) (*portsmocks.MockClock, chan time.Time) { | ||