detached 3d0a029

fix(core): no clicks on hidden windows, focus stays on client fullscreen

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

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