detached 267e7d1

feat(core): per-monitor workspaces with equal-share columns

Core owns a Monitor with an ordered workspace list (ADR 011): focus-workspace N and move-to-workspace N by position, an empty workspace always below the last, empty workspaces removed once left except the first, and cmd+up/down crossing workspaces at the column edge. Columns share the width equally up to layout.max-columns (replacing layout.default-width); a lone column fills the width without a border. Binds also match a key's shifted level so cmd+1..9 work on AZERTY.
commit
267e7d104700813f27e27489c7b5253218d27fd8
author
brice · · 2026-09-24 14:01 UTC
parent
45a1428
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
17 files · +718 -92
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 +20 -7
@@ -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,10 @@ func Defaults() ports.Config {
8286 }
8387 c.Binds[combo] = b.action
8488 }
89+ for n := 1; n <= 9; n++ {
90+ c.Binds[fmt.Sprintf("Cmd+%d", n)] = fmt.Sprintf("focus-workspace %d", n)
91+ c.Binds[fmt.Sprintf("Cmd+Shift+%d", n)] = fmt.Sprintf("move-to-workspace %d", n)
92+ }
8593 c.Render.DirectScanout = true
8694 c.Log.Level = "info"
8795 c.Log.Debug = []string{}
@@ -309,11 +317,8 @@ func set(c *ports.Config, key, v string) error {
309317 return fmt.Errorf("must be between 0 and 200")
310318 }
311319 c.Layout.Gaps = n
312- 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
320+ case "layout.max-columns":
321+ return positive(&c.Layout.MaxColumns, v, 16)
317322 case "layout.presets":
318323 list := splitList(v)
319324 if len(list) == 0 {
@@ -397,6 +402,14 @@ func checkAction(v string) error {
397402 }
398403 return nil
399404 }
405+ for _, prefix := range []string{"focus-workspace ", "move-to-workspace "} {
406+ if rest, ok := strings.CutPrefix(v, prefix); ok {
407+ if n, err := strconv.Atoi(strings.TrimSpace(rest)); err != nil || n < 1 || n > 99 {
408+ return fmt.Errorf("%snumber must be between 1 and 99", prefix)
409+ }
410+ return nil
411+ }
412+ }
400413 if !actions[v] {
401414 return fmt.Errorf(`unknown action %q (or "spawn <command>")`, v)
402415 }
@@ -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,10 @@ 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+ c.Binds[fmt.Sprintf("Cmd+%d", n)] = fmt.Sprintf("focus-workspace %d", n)
91+ c.Binds[fmt.Sprintf("Cmd+Shift+%d", n)] = fmt.Sprintf("move-to-workspace %d", n)
92+ }
85 c.Render.DirectScanout = true93 c.Render.DirectScanout = true
86 c.Log.Level = "info"94 c.Log.Level = "info"
87 c.Log.Debug = []string{}95 c.Log.Debug = []string{}
@@ -309,11 +317,8 @@ func set(c *ports.Config, key, v string) error {
309 return fmt.Errorf("must be between 0 and 200")317 return fmt.Errorf("must be between 0 and 200")
310 }318 }
311 c.Layout.Gaps = n319 c.Layout.Gaps = n
312- case "layout.default-width":320+ case "layout.max-columns":
313- if !width(v) {321+ return positive(&c.Layout.MaxColumns, v, 16)
314- return fmt.Errorf("must be a fraction like 1/2, 1, or pixels like 800px")
315- }
316- c.Layout.DefaultColumnWidth = v
317 case "layout.presets":322 case "layout.presets":
318 list := splitList(v)323 list := splitList(v)
319 if len(list) == 0 {324 if len(list) == 0 {
@@ -397,6 +402,14 @@ func checkAction(v string) error {
397 }402 }
398 return nil403 return nil
399 }404 }
405+ for _, prefix := range []string{"focus-workspace ", "move-to-workspace "} {
406+ if rest, ok := strings.CutPrefix(v, prefix); ok {
407+ if n, err := strconv.Atoi(strings.TrimSpace(rest)); err != nil || n < 1 || n > 99 {
408+ return fmt.Errorf("%snumber must be between 1 and 99", prefix)
409+ }
410+ return nil
411+ }
412+ }
400 if !actions[v] {413 if !actions[v] {
401 return fmt.Errorf(`unknown action %q (or "spawn <command>")`, v)414 return fmt.Errorf(`unknown action %q (or "spawn <command>")`, v)
402 }415 }
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 +28 -15
@@ -30,7 +30,7 @@ type binding struct {
3030 type Core struct {
3131 ch Channels
3232 cfg ports.Config
33- ws Workspace
33+ ws *Monitor
3434 binds map[binding]Action
3535 pressed map[string]bool
3636 sent map[WindowID]ports.ConfigureWindow
@@ -86,9 +86,8 @@ func parseBind(s, cmd string) (binding, error) {
8686 return b, nil
8787 }
8888 func (c *Core) apply(cfg ports.Config) error {
89- width, err := ParseWidth(cfg.Layout.DefaultColumnWidth)
90- if err != nil {
91- return err
89+ if cfg.Layout.MaxColumns < 1 {
90+ return fmt.Errorf("invalid max columns")
9291 }
9392 presets := make([]Width, 0, len(cfg.Layout.Presets))
9493 for _, s := range cfg.Layout.Presets {
@@ -117,8 +116,12 @@ func (c *Core) apply(cfg ports.Config) error {
117116 binds[b] = Action(a)
118117 continue
119118 }
119+ if _, _, ok := WorkspaceArg(Action(a)); ok {
120+ binds[b] = Action(a)
121+ continue
122+ }
120123 switch Action(a) {
121- case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit:
124+ case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveToWorkspaceUp, ActionMoveToWorkspaceDown:
122125 default:
123126 return fmt.Errorf("invalid action %q", a)
124127 }
@@ -126,7 +129,7 @@ func (c *Core) apply(cfg ports.Config) error {
126129 }
127130 c.cfg = cfg
128131 c.binds = binds
129- c.ws.SetDefaultWidth(width)
132+ c.ws.SetMaxColumns(cfg.Layout.MaxColumns)
130133 c.ws.SetPresets(presets)
131134 c.ws.SetGaps(cfg.Layout.Gaps)
132135 return nil
@@ -135,7 +138,7 @@ func New(cfg ports.Config, ch Channels) (*Core, error) {
135138 if cap(ch.Scenes) != 1 {
136139 return nil, fmt.Errorf("scenes must have capacity 1")
137140 }
138- c := &Core{ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}}
141+ c := &Core{ws: NewMonitor(), ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}}
139142 if err := c.apply(cfg); err != nil {
140143 return nil, err
141144 }
@@ -154,7 +157,7 @@ func (c *Core) command(ctx context.Context, v ports.ClientCommand) error {
154157 // The renderer applies the same inset (ports.Border).
155158 func (c *Core) clientRect(p Placement) Rect {
156159 r := p.Rect
157- if p.Fullscreen {
160+ if p.Fullscreen || p.Borderless {
158161 return r
159162 }
160163 b := min(max(c.cfg.Border.Width, 0), r.W/2, r.H/2)
@@ -180,12 +183,12 @@ func (c *Core) keyboardFocus() WindowID {
180183
181184 func (c *Core) publish(ctx context.Context) error {
182185 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...)}
186+ 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...)}
184187 alive := map[WindowID]bool{}
185188 focus := c.keyboardFocus()
186189 for _, p := range c.ws.Layout() {
187190 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})
191+ 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})
189192 if p.Hidden {
190193 if old, ok := c.sent[p.ID]; ok && old.Activated {
191194 old.Activated = false
@@ -246,7 +249,7 @@ func (c *Core) Run(ctx context.Context) error {
246249 c.layers = append([]ports.LayerSurface(nil), v.Layers...)
247250 c.layerChanged = true
248251 var usable ports.Rect
249- c.placed, usable = arrangeLayers(c.ws.Output.W, c.ws.Output.H, c.layers)
252+ c.placed, usable = arrangeLayers(c.ws.Output().W, c.ws.Output().H, c.layers)
250253 c.ws.SetUsable(usable)
251254 case ports.WindowMapped:
252255 c.ws.AddWindow(v.ID)
@@ -274,7 +277,7 @@ func (c *Core) Run(ctx context.Context) error {
274277 c.ws.SetOutput(v.Width, v.Height)
275278 if c.layerChanged {
276279 var usable ports.Rect
277- c.placed, usable = arrangeLayers(c.ws.Output.W, c.ws.Output.H, c.layers)
280+ c.placed, usable = arrangeLayers(c.ws.Output().W, c.ws.Output().H, c.layers)
278281 c.ws.SetUsable(usable)
279282 }
280283 case ports.OutputUsable:
@@ -308,8 +311,8 @@ func (c *Core) Run(ctx context.Context) error {
308311 }
309312 switch v := ev.(type) {
310313 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)))
314+ c.cursorX = min(max(v.X, 0), float64(max(c.ws.Output().W-1, 0)))
315+ c.cursorY = min(max(v.Y, 0), float64(max(c.ws.Output().H-1, 0)))
313316 var id WindowID
314317 var x, y float64
315318 // Fullscreen wins; otherwise the last visible placement is topmost.
@@ -370,6 +373,16 @@ func (c *Core) Run(ctx context.Context) error {
370373 continue
371374 }
372375 name := keyName(key.Keysym)
376+ action, bound := c.binds[binding{key: name, mods: key.Mods}]
377+ // Layouts like AZERTY put digits on the shifted level: Cmd+1 there
378+ // is Super+ampersand, so also try the key's other levels.
379+ for _, alt := range []string{key.Base, key.Shifted} {
380+ if !bound && alt != "" {
381+ if a, ok := c.binds[binding{key: keyName(alt), mods: key.Mods}]; ok {
382+ action, bound = a, true
383+ }
384+ }
385+ }
373386 if !key.Pressed {
374387 consumed := c.pressed[name]
375388 delete(c.pressed, name)
@@ -378,7 +391,7 @@ func (c *Core) Run(ctx context.Context) error {
378391 }
379392 }
380393 if key.Pressed {
381- if action, ok := c.binds[binding{key: name, mods: key.Mods}]; ok {
394+ if bound {
382395 c.pressed[name] = true
383396 effect := c.ws.Apply(action)
384397 if effect.Quit {
@@ -30,7 +30,7 @@ type binding struct {
30 type Core struct {30 type Core struct {
31 ch Channels31 ch Channels
32 cfg ports.Config32 cfg ports.Config
33- ws Workspace33+ ws *Monitor
34 binds map[binding]Action34 binds map[binding]Action
35 pressed map[string]bool35 pressed map[string]bool
36 sent map[WindowID]ports.ConfigureWindow36 sent map[WindowID]ports.ConfigureWindow
@@ -86,9 +86,8 @@ func parseBind(s, cmd string) (binding, error) {
86 return b, nil86 return b, nil
87 }87 }
88 func (c *Core) apply(cfg ports.Config) error {88 func (c *Core) apply(cfg ports.Config) error {
89- width, err := ParseWidth(cfg.Layout.DefaultColumnWidth)89+ if cfg.Layout.MaxColumns < 1 {
90- if err != nil {90+ return fmt.Errorf("invalid max columns")
91- return err
92 }91 }
93 presets := make([]Width, 0, len(cfg.Layout.Presets))92 presets := make([]Width, 0, len(cfg.Layout.Presets))
94 for _, s := range cfg.Layout.Presets {93 for _, s := range cfg.Layout.Presets {
@@ -117,8 +116,12 @@ func (c *Core) apply(cfg ports.Config) error {
117 binds[b] = Action(a)116 binds[b] = Action(a)
118 continue117 continue
119 }118 }
119+ if _, _, ok := WorkspaceArg(Action(a)); ok {
120+ binds[b] = Action(a)
121+ continue
122+ }
120 switch Action(a) {123 switch Action(a) {
121- case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit:124+ case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveToWorkspaceUp, ActionMoveToWorkspaceDown:
122 default:125 default:
123 return fmt.Errorf("invalid action %q", a)126 return fmt.Errorf("invalid action %q", a)
124 }127 }
@@ -126,7 +129,7 @@ func (c *Core) apply(cfg ports.Config) error {
126 }129 }
127 c.cfg = cfg130 c.cfg = cfg
128 c.binds = binds131 c.binds = binds
129- c.ws.SetDefaultWidth(width)132+ c.ws.SetMaxColumns(cfg.Layout.MaxColumns)
130 c.ws.SetPresets(presets)133 c.ws.SetPresets(presets)
131 c.ws.SetGaps(cfg.Layout.Gaps)134 c.ws.SetGaps(cfg.Layout.Gaps)
132 return nil135 return nil
@@ -135,7 +138,7 @@ func New(cfg ports.Config, ch Channels) (*Core, error) {
135 if cap(ch.Scenes) != 1 {138 if cap(ch.Scenes) != 1 {
136 return nil, fmt.Errorf("scenes must have capacity 1")139 return nil, fmt.Errorf("scenes must have capacity 1")
137 }140 }
138- c := &Core{ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}}141+ 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 {142 if err := c.apply(cfg); err != nil {
140 return nil, err143 return nil, err
141 }144 }
@@ -154,7 +157,7 @@ func (c *Core) command(ctx context.Context, v ports.ClientCommand) error {
154 // The renderer applies the same inset (ports.Border).157 // The renderer applies the same inset (ports.Border).
155 func (c *Core) clientRect(p Placement) Rect {158 func (c *Core) clientRect(p Placement) Rect {
156 r := p.Rect159 r := p.Rect
157- if p.Fullscreen {160+ if p.Fullscreen || p.Borderless {
158 return r161 return r
159 }162 }
160 b := min(max(c.cfg.Border.Width, 0), r.W/2, r.H/2)163 b := min(max(c.cfg.Border.Width, 0), r.W/2, r.H/2)
@@ -180,12 +183,12 @@ func (c *Core) keyboardFocus() WindowID {
180 183
181 func (c *Core) publish(ctx context.Context) error {184 func (c *Core) publish(ctx context.Context) error {
182 c.seq++185 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...)}186+ 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{}187 alive := map[WindowID]bool{}
185 focus := c.keyboardFocus()188 focus := c.keyboardFocus()
186 for _, p := range c.ws.Layout() {189 for _, p := range c.ws.Layout() {
187 alive[p.ID] = true190 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})191+ 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 {192 if p.Hidden {
190 if old, ok := c.sent[p.ID]; ok && old.Activated {193 if old, ok := c.sent[p.ID]; ok && old.Activated {
191 old.Activated = false194 old.Activated = false
@@ -246,7 +249,7 @@ func (c *Core) Run(ctx context.Context) error {
246 c.layers = append([]ports.LayerSurface(nil), v.Layers...)249 c.layers = append([]ports.LayerSurface(nil), v.Layers...)
247 c.layerChanged = true250 c.layerChanged = true
248 var usable ports.Rect251 var usable ports.Rect
249- c.placed, usable = arrangeLayers(c.ws.Output.W, c.ws.Output.H, c.layers)252+ c.placed, usable = arrangeLayers(c.ws.Output().W, c.ws.Output().H, c.layers)
250 c.ws.SetUsable(usable)253 c.ws.SetUsable(usable)
251 case ports.WindowMapped:254 case ports.WindowMapped:
252 c.ws.AddWindow(v.ID)255 c.ws.AddWindow(v.ID)
@@ -274,7 +277,7 @@ func (c *Core) Run(ctx context.Context) error {
274 c.ws.SetOutput(v.Width, v.Height)277 c.ws.SetOutput(v.Width, v.Height)
275 if c.layerChanged {278 if c.layerChanged {
276 var usable ports.Rect279 var usable ports.Rect
277- c.placed, usable = arrangeLayers(c.ws.Output.W, c.ws.Output.H, c.layers)280+ c.placed, usable = arrangeLayers(c.ws.Output().W, c.ws.Output().H, c.layers)
278 c.ws.SetUsable(usable)281 c.ws.SetUsable(usable)
279 }282 }
280 case ports.OutputUsable:283 case ports.OutputUsable:
@@ -308,8 +311,8 @@ func (c *Core) Run(ctx context.Context) error {
308 }311 }
309 switch v := ev.(type) {312 switch v := ev.(type) {
310 case ports.PointerMotion:313 case ports.PointerMotion:
311- c.cursorX = min(max(v.X, 0), float64(max(c.ws.Output.W-1, 0)))314+ 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)))315+ c.cursorY = min(max(v.Y, 0), float64(max(c.ws.Output().H-1, 0)))
313 var id WindowID316 var id WindowID
314 var x, y float64317 var x, y float64
315 // Fullscreen wins; otherwise the last visible placement is topmost.318 // Fullscreen wins; otherwise the last visible placement is topmost.
@@ -370,6 +373,16 @@ func (c *Core) Run(ctx context.Context) error {
370 continue373 continue
371 }374 }
372 name := keyName(key.Keysym)375 name := keyName(key.Keysym)
376+ action, bound := c.binds[binding{key: name, mods: key.Mods}]
377+ // Layouts like AZERTY put digits on the shifted level: Cmd+1 there
378+ // is Super+ampersand, so also try the key's other levels.
379+ for _, alt := range []string{key.Base, key.Shifted} {
380+ if !bound && alt != "" {
381+ if a, ok := c.binds[binding{key: keyName(alt), mods: key.Mods}]; ok {
382+ action, bound = a, true
383+ }
384+ }
385+ }
373 if !key.Pressed {386 if !key.Pressed {
374 consumed := c.pressed[name]387 consumed := c.pressed[name]
375 delete(c.pressed, name)388 delete(c.pressed, name)
@@ -378,7 +391,7 @@ func (c *Core) Run(ctx context.Context) error {
378 }391 }
379 }392 }
380 if key.Pressed {393 if key.Pressed {
381- if action, ok := c.binds[binding{key: name, mods: key.Mods}]; ok {394+ if bound {
382 c.pressed[name] = true395 c.pressed[name] = true
383 effect := c.ws.Apply(action)396 effect := c.ws.Apply(action)
384 if effect.Quit {397 if effect.Quit {
M internal/core/core_test.go +73 -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,51 @@ 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+}
@@ -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,51 @@ 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+}
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 +61 -29
@@ -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() {
@@ -273,9 +293,12 @@ func (w *Workspace) SetGaps(g int) {
273293 w.Gaps = g
274294 w.scroll()
275295 }
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) }
296+func (w *Workspace) SetPresets(v []Width) { w.presets = append([]Width(nil), v...) }
297+func (w *Workspace) SetMaxColumns(n int) {
298+ w.MaxColumns = n
299+ w.scroll()
300+}
301+func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) }
279302 func (w *Workspace) fullscreenColumn(i int) bool {
280303 for _, id := range w.Columns[i].Windows {
281304 if id == w.fullscreen && id != 0 {
@@ -288,7 +311,16 @@ func (w *Workspace) columnWidth(i int) int {
288311 if w.fullscreenColumn(i) {
289312 return w.Output.W
290313 }
291- return w.Columns[i].Width.Resolve(w.Usable.W, w.gap())
314+ g := w.gap()
315+ if len(w.Columns) == 1 {
316+ return max(w.Usable.W-2*g, 0)
317+ }
318+ if w.Columns[i].Width == (Width{}) {
319+ // Equal shares of the width left after gaps; a remainder under k pixels stays empty.
320+ k := min(len(w.Columns), max(w.MaxColumns, 1))
321+ return max((w.Usable.W-g*(k+1))/k, 0)
322+ }
323+ return w.Columns[i].Width.Resolve(w.Usable.W, g)
292324 }
293325 func (w *Workspace) columnX(i int) int {
294326 x := w.Usable.X + w.gap()
@@ -347,7 +379,7 @@ func (w *Workspace) Layout() []Placement {
347379 if hidden {
348380 r = Rect{}
349381 }
350- result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: i == w.Focus && j == c.Focus, Hidden: hidden})
382+ 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})
351383 y += h + gap
352384 }
353385 }
@@ -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() {
@@ -273,9 +293,12 @@ func (w *Workspace) SetGaps(g int) {
273 w.Gaps = g293 w.Gaps = g
274 w.scroll()294 w.scroll()
275 }295 }
276-func (w *Workspace) SetDefaultWidth(v Width) { w.DefaultWidth = v }296+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...) }297+func (w *Workspace) SetMaxColumns(n int) {
278-func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) }298+ w.MaxColumns = n
299+ w.scroll()
300+}
301+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 {302 func (w *Workspace) fullscreenColumn(i int) bool {
280 for _, id := range w.Columns[i].Windows {303 for _, id := range w.Columns[i].Windows {
281 if id == w.fullscreen && id != 0 {304 if id == w.fullscreen && id != 0 {
@@ -288,7 +311,16 @@ func (w *Workspace) columnWidth(i int) int {
288 if w.fullscreenColumn(i) {311 if w.fullscreenColumn(i) {
289 return w.Output.W312 return w.Output.W
290 }313 }
291- return w.Columns[i].Width.Resolve(w.Usable.W, w.gap())314+ g := w.gap()
315+ if len(w.Columns) == 1 {
316+ return max(w.Usable.W-2*g, 0)
317+ }
318+ if w.Columns[i].Width == (Width{}) {
319+ // Equal shares of the width left after gaps; a remainder under k pixels stays empty.
320+ k := min(len(w.Columns), max(w.MaxColumns, 1))
321+ return max((w.Usable.W-g*(k+1))/k, 0)
322+ }
323+ return w.Columns[i].Width.Resolve(w.Usable.W, g)
292 }324 }
293 func (w *Workspace) columnX(i int) int {325 func (w *Workspace) columnX(i int) int {
294 x := w.Usable.X + w.gap()326 x := w.Usable.X + w.gap()
@@ -347,7 +379,7 @@ func (w *Workspace) Layout() []Placement {
347 if hidden {379 if hidden {
348 r = Rect{}380 r = Rect{}
349 }381 }
350- result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: i == w.Focus && j == c.Focus, Hidden: hidden})382+ 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 + gap383 y += h + gap
352 }384 }
353 }385 }
M internal/core/layout_test.go +13 -10
@@ -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)
@@ -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,7 +338,7 @@ 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}}
339342 w.SetFullscreen(2, true)
340343 p := w.Layout()
341344 if !p[0].Hidden || !p[1].Fullscreen || !p[1].Focused {
@@ -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)
@@ -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,7 +338,7 @@ 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}}
339 w.SetFullscreen(2, true)342 w.SetFullscreen(2, true)
340 p := w.Layout()343 p := w.Layout()
341 if !p[0].Hidden || !p[1].Fullscreen || !p[1].Focused {344 if !p[0].Hidden || !p[1].Fullscreen || !p[1].Focused {
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 +218 -0
@@ -0,0 +1,218 @@
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+}
@@ -0,0 +1,218 @@
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+}
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