merge: output-fixes (pointer focus, column moves across outputs, cursor hide, terminal on empty workspaces)
10 files · +294 -13
M internal/adapters/drm/cursor.go +13 -3
| @@ -74,11 +74,12 @@ type Cursor struct { | |||
| 74 | 74 | size int // buffer side | |
| 75 | 75 | hotX, hotY int | |
| 76 | 76 | shown bool | |
| 77 | - | hidden bool // Hide was called; the next move shows it again | |
| 77 | + | hidden bool // the plane is off because the pointer left this output | |
| 78 | 78 | plane cursorPlane | |
| 79 | 79 | ||
| 80 | - | mu sync.Mutex // guards x, y and stats; never held across an ioctl | |
| 80 | + | mu sync.Mutex // guards x, y, away and stats; never held across an ioctl | |
| 81 | 81 | x, y int // physical position of the hotspot | |
| 82 | + | away bool // Hide was called and no Move since | |
| 82 | 83 | stats CursorStats | |
| 83 | 84 | ||
| 84 | 85 | wake chan struct{} | |
| @@ -148,7 +149,12 @@ func (c *Cursor) start() { | |||
| 148 | 149 | // fails too and Reapply restores the cursor. | |
| 149 | 150 | _ = c.plane.WaitVblank() | |
| 150 | 151 | c.io.Lock() | |
| 151 | - | if c.hidden { | |
| 152 | + | c.mu.Lock() | |
| 153 | + | away := c.away | |
| 154 | + | c.mu.Unlock() | |
| 155 | + | if away { | |
| 156 | + | // A move queued before Hide: the pointer is elsewhere. | |
| 157 | + | } else if c.hidden { | |
| 152 | 158 | // A move shows it again, even before its first image. | |
| 153 | 159 | c.hidden = false | |
| 154 | 160 | _ = c.apply() | |
| @@ -213,6 +219,7 @@ func (c *Cursor) apply() error { | |||
| 213 | 219 | func (c *Cursor) Move(x, y float64) { | |
| 214 | 220 | c.mu.Lock() | |
| 215 | 221 | c.x, c.y = int(x), int(y) | |
| 222 | + | c.away = false | |
| 216 | 223 | c.stats.Moves++ | |
| 217 | 224 | c.mu.Unlock() | |
| 218 | 225 | select { | |
| @@ -226,6 +233,9 @@ func (c *Cursor) Move(x, y float64) { | |||
| 226 | 233 | func (c *Cursor) Hide() { | |
| 227 | 234 | c.io.Lock() | |
| 228 | 235 | defer c.io.Unlock() | |
| 236 | + | c.mu.Lock() | |
| 237 | + | c.away = true | |
| 238 | + | c.mu.Unlock() | |
| 229 | 239 | if c.buf == nil || c.hidden { | |
| 230 | 240 | return | |
| 231 | 241 | } | |
| @@ -74,11 +74,12 @@ type Cursor struct { | |||||
| 74 | size int // buffer side | 74 | size int // buffer side | ||
| 75 | hotX, hotY int | 75 | hotX, hotY int | ||
| 76 | shown bool | 76 | shown bool | ||
| 77 | - | hidden bool // Hide was called; the next move shows it again | 77 | + | hidden bool // the plane is off because the pointer left this output |
| 78 | plane cursorPlane | 78 | plane cursorPlane | ||
| 79 | 79 | ||||
| 80 | - | mu sync.Mutex // guards x, y and stats; never held across an ioctl | 80 | + | mu sync.Mutex // guards x, y, away and stats; never held across an ioctl |
| 81 | x, y int // physical position of the hotspot | 81 | x, y int // physical position of the hotspot | ||
| 82 | + | away bool // Hide was called and no Move since | |||
| 82 | stats CursorStats | 83 | stats CursorStats | ||
| 83 | 84 | ||||
| 84 | wake chan struct{} | 85 | wake chan struct{} | ||
| @@ -148,7 +149,12 @@ func (c *Cursor) start() { | |||||
| 148 | // fails too and Reapply restores the cursor. | 149 | // fails too and Reapply restores the cursor. | ||
| 149 | _ = c.plane.WaitVblank() | 150 | _ = c.plane.WaitVblank() | ||
| 150 | c.io.Lock() | 151 | c.io.Lock() | ||
| 151 | - | if c.hidden { | 152 | + | c.mu.Lock() |
| 153 | + | away := c.away | |||
| 154 | + | c.mu.Unlock() | |||
| 155 | + | if away { | |||
| 156 | + | // A move queued before Hide: the pointer is elsewhere. | |||
| 157 | + | } else if c.hidden { | |||
| 152 | // A move shows it again, even before its first image. | 158 | // A move shows it again, even before its first image. | ||
| 153 | c.hidden = false | 159 | c.hidden = false | ||
| 154 | _ = c.apply() | 160 | _ = c.apply() | ||
| @@ -213,6 +219,7 @@ func (c *Cursor) apply() error { | |||||
| 213 | func (c *Cursor) Move(x, y float64) { | 219 | func (c *Cursor) Move(x, y float64) { | ||
| 214 | c.mu.Lock() | 220 | c.mu.Lock() | ||
| 215 | c.x, c.y = int(x), int(y) | 221 | c.x, c.y = int(x), int(y) | ||
| 222 | + | c.away = false | |||
| 216 | c.stats.Moves++ | 223 | c.stats.Moves++ | ||
| 217 | c.mu.Unlock() | 224 | c.mu.Unlock() | ||
| 218 | select { | 225 | select { | ||
| @@ -226,6 +233,9 @@ func (c *Cursor) Move(x, y float64) { | |||||
| 226 | func (c *Cursor) Hide() { | 233 | func (c *Cursor) Hide() { | ||
| 227 | c.io.Lock() | 234 | c.io.Lock() | ||
| 228 | defer c.io.Unlock() | 235 | defer c.io.Unlock() | ||
| 236 | + | c.mu.Lock() | |||
| 237 | + | c.away = true | |||
| 238 | + | c.mu.Unlock() | |||
| 229 | if c.buf == nil || c.hidden { | 239 | if c.buf == nil || c.hidden { | ||
| 230 | return | 240 | return | ||
| 231 | } | 241 | } | ||
M internal/adapters/drm/cursor_test.go +29 -0
| @@ -139,3 +139,32 @@ func TestCursorStats(t *testing.T) { | |||
| 139 | 139 | t.Fatalf("not reset: %+v", s) | |
| 140 | 140 | } | |
| 141 | 141 | } | |
| 142 | + | ||
| 143 | + | // A move still waiting for its vblank when the pointer leaves the output | |
| 144 | + | // must not show the cursor again. | |
| 145 | + | func TestCursorHideDropsQueuedMove(t *testing.T) { | |
| 146 | + | c, plane := testCursor(t) | |
| 147 | + | vblank := make(chan struct{}) | |
| 148 | + | set := make(chan modeCursor2, 16) | |
| 149 | + | plane.EXPECT().WaitVblank().RunAndReturn(func() error { <-vblank; return nil }) | |
| 150 | + | plane.EXPECT().Set(mock.Anything).RunAndReturn(func(v *modeCursor2) error { set <- *v; return nil }) | |
| 151 | + | c.start() | |
| 152 | + | defer func() { close(vblank); c.close() }() | |
| 153 | + | c.Move(10, 10) | |
| 154 | + | c.Hide() | |
| 155 | + | if v := <-set; v.handle != 0 || v.flags != cursorBO { | |
| 156 | + | t.Fatalf("hide: %+v", v) | |
| 157 | + | } | |
| 158 | + | vblank <- struct{}{} | |
| 159 | + | select { | |
| 160 | + | case v := <-set: | |
| 161 | + | t.Fatalf("queued move applied after hide: %+v", v) | |
| 162 | + | case <-time.After(20 * time.Millisecond): | |
| 163 | + | } | |
| 164 | + | // The next move shows it again. | |
| 165 | + | c.Move(20, 20) | |
| 166 | + | vblank <- struct{}{} | |
| 167 | + | if v := <-set; v.flags != cursorBO|cursorMove || v.x != 20 { | |
| 168 | + | t.Fatalf("show: %+v", v) | |
| 169 | + | } | |
| 170 | + | } | |
| @@ -139,3 +139,32 @@ func TestCursorStats(t *testing.T) { | |||||
| 139 | t.Fatalf("not reset: %+v", s) | 139 | t.Fatalf("not reset: %+v", s) | ||
| 140 | } | 140 | } | ||
| 141 | } | 141 | } | ||
| 142 | + | ||||
| 143 | + | // A move still waiting for its vblank when the pointer leaves the output | |||
| 144 | + | // must not show the cursor again. | |||
| 145 | + | func TestCursorHideDropsQueuedMove(t *testing.T) { | |||
| 146 | + | c, plane := testCursor(t) | |||
| 147 | + | vblank := make(chan struct{}) | |||
| 148 | + | set := make(chan modeCursor2, 16) | |||
| 149 | + | plane.EXPECT().WaitVblank().RunAndReturn(func() error { <-vblank; return nil }) | |||
| 150 | + | plane.EXPECT().Set(mock.Anything).RunAndReturn(func(v *modeCursor2) error { set <- *v; return nil }) | |||
| 151 | + | c.start() | |||
| 152 | + | defer func() { close(vblank); c.close() }() | |||
| 153 | + | c.Move(10, 10) | |||
| 154 | + | c.Hide() | |||
| 155 | + | if v := <-set; v.handle != 0 || v.flags != cursorBO { | |||
| 156 | + | t.Fatalf("hide: %+v", v) | |||
| 157 | + | } | |||
| 158 | + | vblank <- struct{}{} | |||
| 159 | + | select { | |||
| 160 | + | case v := <-set: | |||
| 161 | + | t.Fatalf("queued move applied after hide: %+v", v) | |||
| 162 | + | case <-time.After(20 * time.Millisecond): | |||
| 163 | + | } | |||
| 164 | + | // The next move shows it again. | |||
| 165 | + | c.Move(20, 20) | |||
| 166 | + | vblank <- struct{}{} | |||
| 167 | + | if v := <-set; v.flags != cursorBO|cursorMove || v.x != 20 { | |||
| 168 | + | t.Fatalf("show: %+v", v) | |||
| 169 | + | } | |||
| 170 | + | } | |||
M internal/app/app.go +1 -4
| @@ -76,7 +76,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 76 | 76 | configErrors := make(chan error, 8) | |
| 77 | 77 | renderScenes := make(chan []ports.Scene, 1) | |
| 78 | 78 | contents := make(chan ports.SurfaceContent, 64) | |
| 79 | - | ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, ConfigErrors: configErrors} | |
| 79 | + | ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, ConfigErrors: configErrors, Terminal: !opts.NoTerminal} | |
| 80 | 80 | c, err := core.New(opts.Config, ch) | |
| 81 | 81 | if err != nil { | |
| 82 | 82 | return err | |
| @@ -94,9 +94,6 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 94 | 94 | } | |
| 95 | 95 | log.Info().Str("WAYLAND_DISPLAY", server.SocketName()).Msg("listening") | |
| 96 | 96 | child := launcher.New(launcher.ChildEnv(os.Environ(), server.SocketName(), runtimeDir), logging.For(ctx, "launcher")) | |
| 97 | - | if !opts.NoTerminal { | |
| 98 | - | spawn <- ports.SpawnRequest{Argv: append([]string(nil), opts.Config.Terminal.Command...)} | |
| 99 | - | } | |
| 100 | 97 | if inject != nil { | |
| 101 | 98 | inject(input) | |
| 102 | 99 | } | |
| @@ -76,7 +76,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 76 | configErrors := make(chan error, 8) | 76 | configErrors := make(chan error, 8) | ||
| 77 | renderScenes := make(chan []ports.Scene, 1) | 77 | renderScenes := make(chan []ports.Scene, 1) | ||
| 78 | contents := make(chan ports.SurfaceContent, 64) | 78 | contents := make(chan ports.SurfaceContent, 64) | ||
| 79 | - | ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, ConfigErrors: configErrors} | 79 | + | ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, ConfigErrors: configErrors, Terminal: !opts.NoTerminal} |
| 80 | c, err := core.New(opts.Config, ch) | 80 | c, err := core.New(opts.Config, ch) | ||
| 81 | if err != nil { | 81 | if err != nil { | ||
| 82 | return err | 82 | return err | ||
| @@ -94,9 +94,6 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 94 | } | 94 | } | ||
| 95 | log.Info().Str("WAYLAND_DISPLAY", server.SocketName()).Msg("listening") | 95 | log.Info().Str("WAYLAND_DISPLAY", server.SocketName()).Msg("listening") | ||
| 96 | child := launcher.New(launcher.ChildEnv(os.Environ(), server.SocketName(), runtimeDir), logging.For(ctx, "launcher")) | 96 | child := launcher.New(launcher.ChildEnv(os.Environ(), server.SocketName(), runtimeDir), logging.For(ctx, "launcher")) | ||
| 97 | - | if !opts.NoTerminal { | |||
| 98 | - | spawn <- ports.SpawnRequest{Argv: append([]string(nil), opts.Config.Terminal.Command...)} | |||
| 99 | - | } | |||
| 100 | if inject != nil { | 97 | if inject != nil { | ||
| 101 | inject(input) | 98 | inject(input) | ||
| 102 | } | 99 | } | ||
M internal/app/outputs.go +3 -2
| @@ -139,8 +139,9 @@ func (c *cursors) set(output string, cur cursor) { | |||
| 139 | 139 | return | |
| 140 | 140 | } | |
| 141 | 141 | c.all[output] = cur | |
| 142 | - | // Before the first move nothing is shown yet: hiding would stick. | |
| 143 | - | if c.last != "" && output != c.last { | |
| 142 | + | // Only the output under the pointer shows it; before the first move, | |
| 143 | + | // none does (a new cursor would show at its top-left corner). | |
| 144 | + | if output != c.last { | |
| 144 | 145 | cur.Hide() | |
| 145 | 146 | } | |
| 146 | 147 | } | |
| @@ -139,8 +139,9 @@ func (c *cursors) set(output string, cur cursor) { | |||||
| 139 | return | 139 | return | ||
| 140 | } | 140 | } | ||
| 141 | c.all[output] = cur | 141 | c.all[output] = cur | ||
| 142 | - | // Before the first move nothing is shown yet: hiding would stick. | 142 | + | // Only the output under the pointer shows it; before the first move, |
| 143 | - | if c.last != "" && output != c.last { | 143 | + | // none does (a new cursor would show at its top-left corner). |
| 144 | + | if output != c.last { | |||
| 144 | cur.Hide() | 145 | cur.Hide() | ||
| 145 | } | 146 | } | ||
| 146 | } | 147 | } | ||
M internal/core/action.go +26 -0
| @@ -114,6 +114,32 @@ func (c *Core) applyAction(a Action) Effect { | |||
| 114 | 114 | c.focusScreen = i | |
| 115 | 115 | return Effect{} | |
| 116 | 116 | } | |
| 117 | + | case ActionMoveColumnLeft, ActionMoveColumnRight: | |
| 118 | + | // Past the edge the column moves to the neighbor screen, on the | |
| 119 | + | // side facing this one, and focus follows it. | |
| 120 | + | w := c.cur().mon.Current() | |
| 121 | + | dir = 1 | |
| 122 | + | if a == ActionMoveColumnLeft { | |
| 123 | + | dir = -1 | |
| 124 | + | } | |
| 125 | + | edge := (dir < 0 && w.Focus == 0) || (dir > 0 && w.Focus == len(w.Columns)-1) | |
| 126 | + | if i := c.neighbor(dir); edge && i >= 0 { | |
| 127 | + | col, ok := w.takeColumn() | |
| 128 | + | if !ok { | |
| 129 | + | return Effect{} | |
| 130 | + | } | |
| 131 | + | from := c.cur() | |
| 132 | + | c.focusScreen = i | |
| 133 | + | to := c.cur().mon.Current() | |
| 134 | + | at := 0 | |
| 135 | + | if dir < 0 { | |
| 136 | + | at = len(to.Columns) | |
| 137 | + | } | |
| 138 | + | to.insertColumn(at, col) | |
| 139 | + | from.mon.normalize() | |
| 140 | + | c.cur().mon.normalize() | |
| 141 | + | return Effect{} | |
| 142 | + | } | |
| 117 | 143 | } | |
| 118 | 144 | return c.cur().mon.Apply(a) | |
| 119 | 145 | } | |
| @@ -114,6 +114,32 @@ func (c *Core) applyAction(a Action) Effect { | |||||
| 114 | c.focusScreen = i | 114 | c.focusScreen = i | ||
| 115 | return Effect{} | 115 | return Effect{} | ||
| 116 | } | 116 | } | ||
| 117 | + | case ActionMoveColumnLeft, ActionMoveColumnRight: | |||
| 118 | + | // Past the edge the column moves to the neighbor screen, on the | |||
| 119 | + | // side facing this one, and focus follows it. | |||
| 120 | + | w := c.cur().mon.Current() | |||
| 121 | + | dir = 1 | |||
| 122 | + | if a == ActionMoveColumnLeft { | |||
| 123 | + | dir = -1 | |||
| 124 | + | } | |||
| 125 | + | edge := (dir < 0 && w.Focus == 0) || (dir > 0 && w.Focus == len(w.Columns)-1) | |||
| 126 | + | if i := c.neighbor(dir); edge && i >= 0 { | |||
| 127 | + | col, ok := w.takeColumn() | |||
| 128 | + | if !ok { | |||
| 129 | + | return Effect{} | |||
| 130 | + | } | |||
| 131 | + | from := c.cur() | |||
| 132 | + | c.focusScreen = i | |||
| 133 | + | to := c.cur().mon.Current() | |||
| 134 | + | at := 0 | |||
| 135 | + | if dir < 0 { | |||
| 136 | + | at = len(to.Columns) | |||
| 137 | + | } | |||
| 138 | + | to.insertColumn(at, col) | |||
| 139 | + | from.mon.normalize() | |||
| 140 | + | c.cur().mon.normalize() | |||
| 141 | + | return Effect{} | |||
| 142 | + | } | |||
| 117 | } | 143 | } | ||
| 118 | return c.cur().mon.Apply(a) | 144 | return c.cur().mon.Apply(a) | ||
| 119 | } | 145 | } | ||
M internal/core/core.go +24 -2
| @@ -28,6 +28,9 @@ type Channels struct { | |||
| 28 | 28 | // latest first (capacity 1, drained like Scenes). Input and cursors use it. | |
| 29 | 29 | Layouts chan ports.Layout | |
| 30 | 30 | ConfigErrors chan<- error | |
| 31 | + | // Terminal, when set, keeps a window on every workspace on screen: an | |
| 32 | + | // empty one gets the configured terminal (the terminal is the desktop). | |
| 33 | + | Terminal bool | |
| 31 | 34 | } | |
| 32 | 35 | type binding struct { | |
| 33 | 36 | mods ports.Mods | |
| @@ -57,6 +60,9 @@ type Core struct { | |||
| 57 | 60 | // toSpawn holds slots to start; Run sends them (apply has no context). | |
| 58 | 61 | toSpawn []slotKey | |
| 59 | 62 | sentPending bool | |
| 63 | + | // terms are the terminals spawned for empty workspaces, by SlotEnv | |
| 64 | + | // token, until their window maps. | |
| 65 | + | terms map[string]*termSpawn | |
| 60 | 66 | } | |
| 61 | 67 | ||
| 62 | 68 | func keyName(s string) string { | |
| @@ -239,7 +245,7 @@ func New(cfg ports.Config, ch Channels) (*Core, error) { | |||
| 239 | 245 | return nil, fmt.Errorf("scenes and layouts must have capacity 1") | |
| 240 | 246 | } | |
| 241 | 247 | // A placeholder screen holds windows until the first output arrives. | |
| 242 | - | c := &Core{screens: []*screen{{mon: NewMonitor(), scale: 1, cfgScale: 1}}, slots: map[slotKey]*slotState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}} | |
| 248 | + | c := &Core{screens: []*screen{{mon: NewMonitor(), scale: 1, cfgScale: 1}}, slots: map[slotKey]*slotState{}, terms: map[string]*termSpawn{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}} | |
| 243 | 249 | if err := c.apply(cfg); err != nil { | |
| 244 | 250 | return nil, err | |
| 245 | 251 | } | |
| @@ -326,9 +332,17 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 326 | 332 | } | |
| 327 | 333 | c.sentOutputs = v | |
| 328 | 334 | } | |
| 335 | + | terms := c.fillEmpty() | |
| 329 | 336 | if err := c.publishPending(ctx); err != nil { | |
| 330 | 337 | return err | |
| 331 | 338 | } | |
| 339 | + | for _, req := range terms { | |
| 340 | + | select { | |
| 341 | + | case <-ctx.Done(): | |
| 342 | + | return ctx.Err() | |
| 343 | + | case c.ch.Spawn <- req: | |
| 344 | + | } | |
| 345 | + | } | |
| 332 | 346 | alive := map[WindowID]bool{} | |
| 333 | 347 | focus := c.keyboardFocus() | |
| 334 | 348 | scenes := make([]ports.Scene, 0, len(c.screens)) | |
| @@ -462,7 +476,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 462 | 476 | c.layerChanged = true | |
| 463 | 477 | c.setLayers(v.Layers) | |
| 464 | 478 | case ports.WindowMapped: | |
| 465 | - | if v.Slot == "" || !c.placeSlotWindow(v.ID, v.Slot) { | |
| 479 | + | if v.Slot == "" || (!c.placeSlotWindow(v.ID, v.Slot) && !c.placeTerminal(v.ID, v.Slot)) { | |
| 466 | 480 | if s, _ := c.screenOf(v.ID); s == nil { | |
| 467 | 481 | c.cur().mon.AddWindow(v.ID) | |
| 468 | 482 | } | |
| @@ -533,6 +547,14 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 533 | 547 | switch v := ev.(type) { | |
| 534 | 548 | case ports.PointerMotion: | |
| 535 | 549 | c.cursorX, c.cursorY = c.layout().Clamp(c.cursorX, c.cursorY, v.X, v.Y) | |
| 550 | + | // The focused screen follows the pointer, so new windows | |
| 551 | + | // and launchers open where the user is. | |
| 552 | + | if o, ok := c.layout().At(c.cursorX, c.cursorY); ok && o.Info.Name != c.cur().name() { | |
| 553 | + | c.focusScreen = c.screenIndex(o.Info.Name) | |
| 554 | + | if err := c.publish(ctx); err != nil { | |
| 555 | + | return nil | |
| 556 | + | } | |
| 557 | + | } | |
| 536 | 558 | id, x, y := c.hit(c.cursorX, c.cursorY) | |
| 537 | 559 | // Layout changes are intentionally re-hit-tested only on motion. | |
| 538 | 560 | if id != c.pointer { | |
| @@ -28,6 +28,9 @@ type Channels struct { | |||||
| 28 | // latest first (capacity 1, drained like Scenes). Input and cursors use it. | 28 | // latest first (capacity 1, drained like Scenes). Input and cursors use it. | ||
| 29 | Layouts chan ports.Layout | 29 | Layouts chan ports.Layout | ||
| 30 | ConfigErrors chan<- error | 30 | ConfigErrors chan<- error | ||
| 31 | + | // Terminal, when set, keeps a window on every workspace on screen: an | |||
| 32 | + | // empty one gets the configured terminal (the terminal is the desktop). | |||
| 33 | + | Terminal bool | |||
| 31 | } | 34 | } | ||
| 32 | type binding struct { | 35 | type binding struct { | ||
| 33 | mods ports.Mods | 36 | mods ports.Mods | ||
| @@ -57,6 +60,9 @@ type Core struct { | |||||
| 57 | // toSpawn holds slots to start; Run sends them (apply has no context). | 60 | // toSpawn holds slots to start; Run sends them (apply has no context). | ||
| 58 | toSpawn []slotKey | 61 | toSpawn []slotKey | ||
| 59 | sentPending bool | 62 | sentPending bool | ||
| 63 | + | // terms are the terminals spawned for empty workspaces, by SlotEnv | |||
| 64 | + | // token, until their window maps. | |||
| 65 | + | terms map[string]*termSpawn | |||
| 60 | } | 66 | } | ||
| 61 | 67 | ||||
| 62 | func keyName(s string) string { | 68 | func keyName(s string) string { | ||
| @@ -239,7 +245,7 @@ func New(cfg ports.Config, ch Channels) (*Core, error) { | |||||
| 239 | return nil, fmt.Errorf("scenes and layouts must have capacity 1") | 245 | return nil, fmt.Errorf("scenes and layouts must have capacity 1") | ||
| 240 | } | 246 | } | ||
| 241 | // A placeholder screen holds windows until the first output arrives. | 247 | // A placeholder screen holds windows until the first output arrives. | ||
| 242 | - | c := &Core{screens: []*screen{{mon: NewMonitor(), scale: 1, cfgScale: 1}}, slots: map[slotKey]*slotState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}} | 248 | + | c := &Core{screens: []*screen{{mon: NewMonitor(), scale: 1, cfgScale: 1}}, slots: map[slotKey]*slotState{}, terms: map[string]*termSpawn{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}} |
| 243 | if err := c.apply(cfg); err != nil { | 249 | if err := c.apply(cfg); err != nil { | ||
| 244 | return nil, err | 250 | return nil, err | ||
| 245 | } | 251 | } | ||
| @@ -326,9 +332,17 @@ func (c *Core) publish(ctx context.Context) error { | |||||
| 326 | } | 332 | } | ||
| 327 | c.sentOutputs = v | 333 | c.sentOutputs = v | ||
| 328 | } | 334 | } | ||
| 335 | + | terms := c.fillEmpty() | |||
| 329 | if err := c.publishPending(ctx); err != nil { | 336 | if err := c.publishPending(ctx); err != nil { | ||
| 330 | return err | 337 | return err | ||
| 331 | } | 338 | } | ||
| 339 | + | for _, req := range terms { | |||
| 340 | + | select { | |||
| 341 | + | case <-ctx.Done(): | |||
| 342 | + | return ctx.Err() | |||
| 343 | + | case c.ch.Spawn <- req: | |||
| 344 | + | } | |||
| 345 | + | } | |||
| 332 | alive := map[WindowID]bool{} | 346 | alive := map[WindowID]bool{} | ||
| 333 | focus := c.keyboardFocus() | 347 | focus := c.keyboardFocus() | ||
| 334 | scenes := make([]ports.Scene, 0, len(c.screens)) | 348 | scenes := make([]ports.Scene, 0, len(c.screens)) | ||
| @@ -462,7 +476,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 462 | c.layerChanged = true | 476 | c.layerChanged = true | ||
| 463 | c.setLayers(v.Layers) | 477 | c.setLayers(v.Layers) | ||
| 464 | case ports.WindowMapped: | 478 | case ports.WindowMapped: | ||
| 465 | - | if v.Slot == "" || !c.placeSlotWindow(v.ID, v.Slot) { | 479 | + | if v.Slot == "" || (!c.placeSlotWindow(v.ID, v.Slot) && !c.placeTerminal(v.ID, v.Slot)) { |
| 466 | if s, _ := c.screenOf(v.ID); s == nil { | 480 | if s, _ := c.screenOf(v.ID); s == nil { | ||
| 467 | c.cur().mon.AddWindow(v.ID) | 481 | c.cur().mon.AddWindow(v.ID) | ||
| 468 | } | 482 | } | ||
| @@ -533,6 +547,14 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 533 | switch v := ev.(type) { | 547 | switch v := ev.(type) { | ||
| 534 | case ports.PointerMotion: | 548 | case ports.PointerMotion: | ||
| 535 | c.cursorX, c.cursorY = c.layout().Clamp(c.cursorX, c.cursorY, v.X, v.Y) | 549 | c.cursorX, c.cursorY = c.layout().Clamp(c.cursorX, c.cursorY, v.X, v.Y) | ||
| 550 | + | // The focused screen follows the pointer, so new windows | |||
| 551 | + | // and launchers open where the user is. | |||
| 552 | + | if o, ok := c.layout().At(c.cursorX, c.cursorY); ok && o.Info.Name != c.cur().name() { | |||
| 553 | + | c.focusScreen = c.screenIndex(o.Info.Name) | |||
| 554 | + | if err := c.publish(ctx); err != nil { | |||
| 555 | + | return nil | |||
| 556 | + | } | |||
| 557 | + | } | |||
| 536 | id, x, y := c.hit(c.cursorX, c.cursorY) | 558 | id, x, y := c.hit(c.cursorX, c.cursorY) | ||
| 537 | // Layout changes are intentionally re-hit-tested only on motion. | 559 | // Layout changes are intentionally re-hit-tested only on motion. | ||
| 538 | if id != c.pointer { | 560 | if id != c.pointer { | ||
M internal/core/layout.go +29 -0
| @@ -295,6 +295,35 @@ func (w *Workspace) MoveColumn(dir int) { | |||
| 295 | 295 | w.scroll() | |
| 296 | 296 | } | |
| 297 | 297 | } | |
| 298 | + | ||
| 299 | + | // takeColumn removes the focused column and returns it, as a normal column. | |
| 300 | + | func (w *Workspace) takeColumn() (Column, bool) { | |
| 301 | + | if len(w.Columns) == 0 { | |
| 302 | + | return Column{}, false | |
| 303 | + | } | |
| 304 | + | col := w.Columns[w.Focus] | |
| 305 | + | col.Slot = 0 | |
| 306 | + | if slices.Contains(col.Windows, w.fullscreen) { | |
| 307 | + | w.fullscreen = 0 | |
| 308 | + | } | |
| 309 | + | w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1) | |
| 310 | + | w.Focus = min(w.Focus, max(len(w.Columns)-1, 0)) | |
| 311 | + | if len(w.Columns) == 0 { | |
| 312 | + | w.ViewX = 0 | |
| 313 | + | } else { | |
| 314 | + | w.scroll() | |
| 315 | + | } | |
| 316 | + | return col, true | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | // insertColumn adds a column at index at (clamped) and focuses it. | |
| 320 | + | func (w *Workspace) insertColumn(at int, col Column) { | |
| 321 | + | at = min(max(at, 0), len(w.Columns)) | |
| 322 | + | w.Columns = slices.Insert(w.Columns, at, col) | |
| 323 | + | w.Focus = at | |
| 324 | + | w.scroll() | |
| 325 | + | } | |
| 326 | + | ||
| 298 | 327 | func (w *Workspace) CycleWidth() { | |
| 299 | 328 | if len(w.Columns) == 0 || len(w.presets) == 0 { | |
| 300 | 329 | return | |
| @@ -295,6 +295,35 @@ func (w *Workspace) MoveColumn(dir int) { | |||||
| 295 | w.scroll() | 295 | w.scroll() | ||
| 296 | } | 296 | } | ||
| 297 | } | 297 | } | ||
| 298 | + | ||||
| 299 | + | // takeColumn removes the focused column and returns it, as a normal column. | |||
| 300 | + | func (w *Workspace) takeColumn() (Column, bool) { | |||
| 301 | + | if len(w.Columns) == 0 { | |||
| 302 | + | return Column{}, false | |||
| 303 | + | } | |||
| 304 | + | col := w.Columns[w.Focus] | |||
| 305 | + | col.Slot = 0 | |||
| 306 | + | if slices.Contains(col.Windows, w.fullscreen) { | |||
| 307 | + | w.fullscreen = 0 | |||
| 308 | + | } | |||
| 309 | + | w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1) | |||
| 310 | + | w.Focus = min(w.Focus, max(len(w.Columns)-1, 0)) | |||
| 311 | + | if len(w.Columns) == 0 { | |||
| 312 | + | w.ViewX = 0 | |||
| 313 | + | } else { | |||
| 314 | + | w.scroll() | |||
| 315 | + | } | |||
| 316 | + | return col, true | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | // insertColumn adds a column at index at (clamped) and focuses it. | |||
| 320 | + | func (w *Workspace) insertColumn(at int, col Column) { | |||
| 321 | + | at = min(max(at, 0), len(w.Columns)) | |||
| 322 | + | w.Columns = slices.Insert(w.Columns, at, col) | |||
| 323 | + | w.Focus = at | |||
| 324 | + | w.scroll() | |||
| 325 | + | } | |||
| 326 | + | ||||
| 298 | func (w *Workspace) CycleWidth() { | 327 | func (w *Workspace) CycleWidth() { | ||
| 299 | if len(w.Columns) == 0 || len(w.presets) == 0 { | 328 | if len(w.Columns) == 0 || len(w.presets) == 0 { | ||
| 300 | return | 329 | return | ||
M internal/core/screens_test.go +82 -2
| @@ -16,6 +16,7 @@ type multiRig struct { | |||
| 16 | 16 | reload chan ports.ConfigChanged | |
| 17 | 17 | commands chan ports.ClientCommand | |
| 18 | 18 | scenes chan []ports.Scene | |
| 19 | + | spawn chan ports.SpawnRequest | |
| 19 | 20 | cfg ports.Config | |
| 20 | 21 | } | |
| 21 | 22 | ||
| @@ -27,6 +28,13 @@ var ( | |||
| 27 | 28 | // startMulti runs core with Alt as Cmd, no border and no gaps, and plugs | |
| 28 | 29 | // the given outputs in order. | |
| 29 | 30 | func startMulti(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo) *multiRig { | |
| 31 | + | t.Helper() | |
| 32 | + | return startRig(t, false, edit, outs...) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // startRig is startMulti; with terminal set, core keeps a terminal on every | |
| 36 | + | // empty workspace on screen. | |
| 37 | + | func startRig(t *testing.T, terminal bool, edit func(*ports.Config), outs ...ports.OutputInfo) *multiRig { | |
| 30 | 38 | t.Helper() | |
| 31 | 39 | cfg := config.Defaults() | |
| 32 | 40 | cfg.Keyboard.CmdKey = "alt" | |
| @@ -37,9 +45,9 @@ func startMulti(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo | |||
| 37 | 45 | r := &multiRig{ | |
| 38 | 46 | client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 16), | |
| 39 | 47 | output: make(chan ports.OutputEvent, 4), reload: make(chan ports.ConfigChanged, 4), | |
| 40 | - | commands: make(chan ports.ClientCommand, 1024), scenes: make(chan []ports.Scene, 1), cfg: cfg, | |
| 48 | + | commands: make(chan ports.ClientCommand, 1024), scenes: make(chan []ports.Scene, 1), spawn: make(chan ports.SpawnRequest, 16), cfg: cfg, | |
| 41 | 49 | } | |
| 42 | - | c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes}) | |
| 50 | + | c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Spawn: r.spawn, Terminal: terminal}) | |
| 43 | 51 | if err != nil { | |
| 44 | 52 | t.Fatal(err) | |
| 45 | 53 | } | |
| @@ -339,3 +347,75 @@ func TestPrimaryOutputGetsFocusAtStartup(t *testing.T) { | |||
| 339 | 347 | t.Fatal(got) | |
| 340 | 348 | } | |
| 341 | 349 | } | |
| 350 | + | ||
| 351 | + | func TestMoveColumnCrossesOutputs(t *testing.T) { | |
| 352 | + | r := startMulti(t, nil, left, right) | |
| 353 | + | r.mapWindow(t, 1) | |
| 354 | + | r.mapWindow(t, 2) | |
| 355 | + | // Window 2 is the right column of DP-1: moving right first hits the | |
| 356 | + | // edge, then the column goes to DP-2 and focus follows it. | |
| 357 | + | set := r.key(t, "Right", ports.ModAlt|ports.ModShift) | |
| 358 | + | if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 1 || len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 { | |
| 359 | + | t.Fatal(got) | |
| 360 | + | } | |
| 361 | + | if out := lastOutputs(t, r.commands); out.Focused != "DP-2" { | |
| 362 | + | t.Fatal(out.Focused) | |
| 363 | + | } | |
| 364 | + | // Back left: it lands on the right side of DP-1. | |
| 365 | + | set = r.key(t, "Left", ports.ModAlt|ports.ModShift) | |
| 366 | + | if got := shown(set)["DP-1"]; len(got) != 2 { | |
| 367 | + | t.Fatal(got) | |
| 368 | + | } | |
| 369 | + | for _, s := range set { | |
| 370 | + | for _, w := range s.Windows { | |
| 371 | + | if w.ID == 2 && (s.Output != "DP-1" || !w.Focused || w.Rect.X == 0) { | |
| 372 | + | t.Fatalf("%s %+v", s.Output, w) | |
| 373 | + | } | |
| 374 | + | } | |
| 375 | + | } | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | func TestPointerFocusesOutput(t *testing.T) { | |
| 379 | + | r := startMulti(t, nil, left, right) | |
| 380 | + | r.input <- ports.PointerMotion{X: 250, Y: 50} | |
| 381 | + | receive(t, r.scenes) | |
| 382 | + | if out := lastOutputs(t, r.commands); out.Focused != "DP-2" { | |
| 383 | + | t.Fatal(out.Focused) | |
| 384 | + | } | |
| 385 | + | // New windows open where the pointer is. | |
| 386 | + | if got := shown(r.mapWindow(t, 1)); len(got["DP-2"]) != 1 { | |
| 387 | + | t.Fatal(got) | |
| 388 | + | } | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | func TestEmptyWorkspaceGetsTerminal(t *testing.T) { | |
| 392 | + | r := startRig(t, true, nil, left, right) | |
| 393 | + | // One terminal per output, each tagged for its workspace. | |
| 394 | + | var reqs []ports.SpawnRequest | |
| 395 | + | for len(reqs) < 2 { | |
| 396 | + | reqs = append(reqs, receive(t, r.spawn)) | |
| 397 | + | } | |
| 398 | + | if len(r.spawn) != 0 || reqs[0].Argv[0] != "foot" || reqs[0].Env[0] == reqs[1].Env[0] { | |
| 399 | + | t.Fatal(reqs, len(r.spawn)) | |
| 400 | + | } | |
| 401 | + | token := func(req ports.SpawnRequest) string { return req.Env[0][len(ports.SlotEnv)+1:] } | |
| 402 | + | // The focused screen is DP-2 now; DP-1's terminal still lands on DP-1. | |
| 403 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 404 | + | r.client <- ports.WindowMapped{ID: 1, Slot: token(reqs[0])} | |
| 405 | + | r.client <- ports.WindowMapped{ID: 2, Slot: token(reqs[1])} | |
| 406 | + | receive(t, r.scenes) | |
| 407 | + | set := receive(t, r.scenes) | |
| 408 | + | if got := shown(set); len(got["DP-1"]) != 1 || len(got["DP-2"]) != 1 || got["DP-1"][0] == got["DP-2"][0] { | |
| 409 | + | t.Fatal(got) | |
| 410 | + | } | |
| 411 | + | // Closing the last window spawns a new terminal; nothing while pending. | |
| 412 | + | r.client <- ports.WindowUnmapped{ID: 2} | |
| 413 | + | receive(t, r.scenes) | |
| 414 | + | if v := receive(t, r.spawn); v.Argv[0] != "foot" { | |
| 415 | + | t.Fatal(v) | |
| 416 | + | } | |
| 417 | + | r.key(t, "Left", ports.ModAlt|ports.ModCtrl) | |
| 418 | + | if len(r.spawn) != 0 { | |
| 419 | + | t.Fatal("spawned twice") | |
| 420 | + | } | |
| 421 | + | } | |
| @@ -16,6 +16,7 @@ type multiRig struct { | |||||
| 16 | reload chan ports.ConfigChanged | 16 | reload chan ports.ConfigChanged | ||
| 17 | commands chan ports.ClientCommand | 17 | commands chan ports.ClientCommand | ||
| 18 | scenes chan []ports.Scene | 18 | scenes chan []ports.Scene | ||
| 19 | + | spawn chan ports.SpawnRequest | |||
| 19 | cfg ports.Config | 20 | cfg ports.Config | ||
| 20 | } | 21 | } | ||
| 21 | 22 | ||||
| @@ -27,6 +28,13 @@ var ( | |||||
| 27 | // startMulti runs core with Alt as Cmd, no border and no gaps, and plugs | 28 | // startMulti runs core with Alt as Cmd, no border and no gaps, and plugs | ||
| 28 | // the given outputs in order. | 29 | // the given outputs in order. | ||
| 29 | func startMulti(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo) *multiRig { | 30 | func startMulti(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo) *multiRig { | ||
| 31 | + | t.Helper() | |||
| 32 | + | return startRig(t, false, edit, outs...) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // startRig is startMulti; with terminal set, core keeps a terminal on every | |||
| 36 | + | // empty workspace on screen. | |||
| 37 | + | func startRig(t *testing.T, terminal bool, edit func(*ports.Config), outs ...ports.OutputInfo) *multiRig { | |||
| 30 | t.Helper() | 38 | t.Helper() | ||
| 31 | cfg := config.Defaults() | 39 | cfg := config.Defaults() | ||
| 32 | cfg.Keyboard.CmdKey = "alt" | 40 | cfg.Keyboard.CmdKey = "alt" | ||
| @@ -37,9 +45,9 @@ func startMulti(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo | |||||
| 37 | r := &multiRig{ | 45 | r := &multiRig{ | ||
| 38 | client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 16), | 46 | client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 16), | ||
| 39 | output: make(chan ports.OutputEvent, 4), reload: make(chan ports.ConfigChanged, 4), | 47 | output: make(chan ports.OutputEvent, 4), reload: make(chan ports.ConfigChanged, 4), | ||
| 40 | - | commands: make(chan ports.ClientCommand, 1024), scenes: make(chan []ports.Scene, 1), cfg: cfg, | 48 | + | commands: make(chan ports.ClientCommand, 1024), scenes: make(chan []ports.Scene, 1), spawn: make(chan ports.SpawnRequest, 16), cfg: cfg, |
| 41 | } | 49 | } | ||
| 42 | - | c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes}) | 50 | + | c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Spawn: r.spawn, Terminal: terminal}) |
| 43 | if err != nil { | 51 | if err != nil { | ||
| 44 | t.Fatal(err) | 52 | t.Fatal(err) | ||
| 45 | } | 53 | } | ||
| @@ -339,3 +347,75 @@ func TestPrimaryOutputGetsFocusAtStartup(t *testing.T) { | |||||
| 339 | t.Fatal(got) | 347 | t.Fatal(got) | ||
| 340 | } | 348 | } | ||
| 341 | } | 349 | } | ||
| 350 | + | ||||
| 351 | + | func TestMoveColumnCrossesOutputs(t *testing.T) { | |||
| 352 | + | r := startMulti(t, nil, left, right) | |||
| 353 | + | r.mapWindow(t, 1) | |||
| 354 | + | r.mapWindow(t, 2) | |||
| 355 | + | // Window 2 is the right column of DP-1: moving right first hits the | |||
| 356 | + | // edge, then the column goes to DP-2 and focus follows it. | |||
| 357 | + | set := r.key(t, "Right", ports.ModAlt|ports.ModShift) | |||
| 358 | + | if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 1 || len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 { | |||
| 359 | + | t.Fatal(got) | |||
| 360 | + | } | |||
| 361 | + | if out := lastOutputs(t, r.commands); out.Focused != "DP-2" { | |||
| 362 | + | t.Fatal(out.Focused) | |||
| 363 | + | } | |||
| 364 | + | // Back left: it lands on the right side of DP-1. | |||
| 365 | + | set = r.key(t, "Left", ports.ModAlt|ports.ModShift) | |||
| 366 | + | if got := shown(set)["DP-1"]; len(got) != 2 { | |||
| 367 | + | t.Fatal(got) | |||
| 368 | + | } | |||
| 369 | + | for _, s := range set { | |||
| 370 | + | for _, w := range s.Windows { | |||
| 371 | + | if w.ID == 2 && (s.Output != "DP-1" || !w.Focused || w.Rect.X == 0) { | |||
| 372 | + | t.Fatalf("%s %+v", s.Output, w) | |||
| 373 | + | } | |||
| 374 | + | } | |||
| 375 | + | } | |||
| 376 | + | } | |||
| 377 | + | ||||
| 378 | + | func TestPointerFocusesOutput(t *testing.T) { | |||
| 379 | + | r := startMulti(t, nil, left, right) | |||
| 380 | + | r.input <- ports.PointerMotion{X: 250, Y: 50} | |||
| 381 | + | receive(t, r.scenes) | |||
| 382 | + | if out := lastOutputs(t, r.commands); out.Focused != "DP-2" { | |||
| 383 | + | t.Fatal(out.Focused) | |||
| 384 | + | } | |||
| 385 | + | // New windows open where the pointer is. | |||
| 386 | + | if got := shown(r.mapWindow(t, 1)); len(got["DP-2"]) != 1 { | |||
| 387 | + | t.Fatal(got) | |||
| 388 | + | } | |||
| 389 | + | } | |||
| 390 | + | ||||
| 391 | + | func TestEmptyWorkspaceGetsTerminal(t *testing.T) { | |||
| 392 | + | r := startRig(t, true, nil, left, right) | |||
| 393 | + | // One terminal per output, each tagged for its workspace. | |||
| 394 | + | var reqs []ports.SpawnRequest | |||
| 395 | + | for len(reqs) < 2 { | |||
| 396 | + | reqs = append(reqs, receive(t, r.spawn)) | |||
| 397 | + | } | |||
| 398 | + | if len(r.spawn) != 0 || reqs[0].Argv[0] != "foot" || reqs[0].Env[0] == reqs[1].Env[0] { | |||
| 399 | + | t.Fatal(reqs, len(r.spawn)) | |||
| 400 | + | } | |||
| 401 | + | token := func(req ports.SpawnRequest) string { return req.Env[0][len(ports.SlotEnv)+1:] } | |||
| 402 | + | // The focused screen is DP-2 now; DP-1's terminal still lands on DP-1. | |||
| 403 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |||
| 404 | + | r.client <- ports.WindowMapped{ID: 1, Slot: token(reqs[0])} | |||
| 405 | + | r.client <- ports.WindowMapped{ID: 2, Slot: token(reqs[1])} | |||
| 406 | + | receive(t, r.scenes) | |||
| 407 | + | set := receive(t, r.scenes) | |||
| 408 | + | if got := shown(set); len(got["DP-1"]) != 1 || len(got["DP-2"]) != 1 || got["DP-1"][0] == got["DP-2"][0] { | |||
| 409 | + | t.Fatal(got) | |||
| 410 | + | } | |||
| 411 | + | // Closing the last window spawns a new terminal; nothing while pending. | |||
| 412 | + | r.client <- ports.WindowUnmapped{ID: 2} | |||
| 413 | + | receive(t, r.scenes) | |||
| 414 | + | if v := receive(t, r.spawn); v.Argv[0] != "foot" { | |||
| 415 | + | t.Fatal(v) | |||
| 416 | + | } | |||
| 417 | + | r.key(t, "Left", ports.ModAlt|ports.ModCtrl) | |||
| 418 | + | if len(r.spawn) != 0 { | |||
| 419 | + | t.Fatal("spawned twice") | |||
| 420 | + | } | |||
| 421 | + | } | |||
M internal/core/slots.go +3 -0
| @@ -173,6 +173,9 @@ func (c *Core) refill() []slotKey { | |||
| 173 | 173 | ||
| 174 | 174 | // anyPending reports whether a slot waits for its window. | |
| 175 | 175 | func (c *Core) anyPending() bool { | |
| 176 | + | if len(c.terms) > 0 { | |
| 177 | + | return true | |
| 178 | + | } | |
| 176 | 179 | for _, st := range c.slots { | |
| 177 | 180 | if st.pending() { | |
| 178 | 181 | return true | |
| @@ -173,6 +173,9 @@ func (c *Core) refill() []slotKey { | |||||
| 173 | 173 | ||||
| 174 | // anyPending reports whether a slot waits for its window. | 174 | // anyPending reports whether a slot waits for its window. | ||
| 175 | func (c *Core) anyPending() bool { | 175 | func (c *Core) anyPending() bool { | ||
| 176 | + | if len(c.terms) > 0 { | |||
| 177 | + | return true | |||
| 178 | + | } | |||
| 176 | for _, st := range c.slots { | 179 | for _, st := range c.slots { | ||
| 177 | if st.pending() { | 180 | if st.pending() { | ||
| 178 | return true | 181 | return true | ||
A internal/core/terminal.go +84 -0
| @@ -0,0 +1,84 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "crypto/rand" | |
| 5 | + | "slices" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/nefertty/internal/ports" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // The terminal is the desktop: a workspace on screen is never empty. When | |
| 12 | + | // one is, core spawns the configured terminal with a SlotEnv token, and the | |
| 13 | + | // window carrying it fills that workspace, wherever the focus is by then. | |
| 14 | + | ||
| 15 | + | // termRetry is how long a terminal may take to map before another is spawned | |
| 16 | + | // (the first one failed, or the terminal command is broken). | |
| 17 | + | const termRetry = 5 * time.Second | |
| 18 | + | ||
| 19 | + | type termSpawn struct { | |
| 20 | + | w *Workspace | |
| 21 | + | at time.Time | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // fillEmpty returns the terminals to spawn for empty workspaces on screen. | |
| 25 | + | // Workspaces with slots are left to them. | |
| 26 | + | func (c *Core) fillEmpty() []ports.SpawnRequest { | |
| 27 | + | if !c.ch.Terminal || len(c.cfg.Terminal.Command) == 0 { | |
| 28 | + | return nil | |
| 29 | + | } | |
| 30 | + | now := time.Now() | |
| 31 | + | var reqs []ports.SpawnRequest | |
| 32 | + | for _, s := range c.screens { | |
| 33 | + | w := s.mon.Current() | |
| 34 | + | if s.name() == "" || !w.empty() || c.hasSlots(w) || c.termPending(w, now) { | |
| 35 | + | continue | |
| 36 | + | } | |
| 37 | + | token := rand.Text() | |
| 38 | + | c.terms[token] = &termSpawn{w: w, at: now} | |
| 39 | + | reqs = append(reqs, ports.SpawnRequest{Argv: slices.Clone(c.cfg.Terminal.Command), Env: []string{ports.SlotEnv + "=" + token}}) | |
| 40 | + | } | |
| 41 | + | return reqs | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // termPending reports whether a recent terminal spawn waits for w; it drops | |
| 45 | + | // expired ones. | |
| 46 | + | func (c *Core) termPending(w *Workspace, now time.Time) bool { | |
| 47 | + | found := false | |
| 48 | + | for token, t := range c.terms { | |
| 49 | + | switch { | |
| 50 | + | case now.Sub(t.at) > termRetry: | |
| 51 | + | delete(c.terms, token) | |
| 52 | + | case t.w == w: | |
| 53 | + | found = true | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | return found | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func (c *Core) hasSlots(w *Workspace) bool { | |
| 60 | + | for key := range c.slots { | |
| 61 | + | if w.Name != "" && key.workspace == w.Name { | |
| 62 | + | return true | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | return false | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // placeTerminal puts the window of a pending terminal on its workspace. It | |
| 69 | + | // returns false for any other window, or when the workspace is gone. | |
| 70 | + | func (c *Core) placeTerminal(id WindowID, token string) bool { | |
| 71 | + | t := c.terms[token] | |
| 72 | + | if t == nil { | |
| 73 | + | return false | |
| 74 | + | } | |
| 75 | + | delete(c.terms, token) | |
| 76 | + | for _, s := range c.screens { | |
| 77 | + | if slices.Contains(s.mon.all(), t.w) { | |
| 78 | + | t.w.AddWindow(id) | |
| 79 | + | s.mon.normalize() | |
| 80 | + | return true | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | return false | |
| 84 | + | } | |
| @@ -0,0 +1,84 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "crypto/rand" | |||
| 5 | + | "slices" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // The terminal is the desktop: a workspace on screen is never empty. When | |||
| 12 | + | // one is, core spawns the configured terminal with a SlotEnv token, and the | |||
| 13 | + | // window carrying it fills that workspace, wherever the focus is by then. | |||
| 14 | + | ||||
| 15 | + | // termRetry is how long a terminal may take to map before another is spawned | |||
| 16 | + | // (the first one failed, or the terminal command is broken). | |||
| 17 | + | const termRetry = 5 * time.Second | |||
| 18 | + | ||||
| 19 | + | type termSpawn struct { | |||
| 20 | + | w *Workspace | |||
| 21 | + | at time.Time | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // fillEmpty returns the terminals to spawn for empty workspaces on screen. | |||
| 25 | + | // Workspaces with slots are left to them. | |||
| 26 | + | func (c *Core) fillEmpty() []ports.SpawnRequest { | |||
| 27 | + | if !c.ch.Terminal || len(c.cfg.Terminal.Command) == 0 { | |||
| 28 | + | return nil | |||
| 29 | + | } | |||
| 30 | + | now := time.Now() | |||
| 31 | + | var reqs []ports.SpawnRequest | |||
| 32 | + | for _, s := range c.screens { | |||
| 33 | + | w := s.mon.Current() | |||
| 34 | + | if s.name() == "" || !w.empty() || c.hasSlots(w) || c.termPending(w, now) { | |||
| 35 | + | continue | |||
| 36 | + | } | |||
| 37 | + | token := rand.Text() | |||
| 38 | + | c.terms[token] = &termSpawn{w: w, at: now} | |||
| 39 | + | reqs = append(reqs, ports.SpawnRequest{Argv: slices.Clone(c.cfg.Terminal.Command), Env: []string{ports.SlotEnv + "=" + token}}) | |||
| 40 | + | } | |||
| 41 | + | return reqs | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // termPending reports whether a recent terminal spawn waits for w; it drops | |||
| 45 | + | // expired ones. | |||
| 46 | + | func (c *Core) termPending(w *Workspace, now time.Time) bool { | |||
| 47 | + | found := false | |||
| 48 | + | for token, t := range c.terms { | |||
| 49 | + | switch { | |||
| 50 | + | case now.Sub(t.at) > termRetry: | |||
| 51 | + | delete(c.terms, token) | |||
| 52 | + | case t.w == w: | |||
| 53 | + | found = true | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | return found | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func (c *Core) hasSlots(w *Workspace) bool { | |||
| 60 | + | for key := range c.slots { | |||
| 61 | + | if w.Name != "" && key.workspace == w.Name { | |||
| 62 | + | return true | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | return false | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // placeTerminal puts the window of a pending terminal on its workspace. It | |||
| 69 | + | // returns false for any other window, or when the workspace is gone. | |||
| 70 | + | func (c *Core) placeTerminal(id WindowID, token string) bool { | |||
| 71 | + | t := c.terms[token] | |||
| 72 | + | if t == nil { | |||
| 73 | + | return false | |||
| 74 | + | } | |||
| 75 | + | delete(c.terms, token) | |||
| 76 | + | for _, s := range c.screens { | |||
| 77 | + | if slices.Contains(s.mon.all(), t.w) { | |||
| 78 | + | t.w.AddWindow(id) | |||
| 79 | + | s.mon.normalize() | |||
| 80 | + | return true | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | return false | |||
| 84 | + | } | |||