detached f94690a

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
8989 | `spawn <command>` | Run a program |
9090 | `spawn-terminal` | Run `terminal` |
9191 | `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 |
9393 | `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 |
9494 | `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 |
9595 | `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)
103103 // Only real sessions save scales: headless runs never touch the config file.
104104 scales = make(chan ports.ScaleChanged, 8)
105105 }
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{}}
107107 c, err := core.New(opts.Config, ch)
108108 if err != nil {
109109 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 err109 return err
M internal/core/core.go +23 -1
@@ -43,7 +43,16 @@ type Channels struct {
4343 Scales chan<- ports.ScaleChanged
4444 // Terminal enables automatic terminal opening according to terminal.auto-open.
4545 Terminal bool
46+ // Clock tells the time for fullscreenGrace; nil is the system clock.
47+ Clock ports.Clock
4648 }
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
4756 type binding struct {
4857 mods ports.Mods
4958 key string
@@ -81,6 +90,7 @@ type Core struct {
8190 firstTerminalResolved bool
8291 // clients holds the app ID and PID of mapped windows, for State.
8392 clients map[WindowID]ports.WindowMapped
93+ mappedAt map[WindowID]time.Time
8494 inputRegions map[WindowID]ports.InputRegionChanged
8595 sentState ports.State
8696 sentWorkspaces ports.Workspaces
@@ -292,13 +302,20 @@ func New(cfg ports.Config, ch Channels) (*Core, error) {
292302 return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1")
293303 }
294304 // 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{}}
296306 c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}}
297307 if err := c.apply(cfg); err != nil {
298308 return nil, err
299309 }
300310 return c, nil
301311 }
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+
302319 func (c *Core) command(ctx context.Context, v ports.ClientCommand) error {
303320 select {
304321 case <-ctx.Done():
@@ -703,6 +720,7 @@ func (c *Core) Run(ctx context.Context) error {
703720 c.inputRegions[v.ID] = v
704721 case ports.WindowMapped:
705722 c.clients[v.ID] = v
723+ c.mappedAt[v.ID] = c.now()
706724 c.placement.place(c, v)
707725 case ports.WindowResized:
708726 if _, w := c.screenOf(v.ID); w != nil {
@@ -753,6 +771,7 @@ func (c *Core) Run(ctx context.Context) error {
753771 return nil
754772 }
755773 delete(c.clients, v.ID)
774+ delete(c.mappedAt, v.ID)
756775 delete(c.inputRegions, v.ID)
757776 delete(c.inhibitors, v.ID)
758777 delete(c.idle, v.ID)
@@ -769,6 +788,9 @@ func (c *Core) Run(ctx context.Context) error {
769788 case ports.PointerConstrained:
770789 c.constrained = v
771790 case ports.WindowFullscreenRequest:
791+ if v.Fullscreen && c.now().Sub(c.mappedAt[v.ID]) < fullscreenGrace {
792+ continue
793+ }
772794 if s, _ := c.screenOf(v.ID); s != nil {
773795 s.mon.SetFullscreen(v.ID, v.Fullscreen)
774796 }
@@ -43,7 +43,16 @@ type Channels struct {
43 Scales chan<- ports.ScaleChanged43 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 bool45 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.Mods57 mods ports.Mods
49 key string58 key string
@@ -81,6 +90,7 @@ type Core struct {
81 firstTerminalResolved bool90 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.WindowMapped92 clients map[WindowID]ports.WindowMapped
93+ mappedAt map[WindowID]time.Time
84 inputRegions map[WindowID]ports.InputRegionChanged94 inputRegions map[WindowID]ports.InputRegionChanged
85 sentState ports.State95 sentState ports.State
86 sentWorkspaces ports.Workspaces96 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, err308 return nil, err
299 }309 }
300 return c, nil310 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] = v720 c.inputRegions[v.ID] = v
704 case ports.WindowMapped:721 case ports.WindowMapped:
705 c.clients[v.ID] = v722 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 nil771 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 = v789 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 (
55 "errors"
66 "github.com/bnema/neferwl/internal/adapters/config"
77 "github.com/bnema/neferwl/internal/core"
8+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
89 "github.com/bnema/neferwl/internal/ports"
10+ "slices"
11+ "sync/atomic"
912 "testing"
1013 "time"
1114 )
@@ -708,7 +711,7 @@ func TestFloatOverFullscreenHit(t *testing.T) {
708711 output := make(chan ports.OutputEvent, 8)
709712 commands := make(chan ports.ClientCommand, 64)
710713 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)})
712715 if err != nil {
713716 t.Fatal(err)
714717 }
@@ -741,6 +744,51 @@ func TestFloatOverFullscreenHit(t *testing.T) {
741744 }
742745 }
743746
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+
744792 // An activated window on another workspace comes on screen with focus.
745793 func TestWindowActivateShowsAndFocuses(t *testing.T) {
746794 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) {
186186 output := make(chan ports.OutputEvent, 8)
187187 commands := make(chan ports.ClientCommand, 256)
188188 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)})
190190 if err != nil {
191191 t.Fatal(err)
192192 }
@@ -404,7 +404,7 @@ func TestHiddenGrabPopupLosesKeyboard(t *testing.T) {
404404 output := make(chan ports.OutputEvent, 8)
405405 commands := make(chan ports.ClientCommand, 64)
406406 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)})
408408 if err != nil {
409409 t.Fatal(err)
410410 }
@@ -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 (
55 "testing"
66 "time"
77
8+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
89 "github.com/bnema/neferwl/internal/ports"
910 )
1011
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+
1125 func TestArrangeLayers(t *testing.T) {
1226 bar := ports.LayerSurface{ID: 1, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Width: 1920, Height: 30, ExclusiveZone: 30}
1327 tests := []struct {
@@ -139,7 +153,7 @@ func TestFullscreenSceneLayers(t *testing.T) {
139153 cfg := ports.Config{}
140154 cfg.Keyboard.CmdKey = "super"
141155 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)})
143157 if err != nil {
144158 t.Fatal(err)
145159 }
@@ -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 = 2155 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 (
1212 "github.com/bnema/neferwl/internal/ports"
1313 )
1414
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+
1528 // persistClock returns a clock whose debounce timer fires when the test
1629 // sends on the returned channel. Each change restarts the timer.
1730 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 test28 // 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) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close