detached 3760356

fix(outputs): focus follows the pointer, columns cross outputs, cursor hides, empty workspaces get a terminal

commit
376035669e977296b56c66c47f2e270ac486f8cc
author
brice · · 2026-09-25 04:45 UTC
parent
43af880
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +294 -13
M internal/adapters/drm/cursor.go +13 -3
@@ -74,11 +74,12 @@ type Cursor struct {
7474 size int // buffer side
7575 hotX, hotY int
7676 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
7878 plane cursorPlane
7979
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
8181 x, y int // physical position of the hotspot
82+ away bool // Hide was called and no Move since
8283 stats CursorStats
8384
8485 wake chan struct{}
@@ -148,7 +149,12 @@ func (c *Cursor) start() {
148149 // fails too and Reapply restores the cursor.
149150 _ = c.plane.WaitVblank()
150151 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 {
152158 // A move shows it again, even before its first image.
153159 c.hidden = false
154160 _ = c.apply()
@@ -213,6 +219,7 @@ func (c *Cursor) apply() error {
213219 func (c *Cursor) Move(x, y float64) {
214220 c.mu.Lock()
215221 c.x, c.y = int(x), int(y)
222+ c.away = false
216223 c.stats.Moves++
217224 c.mu.Unlock()
218225 select {
@@ -226,6 +233,9 @@ func (c *Cursor) Move(x, y float64) {
226233 func (c *Cursor) Hide() {
227234 c.io.Lock()
228235 defer c.io.Unlock()
236+ c.mu.Lock()
237+ c.away = true
238+ c.mu.Unlock()
229239 if c.buf == nil || c.hidden {
230240 return
231241 }
@@ -74,11 +74,12 @@ type Cursor struct {
74 size int // buffer side74 size int // buffer side
75 hotX, hotY int75 hotX, hotY int
76 shown bool76 shown bool
77- hidden bool // Hide was called; the next move shows it again77+ hidden bool // the plane is off because the pointer left this output
78 plane cursorPlane78 plane cursorPlane
79 79
80- mu sync.Mutex // guards x, y and stats; never held across an ioctl80+ mu sync.Mutex // guards x, y, away and stats; never held across an ioctl
81 x, y int // physical position of the hotspot81 x, y int // physical position of the hotspot
82+ away bool // Hide was called and no Move since
82 stats CursorStats83 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 = false159 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 return240 return
231 }241 }
M internal/adapters/drm/cursor_test.go +29 -0
@@ -139,3 +139,32 @@ func TestCursorStats(t *testing.T) {
139139 t.Fatalf("not reset: %+v", s)
140140 }
141141 }
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)
7676 configErrors := make(chan error, 8)
7777 renderScenes := make(chan []ports.Scene, 1)
7878 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}
8080 c, err := core.New(opts.Config, ch)
8181 if err != nil {
8282 return err
@@ -94,9 +94,6 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
9494 }
9595 log.Info().Str("WAYLAND_DISPLAY", server.SocketName()).Msg("listening")
9696 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- }
10097 if inject != nil {
10198 inject(input)
10299 }
@@ -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 err82 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) {
139139 return
140140 }
141141 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 {
144145 cur.Hide()
145146 }
146147 }
@@ -139,8 +139,9 @@ func (c *cursors) set(output string, cur cursor) {
139 return139 return
140 }140 }
141 c.all[output] = cur141 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 {
114114 c.focusScreen = i
115115 return Effect{}
116116 }
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+ }
117143 }
118144 return c.cur().mon.Apply(a)
119145 }
@@ -114,6 +114,32 @@ func (c *Core) applyAction(a Action) Effect {
114 c.focusScreen = i114 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 {
2828 // latest first (capacity 1, drained like Scenes). Input and cursors use it.
2929 Layouts chan ports.Layout
3030 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
3134 }
3235 type binding struct {
3336 mods ports.Mods
@@ -57,6 +60,9 @@ type Core struct {
5760 // toSpawn holds slots to start; Run sends them (apply has no context).
5861 toSpawn []slotKey
5962 sentPending bool
63+ // terms are the terminals spawned for empty workspaces, by SlotEnv
64+ // token, until their window maps.
65+ terms map[string]*termSpawn
6066 }
6167
6268 func keyName(s string) string {
@@ -239,7 +245,7 @@ func New(cfg ports.Config, ch Channels) (*Core, error) {
239245 return nil, fmt.Errorf("scenes and layouts must have capacity 1")
240246 }
241247 // 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{}}
243249 if err := c.apply(cfg); err != nil {
244250 return nil, err
245251 }
@@ -326,9 +332,17 @@ func (c *Core) publish(ctx context.Context) error {
326332 }
327333 c.sentOutputs = v
328334 }
335+ terms := c.fillEmpty()
329336 if err := c.publishPending(ctx); err != nil {
330337 return err
331338 }
339+ for _, req := range terms {
340+ select {
341+ case <-ctx.Done():
342+ return ctx.Err()
343+ case c.ch.Spawn <- req:
344+ }
345+ }
332346 alive := map[WindowID]bool{}
333347 focus := c.keyboardFocus()
334348 scenes := make([]ports.Scene, 0, len(c.screens))
@@ -462,7 +476,7 @@ func (c *Core) Run(ctx context.Context) error {
462476 c.layerChanged = true
463477 c.setLayers(v.Layers)
464478 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)) {
466480 if s, _ := c.screenOf(v.ID); s == nil {
467481 c.cur().mon.AddWindow(v.ID)
468482 }
@@ -533,6 +547,14 @@ func (c *Core) Run(ctx context.Context) error {
533547 switch v := ev.(type) {
534548 case ports.PointerMotion:
535549 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+ }
536558 id, x, y := c.hit(c.cursorX, c.cursorY)
537559 // Layout changes are intentionally re-hit-tested only on motion.
538560 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.Layout29 Layouts chan ports.Layout
30 ConfigErrors chan<- error30 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.Mods36 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 []slotKey61 toSpawn []slotKey
59 sentPending bool62 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, err250 return nil, err
245 }251 }
@@ -326,9 +332,17 @@ func (c *Core) publish(ctx context.Context) error {
326 }332 }
327 c.sentOutputs = v333 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 err337 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 = true476 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) {
295295 w.scroll()
296296 }
297297 }
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+
298327 func (w *Workspace) CycleWidth() {
299328 if len(w.Columns) == 0 || len(w.presets) == 0 {
300329 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 return329 return
M internal/core/screens_test.go +82 -2
@@ -16,6 +16,7 @@ type multiRig struct {
1616 reload chan ports.ConfigChanged
1717 commands chan ports.ClientCommand
1818 scenes chan []ports.Scene
19+ spawn chan ports.SpawnRequest
1920 cfg ports.Config
2021 }
2122
@@ -27,6 +28,13 @@ var (
2728 // startMulti runs core with Alt as Cmd, no border and no gaps, and plugs
2829 // the given outputs in order.
2930 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 {
3038 t.Helper()
3139 cfg := config.Defaults()
3240 cfg.Keyboard.CmdKey = "alt"
@@ -37,9 +45,9 @@ func startMulti(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo
3745 r := &multiRig{
3846 client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 16),
3947 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,
4149 }
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})
4351 if err != nil {
4452 t.Fatal(err)
4553 }
@@ -339,3 +347,75 @@ func TestPrimaryOutputGetsFocusAtStartup(t *testing.T) {
339347 t.Fatal(got)
340348 }
341349 }
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.ConfigChanged16 reload chan ports.ConfigChanged
17 commands chan ports.ClientCommand17 commands chan ports.ClientCommand
18 scenes chan []ports.Scene18 scenes chan []ports.Scene
19+ spawn chan ports.SpawnRequest
19 cfg ports.Config20 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 plugs28 // 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 {
173173
174174 // anyPending reports whether a slot waits for its window.
175175 func (c *Core) anyPending() bool {
176+ if len(c.terms) > 0 {
177+ return true
178+ }
176179 for _, st := range c.slots {
177180 if st.pending() {
178181 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 true181 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+}

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 23

Tags 10

↑↓ moveenter openesc close