detached 797645f

merge: workspaces (per-monitor workspaces, equal-share columns)

commit
797645f8d5c38ffd9454a2329ba7fddc9b3657a8
author
brice · · 2026-09-24 14:12 UTC
merge of
45a1428 + 3d0a029 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
17 files · +862 -102
M examples/config +29 -1
@@ -22,7 +22,10 @@ border.active = #b4befe
2222 border.inactive =
2323
2424 layout.gaps = 0
25-layout.default-width = 1/2
25+# Columns sharing the screen equally before new ones scroll off to the right.
26+# A lone column fills the screen without a border.
27+layout.max-columns = 2
28+# cycle-column-width steps through these, then back to the equal share.
2629 layout.presets = 1/3, 1/2, 2/3, 1
2730
2831 # Displays (drm backend): WxH, WxH@Hz, preferred or off.
@@ -52,3 +55,28 @@ bind.cmd+shift+f = toggle-fullscreen
5255 bind.cmd+q = close-window
5356 bind.ctrl+alt+backspace = quit
5457 bind.ctrl+cmd+space = spawn fuzzel
58+
59+# Workspaces stack vertically; numbers follow their order. An empty one always
60+# waits below the last. cmd+up/down past the column edge also switch workspace.
61+bind.cmd+pageup = focus-workspace-up
62+bind.cmd+pagedown = focus-workspace-down
63+bind.cmd+shift+pageup = move-to-workspace-up
64+bind.cmd+shift+pagedown = move-to-workspace-down
65+bind.cmd+1 = focus-workspace 1
66+bind.cmd+2 = focus-workspace 2
67+bind.cmd+3 = focus-workspace 3
68+bind.cmd+4 = focus-workspace 4
69+bind.cmd+5 = focus-workspace 5
70+bind.cmd+6 = focus-workspace 6
71+bind.cmd+7 = focus-workspace 7
72+bind.cmd+8 = focus-workspace 8
73+bind.cmd+9 = focus-workspace 9
74+bind.cmd+shift+1 = move-to-workspace 1
75+bind.cmd+shift+2 = move-to-workspace 2
76+bind.cmd+shift+3 = move-to-workspace 3
77+bind.cmd+shift+4 = move-to-workspace 4
78+bind.cmd+shift+5 = move-to-workspace 5
79+bind.cmd+shift+6 = move-to-workspace 6
80+bind.cmd+shift+7 = move-to-workspace 7
81+bind.cmd+shift+8 = move-to-workspace 8
82+bind.cmd+shift+9 = move-to-workspace 9
@@ -22,7 +22,10 @@ border.active = #b4befe
22 border.inactive =22 border.inactive =
23 23
24 layout.gaps = 024 layout.gaps = 0
25-layout.default-width = 1/225+# Columns sharing the screen equally before new ones scroll off to the right.
26+# A lone column fills the screen without a border.
27+layout.max-columns = 2
28+# cycle-column-width steps through these, then back to the equal share.
26 layout.presets = 1/3, 1/2, 2/3, 129 layout.presets = 1/3, 1/2, 2/3, 1
27 30
28 # Displays (drm backend): WxH, WxH@Hz, preferred or off.31 # Displays (drm backend): WxH, WxH@Hz, preferred or off.
@@ -52,3 +55,28 @@ bind.cmd+shift+f = toggle-fullscreen
52 bind.cmd+q = close-window55 bind.cmd+q = close-window
53 bind.ctrl+alt+backspace = quit56 bind.ctrl+alt+backspace = quit
54 bind.ctrl+cmd+space = spawn fuzzel57 bind.ctrl+cmd+space = spawn fuzzel
58+
59+# Workspaces stack vertically; numbers follow their order. An empty one always
60+# waits below the last. cmd+up/down past the column edge also switch workspace.
61+bind.cmd+pageup = focus-workspace-up
62+bind.cmd+pagedown = focus-workspace-down
63+bind.cmd+shift+pageup = move-to-workspace-up
64+bind.cmd+shift+pagedown = move-to-workspace-down
65+bind.cmd+1 = focus-workspace 1
66+bind.cmd+2 = focus-workspace 2
67+bind.cmd+3 = focus-workspace 3
68+bind.cmd+4 = focus-workspace 4
69+bind.cmd+5 = focus-workspace 5
70+bind.cmd+6 = focus-workspace 6
71+bind.cmd+7 = focus-workspace 7
72+bind.cmd+8 = focus-workspace 8
73+bind.cmd+9 = focus-workspace 9
74+bind.cmd+shift+1 = move-to-workspace 1
75+bind.cmd+shift+2 = move-to-workspace 2
76+bind.cmd+shift+3 = move-to-workspace 3
77+bind.cmd+shift+4 = move-to-workspace 4
78+bind.cmd+shift+5 = move-to-workspace 5
79+bind.cmd+shift+6 = move-to-workspace 6
80+bind.cmd+shift+7 = move-to-workspace 7
81+bind.cmd+shift+8 = move-to-workspace 8
82+bind.cmd+shift+9 = move-to-workspace 9
M internal/adapters/config/config.go +23 -6
@@ -31,8 +31,12 @@ var defaultBinds = []struct{ combo, action string }{
3131 {"cmd+q", "close-window"},
3232 {"ctrl+alt+backspace", "quit"},
3333 {"ctrl+cmd+space", "spawn fuzzel"},
34+ {"cmd+pageup", "focus-workspace-up"},
35+ {"cmd+pagedown", "focus-workspace-down"},
36+ {"cmd+shift+pageup", "move-to-workspace-up"},
37+ {"cmd+shift+pagedown", "move-to-workspace-down"},
3438 }
35-var actions = map[string]bool{"none": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "toggle-fullscreen": true, "close-window": true, "quit": true}
39+var actions = map[string]bool{"focus-workspace-up": true, "focus-workspace-down": true, "move-to-workspace-up": true, "move-to-workspace-down": true, "none": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "toggle-fullscreen": true, "close-window": true, "quit": true}
3640 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}
3741 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
3842
@@ -72,7 +76,7 @@ func Defaults() ports.Config {
7276 c.Background.Color = "#1e1e2e"
7377 c.Border.Width = 2
7478 c.Border.Active = "#b4befe"
75- c.Layout.DefaultColumnWidth = "1/2"
79+ c.Layout.MaxColumns = 2
7680 c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"}
7781 c.Binds = map[string]string{}
7882 for _, b := range defaultBinds {
@@ -82,6 +86,12 @@ func Defaults() ports.Config {
8286 }
8387 c.Binds[combo] = b.action
8488 }
89+ for n := 1; n <= 9; 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)
94+ }
8595 c.Render.DirectScanout = true
8696 c.Log.Level = "info"
8797 c.Log.Debug = []string{}
@@ -310,10 +320,9 @@ func set(c *ports.Config, key, v string) error {
310320 }
311321 c.Layout.Gaps = n
312322 case "layout.default-width":
313- if !width(v) {
314- return fmt.Errorf("must be a fraction like 1/2, 1, or pixels like 800px")
315- }
316- c.Layout.DefaultColumnWidth = v
323+ return fmt.Errorf("replaced by layout.max-columns (columns share the width equally)")
324+ case "layout.max-columns":
325+ return positive(&c.Layout.MaxColumns, v, 16)
317326 case "layout.presets":
318327 list := splitList(v)
319328 if len(list) == 0 {
@@ -397,6 +406,14 @@ func checkAction(v string) error {
397406 }
398407 return nil
399408 }
409+ for _, prefix := range []string{"focus-workspace ", "move-to-workspace "} {
410+ if rest, ok := strings.CutPrefix(v, prefix); ok {
411+ if n, err := strconv.Atoi(strings.TrimSpace(rest)); err != nil || n < 1 || n > 99 {
412+ return fmt.Errorf("%snumber must be between 1 and 99", prefix)
413+ }
414+ return nil
415+ }
416+ }
400417 if !actions[v] {
401418 return fmt.Errorf(`unknown action %q (or "spawn <command>")`, v)
402419 }
@@ -31,8 +31,12 @@ var defaultBinds = []struct{ combo, action string }{
31 {"cmd+q", "close-window"},31 {"cmd+q", "close-window"},
32 {"ctrl+alt+backspace", "quit"},32 {"ctrl+alt+backspace", "quit"},
33 {"ctrl+cmd+space", "spawn fuzzel"},33 {"ctrl+cmd+space", "spawn fuzzel"},
34+ {"cmd+pageup", "focus-workspace-up"},
35+ {"cmd+pagedown", "focus-workspace-down"},
36+ {"cmd+shift+pageup", "move-to-workspace-up"},
37+ {"cmd+shift+pagedown", "move-to-workspace-down"},
34 }38 }
35-var actions = map[string]bool{"none": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "toggle-fullscreen": true, "close-window": true, "quit": true}39+var actions = map[string]bool{"focus-workspace-up": true, "focus-workspace-down": true, "move-to-workspace-up": true, "move-to-workspace-down": true, "none": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "toggle-fullscreen": true, "close-window": true, "quit": true}
36 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}40 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}
37 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)41 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
38 42
@@ -72,7 +76,7 @@ func Defaults() ports.Config {
72 c.Background.Color = "#1e1e2e"76 c.Background.Color = "#1e1e2e"
73 c.Border.Width = 277 c.Border.Width = 2
74 c.Border.Active = "#b4befe"78 c.Border.Active = "#b4befe"
75- c.Layout.DefaultColumnWidth = "1/2"79+ c.Layout.MaxColumns = 2
76 c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"}80 c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"}
77 c.Binds = map[string]string{}81 c.Binds = map[string]string{}
78 for _, b := range defaultBinds {82 for _, b := range defaultBinds {
@@ -82,6 +86,12 @@ func Defaults() ports.Config {
82 }86 }
83 c.Binds[combo] = b.action87 c.Binds[combo] = b.action
84 }88 }
89+ for n := 1; n <= 9; 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)
94+ }
85 c.Render.DirectScanout = true95 c.Render.DirectScanout = true
86 c.Log.Level = "info"96 c.Log.Level = "info"
87 c.Log.Debug = []string{}97 c.Log.Debug = []string{}
@@ -310,10 +320,9 @@ func set(c *ports.Config, key, v string) error {
310 }320 }
311 c.Layout.Gaps = n321 c.Layout.Gaps = n
312 case "layout.default-width":322 case "layout.default-width":
313- if !width(v) {323+ return fmt.Errorf("replaced by layout.max-columns (columns share the width equally)")
314- return fmt.Errorf("must be a fraction like 1/2, 1, or pixels like 800px")324+ case "layout.max-columns":
315- }325+ return positive(&c.Layout.MaxColumns, v, 16)
316- c.Layout.DefaultColumnWidth = v
317 case "layout.presets":326 case "layout.presets":
318 list := splitList(v)327 list := splitList(v)
319 if len(list) == 0 {328 if len(list) == 0 {
@@ -397,6 +406,14 @@ func checkAction(v string) error {
397 }406 }
398 return nil407 return nil
399 }408 }
409+ for _, prefix := range []string{"focus-workspace ", "move-to-workspace "} {
410+ if rest, ok := strings.CutPrefix(v, prefix); ok {
411+ if n, err := strconv.Atoi(strings.TrimSpace(rest)); err != nil || n < 1 || n > 99 {
412+ return fmt.Errorf("%snumber must be between 1 and 99", prefix)
413+ }
414+ return nil
415+ }
416+ }
400 if !actions[v] {417 if !actions[v] {
401 return fmt.Errorf(`unknown action %q (or "spawn <command>")`, v)418 return fmt.Errorf(`unknown action %q (or "spawn <command>")`, v)
402 }419 }
M internal/adapters/config/config_test.go +4 -2
@@ -21,7 +21,7 @@ func parseString(t *testing.T, s string) (ports.Config, []Warning) {
2121
2222 func TestDefaultsAndLoad(t *testing.T) {
2323 d := Defaults()
24- if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 12 {
24+ if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 34 || d.Layout.MaxColumns != 2 {
2525 t.Fatalf("defaults: %+v", d)
2626 }
2727 if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" {
@@ -80,7 +80,8 @@ func TestWarningsKeepDefaults(t *testing.T) {
8080 {"terminal =", "terminal"},
8181 {"background = red", "background"},
8282 {"layout.gaps = 201", "layout.gaps"},
83- {"layout.default-width = 2/1", "layout.default-width"},
83+ {"layout.max-columns = 0", "layout.max-columns"},
84+ {"bind.cmd+x = focus-workspace 0", "focus-workspace"},
8485 {"layout.presets = 0px", "layout.presets"},
8586 {"log.level = trace", "log.level"},
8687 {"log.debug = nope", "log.debug"},
@@ -124,6 +125,7 @@ bind.cmd+# = none
124125 bind.cmd++ = quit
125126 bind.alt+f5 = quit
126127 bind.cmd+return = none
128+bind.cmd+w = focus-workspace 12
127129 `)
128130 want := map[string]string{
129131 "Cmd+Ctrl+e": "spawn foo --bar",
@@ -21,7 +21,7 @@ func parseString(t *testing.T, s string) (ports.Config, []Warning) {
21 21
22 func TestDefaultsAndLoad(t *testing.T) {22 func TestDefaultsAndLoad(t *testing.T) {
23 d := Defaults()23 d := Defaults()
24- if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 12 {24+ if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 34 || d.Layout.MaxColumns != 2 {
25 t.Fatalf("defaults: %+v", d)25 t.Fatalf("defaults: %+v", d)
26 }26 }
27 if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" {27 if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" {
@@ -80,7 +80,8 @@ func TestWarningsKeepDefaults(t *testing.T) {
80 {"terminal =", "terminal"},80 {"terminal =", "terminal"},
81 {"background = red", "background"},81 {"background = red", "background"},
82 {"layout.gaps = 201", "layout.gaps"},82 {"layout.gaps = 201", "layout.gaps"},
83- {"layout.default-width = 2/1", "layout.default-width"},83+ {"layout.max-columns = 0", "layout.max-columns"},
84+ {"bind.cmd+x = focus-workspace 0", "focus-workspace"},
84 {"layout.presets = 0px", "layout.presets"},85 {"layout.presets = 0px", "layout.presets"},
85 {"log.level = trace", "log.level"},86 {"log.level = trace", "log.level"},
86 {"log.debug = nope", "log.debug"},87 {"log.debug = nope", "log.debug"},
@@ -124,6 +125,7 @@ bind.cmd+# = none
124 bind.cmd++ = quit125 bind.cmd++ = quit
125 bind.alt+f5 = quit126 bind.alt+f5 = quit
126 bind.cmd+return = none127 bind.cmd+return = none
128+bind.cmd+w = focus-workspace 12
127 `)129 `)
128 want := map[string]string{130 want := map[string]string{
129 "Cmd+Ctrl+e": "spawn foo --bar",131 "Cmd+Ctrl+e": "spawn foo --bar",
M internal/adapters/vulkan/renderer.go +1 -1
@@ -368,7 +368,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
368368 x, y := w.Rect.X, w.Rect.Y
369369 // Content sits inside the border; core sized the client to match.
370370 b := 0
371- if !w.Fullscreen {
371+ if !w.Fullscreen && !w.Borderless {
372372 b = min(max(s.Border.Width, 0), w.Rect.W/2, w.Rect.H/2)
373373 }
374374 cx, cy, cw, ch := x+b, y+b, w.Rect.W-2*b, w.Rect.H-2*b
@@ -368,7 +368,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
368 x, y := w.Rect.X, w.Rect.Y368 x, y := w.Rect.X, w.Rect.Y
369 // Content sits inside the border; core sized the client to match.369 // Content sits inside the border; core sized the client to match.
370 b := 0370 b := 0
371- if !w.Fullscreen {371+ if !w.Fullscreen && !w.Borderless {
372 b = min(max(s.Border.Width, 0), w.Rect.W/2, w.Rect.H/2)372 b = min(max(s.Border.Width, 0), w.Rect.W/2, w.Rect.H/2)
373 }373 }
374 cx, cy, cw, ch := x+b, y+b, w.Rect.W-2*b, w.Rect.H-2*b374 cx, cy, cw, ch := x+b, y+b, w.Rect.W-2*b, w.Rect.H-2*b
M internal/adapters/xkb/xkb.go +18 -0
@@ -139,6 +139,10 @@ func (k *Keymap) Key(evdevCode uint32, pressed bool, timeMsec uint32) ports.KeyE
139139 }
140140 k.updateKey(k.state, code, direction)
141141 event := ports.KeyEvent{Keysym: name, Pressed: pressed, TimeMsec: timeMsec, Keycode: evdevCode}
142+ if pressed {
143+ layout := k.serializeLayout(k.state, layoutEffective)
144+ event.Base, event.Shifted = k.levelName(code, layout, 0), k.levelName(code, layout, 1)
145+ }
142146 event.State = ports.ModState{
143147 Depressed: k.serializeMods(k.state, modsDepressed), Latched: k.serializeMods(k.state, modsLatched),
144148 Locked: k.serializeMods(k.state, modsLocked), Group: k.serializeLayout(k.state, layoutEffective),
@@ -156,6 +160,20 @@ func (k *Keymap) Key(evdevCode uint32, pressed bool, timeMsec uint32) ports.KeyE
156160 return event
157161 }
158162
163+// levelName is the first keysym name of a key at one shift level, or "".
164+func (k *Keymap) levelName(code, layout, level uint32) string {
165+ var ptr unsafe.Pointer
166+ if k.symsLevel(k.mapPtr, code, layout, level, &ptr) < 1 {
167+ return ""
168+ }
169+ var buf [64]byte
170+ n := k.symName(*(*uint32)(ptr), uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)))
171+ if n <= 0 || n >= int32(len(buf)) {
172+ return ""
173+ }
174+ return string(buf[:n])
175+}
176+
159177 func (k *Keymap) KeycodeFor(keysymName string) (evdev uint32, shift bool, ok bool) {
160178 b := append([]byte(keysymName), 0)
161179 sym := k.fromName(uintptr(unsafe.Pointer(&b[0])), 0)
@@ -139,6 +139,10 @@ func (k *Keymap) Key(evdevCode uint32, pressed bool, timeMsec uint32) ports.KeyE
139 }139 }
140 k.updateKey(k.state, code, direction)140 k.updateKey(k.state, code, direction)
141 event := ports.KeyEvent{Keysym: name, Pressed: pressed, TimeMsec: timeMsec, Keycode: evdevCode}141 event := ports.KeyEvent{Keysym: name, Pressed: pressed, TimeMsec: timeMsec, Keycode: evdevCode}
142+ if pressed {
143+ layout := k.serializeLayout(k.state, layoutEffective)
144+ event.Base, event.Shifted = k.levelName(code, layout, 0), k.levelName(code, layout, 1)
145+ }
142 event.State = ports.ModState{146 event.State = ports.ModState{
143 Depressed: k.serializeMods(k.state, modsDepressed), Latched: k.serializeMods(k.state, modsLatched),147 Depressed: k.serializeMods(k.state, modsDepressed), Latched: k.serializeMods(k.state, modsLatched),
144 Locked: k.serializeMods(k.state, modsLocked), Group: k.serializeLayout(k.state, layoutEffective),148 Locked: k.serializeMods(k.state, modsLocked), Group: k.serializeLayout(k.state, layoutEffective),
@@ -156,6 +160,20 @@ func (k *Keymap) Key(evdevCode uint32, pressed bool, timeMsec uint32) ports.KeyE
156 return event160 return event
157 }161 }
158 162
163+// levelName is the first keysym name of a key at one shift level, or "".
164+func (k *Keymap) levelName(code, layout, level uint32) string {
165+ var ptr unsafe.Pointer
166+ if k.symsLevel(k.mapPtr, code, layout, level, &ptr) < 1 {
167+ return ""
168+ }
169+ var buf [64]byte
170+ n := k.symName(*(*uint32)(ptr), uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)))
171+ if n <= 0 || n >= int32(len(buf)) {
172+ return ""
173+ }
174+ return string(buf[:n])
175+}
176+
159 func (k *Keymap) KeycodeFor(keysymName string) (evdev uint32, shift bool, ok bool) {177 func (k *Keymap) KeycodeFor(keysymName string) (evdev uint32, shift bool, ok bool) {
160 b := append([]byte(keysymName), 0)178 b := append([]byte(keysymName), 0)
161 sym := k.fromName(uintptr(unsafe.Pointer(&b[0])), 0)179 sym := k.fromName(uintptr(unsafe.Pointer(&b[0])), 0)
M internal/adapters/xkb/xkb_test.go +11 -0
@@ -52,3 +52,14 @@ func TestKeymap(t *testing.T) {
5252 t.Errorf("fr a: %d %v", code, ok)
5353 }
5454 }
55+
56+func TestKeyLevels(t *testing.T) {
57+ // On AZERTY the digit row prints & é " ... unshifted and 1 2 3 with Shift.
58+ k := newTest(t, "fr")
59+ if ev := k.Key(2, true, 0); ev.Keysym != "ampersand" || ev.Base != "ampersand" || ev.Shifted != "1" {
60+ t.Fatalf("fr 1 key: %+v", ev)
61+ }
62+ if ev := k.Key(2, false, 0); ev.Base != "" {
63+ t.Fatalf("release carries levels: %+v", ev)
64+ }
65+}
@@ -52,3 +52,14 @@ func TestKeymap(t *testing.T) {
52 t.Errorf("fr a: %d %v", code, ok)52 t.Errorf("fr a: %d %v", code, ok)
53 }53 }
54 }54 }
55+
56+func TestKeyLevels(t *testing.T) {
57+ // On AZERTY the digit row prints & é " ... unshifted and 1 2 3 with Shift.
58+ k := newTest(t, "fr")
59+ if ev := k.Key(2, true, 0); ev.Keysym != "ampersand" || ev.Base != "ampersand" || ev.Shifted != "1" {
60+ t.Fatalf("fr 1 key: %+v", ev)
61+ }
62+ if ev := k.Key(2, false, 0); ev.Base != "" {
63+ t.Fatalf("release carries levels: %+v", ev)
64+ }
65+}
M internal/app/app_test.go +7 -7
@@ -142,9 +142,9 @@ func TestHeadlessSpawnClose(t *testing.T) {
142142 select {
143143 case s := <-scenes:
144144 if len(s.Windows) == count {
145- // Half of 1920 with the default zero gaps.
146- if count == 1 && s.Windows[0].Rect.W != 960 {
147- t.Errorf("width = %d, want 960", s.Windows[0].Rect.W)
145+ // A lone column fills the 1920 output (default zero gaps).
146+ if count == 1 && s.Windows[0].Rect.W != 1920 {
147+ t.Errorf("width = %d, want 1920", s.Windows[0].Rect.W)
148148 }
149149 return
150150 }
@@ -187,11 +187,11 @@ func TestHeadlessTyping(t *testing.T) {
187187 if err != nil {
188188 t.Fatal(err)
189189 }
190- // The default column occupies the central portion of the 1920x1080 output.
191- first := img.At(600, 300)
190+ // The lone column fills the output; the typed text sits in its top-left area.
191+ first := img.At(1500, 600)
192192 varied := false
193- for y := 200; y < 850 && !varied; y += 4 {
194- for x := 500; x < 1400; x += 4 {
193+ for y := 0; y < 400 && !varied; y += 2 {
194+ for x := 0; x < 900; x += 2 {
195195 if img.At(x, y) != first {
196196 varied = true
197197 break
@@ -142,9 +142,9 @@ func TestHeadlessSpawnClose(t *testing.T) {
142 select {142 select {
143 case s := <-scenes:143 case s := <-scenes:
144 if len(s.Windows) == count {144 if len(s.Windows) == count {
145- // Half of 1920 with the default zero gaps.145+ // A lone column fills the 1920 output (default zero gaps).
146- if count == 1 && s.Windows[0].Rect.W != 960 {146+ if count == 1 && s.Windows[0].Rect.W != 1920 {
147- t.Errorf("width = %d, want 960", s.Windows[0].Rect.W)147+ t.Errorf("width = %d, want 1920", s.Windows[0].Rect.W)
148 }148 }
149 return149 return
150 }150 }
@@ -187,11 +187,11 @@ func TestHeadlessTyping(t *testing.T) {
187 if err != nil {187 if err != nil {
188 t.Fatal(err)188 t.Fatal(err)
189 }189 }
190- // The default column occupies the central portion of the 1920x1080 output.190+ // The lone column fills the output; the typed text sits in its top-left area.
191- first := img.At(600, 300)191+ first := img.At(1500, 600)
192 varied := false192 varied := false
193- for y := 200; y < 850 && !varied; y += 4 {193+ for y := 0; y < 400 && !varied; y += 2 {
194- for x := 500; x < 1400; x += 4 {194+ for x := 0; x < 900; x += 2 {
195 if img.At(x, y) != first {195 if img.At(x, y) != first {
196 varied = true196 varied = true
197 break197 break
M internal/core/action.go +69 -1
@@ -1,6 +1,9 @@
11 package core
22
3-import "strings"
3+import (
4+ "strconv"
5+ "strings"
6+)
47
58 type Action string
69
@@ -16,8 +19,33 @@ const (
1619 ActionToggleFullscreen Action = "toggle-fullscreen"
1720 ActionCloseWindow Action = "close-window"
1821 ActionQuit Action = "quit"
22+ // Workspaces stack vertically; up/down stop at the ends.
23+ ActionFocusWorkspaceUp Action = "focus-workspace-up"
24+ ActionFocusWorkspaceDown Action = "focus-workspace-down"
25+ ActionMoveToWorkspaceUp Action = "move-to-workspace-up"
26+ ActionMoveToWorkspaceDown Action = "move-to-workspace-down"
27+ actionFocusWorkspace = "focus-workspace "
28+ actionMoveToWorkspace = "move-to-workspace "
1929 )
2030
31+// WorkspaceArg parses "focus-workspace N" and "move-to-workspace N" (N from 1).
32+// move is true for move-to-workspace.
33+func WorkspaceArg(a Action) (n int, move bool, ok bool) {
34+ rest, found := strings.CutPrefix(string(a), actionFocusWorkspace)
35+ if !found {
36+ rest, found = strings.CutPrefix(string(a), actionMoveToWorkspace)
37+ move = true
38+ }
39+ if !found {
40+ return 0, false, false
41+ }
42+ n, err := strconv.Atoi(strings.TrimSpace(rest))
43+ if err != nil || n < 1 || n > 99 {
44+ return 0, false, false
45+ }
46+ return n, move, true
47+}
48+
2149 // spawnPrefix starts a bind action that runs a command: "spawn fuzzel --flag".
2250 // Arguments are split on whitespace; there is no shell (use "spawn sh -c ...").
2351 const spawnPrefix = "spawn "
@@ -36,6 +64,46 @@ type Effect struct {
3664 Quit bool
3765 }
3866
67+// Apply runs a bind action on the monitor.
68+func (m *Monitor) Apply(a Action) Effect {
69+ if n, move, ok := WorkspaceArg(a); ok {
70+ if move {
71+ m.MoveToWorkspace(n - 1)
72+ } else {
73+ m.FocusNumber(n)
74+ }
75+ return Effect{}
76+ }
77+ switch a {
78+ case ActionFocusWindowUp, ActionFocusWindowDown:
79+ // Past the top or bottom window of the column, move to the next workspace.
80+ dir := 1
81+ if a == ActionFocusWindowUp {
82+ dir = -1
83+ }
84+ if !m.Current().FocusWindow(dir) {
85+ m.Focus(m.Active + dir)
86+ }
87+ return Effect{}
88+ case ActionFocusWorkspaceUp:
89+ m.Focus(m.Active - 1)
90+ return Effect{}
91+ case ActionFocusWorkspaceDown:
92+ m.Focus(m.Active + 1)
93+ return Effect{}
94+ case ActionMoveToWorkspaceUp:
95+ if m.Active > 0 {
96+ m.MoveToWorkspace(m.Active - 1)
97+ }
98+ return Effect{}
99+ case ActionMoveToWorkspaceDown:
100+ m.MoveToWorkspace(m.Active + 1)
101+ return Effect{}
102+ }
103+ return m.Current().Apply(a)
104+}
105+
106+// Apply runs a bind action that only touches this workspace.
39107 func (w *Workspace) Apply(a Action) Effect {
40108 if argv, ok := SpawnArgv(a); ok {
41109 return Effect{Spawn: true, Argv: argv}
@@ -1,6 +1,9 @@
1 package core1 package core
2 2
3-import "strings"3+import (
4+ "strconv"
5+ "strings"
6+)
4 7
5 type Action string8 type Action string
6 9
@@ -16,8 +19,33 @@ const (
16 ActionToggleFullscreen Action = "toggle-fullscreen"19 ActionToggleFullscreen Action = "toggle-fullscreen"
17 ActionCloseWindow Action = "close-window"20 ActionCloseWindow Action = "close-window"
18 ActionQuit Action = "quit"21 ActionQuit Action = "quit"
22+ // Workspaces stack vertically; up/down stop at the ends.
23+ ActionFocusWorkspaceUp Action = "focus-workspace-up"
24+ ActionFocusWorkspaceDown Action = "focus-workspace-down"
25+ ActionMoveToWorkspaceUp Action = "move-to-workspace-up"
26+ ActionMoveToWorkspaceDown Action = "move-to-workspace-down"
27+ actionFocusWorkspace = "focus-workspace "
28+ actionMoveToWorkspace = "move-to-workspace "
19 )29 )
20 30
31+// WorkspaceArg parses "focus-workspace N" and "move-to-workspace N" (N from 1).
32+// move is true for move-to-workspace.
33+func WorkspaceArg(a Action) (n int, move bool, ok bool) {
34+ rest, found := strings.CutPrefix(string(a), actionFocusWorkspace)
35+ if !found {
36+ rest, found = strings.CutPrefix(string(a), actionMoveToWorkspace)
37+ move = true
38+ }
39+ if !found {
40+ return 0, false, false
41+ }
42+ n, err := strconv.Atoi(strings.TrimSpace(rest))
43+ if err != nil || n < 1 || n > 99 {
44+ return 0, false, false
45+ }
46+ return n, move, true
47+}
48+
21 // spawnPrefix starts a bind action that runs a command: "spawn fuzzel --flag".49 // spawnPrefix starts a bind action that runs a command: "spawn fuzzel --flag".
22 // Arguments are split on whitespace; there is no shell (use "spawn sh -c ...").50 // Arguments are split on whitespace; there is no shell (use "spawn sh -c ...").
23 const spawnPrefix = "spawn "51 const spawnPrefix = "spawn "
@@ -36,6 +64,46 @@ type Effect struct {
36 Quit bool64 Quit bool
37 }65 }
38 66
67+// Apply runs a bind action on the monitor.
68+func (m *Monitor) Apply(a Action) Effect {
69+ if n, move, ok := WorkspaceArg(a); ok {
70+ if move {
71+ m.MoveToWorkspace(n - 1)
72+ } else {
73+ m.FocusNumber(n)
74+ }
75+ return Effect{}
76+ }
77+ switch a {
78+ case ActionFocusWindowUp, ActionFocusWindowDown:
79+ // Past the top or bottom window of the column, move to the next workspace.
80+ dir := 1
81+ if a == ActionFocusWindowUp {
82+ dir = -1
83+ }
84+ if !m.Current().FocusWindow(dir) {
85+ m.Focus(m.Active + dir)
86+ }
87+ return Effect{}
88+ case ActionFocusWorkspaceUp:
89+ m.Focus(m.Active - 1)
90+ return Effect{}
91+ case ActionFocusWorkspaceDown:
92+ m.Focus(m.Active + 1)
93+ return Effect{}
94+ case ActionMoveToWorkspaceUp:
95+ if m.Active > 0 {
96+ m.MoveToWorkspace(m.Active - 1)
97+ }
98+ return Effect{}
99+ case ActionMoveToWorkspaceDown:
100+ m.MoveToWorkspace(m.Active + 1)
101+ return Effect{}
102+ }
103+ return m.Current().Apply(a)
104+}
105+
106+// Apply runs a bind action that only touches this workspace.
39 func (w *Workspace) Apply(a Action) Effect {107 func (w *Workspace) Apply(a Action) Effect {
40 if argv, ok := SpawnArgv(a); ok {108 if argv, ok := SpawnArgv(a); ok {
41 return Effect{Spawn: true, Argv: argv}109 return Effect{Spawn: true, Argv: argv}
M internal/core/core.go +59 -20
@@ -5,6 +5,7 @@ import (
55 "errors"
66 "fmt"
77 "github.com/bnema/nefertty/internal/ports"
8+ "strconv"
89 "strings"
910 "unicode"
1011 )
@@ -30,7 +31,7 @@ type binding struct {
3031 type Core struct {
3132 ch Channels
3233 cfg ports.Config
33- ws Workspace
34+ ws *Monitor
3435 binds map[binding]Action
3536 pressed map[string]bool
3637 sent map[WindowID]ports.ConfigureWindow
@@ -86,9 +87,8 @@ func parseBind(s, cmd string) (binding, error) {
8687 return b, nil
8788 }
8889 func (c *Core) apply(cfg ports.Config) error {
89- width, err := ParseWidth(cfg.Layout.DefaultColumnWidth)
90- if err != nil {
91- return err
90+ if cfg.Layout.MaxColumns < 1 {
91+ return fmt.Errorf("invalid max columns")
9292 }
9393 presets := make([]Width, 0, len(cfg.Layout.Presets))
9494 for _, s := range cfg.Layout.Presets {
@@ -117,8 +117,12 @@ func (c *Core) apply(cfg ports.Config) error {
117117 binds[b] = Action(a)
118118 continue
119119 }
120+ if _, _, ok := WorkspaceArg(Action(a)); ok {
121+ binds[b] = Action(a)
122+ continue
123+ }
120124 switch Action(a) {
121- case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit:
125+ case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveToWorkspaceUp, ActionMoveToWorkspaceDown:
122126 default:
123127 return fmt.Errorf("invalid action %q", a)
124128 }
@@ -126,7 +130,7 @@ func (c *Core) apply(cfg ports.Config) error {
126130 }
127131 c.cfg = cfg
128132 c.binds = binds
129- c.ws.SetDefaultWidth(width)
133+ c.ws.SetMaxColumns(cfg.Layout.MaxColumns)
130134 c.ws.SetPresets(presets)
131135 c.ws.SetGaps(cfg.Layout.Gaps)
132136 return nil
@@ -135,7 +139,7 @@ func New(cfg ports.Config, ch Channels) (*Core, error) {
135139 if cap(ch.Scenes) != 1 {
136140 return nil, fmt.Errorf("scenes must have capacity 1")
137141 }
138- c := &Core{ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}}
142+ c := &Core{ws: NewMonitor(), ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}}
139143 if err := c.apply(cfg); err != nil {
140144 return nil, err
141145 }
@@ -154,13 +158,23 @@ func (c *Core) command(ctx context.Context, v ports.ClientCommand) error {
154158 // The renderer applies the same inset (ports.Border).
155159 func (c *Core) clientRect(p Placement) Rect {
156160 r := p.Rect
157- if p.Fullscreen {
161+ if p.Fullscreen || p.Borderless {
158162 return r
159163 }
160164 b := min(max(c.cfg.Border.Width, 0), r.W/2, r.H/2)
161165 return Rect{X: r.X + b, Y: r.Y + b, W: r.W - 2*b, H: r.H - 2*b}
162166 }
163167
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+
164178 // keyboardFocus is the mapped top/overlay layer with exclusive keyboard
165179 // interactivity and the highest ID, else the focused window. On-demand layers
166180 // never take focus automatically. When the layer unmaps, focus returns to the window.
@@ -180,12 +194,12 @@ func (c *Core) keyboardFocus() WindowID {
180194
181195 func (c *Core) publish(ctx context.Context) error {
182196 c.seq++
183- scene := ports.Scene{Seq: c.seq, OutputWidth: c.ws.Output.W, OutputHeight: c.ws.Output.H, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: append([]ports.SceneLayer(nil), c.placed...)}
197+ scene := ports.Scene{Seq: c.seq, OutputWidth: c.ws.Output().W, OutputHeight: c.ws.Output().H, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: append([]ports.SceneLayer(nil), c.placed...)}
184198 alive := map[WindowID]bool{}
185199 focus := c.keyboardFocus()
186200 for _, p := range c.ws.Layout() {
187201 alive[p.ID] = true
188- scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: p.Focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden})
202+ scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: p.Focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Borderless: p.Borderless})
189203 if p.Hidden {
190204 if old, ok := c.sent[p.ID]; ok && old.Activated {
191205 old.Activated = false
@@ -210,6 +224,16 @@ func (c *Core) publish(ctx context.Context) error {
210224 delete(c.sent, id)
211225 }
212226 }
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+ }
213237 if focus != c.focus {
214238 if err := c.command(ctx, ports.FocusWindow{ID: focus}); err != nil {
215239 return err
@@ -246,7 +270,7 @@ func (c *Core) Run(ctx context.Context) error {
246270 c.layers = append([]ports.LayerSurface(nil), v.Layers...)
247271 c.layerChanged = true
248272 var usable ports.Rect
249- c.placed, usable = arrangeLayers(c.ws.Output.W, c.ws.Output.H, c.layers)
273+ c.placed, usable = arrangeLayers(c.ws.Output().W, c.ws.Output().H, c.layers)
250274 c.ws.SetUsable(usable)
251275 case ports.WindowMapped:
252276 c.ws.AddWindow(v.ID)
@@ -274,7 +298,7 @@ func (c *Core) Run(ctx context.Context) error {
274298 c.ws.SetOutput(v.Width, v.Height)
275299 if c.layerChanged {
276300 var usable ports.Rect
277- c.placed, usable = arrangeLayers(c.ws.Output.W, c.ws.Output.H, c.layers)
301+ c.placed, usable = arrangeLayers(c.ws.Output().W, c.ws.Output().H, c.layers)
278302 c.ws.SetUsable(usable)
279303 }
280304 case ports.OutputUsable:
@@ -308,8 +332,8 @@ func (c *Core) Run(ctx context.Context) error {
308332 }
309333 switch v := ev.(type) {
310334 case ports.PointerMotion:
311- c.cursorX = min(max(v.X, 0), float64(max(c.ws.Output.W-1, 0)))
312- c.cursorY = min(max(v.Y, 0), float64(max(c.ws.Output.H-1, 0)))
335+ c.cursorX = min(max(v.X, 0), float64(max(c.ws.Output().W-1, 0)))
336+ c.cursorY = min(max(v.Y, 0), float64(max(c.ws.Output().H-1, 0)))
313337 var id WindowID
314338 var x, y float64
315339 // Fullscreen wins; otherwise the last visible placement is topmost.
@@ -354,7 +378,7 @@ func (c *Core) Run(ctx context.Context) error {
354378 if err := c.command(ctx, ports.PointerButtonTo{ID: id, Button: v.Button, Pressed: v.Pressed, TimeMsec: v.TimeMsec}); err != nil {
355379 return nil
356380 }
357- if v.Pressed && c.focus != id && c.ws.FocusID(id) {
381+ if v.Pressed && c.focus != id && c.ws.Current().FocusID(id) {
358382 if err := c.publish(ctx); err != nil {
359383 return nil
360384 }
@@ -370,16 +394,31 @@ func (c *Core) Run(ctx context.Context) error {
370394 continue
371395 }
372396 name := keyName(key.Keysym)
397+ action, bound := c.binds[binding{key: name, mods: key.Mods}]
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)
411+ }
373412 if !key.Pressed {
374- consumed := c.pressed[name]
375- delete(c.pressed, name)
413+ consumed := c.pressed[held]
414+ delete(c.pressed, held)
376415 if consumed {
377416 continue
378417 }
379418 }
380419 if key.Pressed {
381- if action, ok := c.binds[binding{key: name, mods: key.Mods}]; ok {
382- c.pressed[name] = true
420+ if bound {
421+ c.pressed[held] = true
383422 effect := c.ws.Apply(action)
384423 if effect.Quit {
385424 return ErrQuit
@@ -405,7 +444,7 @@ func (c *Core) Run(ctx context.Context) error {
405444 }
406445 continue
407446 }
408- c.pressed[name] = false
447+ c.pressed[held] = false
409448 }
410449 if id := c.keyboardFocus(); id != 0 {
411450 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 )
@@ -30,7 +31,7 @@ type binding struct {
30 type Core struct {31 type Core struct {
31 ch Channels32 ch Channels
32 cfg ports.Config33 cfg ports.Config
33- ws Workspace34+ ws *Monitor
34 binds map[binding]Action35 binds map[binding]Action
35 pressed map[string]bool36 pressed map[string]bool
36 sent map[WindowID]ports.ConfigureWindow37 sent map[WindowID]ports.ConfigureWindow
@@ -86,9 +87,8 @@ func parseBind(s, cmd string) (binding, error) {
86 return b, nil87 return b, nil
87 }88 }
88 func (c *Core) apply(cfg ports.Config) error {89 func (c *Core) apply(cfg ports.Config) error {
89- width, err := ParseWidth(cfg.Layout.DefaultColumnWidth)90+ if cfg.Layout.MaxColumns < 1 {
90- if err != nil {91+ return fmt.Errorf("invalid max columns")
91- return err
92 }92 }
93 presets := make([]Width, 0, len(cfg.Layout.Presets))93 presets := make([]Width, 0, len(cfg.Layout.Presets))
94 for _, s := range cfg.Layout.Presets {94 for _, s := range cfg.Layout.Presets {
@@ -117,8 +117,12 @@ func (c *Core) apply(cfg ports.Config) error {
117 binds[b] = Action(a)117 binds[b] = Action(a)
118 continue118 continue
119 }119 }
120+ if _, _, ok := WorkspaceArg(Action(a)); ok {
121+ binds[b] = Action(a)
122+ continue
123+ }
120 switch Action(a) {124 switch Action(a) {
121- case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit:125+ case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveToWorkspaceUp, ActionMoveToWorkspaceDown:
122 default:126 default:
123 return fmt.Errorf("invalid action %q", a)127 return fmt.Errorf("invalid action %q", a)
124 }128 }
@@ -126,7 +130,7 @@ func (c *Core) apply(cfg ports.Config) error {
126 }130 }
127 c.cfg = cfg131 c.cfg = cfg
128 c.binds = binds132 c.binds = binds
129- c.ws.SetDefaultWidth(width)133+ c.ws.SetMaxColumns(cfg.Layout.MaxColumns)
130 c.ws.SetPresets(presets)134 c.ws.SetPresets(presets)
131 c.ws.SetGaps(cfg.Layout.Gaps)135 c.ws.SetGaps(cfg.Layout.Gaps)
132 return nil136 return nil
@@ -135,7 +139,7 @@ func New(cfg ports.Config, ch Channels) (*Core, error) {
135 if cap(ch.Scenes) != 1 {139 if cap(ch.Scenes) != 1 {
136 return nil, fmt.Errorf("scenes must have capacity 1")140 return nil, fmt.Errorf("scenes must have capacity 1")
137 }141 }
138- c := &Core{ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}}142+ c := &Core{ws: NewMonitor(), ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}}
139 if err := c.apply(cfg); err != nil {143 if err := c.apply(cfg); err != nil {
140 return nil, err144 return nil, err
141 }145 }
@@ -154,13 +158,23 @@ func (c *Core) command(ctx context.Context, v ports.ClientCommand) error {
154 // The renderer applies the same inset (ports.Border).158 // The renderer applies the same inset (ports.Border).
155 func (c *Core) clientRect(p Placement) Rect {159 func (c *Core) clientRect(p Placement) Rect {
156 r := p.Rect160 r := p.Rect
157- if p.Fullscreen {161+ if p.Fullscreen || p.Borderless {
158 return r162 return r
159 }163 }
160 b := min(max(c.cfg.Border.Width, 0), r.W/2, r.H/2)164 b := min(max(c.cfg.Border.Width, 0), r.W/2, r.H/2)
161 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}
162 }166 }
163 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+
164 // keyboardFocus is the mapped top/overlay layer with exclusive keyboard178 // keyboardFocus is the mapped top/overlay layer with exclusive keyboard
165 // interactivity and the highest ID, else the focused window. On-demand layers179 // interactivity and the highest ID, else the focused window. On-demand layers
166 // 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.
@@ -180,12 +194,12 @@ func (c *Core) keyboardFocus() WindowID {
180 194
181 func (c *Core) publish(ctx context.Context) error {195 func (c *Core) publish(ctx context.Context) error {
182 c.seq++196 c.seq++
183- scene := ports.Scene{Seq: c.seq, OutputWidth: c.ws.Output.W, OutputHeight: c.ws.Output.H, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: append([]ports.SceneLayer(nil), c.placed...)}197+ scene := ports.Scene{Seq: c.seq, OutputWidth: c.ws.Output().W, OutputHeight: c.ws.Output().H, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: append([]ports.SceneLayer(nil), c.placed...)}
184 alive := map[WindowID]bool{}198 alive := map[WindowID]bool{}
185 focus := c.keyboardFocus()199 focus := c.keyboardFocus()
186 for _, p := range c.ws.Layout() {200 for _, p := range c.ws.Layout() {
187 alive[p.ID] = true201 alive[p.ID] = true
188- scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: p.Focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden})202+ scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: p.Focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Borderless: p.Borderless})
189 if p.Hidden {203 if p.Hidden {
190 if old, ok := c.sent[p.ID]; ok && old.Activated {204 if old, ok := c.sent[p.ID]; ok && old.Activated {
191 old.Activated = false205 old.Activated = false
@@ -210,6 +224,16 @@ func (c *Core) publish(ctx context.Context) error {
210 delete(c.sent, id)224 delete(c.sent, id)
211 }225 }
212 }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+ }
213 if focus != c.focus {237 if focus != c.focus {
214 if err := c.command(ctx, ports.FocusWindow{ID: focus}); err != nil {238 if err := c.command(ctx, ports.FocusWindow{ID: focus}); err != nil {
215 return err239 return err
@@ -246,7 +270,7 @@ func (c *Core) Run(ctx context.Context) error {
246 c.layers = append([]ports.LayerSurface(nil), v.Layers...)270 c.layers = append([]ports.LayerSurface(nil), v.Layers...)
247 c.layerChanged = true271 c.layerChanged = true
248 var usable ports.Rect272 var usable ports.Rect
249- c.placed, usable = arrangeLayers(c.ws.Output.W, c.ws.Output.H, c.layers)273+ c.placed, usable = arrangeLayers(c.ws.Output().W, c.ws.Output().H, c.layers)
250 c.ws.SetUsable(usable)274 c.ws.SetUsable(usable)
251 case ports.WindowMapped:275 case ports.WindowMapped:
252 c.ws.AddWindow(v.ID)276 c.ws.AddWindow(v.ID)
@@ -274,7 +298,7 @@ func (c *Core) Run(ctx context.Context) error {
274 c.ws.SetOutput(v.Width, v.Height)298 c.ws.SetOutput(v.Width, v.Height)
275 if c.layerChanged {299 if c.layerChanged {
276 var usable ports.Rect300 var usable ports.Rect
277- c.placed, usable = arrangeLayers(c.ws.Output.W, c.ws.Output.H, c.layers)301+ c.placed, usable = arrangeLayers(c.ws.Output().W, c.ws.Output().H, c.layers)
278 c.ws.SetUsable(usable)302 c.ws.SetUsable(usable)
279 }303 }
280 case ports.OutputUsable:304 case ports.OutputUsable:
@@ -308,8 +332,8 @@ func (c *Core) Run(ctx context.Context) error {
308 }332 }
309 switch v := ev.(type) {333 switch v := ev.(type) {
310 case ports.PointerMotion:334 case ports.PointerMotion:
311- c.cursorX = min(max(v.X, 0), float64(max(c.ws.Output.W-1, 0)))335+ c.cursorX = min(max(v.X, 0), float64(max(c.ws.Output().W-1, 0)))
312- c.cursorY = min(max(v.Y, 0), float64(max(c.ws.Output.H-1, 0)))336+ c.cursorY = min(max(v.Y, 0), float64(max(c.ws.Output().H-1, 0)))
313 var id WindowID337 var id WindowID
314 var x, y float64338 var x, y float64
315 // Fullscreen wins; otherwise the last visible placement is topmost.339 // Fullscreen wins; otherwise the last visible placement is topmost.
@@ -354,7 +378,7 @@ func (c *Core) Run(ctx context.Context) error {
354 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 {
355 return nil379 return nil
356 }380 }
357- if v.Pressed && c.focus != id && c.ws.FocusID(id) {381+ if v.Pressed && c.focus != id && c.ws.Current().FocusID(id) {
358 if err := c.publish(ctx); err != nil {382 if err := c.publish(ctx); err != nil {
359 return nil383 return nil
360 }384 }
@@ -370,16 +394,31 @@ func (c *Core) Run(ctx context.Context) error {
370 continue394 continue
371 }395 }
372 name := keyName(key.Keysym)396 name := keyName(key.Keysym)
397+ action, bound := c.binds[binding{key: name, mods: key.Mods}]
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)
411+ }
373 if !key.Pressed {412 if !key.Pressed {
374- consumed := c.pressed[name]413+ consumed := c.pressed[held]
375- delete(c.pressed, name)414+ delete(c.pressed, held)
376 if consumed {415 if consumed {
377 continue416 continue
378 }417 }
379 }418 }
380 if key.Pressed {419 if key.Pressed {
381- if action, ok := c.binds[binding{key: name, mods: key.Mods}]; ok {420+ if bound {
382- c.pressed[name] = true421+ c.pressed[held] = true
383 effect := c.ws.Apply(action)422 effect := c.ws.Apply(action)
384 if effect.Quit {423 if effect.Quit {
385 return ErrQuit424 return ErrQuit
@@ -405,7 +444,7 @@ func (c *Core) Run(ctx context.Context) error {
405 }444 }
406 continue445 continue
407 }446 }
408- c.pressed[name] = false447+ c.pressed[held] = false
409 }448 }
410 if id := c.keyboardFocus(); id != 0 {449 if id := c.keyboardFocus(); id != 0 {
411 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 +154 -9
@@ -49,10 +49,11 @@ func TestOwner(t *testing.T) {
4949 }
5050 client <- ports.WindowMapped{ID: 1}
5151 s = receive(t, scenes)
52- if len(s.Windows) != 1 || s.Windows[0].Rect.W != 38 {
52+ // A single column fills the usable width.
53+ if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || !s.Windows[0].Borderless {
5354 t.Fatal(s)
5455 }
55- if v := receive(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 38, Height: 64, Activated: true}) {
56+ if v := receive(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 84, Height: 64, Activated: true}) {
5657 t.Fatal(v)
5758 }
5859 receive(t, commands)
@@ -93,13 +94,13 @@ func TestOwner(t *testing.T) {
9394 cfg.Layout.Gaps = 4
9495 reload <- ports.ConfigChanged{Config: cfg}
9596 s = receive(t, scenes)
96- if s.Windows[0].Rect.W != 44 {
97+ if s.Windows[0].Rect.W != 92 {
9798 t.Fatal(s)
9899 }
99- if v := receive(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 44, Height: 72, Activated: true}) {
100+ if v := receive(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 92, Height: 72, Activated: true}) {
100101 t.Fatal(v)
101102 }
102- cfg.Layout.DefaultColumnWidth = "bad"
103+ cfg.Layout.MaxColumns = 0
103104 reload <- ports.ConfigChanged{Config: cfg}
104105 receive(t, errs)
105106 for i := 0; i < 100; i++ {
@@ -268,17 +269,32 @@ func TestBorderInset(t *testing.T) {
268269 go c.Run(ctx)
269270 output <- ports.OutputMode{Width: 100, Height: 80}
270271 receive(t, scenes)
272+ // Alone, the window is borderless and gets the full rect.
271273 client <- ports.WindowMapped{ID: 1}
272274 r := receive(t, scenes).Windows[0].Rect
273- // The scene keeps the outer rect; the client is configured 2px smaller on each side.
274- if v := receive(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W - 4, Height: r.H - 4, Activated: true}) {
275+ if v := receive(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W, Height: r.H, Activated: true}) {
275276 t.Fatalf("%v for outer %v", v, r)
276277 }
278+ // With a second column, the scene keeps the outer rect and the client is
279+ // configured 2px smaller on each side.
280+ client <- ports.WindowMapped{ID: 2}
281+ s := receive(t, scenes)
282+ r = s.Windows[1].Rect
283+ if s.Windows[1].ID != 2 || s.Windows[1].Borderless {
284+ t.Fatal(s)
285+ }
286+ want := ports.ConfigureWindow{ID: 2, Width: r.W - 4, Height: r.H - 4, Activated: true}
287+ found := false
277288 for len(commands) > 0 {
278- <-commands
289+ if v := <-commands; v == want {
290+ found = true
291+ }
292+ }
293+ if !found {
294+ t.Fatalf("no %v for outer %v", want, r)
279295 }
280296 input <- ports.PointerMotion{X: float64(r.X + 2), Y: float64(r.Y + 5), TimeMsec: 1}
281- if v := receive(t, commands); v != (ports.PointerFocus{ID: 1, X: 0, Y: 3}) {
297+ if v := receive(t, commands); v != (ports.PointerFocus{ID: 2, X: 0, Y: 3}) {
282298 t.Fatal(v)
283299 }
284300 }
@@ -335,3 +351,132 @@ func TestLayerKeyboardFocus(t *testing.T) {
335351 t.Fatalf("focus not restored: %d", id)
336352 }
337353 }
354+
355+func TestWorkspaceSwitch(t *testing.T) {
356+ cfg := config.Defaults()
357+ client := make(chan ports.ClientEvent, 8)
358+ input := make(chan ports.InputEvent, 8)
359+ output := make(chan ports.OutputEvent, 8)
360+ commands := make(chan ports.ClientCommand, 64)
361+ scenes := make(chan ports.Scene, 1)
362+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
363+ if err != nil {
364+ t.Fatal(err)
365+ }
366+ ctx, cancel := context.WithCancel(context.Background())
367+ defer cancel()
368+ go c.Run(ctx)
369+ output <- ports.OutputMode{Width: 100, Height: 80}
370+ receive(t, scenes)
371+ client <- ports.WindowMapped{ID: 1}
372+ receive(t, scenes)
373+ for len(commands) > 0 {
374+ <-commands
375+ }
376+ // AZERTY: the 2 key is eacute unshifted; Cmd+2 still matches through its shifted level.
377+ input <- ports.KeyEvent{Keysym: "eacute", Base: "eacute", Shifted: "2", Mods: ports.ModSuper, Pressed: true}
378+ s := receive(t, scenes)
379+ if len(s.Windows) != 1 || !s.Windows[0].Hidden {
380+ t.Fatal(s)
381+ }
382+ // Window 1 is deactivated and loses keyboard focus.
383+ var sawDeactivate, sawFocus bool
384+ for len(commands) > 0 {
385+ switch v := (<-commands).(type) {
386+ case ports.ConfigureWindow:
387+ sawDeactivate = sawDeactivate || (v.ID == 1 && !v.Activated)
388+ case ports.FocusWindow:
389+ sawFocus = sawFocus || v.ID == 0
390+ }
391+ }
392+ if !sawDeactivate || !sawFocus {
393+ t.Fatal(sawDeactivate, sawFocus)
394+ }
395+ // A new window opens on workspace 2, alone and borderless.
396+ client <- ports.WindowMapped{ID: 2}
397+ s = receive(t, scenes)
398+ if len(s.Windows) != 2 || s.Windows[1].ID != 2 || s.Windows[1].Hidden || !s.Windows[1].Borderless {
399+ t.Fatal(s)
400+ }
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+}
@@ -49,10 +49,11 @@ func TestOwner(t *testing.T) {
49 }49 }
50 client <- ports.WindowMapped{ID: 1}50 client <- ports.WindowMapped{ID: 1}
51 s = receive(t, scenes)51 s = receive(t, scenes)
52- if len(s.Windows) != 1 || s.Windows[0].Rect.W != 38 {52+ // A single column fills the usable width.
53+ if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || !s.Windows[0].Borderless {
53 t.Fatal(s)54 t.Fatal(s)
54 }55 }
55- if v := receive(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 38, Height: 64, Activated: true}) {56+ if v := receive(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 84, Height: 64, Activated: true}) {
56 t.Fatal(v)57 t.Fatal(v)
57 }58 }
58 receive(t, commands)59 receive(t, commands)
@@ -93,13 +94,13 @@ func TestOwner(t *testing.T) {
93 cfg.Layout.Gaps = 494 cfg.Layout.Gaps = 4
94 reload <- ports.ConfigChanged{Config: cfg}95 reload <- ports.ConfigChanged{Config: cfg}
95 s = receive(t, scenes)96 s = receive(t, scenes)
96- if s.Windows[0].Rect.W != 44 {97+ if s.Windows[0].Rect.W != 92 {
97 t.Fatal(s)98 t.Fatal(s)
98 }99 }
99- if v := receive(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 44, Height: 72, Activated: true}) {100+ if v := receive(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 92, Height: 72, Activated: true}) {
100 t.Fatal(v)101 t.Fatal(v)
101 }102 }
102- cfg.Layout.DefaultColumnWidth = "bad"103+ cfg.Layout.MaxColumns = 0
103 reload <- ports.ConfigChanged{Config: cfg}104 reload <- ports.ConfigChanged{Config: cfg}
104 receive(t, errs)105 receive(t, errs)
105 for i := 0; i < 100; i++ {106 for i := 0; i < 100; i++ {
@@ -268,17 +269,32 @@ func TestBorderInset(t *testing.T) {
268 go c.Run(ctx)269 go c.Run(ctx)
269 output <- ports.OutputMode{Width: 100, Height: 80}270 output <- ports.OutputMode{Width: 100, Height: 80}
270 receive(t, scenes)271 receive(t, scenes)
272+ // Alone, the window is borderless and gets the full rect.
271 client <- ports.WindowMapped{ID: 1}273 client <- ports.WindowMapped{ID: 1}
272 r := receive(t, scenes).Windows[0].Rect274 r := receive(t, scenes).Windows[0].Rect
273- // The scene keeps the outer rect; the client is configured 2px smaller on each side.275+ if v := receive(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W, Height: r.H, Activated: true}) {
274- if v := receive(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W - 4, Height: r.H - 4, Activated: true}) {
275 t.Fatalf("%v for outer %v", v, r)276 t.Fatalf("%v for outer %v", v, r)
276 }277 }
278+ // With a second column, the scene keeps the outer rect and the client is
279+ // configured 2px smaller on each side.
280+ client <- ports.WindowMapped{ID: 2}
281+ s := receive(t, scenes)
282+ r = s.Windows[1].Rect
283+ if s.Windows[1].ID != 2 || s.Windows[1].Borderless {
284+ t.Fatal(s)
285+ }
286+ want := ports.ConfigureWindow{ID: 2, Width: r.W - 4, Height: r.H - 4, Activated: true}
287+ found := false
277 for len(commands) > 0 {288 for len(commands) > 0 {
278- <-commands289+ if v := <-commands; v == want {
290+ found = true
291+ }
292+ }
293+ if !found {
294+ t.Fatalf("no %v for outer %v", want, r)
279 }295 }
280 input <- ports.PointerMotion{X: float64(r.X + 2), Y: float64(r.Y + 5), TimeMsec: 1}296 input <- ports.PointerMotion{X: float64(r.X + 2), Y: float64(r.Y + 5), TimeMsec: 1}
281- if v := receive(t, commands); v != (ports.PointerFocus{ID: 1, X: 0, Y: 3}) {297+ if v := receive(t, commands); v != (ports.PointerFocus{ID: 2, X: 0, Y: 3}) {
282 t.Fatal(v)298 t.Fatal(v)
283 }299 }
284 }300 }
@@ -335,3 +351,132 @@ func TestLayerKeyboardFocus(t *testing.T) {
335 t.Fatalf("focus not restored: %d", id)351 t.Fatalf("focus not restored: %d", id)
336 }352 }
337 }353 }
354+
355+func TestWorkspaceSwitch(t *testing.T) {
356+ cfg := config.Defaults()
357+ client := make(chan ports.ClientEvent, 8)
358+ input := make(chan ports.InputEvent, 8)
359+ output := make(chan ports.OutputEvent, 8)
360+ commands := make(chan ports.ClientCommand, 64)
361+ scenes := make(chan ports.Scene, 1)
362+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
363+ if err != nil {
364+ t.Fatal(err)
365+ }
366+ ctx, cancel := context.WithCancel(context.Background())
367+ defer cancel()
368+ go c.Run(ctx)
369+ output <- ports.OutputMode{Width: 100, Height: 80}
370+ receive(t, scenes)
371+ client <- ports.WindowMapped{ID: 1}
372+ receive(t, scenes)
373+ for len(commands) > 0 {
374+ <-commands
375+ }
376+ // AZERTY: the 2 key is eacute unshifted; Cmd+2 still matches through its shifted level.
377+ input <- ports.KeyEvent{Keysym: "eacute", Base: "eacute", Shifted: "2", Mods: ports.ModSuper, Pressed: true}
378+ s := receive(t, scenes)
379+ if len(s.Windows) != 1 || !s.Windows[0].Hidden {
380+ t.Fatal(s)
381+ }
382+ // Window 1 is deactivated and loses keyboard focus.
383+ var sawDeactivate, sawFocus bool
384+ for len(commands) > 0 {
385+ switch v := (<-commands).(type) {
386+ case ports.ConfigureWindow:
387+ sawDeactivate = sawDeactivate || (v.ID == 1 && !v.Activated)
388+ case ports.FocusWindow:
389+ sawFocus = sawFocus || v.ID == 0
390+ }
391+ }
392+ if !sawDeactivate || !sawFocus {
393+ t.Fatal(sawDeactivate, sawFocus)
394+ }
395+ // A new window opens on workspace 2, alone and borderless.
396+ client <- ports.WindowMapped{ID: 2}
397+ s = receive(t, scenes)
398+ if len(s.Windows) != 2 || s.Windows[1].ID != 2 || s.Windows[1].Hidden || !s.Windows[1].Borderless {
399+ t.Fatal(s)
400+ }
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/layers_test.go +1 -1
@@ -56,7 +56,7 @@ func TestLayerChangedChannels(t *testing.T) {
5656 scenes := make(chan ports.Scene, 1)
5757 cfg := ports.Config{}
5858 cfg.Keyboard.CmdKey = "super"
59- cfg.Layout.DefaultColumnWidth = "1/2"
59+ cfg.Layout.MaxColumns = 2
6060 c, err := New(cfg, Channels{Client: client, Output: output, Commands: commands, Scenes: scenes})
6161 if err != nil {
6262 t.Fatal(err)
@@ -56,7 +56,7 @@ func TestLayerChangedChannels(t *testing.T) {
56 scenes := make(chan ports.Scene, 1)56 scenes := make(chan ports.Scene, 1)
57 cfg := ports.Config{}57 cfg := ports.Config{}
58 cfg.Keyboard.CmdKey = "super"58 cfg.Keyboard.CmdKey = "super"
59- cfg.Layout.DefaultColumnWidth = "1/2"59+ cfg.Layout.MaxColumns = 2
60 c, err := New(cfg, Channels{Client: client, Output: output, Commands: commands, Scenes: scenes})60 c, err := New(cfg, Channels{Client: client, Output: output, Commands: commands, Scenes: scenes})
61 if err != nil {61 if err != nil {
62 t.Fatal(err)62 t.Fatal(err)
M internal/core/layout.go +64 -33
@@ -70,6 +70,8 @@ func (v Width) Resolve(usableW, gaps int) int {
7070 return int(min(rounded-uint64(gaps), uint64(limit)))
7171 }
7272
73+// Column.Width is zero (auto) until the user picks a preset: auto columns share
74+// the usable width equally, up to MaxColumns visible at once.
7375 type Column struct {
7476 Windows []WindowID
7577 Width Width
@@ -79,17 +81,34 @@ type Placement struct {
7981 ID WindowID
8082 Rect Rect
8183 Fullscreen, Focused, Hidden bool
84+ // Borderless is set when the column fills the usable width: it is the only
85+ // column on screen, so no border marks focus.
86+ Borderless bool
8287 }
8388 type Workspace struct {
84- Columns []Column
85- Focus int
86- ViewX int
87- Output Rect
88- Usable Rect
89- Gaps int
90- DefaultWidth Width
91- presets []Width
92- fullscreen WindowID
89+ Columns []Column
90+ Focus int
91+ ViewX int
92+ Output Rect
93+ Usable Rect
94+ Gaps int
95+ MaxColumns int
96+ presets []Width
97+ fullscreen WindowID
98+}
99+
100+func (w *Workspace) empty() bool { return len(w.Columns) == 0 }
101+
102+// has reports whether the workspace holds the window.
103+func (w *Workspace) has(id WindowID) bool {
104+ for _, c := range w.Columns {
105+ for _, v := range c.Windows {
106+ if v == id {
107+ return true
108+ }
109+ }
110+ }
111+ return false
93112 }
94113
95114 func (w *Workspace) Focused() (WindowID, bool) {
@@ -103,23 +122,16 @@ func (w *Workspace) Focused() (WindowID, bool) {
103122 return c.Windows[c.Focus], true
104123 }
105124 func (w *Workspace) AddWindow(id WindowID) {
106- if id == 0 {
125+ if id == 0 || w.has(id) {
107126 return
108127 }
109- for _, c := range w.Columns {
110- for _, v := range c.Windows {
111- if v == id {
112- return
113- }
114- }
115- }
116128 at := 0
117129 if len(w.Columns) > 0 {
118130 at = w.Focus + 1
119131 }
120132 w.Columns = append(w.Columns, Column{})
121133 copy(w.Columns[at+1:], w.Columns[at:])
122- w.Columns[at] = Column{Windows: []WindowID{id}, Width: w.DefaultWidth}
134+ w.Columns[at] = Column{Windows: []WindowID{id}}
123135 w.Focus = at
124136 w.scroll()
125137 }
@@ -177,15 +189,19 @@ func (w *Workspace) FocusColumn(dir int) {
177189 w.scroll()
178190 }
179191 }
180-func (w *Workspace) FocusWindow(dir int) {
192+
193+// FocusWindow moves focus inside the column; false means it was already at the edge.
194+func (w *Workspace) FocusWindow(dir int) bool {
181195 if len(w.Columns) == 0 || (dir != -1 && dir != 1) {
182- return
196+ return false
183197 }
184198 c := &w.Columns[w.Focus]
185199 if c.Focus+dir >= 0 && c.Focus+dir < len(c.Windows) {
186200 c.Focus += dir
187201 w.scroll()
202+ return true
188203 }
204+ return false
189205 }
190206 func (w *Workspace) MoveColumn(dir int) {
191207 if (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) {
@@ -199,15 +215,19 @@ func (w *Workspace) CycleWidth() {
199215 if len(w.Columns) == 0 || len(w.presets) == 0 {
200216 return
201217 }
218+ // auto → presets in order → auto.
202219 c := &w.Columns[w.Focus]
203- next := 0
220+ next := Width{}
221+ if c.Width == next {
222+ next = w.presets[0]
223+ }
204224 for i, v := range w.presets {
205- if v == c.Width {
206- next = (i + 1) % len(w.presets)
225+ if v == c.Width && i+1 < len(w.presets) {
226+ next = w.presets[i+1]
207227 break
208228 }
209229 }
210- c.Width = w.presets[next]
230+ c.Width = next
211231 w.scroll()
212232 }
213233 func (w *Workspace) ToggleFullscreen() {
@@ -223,13 +243,12 @@ func (w *Workspace) ToggleFullscreen() {
223243 w.scroll()
224244 }
225245
226-// 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).
227248 func (w *Workspace) SetFullscreen(id WindowID, on bool) {
228249 for i := range w.Columns {
229- for j, v := range w.Columns[i].Windows {
250+ for _, v := range w.Columns[i].Windows {
230251 if v == id {
231- w.Focus = i
232- w.Columns[i].Focus = j
233252 if on {
234253 w.fullscreen = id
235254 } else if w.fullscreen == id {
@@ -273,9 +292,12 @@ func (w *Workspace) SetGaps(g int) {
273292 w.Gaps = g
274293 w.scroll()
275294 }
276-func (w *Workspace) SetDefaultWidth(v Width) { w.DefaultWidth = v }
277-func (w *Workspace) SetPresets(v []Width) { w.presets = append([]Width(nil), v...) }
278-func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) }
295+func (w *Workspace) SetPresets(v []Width) { w.presets = append([]Width(nil), v...) }
296+func (w *Workspace) SetMaxColumns(n int) {
297+ w.MaxColumns = n
298+ w.scroll()
299+}
300+func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) }
279301 func (w *Workspace) fullscreenColumn(i int) bool {
280302 for _, id := range w.Columns[i].Windows {
281303 if id == w.fullscreen && id != 0 {
@@ -288,7 +310,16 @@ func (w *Workspace) columnWidth(i int) int {
288310 if w.fullscreenColumn(i) {
289311 return w.Output.W
290312 }
291- return w.Columns[i].Width.Resolve(w.Usable.W, w.gap())
313+ g := w.gap()
314+ if w.Columns[i].Width == (Width{}) {
315+ if len(w.Columns) == 1 {
316+ return max(w.Usable.W-2*g, 0)
317+ }
318+ // Equal shares of the width left after gaps; a remainder under k pixels stays empty.
319+ k := min(len(w.Columns), max(w.MaxColumns, 1))
320+ return max((w.Usable.W-g*(k+1))/k, 0)
321+ }
322+ return w.Columns[i].Width.Resolve(w.Usable.W, g)
292323 }
293324 func (w *Workspace) columnX(i int) int {
294325 x := w.Usable.X + w.gap()
@@ -347,7 +378,7 @@ func (w *Workspace) Layout() []Placement {
347378 if hidden {
348379 r = Rect{}
349380 }
350- result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: i == w.Focus && j == c.Focus, Hidden: hidden})
381+ result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: i == w.Focus && j == c.Focus, Hidden: hidden, Borderless: width >= w.Usable.W-2*gap})
351382 y += h + gap
352383 }
353384 }
@@ -70,6 +70,8 @@ func (v Width) Resolve(usableW, gaps int) int {
70 return int(min(rounded-uint64(gaps), uint64(limit)))70 return int(min(rounded-uint64(gaps), uint64(limit)))
71 }71 }
72 72
73+// Column.Width is zero (auto) until the user picks a preset: auto columns share
74+// the usable width equally, up to MaxColumns visible at once.
73 type Column struct {75 type Column struct {
74 Windows []WindowID76 Windows []WindowID
75 Width Width77 Width Width
@@ -79,17 +81,34 @@ type Placement struct {
79 ID WindowID81 ID WindowID
80 Rect Rect82 Rect Rect
81 Fullscreen, Focused, Hidden bool83 Fullscreen, Focused, Hidden bool
84+ // Borderless is set when the column fills the usable width: it is the only
85+ // column on screen, so no border marks focus.
86+ Borderless bool
82 }87 }
83 type Workspace struct {88 type Workspace struct {
84- Columns []Column89+ Columns []Column
85- Focus int90+ Focus int
86- ViewX int91+ ViewX int
87- Output Rect92+ Output Rect
88- Usable Rect93+ Usable Rect
89- Gaps int94+ Gaps int
90- DefaultWidth Width95+ MaxColumns int
91- presets []Width96+ presets []Width
92- fullscreen WindowID97+ fullscreen WindowID
98+}
99+
100+func (w *Workspace) empty() bool { return len(w.Columns) == 0 }
101+
102+// has reports whether the workspace holds the window.
103+func (w *Workspace) has(id WindowID) bool {
104+ for _, c := range w.Columns {
105+ for _, v := range c.Windows {
106+ if v == id {
107+ return true
108+ }
109+ }
110+ }
111+ return false
93 }112 }
94 113
95 func (w *Workspace) Focused() (WindowID, bool) {114 func (w *Workspace) Focused() (WindowID, bool) {
@@ -103,23 +122,16 @@ func (w *Workspace) Focused() (WindowID, bool) {
103 return c.Windows[c.Focus], true122 return c.Windows[c.Focus], true
104 }123 }
105 func (w *Workspace) AddWindow(id WindowID) {124 func (w *Workspace) AddWindow(id WindowID) {
106- if id == 0 {125+ if id == 0 || w.has(id) {
107 return126 return
108 }127 }
109- for _, c := range w.Columns {
110- for _, v := range c.Windows {
111- if v == id {
112- return
113- }
114- }
115- }
116 at := 0128 at := 0
117 if len(w.Columns) > 0 {129 if len(w.Columns) > 0 {
118 at = w.Focus + 1130 at = w.Focus + 1
119 }131 }
120 w.Columns = append(w.Columns, Column{})132 w.Columns = append(w.Columns, Column{})
121 copy(w.Columns[at+1:], w.Columns[at:])133 copy(w.Columns[at+1:], w.Columns[at:])
122- w.Columns[at] = Column{Windows: []WindowID{id}, Width: w.DefaultWidth}134+ w.Columns[at] = Column{Windows: []WindowID{id}}
123 w.Focus = at135 w.Focus = at
124 w.scroll()136 w.scroll()
125 }137 }
@@ -177,15 +189,19 @@ func (w *Workspace) FocusColumn(dir int) {
177 w.scroll()189 w.scroll()
178 }190 }
179 }191 }
180-func (w *Workspace) FocusWindow(dir int) {192+
193+// FocusWindow moves focus inside the column; false means it was already at the edge.
194+func (w *Workspace) FocusWindow(dir int) bool {
181 if len(w.Columns) == 0 || (dir != -1 && dir != 1) {195 if len(w.Columns) == 0 || (dir != -1 && dir != 1) {
182- return196+ return false
183 }197 }
184 c := &w.Columns[w.Focus]198 c := &w.Columns[w.Focus]
185 if c.Focus+dir >= 0 && c.Focus+dir < len(c.Windows) {199 if c.Focus+dir >= 0 && c.Focus+dir < len(c.Windows) {
186 c.Focus += dir200 c.Focus += dir
187 w.scroll()201 w.scroll()
202+ return true
188 }203 }
204+ return false
189 }205 }
190 func (w *Workspace) MoveColumn(dir int) {206 func (w *Workspace) MoveColumn(dir int) {
191 if (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) {207 if (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) {
@@ -199,15 +215,19 @@ func (w *Workspace) CycleWidth() {
199 if len(w.Columns) == 0 || len(w.presets) == 0 {215 if len(w.Columns) == 0 || len(w.presets) == 0 {
200 return216 return
201 }217 }
218+ // auto → presets in order → auto.
202 c := &w.Columns[w.Focus]219 c := &w.Columns[w.Focus]
203- next := 0220+ next := Width{}
221+ if c.Width == next {
222+ next = w.presets[0]
223+ }
204 for i, v := range w.presets {224 for i, v := range w.presets {
205- if v == c.Width {225+ if v == c.Width && i+1 < len(w.presets) {
206- next = (i + 1) % len(w.presets)226+ next = w.presets[i+1]
207 break227 break
208 }228 }
209 }229 }
210- c.Width = w.presets[next]230+ c.Width = next
211 w.scroll()231 w.scroll()
212 }232 }
213 func (w *Workspace) ToggleFullscreen() {233 func (w *Workspace) ToggleFullscreen() {
@@ -223,13 +243,12 @@ func (w *Workspace) ToggleFullscreen() {
223 w.scroll()243 w.scroll()
224 }244 }
225 245
226-// 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).
227 func (w *Workspace) SetFullscreen(id WindowID, on bool) {248 func (w *Workspace) SetFullscreen(id WindowID, on bool) {
228 for i := range w.Columns {249 for i := range w.Columns {
229- for j, v := range w.Columns[i].Windows {250+ for _, v := range w.Columns[i].Windows {
230 if v == id {251 if v == id {
231- w.Focus = i
232- w.Columns[i].Focus = j
233 if on {252 if on {
234 w.fullscreen = id253 w.fullscreen = id
235 } else if w.fullscreen == id {254 } else if w.fullscreen == id {
@@ -273,9 +292,12 @@ func (w *Workspace) SetGaps(g int) {
273 w.Gaps = g292 w.Gaps = g
274 w.scroll()293 w.scroll()
275 }294 }
276-func (w *Workspace) SetDefaultWidth(v Width) { w.DefaultWidth = v }295+func (w *Workspace) SetPresets(v []Width) { w.presets = append([]Width(nil), v...) }
277-func (w *Workspace) SetPresets(v []Width) { w.presets = append([]Width(nil), v...) }296+func (w *Workspace) SetMaxColumns(n int) {
278-func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) }297+ w.MaxColumns = n
298+ w.scroll()
299+}
300+func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) }
279 func (w *Workspace) fullscreenColumn(i int) bool {301 func (w *Workspace) fullscreenColumn(i int) bool {
280 for _, id := range w.Columns[i].Windows {302 for _, id := range w.Columns[i].Windows {
281 if id == w.fullscreen && id != 0 {303 if id == w.fullscreen && id != 0 {
@@ -288,7 +310,16 @@ func (w *Workspace) columnWidth(i int) int {
288 if w.fullscreenColumn(i) {310 if w.fullscreenColumn(i) {
289 return w.Output.W311 return w.Output.W
290 }312 }
291- return w.Columns[i].Width.Resolve(w.Usable.W, w.gap())313+ g := w.gap()
314+ if w.Columns[i].Width == (Width{}) {
315+ if len(w.Columns) == 1 {
316+ return max(w.Usable.W-2*g, 0)
317+ }
318+ // Equal shares of the width left after gaps; a remainder under k pixels stays empty.
319+ k := min(len(w.Columns), max(w.MaxColumns, 1))
320+ return max((w.Usable.W-g*(k+1))/k, 0)
321+ }
322+ return w.Columns[i].Width.Resolve(w.Usable.W, g)
292 }323 }
293 func (w *Workspace) columnX(i int) int {324 func (w *Workspace) columnX(i int) int {
294 x := w.Usable.X + w.gap()325 x := w.Usable.X + w.gap()
@@ -347,7 +378,7 @@ func (w *Workspace) Layout() []Placement {
347 if hidden {378 if hidden {
348 r = Rect{}379 r = Rect{}
349 }380 }
350- result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: i == w.Focus && j == c.Focus, Hidden: hidden})381+ result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: i == w.Focus && j == c.Focus, Hidden: hidden, Borderless: width >= w.Usable.W-2*gap})
351 y += h + gap382 y += h + gap
352 }383 }
353 }384 }
M internal/core/layout_test.go +16 -12
@@ -10,7 +10,7 @@ func workspace() *Workspace {
1010 w := &Workspace{}
1111 w.SetOutput(100, 81)
1212 w.SetGaps(5)
13- w.SetDefaultWidth(Width{Num: 1, Den: 2})
13+ w.SetMaxColumns(2)
1414 return w
1515 }
1616 func TestWidths(t *testing.T) {
@@ -45,11 +45,12 @@ func TestOperations(t *testing.T) {
4545 w.AddWindow(1)
4646 w.AddWindow(2)
4747 w.AddWindow(3)
48- if w.Focus != 2 || w.ViewX != 49 {
48+ // Three auto columns, two visible: each (100-3*5)/2 = 42 wide.
49+ if w.Focus != 2 || w.ViewX != 46 || w.columnWidth(0) != 42 {
4950 t.Fatalf("scroll: %+v", w)
5051 }
5152 w.FocusColumn(-1)
52- if w.ViewX != 48 {
53+ if w.ViewX != 46 {
5354 t.Fatal(w.ViewX)
5455 }
5556 w.FocusColumn(-1)
@@ -72,7 +73,7 @@ func TestOperations(t *testing.T) {
7273 }
7374 func TestStackAndLayout(t *testing.T) {
7475 w := workspace()
75- 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}}
7677 w.RemoveWindow(2)
7778 if id, _ := w.Focused(); id != 1 {
7879 t.Fatal(id)
@@ -83,7 +84,7 @@ func TestStackAndLayout(t *testing.T) {
8384 }
8485 w.FocusWindow(-1)
8586 p := w.Layout()
86- if p[0].Rect != (Rect{X: 5, Y: 5, W: 43, H: 33}) || p[1].Rect != (Rect{X: 5, Y: 43, W: 43, H: 33}) {
87+ if p[0].Rect != (Rect{X: 5, Y: 5, W: 90, H: 33}) || p[1].Rect != (Rect{X: 5, Y: 43, W: 90, H: 33}) || !p[0].Borderless {
8788 t.Fatal(p)
8889 }
8990 w.ToggleFullscreen()
@@ -110,18 +111,21 @@ func TestPresetsAndActions(t *testing.T) {
110111 p := []Width{{Pixels: 30}, {Pixels: 40}}
111112 w.SetPresets(p)
112113 p[0].Pixels = 99
114+ w.AddWindow(2)
113115 w.Apply(ActionCycleColumnWidth)
114- if w.Columns[0].Width.Pixels != 30 {
116+ if w.Columns[1].Width.Pixels != 30 {
115117 t.Fatal(w.Columns)
116118 }
117119 w.CycleWidth()
118- if w.Columns[0].Width.Pixels != 40 {
120+ if w.Columns[1].Width.Pixels != 40 {
119121 t.Fatal(w.Columns)
120122 }
123+ // After the last preset, back to the automatic share.
121124 w.CycleWidth()
122- if w.Columns[0].Width.Pixels != 30 {
125+ if w.Columns[1].Width != (Width{}) {
123126 t.Fatal(w.Columns)
124127 }
128+ w.RemoveWindow(2)
125129 for _, tc := range []struct {
126130 a Action
127131 want Effect
@@ -181,9 +185,8 @@ func FuzzWorkspaceOps(f *testing.F) {
181185 stackFocused(w)
182186 }
183187 case 15:
184- w.SetDefaultWidth(Width{Num: math.MaxInt, Den: math.MaxInt})
185188 if len(w.Columns) > 0 {
186- w.Columns[w.Focus].Width = w.DefaultWidth
189+ w.Columns[w.Focus].Width = Width{Num: math.MaxInt, Den: math.MaxInt}
187190 w.scroll()
188191 }
189192 case 16:
@@ -335,10 +338,11 @@ func TestFullscreenPersistsAndScrolls(t *testing.T) {
335338
336339 func TestSetFullscreenStackDeactivation(t *testing.T) {
337340 w := workspace()
338- w.Columns = []Column{{Windows: []WindowID{1, 2}, Width: w.DefaultWidth, 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.
339343 w.SetFullscreen(2, true)
340344 p := w.Layout()
341- if !p[0].Hidden || !p[1].Fullscreen || !p[1].Focused {
345+ if !p[0].Hidden || !p[1].Fullscreen || p[1].Focused || !p[0].Focused {
342346 t.Fatal(p)
343347 }
344348 w.SetFullscreen(2, false)
@@ -10,7 +10,7 @@ func workspace() *Workspace {
10 w := &Workspace{}10 w := &Workspace{}
11 w.SetOutput(100, 81)11 w.SetOutput(100, 81)
12 w.SetGaps(5)12 w.SetGaps(5)
13- w.SetDefaultWidth(Width{Num: 1, Den: 2})13+ w.SetMaxColumns(2)
14 return w14 return w
15 }15 }
16 func TestWidths(t *testing.T) {16 func TestWidths(t *testing.T) {
@@ -45,11 +45,12 @@ func TestOperations(t *testing.T) {
45 w.AddWindow(1)45 w.AddWindow(1)
46 w.AddWindow(2)46 w.AddWindow(2)
47 w.AddWindow(3)47 w.AddWindow(3)
48- if w.Focus != 2 || w.ViewX != 49 {48+ // Three auto columns, two visible: each (100-3*5)/2 = 42 wide.
49+ if w.Focus != 2 || w.ViewX != 46 || w.columnWidth(0) != 42 {
49 t.Fatalf("scroll: %+v", w)50 t.Fatalf("scroll: %+v", w)
50 }51 }
51 w.FocusColumn(-1)52 w.FocusColumn(-1)
52- if w.ViewX != 48 {53+ if w.ViewX != 46 {
53 t.Fatal(w.ViewX)54 t.Fatal(w.ViewX)
54 }55 }
55 w.FocusColumn(-1)56 w.FocusColumn(-1)
@@ -72,7 +73,7 @@ func TestOperations(t *testing.T) {
72 }73 }
73 func TestStackAndLayout(t *testing.T) {74 func TestStackAndLayout(t *testing.T) {
74 w := workspace()75 w := workspace()
75- 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}}
76 w.RemoveWindow(2)77 w.RemoveWindow(2)
77 if id, _ := w.Focused(); id != 1 {78 if id, _ := w.Focused(); id != 1 {
78 t.Fatal(id)79 t.Fatal(id)
@@ -83,7 +84,7 @@ func TestStackAndLayout(t *testing.T) {
83 }84 }
84 w.FocusWindow(-1)85 w.FocusWindow(-1)
85 p := w.Layout()86 p := w.Layout()
86- if p[0].Rect != (Rect{X: 5, Y: 5, W: 43, H: 33}) || p[1].Rect != (Rect{X: 5, Y: 43, W: 43, H: 33}) {87+ if p[0].Rect != (Rect{X: 5, Y: 5, W: 90, H: 33}) || p[1].Rect != (Rect{X: 5, Y: 43, W: 90, H: 33}) || !p[0].Borderless {
87 t.Fatal(p)88 t.Fatal(p)
88 }89 }
89 w.ToggleFullscreen()90 w.ToggleFullscreen()
@@ -110,18 +111,21 @@ func TestPresetsAndActions(t *testing.T) {
110 p := []Width{{Pixels: 30}, {Pixels: 40}}111 p := []Width{{Pixels: 30}, {Pixels: 40}}
111 w.SetPresets(p)112 w.SetPresets(p)
112 p[0].Pixels = 99113 p[0].Pixels = 99
114+ w.AddWindow(2)
113 w.Apply(ActionCycleColumnWidth)115 w.Apply(ActionCycleColumnWidth)
114- if w.Columns[0].Width.Pixels != 30 {116+ if w.Columns[1].Width.Pixels != 30 {
115 t.Fatal(w.Columns)117 t.Fatal(w.Columns)
116 }118 }
117 w.CycleWidth()119 w.CycleWidth()
118- if w.Columns[0].Width.Pixels != 40 {120+ if w.Columns[1].Width.Pixels != 40 {
119 t.Fatal(w.Columns)121 t.Fatal(w.Columns)
120 }122 }
123+ // After the last preset, back to the automatic share.
121 w.CycleWidth()124 w.CycleWidth()
122- if w.Columns[0].Width.Pixels != 30 {125+ if w.Columns[1].Width != (Width{}) {
123 t.Fatal(w.Columns)126 t.Fatal(w.Columns)
124 }127 }
128+ w.RemoveWindow(2)
125 for _, tc := range []struct {129 for _, tc := range []struct {
126 a Action130 a Action
127 want Effect131 want Effect
@@ -181,9 +185,8 @@ func FuzzWorkspaceOps(f *testing.F) {
181 stackFocused(w)185 stackFocused(w)
182 }186 }
183 case 15:187 case 15:
184- w.SetDefaultWidth(Width{Num: math.MaxInt, Den: math.MaxInt})
185 if len(w.Columns) > 0 {188 if len(w.Columns) > 0 {
186- w.Columns[w.Focus].Width = w.DefaultWidth189+ w.Columns[w.Focus].Width = Width{Num: math.MaxInt, Den: math.MaxInt}
187 w.scroll()190 w.scroll()
188 }191 }
189 case 16:192 case 16:
@@ -335,10 +338,11 @@ func TestFullscreenPersistsAndScrolls(t *testing.T) {
335 338
336 func TestSetFullscreenStackDeactivation(t *testing.T) {339 func TestSetFullscreenStackDeactivation(t *testing.T) {
337 w := workspace()340 w := workspace()
338- w.Columns = []Column{{Windows: []WindowID{1, 2}, Width: w.DefaultWidth, 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.
339 w.SetFullscreen(2, true)343 w.SetFullscreen(2, true)
340 p := w.Layout()344 p := w.Layout()
341- if !p[0].Hidden || !p[1].Fullscreen || !p[1].Focused {345+ if !p[0].Hidden || !p[1].Fullscreen || p[1].Focused || !p[0].Focused {
342 t.Fatal(p)346 t.Fatal(p)
343 }347 }
344 w.SetFullscreen(2, false)348 w.SetFullscreen(2, false)
A internal/core/monitor.go +150 -0
@@ -0,0 +1,150 @@
1+package core
2+
3+// Monitor owns an ordered list of workspaces for one output (ADR 011). Numbers
4+// are positions: Cmd+N targets Workspaces[N-1]. An empty workspace always sits
5+// below the last one, and an empty workspace is removed once left, except the
6+// first. Output-wide settings apply to every workspace.
7+type Monitor struct {
8+ Workspaces []*Workspace
9+ Active int
10+ template Workspace
11+}
12+
13+func NewMonitor() *Monitor {
14+ m := &Monitor{}
15+ m.normalize()
16+ return m
17+}
18+
19+// Current is the workspace on screen.
20+func (m *Monitor) Current() *Workspace { return m.Workspaces[m.Active] }
21+
22+func (m *Monitor) newWorkspace() *Workspace {
23+ w := m.template
24+ w.presets = append([]Width(nil), m.template.presets...)
25+ return &w
26+}
27+
28+// normalize keeps one trailing empty workspace and drops other empty ones that
29+// are neither the first nor active. The active workspace never changes.
30+func (m *Monitor) normalize() {
31+ for i := len(m.Workspaces) - 2; i >= 1; i-- {
32+ if m.Workspaces[i].empty() && i != m.Active {
33+ m.Workspaces = append(m.Workspaces[:i], m.Workspaces[i+1:]...)
34+ if i < m.Active {
35+ m.Active--
36+ }
37+ }
38+ }
39+ for n := len(m.Workspaces); n >= 2 && m.Workspaces[n-1].empty() && m.Workspaces[n-2].empty() && m.Active != n-1; n-- {
40+ m.Workspaces = m.Workspaces[:n-1]
41+ }
42+ if n := len(m.Workspaces); n == 0 || !m.Workspaces[n-1].empty() {
43+ m.Workspaces = append(m.Workspaces, m.newWorkspace())
44+ }
45+}
46+
47+// Focus switches to the workspace at index i, clamped to the list.
48+func (m *Monitor) Focus(i int) {
49+ i = min(max(i, 0), len(m.Workspaces)-1)
50+ m.Active = i
51+ m.normalize()
52+}
53+
54+// FocusNumber switches to workspace n (1-based); past the end means the last.
55+func (m *Monitor) FocusNumber(n int) { m.Focus(n - 1) }
56+
57+// find returns the workspace holding the window.
58+func (m *Monitor) find(id WindowID) (*Workspace, int) {
59+ for i, w := range m.Workspaces {
60+ if w.has(id) {
61+ return w, i
62+ }
63+ }
64+ return nil, -1
65+}
66+
67+// AddWindow places a new window on the active workspace.
68+func (m *Monitor) AddWindow(id WindowID) {
69+ if w, _ := m.find(id); w != nil {
70+ return
71+ }
72+ m.Current().AddWindow(id)
73+ m.normalize()
74+}
75+
76+// RemoveWindow drops the window wherever it is; focus stays on the active workspace.
77+func (m *Monitor) RemoveWindow(id WindowID) {
78+ if w, _ := m.find(id); w != nil {
79+ w.RemoveWindow(id)
80+ m.normalize()
81+ }
82+}
83+
84+// FocusID shows the window's workspace and focuses it.
85+func (m *Monitor) FocusID(id WindowID) bool {
86+ w, i := m.find(id)
87+ if w == nil {
88+ return false
89+ }
90+ m.Active = i
91+ w.FocusID(id)
92+ m.normalize()
93+ return true
94+}
95+
96+// SetFullscreen applies a client request on the window's own workspace. It does
97+// not switch workspaces: client requests never move the user.
98+func (m *Monitor) SetFullscreen(id WindowID, on bool) {
99+ if w, _ := m.find(id); w != nil {
100+ w.SetFullscreen(id, on)
101+ }
102+}
103+
104+// MoveToWorkspace moves the focused window to workspace index i; focus stays here.
105+func (m *Monitor) MoveToWorkspace(i int) {
106+ i = min(max(i, 0), len(m.Workspaces)-1)
107+ cur := m.Current()
108+ id, ok := cur.Focused()
109+ if !ok || i == m.Active {
110+ return
111+ }
112+ cur.RemoveWindow(id)
113+ m.Workspaces[i].AddWindow(id)
114+ m.normalize()
115+}
116+
117+func (m *Monitor) Focused() (WindowID, bool) { return m.Current().Focused() }
118+
119+// Layout places every window: the active workspace on screen, others hidden.
120+func (m *Monitor) Layout() []Placement {
121+ var result []Placement
122+ for i, w := range m.Workspaces {
123+ if i == m.Active {
124+ result = append(result, w.Layout()...)
125+ continue
126+ }
127+ for _, c := range w.Columns {
128+ for _, id := range c.Windows {
129+ result = append(result, Placement{ID: id, Hidden: true})
130+ }
131+ }
132+ }
133+ return result
134+}
135+
136+func (m *Monitor) Output() Rect { return m.template.Output }
137+
138+func (m *Monitor) each(f func(*Workspace)) {
139+ f(&m.template)
140+ for _, w := range m.Workspaces {
141+ f(w)
142+ }
143+}
144+func (m *Monitor) SetOutput(width, height int) {
145+ m.each(func(w *Workspace) { w.SetOutput(width, height) })
146+}
147+func (m *Monitor) SetUsable(r Rect) { m.each(func(w *Workspace) { w.SetUsable(r) }) }
148+func (m *Monitor) SetGaps(g int) { m.each(func(w *Workspace) { w.SetGaps(g) }) }
149+func (m *Monitor) SetPresets(v []Width) { m.each(func(w *Workspace) { w.SetPresets(v) }) }
150+func (m *Monitor) SetMaxColumns(n int) { m.each(func(w *Workspace) { w.SetMaxColumns(n) }) }
@@ -0,0 +1,150 @@
1+package core
2+
3+// Monitor owns an ordered list of workspaces for one output (ADR 011). Numbers
4+// are positions: Cmd+N targets Workspaces[N-1]. An empty workspace always sits
5+// below the last one, and an empty workspace is removed once left, except the
6+// first. Output-wide settings apply to every workspace.
7+type Monitor struct {
8+ Workspaces []*Workspace
9+ Active int
10+ template Workspace
11+}
12+
13+func NewMonitor() *Monitor {
14+ m := &Monitor{}
15+ m.normalize()
16+ return m
17+}
18+
19+// Current is the workspace on screen.
20+func (m *Monitor) Current() *Workspace { return m.Workspaces[m.Active] }
21+
22+func (m *Monitor) newWorkspace() *Workspace {
23+ w := m.template
24+ w.presets = append([]Width(nil), m.template.presets...)
25+ return &w
26+}
27+
28+// normalize keeps one trailing empty workspace and drops other empty ones that
29+// are neither the first nor active. The active workspace never changes.
30+func (m *Monitor) normalize() {
31+ for i := len(m.Workspaces) - 2; i >= 1; i-- {
32+ if m.Workspaces[i].empty() && i != m.Active {
33+ m.Workspaces = append(m.Workspaces[:i], m.Workspaces[i+1:]...)
34+ if i < m.Active {
35+ m.Active--
36+ }
37+ }
38+ }
39+ for n := len(m.Workspaces); n >= 2 && m.Workspaces[n-1].empty() && m.Workspaces[n-2].empty() && m.Active != n-1; n-- {
40+ m.Workspaces = m.Workspaces[:n-1]
41+ }
42+ if n := len(m.Workspaces); n == 0 || !m.Workspaces[n-1].empty() {
43+ m.Workspaces = append(m.Workspaces, m.newWorkspace())
44+ }
45+}
46+
47+// Focus switches to the workspace at index i, clamped to the list.
48+func (m *Monitor) Focus(i int) {
49+ i = min(max(i, 0), len(m.Workspaces)-1)
50+ m.Active = i
51+ m.normalize()
52+}
53+
54+// FocusNumber switches to workspace n (1-based); past the end means the last.
55+func (m *Monitor) FocusNumber(n int) { m.Focus(n - 1) }
56+
57+// find returns the workspace holding the window.
58+func (m *Monitor) find(id WindowID) (*Workspace, int) {
59+ for i, w := range m.Workspaces {
60+ if w.has(id) {
61+ return w, i
62+ }
63+ }
64+ return nil, -1
65+}
66+
67+// AddWindow places a new window on the active workspace.
68+func (m *Monitor) AddWindow(id WindowID) {
69+ if w, _ := m.find(id); w != nil {
70+ return
71+ }
72+ m.Current().AddWindow(id)
73+ m.normalize()
74+}
75+
76+// RemoveWindow drops the window wherever it is; focus stays on the active workspace.
77+func (m *Monitor) RemoveWindow(id WindowID) {
78+ if w, _ := m.find(id); w != nil {
79+ w.RemoveWindow(id)
80+ m.normalize()
81+ }
82+}
83+
84+// FocusID shows the window's workspace and focuses it.
85+func (m *Monitor) FocusID(id WindowID) bool {
86+ w, i := m.find(id)
87+ if w == nil {
88+ return false
89+ }
90+ m.Active = i
91+ w.FocusID(id)
92+ m.normalize()
93+ return true
94+}
95+
96+// SetFullscreen applies a client request on the window's own workspace. It does
97+// not switch workspaces: client requests never move the user.
98+func (m *Monitor) SetFullscreen(id WindowID, on bool) {
99+ if w, _ := m.find(id); w != nil {
100+ w.SetFullscreen(id, on)
101+ }
102+}
103+
104+// MoveToWorkspace moves the focused window to workspace index i; focus stays here.
105+func (m *Monitor) MoveToWorkspace(i int) {
106+ i = min(max(i, 0), len(m.Workspaces)-1)
107+ cur := m.Current()
108+ id, ok := cur.Focused()
109+ if !ok || i == m.Active {
110+ return
111+ }
112+ cur.RemoveWindow(id)
113+ m.Workspaces[i].AddWindow(id)
114+ m.normalize()
115+}
116+
117+func (m *Monitor) Focused() (WindowID, bool) { return m.Current().Focused() }
118+
119+// Layout places every window: the active workspace on screen, others hidden.
120+func (m *Monitor) Layout() []Placement {
121+ var result []Placement
122+ for i, w := range m.Workspaces {
123+ if i == m.Active {
124+ result = append(result, w.Layout()...)
125+ continue
126+ }
127+ for _, c := range w.Columns {
128+ for _, id := range c.Windows {
129+ result = append(result, Placement{ID: id, Hidden: true})
130+ }
131+ }
132+ }
133+ return result
134+}
135+
136+func (m *Monitor) Output() Rect { return m.template.Output }
137+
138+func (m *Monitor) each(f func(*Workspace)) {
139+ f(&m.template)
140+ for _, w := range m.Workspaces {
141+ f(w)
142+ }
143+}
144+func (m *Monitor) SetOutput(width, height int) {
145+ m.each(func(w *Workspace) { w.SetOutput(width, height) })
146+}
147+func (m *Monitor) SetUsable(r Rect) { m.each(func(w *Workspace) { w.SetUsable(r) }) }
148+func (m *Monitor) SetGaps(g int) { m.each(func(w *Workspace) { w.SetGaps(g) }) }
149+func (m *Monitor) SetPresets(v []Width) { m.each(func(w *Workspace) { w.SetPresets(v) }) }
150+func (m *Monitor) SetMaxColumns(n int) { m.each(func(w *Workspace) { w.SetMaxColumns(n) }) }
A internal/core/monitor_test.go +241 -0
@@ -0,0 +1,241 @@
1+package core
2+
3+import (
4+ "reflect"
5+ "testing"
6+)
7+
8+func monitor() *Monitor {
9+ m := NewMonitor()
10+ m.SetOutput(100, 80)
11+ m.SetMaxColumns(2)
12+ return m
13+}
14+
15+// windows lists each workspace's windows, in column order.
16+func windows(m *Monitor) [][]WindowID {
17+ var out [][]WindowID
18+ for _, w := range m.Workspaces {
19+ list := []WindowID{}
20+ for _, c := range w.Columns {
21+ list = append(list, c.Windows...)
22+ }
23+ out = append(out, list)
24+ }
25+ return out
26+}
27+
28+func TestMonitorKeepsOneEmptyWorkspaceBelow(t *testing.T) {
29+ m := monitor()
30+ if len(m.Workspaces) != 1 || m.Active != 0 {
31+ t.Fatal(windows(m))
32+ }
33+ m.AddWindow(1)
34+ if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {}}) {
35+ t.Fatal(windows(m))
36+ }
37+ // Cmd+9 past the end lands on the trailing empty workspace.
38+ m.FocusNumber(9)
39+ if m.Active != 1 {
40+ t.Fatal(m.Active)
41+ }
42+ m.AddWindow(2)
43+ if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {2}, {}}) {
44+ t.Fatal(windows(m))
45+ }
46+}
47+
48+func TestMonitorRemovesEmptyWorkspaceOnceLeft(t *testing.T) {
49+ m := monitor()
50+ m.AddWindow(1)
51+ m.FocusNumber(2)
52+ m.AddWindow(2)
53+ m.FocusNumber(3)
54+ m.AddWindow(3)
55+ // [1:{1}] [2:{2}] [3:{3}] [4:{}]
56+ m.FocusNumber(2)
57+ m.RemoveWindow(2)
58+ // Still on the now-empty workspace 2: it stays while active.
59+ if len(m.Workspaces) != 4 || m.Active != 1 {
60+ t.Fatal(windows(m), m.Active)
61+ }
62+ m.FocusNumber(1)
63+ // Once left it is removed: workspace 3 becomes 2.
64+ if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {3}, {}}) || m.Active != 0 {
65+ t.Fatal(windows(m), m.Active)
66+ }
67+ m.FocusNumber(2)
68+ if id, _ := m.Focused(); id != 3 {
69+ t.Fatal(id)
70+ }
71+}
72+
73+func TestMonitorFirstWorkspaceStays(t *testing.T) {
74+ m := monitor()
75+ m.AddWindow(1)
76+ m.RemoveWindow(1)
77+ m.FocusNumber(2)
78+ if len(m.Workspaces) != 1 || m.Active != 0 {
79+ t.Fatal(windows(m), m.Active)
80+ }
81+ // Leaving an empty first workspace does not remove it.
82+ m.AddWindow(1)
83+ m.FocusNumber(2)
84+ m.AddWindow(2)
85+ m.FocusNumber(1)
86+ m.RemoveWindow(1)
87+ m.FocusNumber(2)
88+ if !reflect.DeepEqual(windows(m), [][]WindowID{{}, {2}, {}}) {
89+ t.Fatal(windows(m))
90+ }
91+}
92+
93+func TestMonitorRemoveElsewhereKeepsActive(t *testing.T) {
94+ m := monitor()
95+ m.AddWindow(1)
96+ m.FocusNumber(2)
97+ m.AddWindow(2)
98+ m.FocusNumber(3)
99+ m.AddWindow(3)
100+ m.FocusNumber(3)
101+ // A window on another workspace closes: the user stays on workspace 3's windows.
102+ m.RemoveWindow(2)
103+ if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {3}, {}}) || m.Active != 1 {
104+ t.Fatal(windows(m), m.Active)
105+ }
106+ if id, _ := m.Focused(); id != 3 {
107+ t.Fatal(id)
108+ }
109+}
110+
111+func TestMonitorLayoutHidesOtherWorkspaces(t *testing.T) {
112+ m := monitor()
113+ m.AddWindow(1)
114+ m.FocusNumber(2)
115+ m.AddWindow(2)
116+ for _, p := range m.Layout() {
117+ if hidden := p.ID == 1; p.Hidden != hidden {
118+ t.Fatal(m.Layout())
119+ }
120+ }
121+ if !m.FocusID(1) || m.Active != 0 {
122+ t.Fatal(m.Active)
123+ }
124+}
125+
126+func TestMonitorFullscreenRequestStaysPut(t *testing.T) {
127+ m := monitor()
128+ m.AddWindow(1)
129+ m.FocusNumber(2)
130+ m.AddWindow(2)
131+ m.SetFullscreen(1, true)
132+ if m.Active != 1 {
133+ t.Fatal("client request switched workspace")
134+ }
135+ if m.Workspaces[0].fullscreen != 1 {
136+ t.Fatal("fullscreen not applied")
137+ }
138+}
139+
140+func TestMonitorActions(t *testing.T) {
141+ m := monitor()
142+ m.AddWindow(1)
143+ m.AddWindow(2)
144+ // Down past the only window of the column goes to the workspace below.
145+ m.Apply(ActionFocusWindowDown)
146+ if m.Active != 1 {
147+ t.Fatal(m.Active)
148+ }
149+ m.Apply(ActionFocusWindowUp)
150+ if m.Active != 0 {
151+ t.Fatal(m.Active)
152+ }
153+ // Up at the top workspace stays.
154+ m.Apply(ActionFocusWorkspaceUp)
155+ if m.Active != 0 {
156+ t.Fatal(m.Active)
157+ }
158+ // Move the focused window (2) down: focus stays on workspace 1.
159+ m.Apply(ActionMoveToWorkspaceDown)
160+ if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {2}, {}}) || m.Active != 0 {
161+ t.Fatal(windows(m), m.Active)
162+ }
163+ m.Apply("move-to-workspace 3")
164+ if !reflect.DeepEqual(windows(m), [][]WindowID{{}, {2}, {1}, {}}) || m.Active != 0 {
165+ t.Fatal(windows(m), m.Active)
166+ }
167+ m.Apply("focus-workspace 3")
168+ if id, _ := m.Focused(); id != 1 {
169+ t.Fatal(id)
170+ }
171+ // Workspace 1 is empty but first: it stays.
172+ if len(m.Workspaces) != 4 {
173+ t.Fatal(windows(m))
174+ }
175+ if e := m.Apply("spawn foot"); !e.Spawn {
176+ t.Fatal(e)
177+ }
178+}
179+
180+func TestEqualSharesAndBorderless(t *testing.T) {
181+ m := monitor()
182+ m.SetMaxColumns(3)
183+ m.AddWindow(1)
184+ p := m.Layout()
185+ if p[0].Rect.W != 100 || !p[0].Borderless {
186+ t.Fatal(p)
187+ }
188+ m.AddWindow(2)
189+ m.AddWindow(3)
190+ p = m.Layout()
191+ if p[0].Rect.W != 33 || p[1].Rect.W != 33 || p[2].Rect.W != 33 || p[0].Borderless {
192+ t.Fatal(p)
193+ }
194+ // A fourth column scrolls: still 1/3 each.
195+ m.AddWindow(4)
196+ if w := m.Current(); w.columnWidth(3) != 33 || w.ViewX == 0 {
197+ t.Fatal(w.ViewX)
198+ }
199+ // A preset on one column is kept; auto columns keep their share.
200+ m.SetPresets([]Width{{Num: 1, Den: 2}})
201+ m.Apply(ActionCycleColumnWidth)
202+ if w := m.Current(); w.columnWidth(w.Focus) != 50 || w.columnWidth(0) != 33 {
203+ t.Fatal(w.columnWidth(w.Focus), w.columnWidth(0))
204+ }
205+}
206+
207+func TestWorkspaceArg(t *testing.T) {
208+ for _, tc := range []struct {
209+ a Action
210+ n int
211+ move, ok bool
212+ }{{"focus-workspace 3", 3, false, true}, {"move-to-workspace 12", 12, true, true}, {"focus-workspace 0", 0, false, false}, {"focus-workspace x", 0, false, false}, {"quit", 0, false, false}} {
213+ n, move, ok := WorkspaceArg(tc.a)
214+ if n != tc.n || move != tc.move || ok != tc.ok {
215+ t.Errorf("%s: %d %v %v", tc.a, n, move, ok)
216+ }
217+ }
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+}
@@ -0,0 +1,241 @@
1+package core
2+
3+import (
4+ "reflect"
5+ "testing"
6+)
7+
8+func monitor() *Monitor {
9+ m := NewMonitor()
10+ m.SetOutput(100, 80)
11+ m.SetMaxColumns(2)
12+ return m
13+}
14+
15+// windows lists each workspace's windows, in column order.
16+func windows(m *Monitor) [][]WindowID {
17+ var out [][]WindowID
18+ for _, w := range m.Workspaces {
19+ list := []WindowID{}
20+ for _, c := range w.Columns {
21+ list = append(list, c.Windows...)
22+ }
23+ out = append(out, list)
24+ }
25+ return out
26+}
27+
28+func TestMonitorKeepsOneEmptyWorkspaceBelow(t *testing.T) {
29+ m := monitor()
30+ if len(m.Workspaces) != 1 || m.Active != 0 {
31+ t.Fatal(windows(m))
32+ }
33+ m.AddWindow(1)
34+ if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {}}) {
35+ t.Fatal(windows(m))
36+ }
37+ // Cmd+9 past the end lands on the trailing empty workspace.
38+ m.FocusNumber(9)
39+ if m.Active != 1 {
40+ t.Fatal(m.Active)
41+ }
42+ m.AddWindow(2)
43+ if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {2}, {}}) {
44+ t.Fatal(windows(m))
45+ }
46+}
47+
48+func TestMonitorRemovesEmptyWorkspaceOnceLeft(t *testing.T) {
49+ m := monitor()
50+ m.AddWindow(1)
51+ m.FocusNumber(2)
52+ m.AddWindow(2)
53+ m.FocusNumber(3)
54+ m.AddWindow(3)
55+ // [1:{1}] [2:{2}] [3:{3}] [4:{}]
56+ m.FocusNumber(2)
57+ m.RemoveWindow(2)
58+ // Still on the now-empty workspace 2: it stays while active.
59+ if len(m.Workspaces) != 4 || m.Active != 1 {
60+ t.Fatal(windows(m), m.Active)
61+ }
62+ m.FocusNumber(1)
63+ // Once left it is removed: workspace 3 becomes 2.
64+ if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {3}, {}}) || m.Active != 0 {
65+ t.Fatal(windows(m), m.Active)
66+ }
67+ m.FocusNumber(2)
68+ if id, _ := m.Focused(); id != 3 {
69+ t.Fatal(id)
70+ }
71+}
72+
73+func TestMonitorFirstWorkspaceStays(t *testing.T) {
74+ m := monitor()
75+ m.AddWindow(1)
76+ m.RemoveWindow(1)
77+ m.FocusNumber(2)
78+ if len(m.Workspaces) != 1 || m.Active != 0 {
79+ t.Fatal(windows(m), m.Active)
80+ }
81+ // Leaving an empty first workspace does not remove it.
82+ m.AddWindow(1)
83+ m.FocusNumber(2)
84+ m.AddWindow(2)
85+ m.FocusNumber(1)
86+ m.RemoveWindow(1)
87+ m.FocusNumber(2)
88+ if !reflect.DeepEqual(windows(m), [][]WindowID{{}, {2}, {}}) {
89+ t.Fatal(windows(m))
90+ }
91+}
92+
93+func TestMonitorRemoveElsewhereKeepsActive(t *testing.T) {
94+ m := monitor()
95+ m.AddWindow(1)
96+ m.FocusNumber(2)
97+ m.AddWindow(2)
98+ m.FocusNumber(3)
99+ m.AddWindow(3)
100+ m.FocusNumber(3)
101+ // A window on another workspace closes: the user stays on workspace 3's windows.
102+ m.RemoveWindow(2)
103+ if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {3}, {}}) || m.Active != 1 {
104+ t.Fatal(windows(m), m.Active)
105+ }
106+ if id, _ := m.Focused(); id != 3 {
107+ t.Fatal(id)
108+ }
109+}
110+
111+func TestMonitorLayoutHidesOtherWorkspaces(t *testing.T) {
112+ m := monitor()
113+ m.AddWindow(1)
114+ m.FocusNumber(2)
115+ m.AddWindow(2)
116+ for _, p := range m.Layout() {
117+ if hidden := p.ID == 1; p.Hidden != hidden {
118+ t.Fatal(m.Layout())
119+ }
120+ }
121+ if !m.FocusID(1) || m.Active != 0 {
122+ t.Fatal(m.Active)
123+ }
124+}
125+
126+func TestMonitorFullscreenRequestStaysPut(t *testing.T) {
127+ m := monitor()
128+ m.AddWindow(1)
129+ m.FocusNumber(2)
130+ m.AddWindow(2)
131+ m.SetFullscreen(1, true)
132+ if m.Active != 1 {
133+ t.Fatal("client request switched workspace")
134+ }
135+ if m.Workspaces[0].fullscreen != 1 {
136+ t.Fatal("fullscreen not applied")
137+ }
138+}
139+
140+func TestMonitorActions(t *testing.T) {
141+ m := monitor()
142+ m.AddWindow(1)
143+ m.AddWindow(2)
144+ // Down past the only window of the column goes to the workspace below.
145+ m.Apply(ActionFocusWindowDown)
146+ if m.Active != 1 {
147+ t.Fatal(m.Active)
148+ }
149+ m.Apply(ActionFocusWindowUp)
150+ if m.Active != 0 {
151+ t.Fatal(m.Active)
152+ }
153+ // Up at the top workspace stays.
154+ m.Apply(ActionFocusWorkspaceUp)
155+ if m.Active != 0 {
156+ t.Fatal(m.Active)
157+ }
158+ // Move the focused window (2) down: focus stays on workspace 1.
159+ m.Apply(ActionMoveToWorkspaceDown)
160+ if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {2}, {}}) || m.Active != 0 {
161+ t.Fatal(windows(m), m.Active)
162+ }
163+ m.Apply("move-to-workspace 3")
164+ if !reflect.DeepEqual(windows(m), [][]WindowID{{}, {2}, {1}, {}}) || m.Active != 0 {
165+ t.Fatal(windows(m), m.Active)
166+ }
167+ m.Apply("focus-workspace 3")
168+ if id, _ := m.Focused(); id != 1 {
169+ t.Fatal(id)
170+ }
171+ // Workspace 1 is empty but first: it stays.
172+ if len(m.Workspaces) != 4 {
173+ t.Fatal(windows(m))
174+ }
175+ if e := m.Apply("spawn foot"); !e.Spawn {
176+ t.Fatal(e)
177+ }
178+}
179+
180+func TestEqualSharesAndBorderless(t *testing.T) {
181+ m := monitor()
182+ m.SetMaxColumns(3)
183+ m.AddWindow(1)
184+ p := m.Layout()
185+ if p[0].Rect.W != 100 || !p[0].Borderless {
186+ t.Fatal(p)
187+ }
188+ m.AddWindow(2)
189+ m.AddWindow(3)
190+ p = m.Layout()
191+ if p[0].Rect.W != 33 || p[1].Rect.W != 33 || p[2].Rect.W != 33 || p[0].Borderless {
192+ t.Fatal(p)
193+ }
194+ // A fourth column scrolls: still 1/3 each.
195+ m.AddWindow(4)
196+ if w := m.Current(); w.columnWidth(3) != 33 || w.ViewX == 0 {
197+ t.Fatal(w.ViewX)
198+ }
199+ // A preset on one column is kept; auto columns keep their share.
200+ m.SetPresets([]Width{{Num: 1, Den: 2}})
201+ m.Apply(ActionCycleColumnWidth)
202+ if w := m.Current(); w.columnWidth(w.Focus) != 50 || w.columnWidth(0) != 33 {
203+ t.Fatal(w.columnWidth(w.Focus), w.columnWidth(0))
204+ }
205+}
206+
207+func TestWorkspaceArg(t *testing.T) {
208+ for _, tc := range []struct {
209+ a Action
210+ n int
211+ move, ok bool
212+ }{{"focus-workspace 3", 3, false, true}, {"move-to-workspace 12", 12, true, true}, {"focus-workspace 0", 0, false, false}, {"focus-workspace x", 0, false, false}, {"quit", 0, false, false}} {
213+ n, move, ok := WorkspaceArg(tc.a)
214+ if n != tc.n || move != tc.move || ok != tc.ok {
215+ t.Errorf("%s: %d %v %v", tc.a, n, move, ok)
216+ }
217+ }
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+}
M internal/ports/config.go +4 -3
@@ -28,9 +28,10 @@ type Config struct {
2828 Inactive string
2929 }
3030 Layout struct {
31- Gaps int
32- DefaultColumnWidth string
33- Presets []string
31+ Gaps int
32+ // MaxColumns is how many columns share the screen before scrolling.
33+ MaxColumns int
34+ Presets []string
3435 }
3536 // Outputs selects and configures physical displays (drm backend).
3637 Outputs []OutputConfig
@@ -28,9 +28,10 @@ type Config struct {
28 Inactive string28 Inactive string
29 }29 }
30 Layout struct {30 Layout struct {
31- Gaps int31+ Gaps int
32- DefaultColumnWidth string32+ // MaxColumns is how many columns share the screen before scrolling.
33- Presets []string33+ MaxColumns int
34+ Presets []string
34 }35 }
35 // Outputs selects and configures physical displays (drm backend).36 // Outputs selects and configures physical displays (drm backend).
36 Outputs []OutputConfig37 Outputs []OutputConfig
M internal/ports/messages.go +11 -6
@@ -80,12 +80,15 @@ type InputEvent interface{ inputEvent() }
8080 // Keycode is the evdev code (xkb keycode - 8) and State the xkb modifier state
8181 // after this transition; wayland forwards both to clients unchanged.
8282 type KeyEvent struct {
83- Keysym string
84- Mods Mods
85- Pressed bool
86- TimeMsec uint32
87- Keycode uint32
88- State ModState
83+ Keysym string
84+ // Base and Shifted are the key's level 1 and 2 keysyms in the active layout,
85+ // so binds like Cmd+1 also match on layouts where 1 needs Shift (AZERTY).
86+ Base, Shifted string
87+ Mods Mods
88+ Pressed bool
89+ TimeMsec uint32
90+ Keycode uint32
91+ State ModState
8992 }
9093
9194 // ModState is the serialized xkb modifier state sent in wl_keyboard.modifiers.
@@ -235,4 +238,6 @@ type SceneWindow struct {
235238 ID WindowID
236239 Rect Rect
237240 Focused, Fullscreen, Hidden bool
241+ // Borderless windows fill the usable width alone; no border is drawn.
242+ Borderless bool
238243 }
@@ -80,12 +80,15 @@ type InputEvent interface{ inputEvent() }
80 // Keycode is the evdev code (xkb keycode - 8) and State the xkb modifier state80 // Keycode is the evdev code (xkb keycode - 8) and State the xkb modifier state
81 // after this transition; wayland forwards both to clients unchanged.81 // after this transition; wayland forwards both to clients unchanged.
82 type KeyEvent struct {82 type KeyEvent struct {
83- Keysym string83+ Keysym string
84- Mods Mods84+ // Base and Shifted are the key's level 1 and 2 keysyms in the active layout,
85- Pressed bool85+ // so binds like Cmd+1 also match on layouts where 1 needs Shift (AZERTY).
86- TimeMsec uint3286+ Base, Shifted string
87- Keycode uint3287+ Mods Mods
88- State ModState88+ Pressed bool
89+ TimeMsec uint32
90+ Keycode uint32
91+ State ModState
89 }92 }
90 93
91 // ModState is the serialized xkb modifier state sent in wl_keyboard.modifiers.94 // ModState is the serialized xkb modifier state sent in wl_keyboard.modifiers.
@@ -235,4 +238,6 @@ type SceneWindow struct {
235 ID WindowID238 ID WindowID
236 Rect Rect239 Rect Rect
237 Focused, Fullscreen, Hidden bool240 Focused, Fullscreen, Hidden bool
241+ // Borderless windows fill the usable width alone; no border is drawn.
242+ Borderless bool
238 }243 }

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