merge: workspaces (per-monitor workspaces, equal-share columns)
17 files · +862 -102
M examples/config +29 -1
| @@ -22,7 +22,10 @@ border.active = #b4befe | |||
| 22 | 22 | border.inactive = | |
| 23 | 23 | ||
| 24 | 24 | 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. | |
| 26 | 29 | layout.presets = 1/3, 1/2, 2/3, 1 | |
| 27 | 30 | ||
| 28 | 31 | # Displays (drm backend): WxH, WxH@Hz, preferred or off. | |
| @@ -52,3 +55,28 @@ bind.cmd+shift+f = toggle-fullscreen | |||
| 52 | 55 | bind.cmd+q = close-window | |
| 53 | 56 | bind.ctrl+alt+backspace = quit | |
| 54 | 57 | 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 = 0 | 24 | 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. | |||
| 26 | layout.presets = 1/3, 1/2, 2/3, 1 | 29 | 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-window | 55 | bind.cmd+q = close-window | ||
| 53 | bind.ctrl+alt+backspace = quit | 56 | bind.ctrl+alt+backspace = quit | ||
| 54 | bind.ctrl+cmd+space = spawn fuzzel | 57 | bind.ctrl+cmd+space = spawn fuzzel | ||
| 58 | + | ||||
| 59 | + | # Workspaces stack vertically; numbers follow their order. An empty one always | |||
| 60 | + | # waits below the last. cmd+up/down past the column edge also switch workspace. | |||
| 61 | + | bind.cmd+pageup = focus-workspace-up | |||
| 62 | + | bind.cmd+pagedown = focus-workspace-down | |||
| 63 | + | bind.cmd+shift+pageup = move-to-workspace-up | |||
| 64 | + | bind.cmd+shift+pagedown = move-to-workspace-down | |||
| 65 | + | bind.cmd+1 = focus-workspace 1 | |||
| 66 | + | bind.cmd+2 = focus-workspace 2 | |||
| 67 | + | bind.cmd+3 = focus-workspace 3 | |||
| 68 | + | bind.cmd+4 = focus-workspace 4 | |||
| 69 | + | bind.cmd+5 = focus-workspace 5 | |||
| 70 | + | bind.cmd+6 = focus-workspace 6 | |||
| 71 | + | bind.cmd+7 = focus-workspace 7 | |||
| 72 | + | bind.cmd+8 = focus-workspace 8 | |||
| 73 | + | bind.cmd+9 = focus-workspace 9 | |||
| 74 | + | bind.cmd+shift+1 = move-to-workspace 1 | |||
| 75 | + | bind.cmd+shift+2 = move-to-workspace 2 | |||
| 76 | + | bind.cmd+shift+3 = move-to-workspace 3 | |||
| 77 | + | bind.cmd+shift+4 = move-to-workspace 4 | |||
| 78 | + | bind.cmd+shift+5 = move-to-workspace 5 | |||
| 79 | + | bind.cmd+shift+6 = move-to-workspace 6 | |||
| 80 | + | bind.cmd+shift+7 = move-to-workspace 7 | |||
| 81 | + | bind.cmd+shift+8 = move-to-workspace 8 | |||
| 82 | + | bind.cmd+shift+9 = move-to-workspace 9 | |||
M internal/adapters/config/config.go +23 -6
| @@ -31,8 +31,12 @@ var defaultBinds = []struct{ combo, action string }{ | |||
| 31 | 31 | {"cmd+q", "close-window"}, | |
| 32 | 32 | {"ctrl+alt+backspace", "quit"}, | |
| 33 | 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 | 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 | 41 | var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) | |
| 38 | 42 | ||
| @@ -72,7 +76,7 @@ func Defaults() ports.Config { | |||
| 72 | 76 | c.Background.Color = "#1e1e2e" | |
| 73 | 77 | c.Border.Width = 2 | |
| 74 | 78 | c.Border.Active = "#b4befe" | |
| 75 | - | c.Layout.DefaultColumnWidth = "1/2" | |
| 79 | + | c.Layout.MaxColumns = 2 | |
| 76 | 80 | c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"} | |
| 77 | 81 | c.Binds = map[string]string{} | |
| 78 | 82 | for _, b := range defaultBinds { | |
| @@ -82,6 +86,12 @@ func Defaults() ports.Config { | |||
| 82 | 86 | } | |
| 83 | 87 | c.Binds[combo] = b.action | |
| 84 | 88 | } | |
| 89 | + | for n := 1; n <= 9; n++ { | |
| 90 | + | focus, _ := parseCombo(fmt.Sprintf("cmd+%d", n)) | |
| 91 | + | move, _ := parseCombo(fmt.Sprintf("cmd+shift+%d", n)) | |
| 92 | + | c.Binds[focus] = fmt.Sprintf("focus-workspace %d", n) | |
| 93 | + | c.Binds[move] = fmt.Sprintf("move-to-workspace %d", n) | |
| 94 | + | } | |
| 85 | 95 | c.Render.DirectScanout = true | |
| 86 | 96 | c.Log.Level = "info" | |
| 87 | 97 | c.Log.Debug = []string{} | |
| @@ -310,10 +320,9 @@ func set(c *ports.Config, key, v string) error { | |||
| 310 | 320 | } | |
| 311 | 321 | c.Layout.Gaps = n | |
| 312 | 322 | case "layout.default-width": | |
| 313 | - | if !width(v) { | |
| 314 | - | return fmt.Errorf("must be a fraction like 1/2, 1, or pixels like 800px") | |
| 315 | - | } | |
| 316 | - | c.Layout.DefaultColumnWidth = v | |
| 323 | + | return fmt.Errorf("replaced by layout.max-columns (columns share the width equally)") | |
| 324 | + | case "layout.max-columns": | |
| 325 | + | return positive(&c.Layout.MaxColumns, v, 16) | |
| 317 | 326 | case "layout.presets": | |
| 318 | 327 | list := splitList(v) | |
| 319 | 328 | if len(list) == 0 { | |
| @@ -397,6 +406,14 @@ func checkAction(v string) error { | |||
| 397 | 406 | } | |
| 398 | 407 | return nil | |
| 399 | 408 | } | |
| 409 | + | for _, prefix := range []string{"focus-workspace ", "move-to-workspace "} { | |
| 410 | + | if rest, ok := strings.CutPrefix(v, prefix); ok { | |
| 411 | + | if n, err := strconv.Atoi(strings.TrimSpace(rest)); err != nil || n < 1 || n > 99 { | |
| 412 | + | return fmt.Errorf("%snumber must be between 1 and 99", prefix) | |
| 413 | + | } | |
| 414 | + | return nil | |
| 415 | + | } | |
| 416 | + | } | |
| 400 | 417 | if !actions[v] { | |
| 401 | 418 | return fmt.Errorf(`unknown action %q (or "spawn <command>")`, v) | |
| 402 | 419 | } | |
| @@ -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 = 2 | 77 | c.Border.Width = 2 | ||
| 74 | c.Border.Active = "#b4befe" | 78 | c.Border.Active = "#b4befe" | ||
| 75 | - | c.Layout.DefaultColumnWidth = "1/2" | 79 | + | c.Layout.MaxColumns = 2 |
| 76 | c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"} | 80 | c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"} | ||
| 77 | c.Binds = map[string]string{} | 81 | c.Binds = map[string]string{} | ||
| 78 | for _, b := range defaultBinds { | 82 | for _, b := range defaultBinds { | ||
| @@ -82,6 +86,12 @@ func Defaults() ports.Config { | |||||
| 82 | } | 86 | } | ||
| 83 | c.Binds[combo] = b.action | 87 | c.Binds[combo] = b.action | ||
| 84 | } | 88 | } | ||
| 89 | + | for n := 1; n <= 9; n++ { | |||
| 90 | + | focus, _ := parseCombo(fmt.Sprintf("cmd+%d", n)) | |||
| 91 | + | move, _ := parseCombo(fmt.Sprintf("cmd+shift+%d", n)) | |||
| 92 | + | c.Binds[focus] = fmt.Sprintf("focus-workspace %d", n) | |||
| 93 | + | c.Binds[move] = fmt.Sprintf("move-to-workspace %d", n) | |||
| 94 | + | } | |||
| 85 | c.Render.DirectScanout = true | 95 | c.Render.DirectScanout = true | ||
| 86 | c.Log.Level = "info" | 96 | c.Log.Level = "info" | ||
| 87 | c.Log.Debug = []string{} | 97 | c.Log.Debug = []string{} | ||
| @@ -310,10 +320,9 @@ func set(c *ports.Config, key, v string) error { | |||||
| 310 | } | 320 | } | ||
| 311 | c.Layout.Gaps = n | 321 | c.Layout.Gaps = n | ||
| 312 | case "layout.default-width": | 322 | case "layout.default-width": | ||
| 313 | - | if !width(v) { | 323 | + | return fmt.Errorf("replaced by layout.max-columns (columns share the width equally)") |
| 314 | - | return fmt.Errorf("must be a fraction like 1/2, 1, or pixels like 800px") | 324 | + | case "layout.max-columns": |
| 315 | - | } | 325 | + | return positive(&c.Layout.MaxColumns, v, 16) |
| 316 | - | c.Layout.DefaultColumnWidth = v | |||
| 317 | case "layout.presets": | 326 | case "layout.presets": | ||
| 318 | list := splitList(v) | 327 | list := splitList(v) | ||
| 319 | if len(list) == 0 { | 328 | if len(list) == 0 { | ||
| @@ -397,6 +406,14 @@ func checkAction(v string) error { | |||||
| 397 | } | 406 | } | ||
| 398 | return nil | 407 | return nil | ||
| 399 | } | 408 | } | ||
| 409 | + | for _, prefix := range []string{"focus-workspace ", "move-to-workspace "} { | |||
| 410 | + | if rest, ok := strings.CutPrefix(v, prefix); ok { | |||
| 411 | + | if n, err := strconv.Atoi(strings.TrimSpace(rest)); err != nil || n < 1 || n > 99 { | |||
| 412 | + | return fmt.Errorf("%snumber must be between 1 and 99", prefix) | |||
| 413 | + | } | |||
| 414 | + | return nil | |||
| 415 | + | } | |||
| 416 | + | } | |||
| 400 | if !actions[v] { | 417 | if !actions[v] { | ||
| 401 | return fmt.Errorf(`unknown action %q (or "spawn <command>")`, v) | 418 | return fmt.Errorf(`unknown action %q (or "spawn <command>")`, v) | ||
| 402 | } | 419 | } | ||
M internal/adapters/config/config_test.go +4 -2
| @@ -21,7 +21,7 @@ func parseString(t *testing.T, s string) (ports.Config, []Warning) { | |||
| 21 | 21 | ||
| 22 | 22 | func TestDefaultsAndLoad(t *testing.T) { | |
| 23 | 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 | 25 | t.Fatalf("defaults: %+v", d) | |
| 26 | 26 | } | |
| 27 | 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 | 80 | {"terminal =", "terminal"}, | |
| 81 | 81 | {"background = red", "background"}, | |
| 82 | 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 | 85 | {"layout.presets = 0px", "layout.presets"}, | |
| 85 | 86 | {"log.level = trace", "log.level"}, | |
| 86 | 87 | {"log.debug = nope", "log.debug"}, | |
| @@ -124,6 +125,7 @@ bind.cmd+# = none | |||
| 124 | 125 | bind.cmd++ = quit | |
| 125 | 126 | bind.alt+f5 = quit | |
| 126 | 127 | bind.cmd+return = none | |
| 128 | + | bind.cmd+w = focus-workspace 12 | |
| 127 | 129 | `) | |
| 128 | 130 | want := map[string]string{ | |
| 129 | 131 | "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++ = quit | 125 | bind.cmd++ = quit | ||
| 125 | bind.alt+f5 = quit | 126 | bind.alt+f5 = quit | ||
| 126 | bind.cmd+return = none | 127 | 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 | |||
| 368 | 368 | x, y := w.Rect.X, w.Rect.Y | |
| 369 | 369 | // Content sits inside the border; core sized the client to match. | |
| 370 | 370 | b := 0 | |
| 371 | - | if !w.Fullscreen { | |
| 371 | + | if !w.Fullscreen && !w.Borderless { | |
| 372 | 372 | b = min(max(s.Border.Width, 0), w.Rect.W/2, w.Rect.H/2) | |
| 373 | 373 | } | |
| 374 | 374 | 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.Y | 368 | 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 := 0 | 370 | 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*b | 374 | 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 | |||
| 139 | 139 | } | |
| 140 | 140 | k.updateKey(k.state, code, direction) | |
| 141 | 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 | 146 | event.State = ports.ModState{ | |
| 143 | 147 | Depressed: k.serializeMods(k.state, modsDepressed), Latched: k.serializeMods(k.state, modsLatched), | |
| 144 | 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 | 160 | 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 | 177 | func (k *Keymap) KeycodeFor(keysymName string) (evdev uint32, shift bool, ok bool) { | |
| 160 | 178 | b := append([]byte(keysymName), 0) | |
| 161 | 179 | 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 event | 160 | 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) { | |||
| 52 | 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 | + | } | |
| @@ -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) { | |||
| 142 | 142 | select { | |
| 143 | 143 | case s := <-scenes: | |
| 144 | 144 | 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) | |
| 148 | 148 | } | |
| 149 | 149 | return | |
| 150 | 150 | } | |
| @@ -187,11 +187,11 @@ func TestHeadlessTyping(t *testing.T) { | |||
| 187 | 187 | if err != nil { | |
| 188 | 188 | t.Fatal(err) | |
| 189 | 189 | } | |
| 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) | |
| 192 | 192 | 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 { | |
| 195 | 195 | if img.At(x, y) != first { | |
| 196 | 196 | varied = true | |
| 197 | 197 | 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 | return | 149 | 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 := false | 192 | 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 = true | 196 | varied = true | ||
| 197 | break | 197 | break | ||
M internal/core/action.go +69 -1
| @@ -1,6 +1,9 @@ | |||
| 1 | 1 | package core | |
| 2 | 2 | ||
| 3 | - | import "strings" | |
| 3 | + | import ( | |
| 4 | + | "strconv" | |
| 5 | + | "strings" | |
| 6 | + | ) | |
| 4 | 7 | ||
| 5 | 8 | type Action string | |
| 6 | 9 | ||
| @@ -16,8 +19,33 @@ const ( | |||
| 16 | 19 | ActionToggleFullscreen Action = "toggle-fullscreen" | |
| 17 | 20 | ActionCloseWindow Action = "close-window" | |
| 18 | 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 | 49 | // spawnPrefix starts a bind action that runs a command: "spawn fuzzel --flag". | |
| 22 | 50 | // Arguments are split on whitespace; there is no shell (use "spawn sh -c ..."). | |
| 23 | 51 | const spawnPrefix = "spawn " | |
| @@ -36,6 +64,46 @@ type Effect struct { | |||
| 36 | 64 | 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 | 107 | func (w *Workspace) Apply(a Action) Effect { | |
| 40 | 108 | if argv, ok := SpawnArgv(a); ok { | |
| 41 | 109 | return Effect{Spawn: true, Argv: argv} | |
| @@ -1,6 +1,9 @@ | |||||
| 1 | package core | 1 | package core | ||
| 2 | 2 | ||||
| 3 | - | import "strings" | 3 | + | import ( |
| 4 | + | "strconv" | |||
| 5 | + | "strings" | |||
| 6 | + | ) | |||
| 4 | 7 | ||||
| 5 | type Action string | 8 | 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 bool | 64 | Quit bool | ||
| 37 | } | 65 | } | ||
| 38 | 66 | ||||
| 67 | + | // Apply runs a bind action on the monitor. | |||
| 68 | + | func (m *Monitor) Apply(a Action) Effect { | |||
| 69 | + | if n, move, ok := WorkspaceArg(a); ok { | |||
| 70 | + | if move { | |||
| 71 | + | m.MoveToWorkspace(n - 1) | |||
| 72 | + | } else { | |||
| 73 | + | m.FocusNumber(n) | |||
| 74 | + | } | |||
| 75 | + | return Effect{} | |||
| 76 | + | } | |||
| 77 | + | switch a { | |||
| 78 | + | case ActionFocusWindowUp, ActionFocusWindowDown: | |||
| 79 | + | // Past the top or bottom window of the column, move to the next workspace. | |||
| 80 | + | dir := 1 | |||
| 81 | + | if a == ActionFocusWindowUp { | |||
| 82 | + | dir = -1 | |||
| 83 | + | } | |||
| 84 | + | if !m.Current().FocusWindow(dir) { | |||
| 85 | + | m.Focus(m.Active + dir) | |||
| 86 | + | } | |||
| 87 | + | return Effect{} | |||
| 88 | + | case ActionFocusWorkspaceUp: | |||
| 89 | + | m.Focus(m.Active - 1) | |||
| 90 | + | return Effect{} | |||
| 91 | + | case ActionFocusWorkspaceDown: | |||
| 92 | + | m.Focus(m.Active + 1) | |||
| 93 | + | return Effect{} | |||
| 94 | + | case ActionMoveToWorkspaceUp: | |||
| 95 | + | if m.Active > 0 { | |||
| 96 | + | m.MoveToWorkspace(m.Active - 1) | |||
| 97 | + | } | |||
| 98 | + | return Effect{} | |||
| 99 | + | case ActionMoveToWorkspaceDown: | |||
| 100 | + | m.MoveToWorkspace(m.Active + 1) | |||
| 101 | + | return Effect{} | |||
| 102 | + | } | |||
| 103 | + | return m.Current().Apply(a) | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | // Apply runs a bind action that only touches this workspace. | |||
| 39 | func (w *Workspace) Apply(a Action) Effect { | 107 | func (w *Workspace) Apply(a Action) Effect { | ||
| 40 | if argv, ok := SpawnArgv(a); ok { | 108 | if argv, ok := SpawnArgv(a); ok { | ||
| 41 | return Effect{Spawn: true, Argv: argv} | 109 | return Effect{Spawn: true, Argv: argv} | ||
M internal/core/core.go +59 -20
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "github.com/bnema/nefertty/internal/ports" | |
| 8 | + | "strconv" | |
| 8 | 9 | "strings" | |
| 9 | 10 | "unicode" | |
| 10 | 11 | ) | |
| @@ -30,7 +31,7 @@ type binding struct { | |||
| 30 | 31 | type Core struct { | |
| 31 | 32 | ch Channels | |
| 32 | 33 | cfg ports.Config | |
| 33 | - | ws Workspace | |
| 34 | + | ws *Monitor | |
| 34 | 35 | binds map[binding]Action | |
| 35 | 36 | pressed map[string]bool | |
| 36 | 37 | sent map[WindowID]ports.ConfigureWindow | |
| @@ -86,9 +87,8 @@ func parseBind(s, cmd string) (binding, error) { | |||
| 86 | 87 | return b, nil | |
| 87 | 88 | } | |
| 88 | 89 | func (c *Core) apply(cfg ports.Config) error { | |
| 89 | - | width, err := ParseWidth(cfg.Layout.DefaultColumnWidth) | |
| 90 | - | if err != nil { | |
| 91 | - | return err | |
| 90 | + | if cfg.Layout.MaxColumns < 1 { | |
| 91 | + | return fmt.Errorf("invalid max columns") | |
| 92 | 92 | } | |
| 93 | 93 | presets := make([]Width, 0, len(cfg.Layout.Presets)) | |
| 94 | 94 | for _, s := range cfg.Layout.Presets { | |
| @@ -117,8 +117,12 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 117 | 117 | binds[b] = Action(a) | |
| 118 | 118 | continue | |
| 119 | 119 | } | |
| 120 | + | if _, _, ok := WorkspaceArg(Action(a)); ok { | |
| 121 | + | binds[b] = Action(a) | |
| 122 | + | continue | |
| 123 | + | } | |
| 120 | 124 | switch Action(a) { | |
| 121 | - | case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit: | |
| 125 | + | case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveToWorkspaceUp, ActionMoveToWorkspaceDown: | |
| 122 | 126 | default: | |
| 123 | 127 | return fmt.Errorf("invalid action %q", a) | |
| 124 | 128 | } | |
| @@ -126,7 +130,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 126 | 130 | } | |
| 127 | 131 | c.cfg = cfg | |
| 128 | 132 | c.binds = binds | |
| 129 | - | c.ws.SetDefaultWidth(width) | |
| 133 | + | c.ws.SetMaxColumns(cfg.Layout.MaxColumns) | |
| 130 | 134 | c.ws.SetPresets(presets) | |
| 131 | 135 | c.ws.SetGaps(cfg.Layout.Gaps) | |
| 132 | 136 | return nil | |
| @@ -135,7 +139,7 @@ func New(cfg ports.Config, ch Channels) (*Core, error) { | |||
| 135 | 139 | if cap(ch.Scenes) != 1 { | |
| 136 | 140 | return nil, fmt.Errorf("scenes must have capacity 1") | |
| 137 | 141 | } | |
| 138 | - | c := &Core{ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}} | |
| 142 | + | c := &Core{ws: NewMonitor(), ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}} | |
| 139 | 143 | if err := c.apply(cfg); err != nil { | |
| 140 | 144 | return nil, err | |
| 141 | 145 | } | |
| @@ -154,13 +158,23 @@ func (c *Core) command(ctx context.Context, v ports.ClientCommand) error { | |||
| 154 | 158 | // The renderer applies the same inset (ports.Border). | |
| 155 | 159 | func (c *Core) clientRect(p Placement) Rect { | |
| 156 | 160 | r := p.Rect | |
| 157 | - | if p.Fullscreen { | |
| 161 | + | if p.Fullscreen || p.Borderless { | |
| 158 | 162 | return r | |
| 159 | 163 | } | |
| 160 | 164 | b := min(max(c.cfg.Border.Width, 0), r.W/2, r.H/2) | |
| 161 | 165 | return Rect{X: r.X + b, Y: r.Y + b, W: r.W - 2*b, H: r.H - 2*b} | |
| 162 | 166 | } | |
| 163 | 167 | ||
| 168 | + | // visible reports whether the window is on screen on the active workspace. | |
| 169 | + | func (c *Core) visible(id WindowID) bool { | |
| 170 | + | for _, p := range c.ws.Layout() { | |
| 171 | + | if p.ID == id { | |
| 172 | + | return !p.Hidden | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | return false | |
| 176 | + | } | |
| 177 | + | ||
| 164 | 178 | // keyboardFocus is the mapped top/overlay layer with exclusive keyboard | |
| 165 | 179 | // interactivity and the highest ID, else the focused window. On-demand layers | |
| 166 | 180 | // never take focus automatically. When the layer unmaps, focus returns to the window. | |
| @@ -180,12 +194,12 @@ func (c *Core) keyboardFocus() WindowID { | |||
| 180 | 194 | ||
| 181 | 195 | func (c *Core) publish(ctx context.Context) error { | |
| 182 | 196 | c.seq++ | |
| 183 | - | scene := ports.Scene{Seq: c.seq, OutputWidth: c.ws.Output.W, OutputHeight: c.ws.Output.H, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: append([]ports.SceneLayer(nil), c.placed...)} | |
| 197 | + | scene := ports.Scene{Seq: c.seq, OutputWidth: c.ws.Output().W, OutputHeight: c.ws.Output().H, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: append([]ports.SceneLayer(nil), c.placed...)} | |
| 184 | 198 | alive := map[WindowID]bool{} | |
| 185 | 199 | focus := c.keyboardFocus() | |
| 186 | 200 | for _, p := range c.ws.Layout() { | |
| 187 | 201 | alive[p.ID] = true | |
| 188 | - | scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: p.Focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden}) | |
| 202 | + | scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: p.Focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Borderless: p.Borderless}) | |
| 189 | 203 | if p.Hidden { | |
| 190 | 204 | if old, ok := c.sent[p.ID]; ok && old.Activated { | |
| 191 | 205 | old.Activated = false | |
| @@ -210,6 +224,16 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 210 | 224 | delete(c.sent, id) | |
| 211 | 225 | } | |
| 212 | 226 | } | |
| 227 | + | // A workspace switch can hide the window under the pointer; it must not get | |
| 228 | + | // clicks. The next motion re-runs hit-testing. | |
| 229 | + | if c.pointer != 0 && !c.visible(c.pointer) { | |
| 230 | + | c.pointer = 0 | |
| 231 | + | if c.grab == 0 { | |
| 232 | + | if err := c.command(ctx, ports.PointerFocus{}); err != nil { | |
| 233 | + | return err | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | } | |
| 213 | 237 | if focus != c.focus { | |
| 214 | 238 | if err := c.command(ctx, ports.FocusWindow{ID: focus}); err != nil { | |
| 215 | 239 | return err | |
| @@ -246,7 +270,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 246 | 270 | c.layers = append([]ports.LayerSurface(nil), v.Layers...) | |
| 247 | 271 | c.layerChanged = true | |
| 248 | 272 | var usable ports.Rect | |
| 249 | - | c.placed, usable = arrangeLayers(c.ws.Output.W, c.ws.Output.H, c.layers) | |
| 273 | + | c.placed, usable = arrangeLayers(c.ws.Output().W, c.ws.Output().H, c.layers) | |
| 250 | 274 | c.ws.SetUsable(usable) | |
| 251 | 275 | case ports.WindowMapped: | |
| 252 | 276 | c.ws.AddWindow(v.ID) | |
| @@ -274,7 +298,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 274 | 298 | c.ws.SetOutput(v.Width, v.Height) | |
| 275 | 299 | if c.layerChanged { | |
| 276 | 300 | var usable ports.Rect | |
| 277 | - | c.placed, usable = arrangeLayers(c.ws.Output.W, c.ws.Output.H, c.layers) | |
| 301 | + | c.placed, usable = arrangeLayers(c.ws.Output().W, c.ws.Output().H, c.layers) | |
| 278 | 302 | c.ws.SetUsable(usable) | |
| 279 | 303 | } | |
| 280 | 304 | case ports.OutputUsable: | |
| @@ -308,8 +332,8 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 308 | 332 | } | |
| 309 | 333 | switch v := ev.(type) { | |
| 310 | 334 | case ports.PointerMotion: | |
| 311 | - | c.cursorX = min(max(v.X, 0), float64(max(c.ws.Output.W-1, 0))) | |
| 312 | - | c.cursorY = min(max(v.Y, 0), float64(max(c.ws.Output.H-1, 0))) | |
| 335 | + | c.cursorX = min(max(v.X, 0), float64(max(c.ws.Output().W-1, 0))) | |
| 336 | + | c.cursorY = min(max(v.Y, 0), float64(max(c.ws.Output().H-1, 0))) | |
| 313 | 337 | var id WindowID | |
| 314 | 338 | var x, y float64 | |
| 315 | 339 | // Fullscreen wins; otherwise the last visible placement is topmost. | |
| @@ -354,7 +378,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 354 | 378 | if err := c.command(ctx, ports.PointerButtonTo{ID: id, Button: v.Button, Pressed: v.Pressed, TimeMsec: v.TimeMsec}); err != nil { | |
| 355 | 379 | return nil | |
| 356 | 380 | } | |
| 357 | - | if v.Pressed && c.focus != id && c.ws.FocusID(id) { | |
| 381 | + | if v.Pressed && c.focus != id && c.ws.Current().FocusID(id) { | |
| 358 | 382 | if err := c.publish(ctx); err != nil { | |
| 359 | 383 | return nil | |
| 360 | 384 | } | |
| @@ -370,16 +394,31 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 370 | 394 | continue | |
| 371 | 395 | } | |
| 372 | 396 | name := keyName(key.Keysym) | |
| 397 | + | action, bound := c.binds[binding{key: name, mods: key.Mods}] | |
| 398 | + | // Cmd+Shift+1 on US prints exclam: match the unshifted keysym. | |
| 399 | + | if !bound && key.Base != "" { | |
| 400 | + | action, bound = c.binds[binding{key: keyName(key.Base), mods: key.Mods}] | |
| 401 | + | } | |
| 402 | + | // AZERTY puts digits on the shifted level: Cmd+1 is Super+ampersand. | |
| 403 | + | // Only digits fall back this way, so cmd+plus stays off Cmd+=. | |
| 404 | + | if !bound && len(key.Shifted) == 1 && key.Shifted[0] >= '0' && key.Shifted[0] <= '9' { | |
| 405 | + | action, bound = c.binds[binding{key: key.Shifted, mods: key.Mods}] | |
| 406 | + | } | |
| 407 | + | // Track presses by physical key: Shift may be released before the key. | |
| 408 | + | held := name | |
| 409 | + | if key.Keycode != 0 { | |
| 410 | + | held = "#" + strconv.FormatUint(uint64(key.Keycode), 10) | |
| 411 | + | } | |
| 373 | 412 | if !key.Pressed { | |
| 374 | - | consumed := c.pressed[name] | |
| 375 | - | delete(c.pressed, name) | |
| 413 | + | consumed := c.pressed[held] | |
| 414 | + | delete(c.pressed, held) | |
| 376 | 415 | if consumed { | |
| 377 | 416 | continue | |
| 378 | 417 | } | |
| 379 | 418 | } | |
| 380 | 419 | if key.Pressed { | |
| 381 | - | if action, ok := c.binds[binding{key: name, mods: key.Mods}]; ok { | |
| 382 | - | c.pressed[name] = true | |
| 420 | + | if bound { | |
| 421 | + | c.pressed[held] = true | |
| 383 | 422 | effect := c.ws.Apply(action) | |
| 384 | 423 | if effect.Quit { | |
| 385 | 424 | return ErrQuit | |
| @@ -405,7 +444,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 405 | 444 | } | |
| 406 | 445 | continue | |
| 407 | 446 | } | |
| 408 | - | c.pressed[name] = false | |
| 447 | + | c.pressed[held] = false | |
| 409 | 448 | } | |
| 410 | 449 | if id := c.keyboardFocus(); id != 0 { | |
| 411 | 450 | if err := c.command(ctx, ports.ForwardKey{ID: id, Key: key}); err != nil { | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "github.com/bnema/nefertty/internal/ports" | 7 | "github.com/bnema/nefertty/internal/ports" | ||
| 8 | + | "strconv" | |||
| 8 | "strings" | 9 | "strings" | ||
| 9 | "unicode" | 10 | "unicode" | ||
| 10 | ) | 11 | ) | ||
| @@ -30,7 +31,7 @@ type binding struct { | |||||
| 30 | type Core struct { | 31 | type Core struct { | ||
| 31 | ch Channels | 32 | ch Channels | ||
| 32 | cfg ports.Config | 33 | cfg ports.Config | ||
| 33 | - | ws Workspace | 34 | + | ws *Monitor |
| 34 | binds map[binding]Action | 35 | binds map[binding]Action | ||
| 35 | pressed map[string]bool | 36 | pressed map[string]bool | ||
| 36 | sent map[WindowID]ports.ConfigureWindow | 37 | sent map[WindowID]ports.ConfigureWindow | ||
| @@ -86,9 +87,8 @@ func parseBind(s, cmd string) (binding, error) { | |||||
| 86 | return b, nil | 87 | return b, nil | ||
| 87 | } | 88 | } | ||
| 88 | func (c *Core) apply(cfg ports.Config) error { | 89 | func (c *Core) apply(cfg ports.Config) error { | ||
| 89 | - | width, err := ParseWidth(cfg.Layout.DefaultColumnWidth) | 90 | + | if cfg.Layout.MaxColumns < 1 { |
| 90 | - | if err != nil { | 91 | + | return fmt.Errorf("invalid max columns") |
| 91 | - | return err | |||
| 92 | } | 92 | } | ||
| 93 | presets := make([]Width, 0, len(cfg.Layout.Presets)) | 93 | presets := make([]Width, 0, len(cfg.Layout.Presets)) | ||
| 94 | for _, s := range cfg.Layout.Presets { | 94 | for _, s := range cfg.Layout.Presets { | ||
| @@ -117,8 +117,12 @@ func (c *Core) apply(cfg ports.Config) error { | |||||
| 117 | binds[b] = Action(a) | 117 | binds[b] = Action(a) | ||
| 118 | continue | 118 | continue | ||
| 119 | } | 119 | } | ||
| 120 | + | if _, _, ok := WorkspaceArg(Action(a)); ok { | |||
| 121 | + | binds[b] = Action(a) | |||
| 122 | + | continue | |||
| 123 | + | } | |||
| 120 | switch Action(a) { | 124 | switch Action(a) { | ||
| 121 | - | case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit: | 125 | + | case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveToWorkspaceUp, ActionMoveToWorkspaceDown: |
| 122 | default: | 126 | default: | ||
| 123 | return fmt.Errorf("invalid action %q", a) | 127 | return fmt.Errorf("invalid action %q", a) | ||
| 124 | } | 128 | } | ||
| @@ -126,7 +130,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||||
| 126 | } | 130 | } | ||
| 127 | c.cfg = cfg | 131 | c.cfg = cfg | ||
| 128 | c.binds = binds | 132 | c.binds = binds | ||
| 129 | - | c.ws.SetDefaultWidth(width) | 133 | + | c.ws.SetMaxColumns(cfg.Layout.MaxColumns) |
| 130 | c.ws.SetPresets(presets) | 134 | c.ws.SetPresets(presets) | ||
| 131 | c.ws.SetGaps(cfg.Layout.Gaps) | 135 | c.ws.SetGaps(cfg.Layout.Gaps) | ||
| 132 | return nil | 136 | return nil | ||
| @@ -135,7 +139,7 @@ func New(cfg ports.Config, ch Channels) (*Core, error) { | |||||
| 135 | if cap(ch.Scenes) != 1 { | 139 | if cap(ch.Scenes) != 1 { | ||
| 136 | return nil, fmt.Errorf("scenes must have capacity 1") | 140 | return nil, fmt.Errorf("scenes must have capacity 1") | ||
| 137 | } | 141 | } | ||
| 138 | - | c := &Core{ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}} | 142 | + | c := &Core{ws: NewMonitor(), ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}} |
| 139 | if err := c.apply(cfg); err != nil { | 143 | if err := c.apply(cfg); err != nil { | ||
| 140 | return nil, err | 144 | return nil, err | ||
| 141 | } | 145 | } | ||
| @@ -154,13 +158,23 @@ func (c *Core) command(ctx context.Context, v ports.ClientCommand) error { | |||||
| 154 | // The renderer applies the same inset (ports.Border). | 158 | // The renderer applies the same inset (ports.Border). | ||
| 155 | func (c *Core) clientRect(p Placement) Rect { | 159 | func (c *Core) clientRect(p Placement) Rect { | ||
| 156 | r := p.Rect | 160 | r := p.Rect | ||
| 157 | - | if p.Fullscreen { | 161 | + | if p.Fullscreen || p.Borderless { |
| 158 | return r | 162 | return r | ||
| 159 | } | 163 | } | ||
| 160 | b := min(max(c.cfg.Border.Width, 0), r.W/2, r.H/2) | 164 | b := min(max(c.cfg.Border.Width, 0), r.W/2, r.H/2) | ||
| 161 | return Rect{X: r.X + b, Y: r.Y + b, W: r.W - 2*b, H: r.H - 2*b} | 165 | return Rect{X: r.X + b, Y: r.Y + b, W: r.W - 2*b, H: r.H - 2*b} | ||
| 162 | } | 166 | } | ||
| 163 | 167 | ||||
| 168 | + | // visible reports whether the window is on screen on the active workspace. | |||
| 169 | + | func (c *Core) visible(id WindowID) bool { | |||
| 170 | + | for _, p := range c.ws.Layout() { | |||
| 171 | + | if p.ID == id { | |||
| 172 | + | return !p.Hidden | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | return false | |||
| 176 | + | } | |||
| 177 | + | ||||
| 164 | // keyboardFocus is the mapped top/overlay layer with exclusive keyboard | 178 | // keyboardFocus is the mapped top/overlay layer with exclusive keyboard | ||
| 165 | // interactivity and the highest ID, else the focused window. On-demand layers | 179 | // interactivity and the highest ID, else the focused window. On-demand layers | ||
| 166 | // never take focus automatically. When the layer unmaps, focus returns to the window. | 180 | // never take focus automatically. When the layer unmaps, focus returns to the window. | ||
| @@ -180,12 +194,12 @@ func (c *Core) keyboardFocus() WindowID { | |||||
| 180 | 194 | ||||
| 181 | func (c *Core) publish(ctx context.Context) error { | 195 | func (c *Core) publish(ctx context.Context) error { | ||
| 182 | c.seq++ | 196 | c.seq++ | ||
| 183 | - | scene := ports.Scene{Seq: c.seq, OutputWidth: c.ws.Output.W, OutputHeight: c.ws.Output.H, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: append([]ports.SceneLayer(nil), c.placed...)} | 197 | + | scene := ports.Scene{Seq: c.seq, OutputWidth: c.ws.Output().W, OutputHeight: c.ws.Output().H, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: append([]ports.SceneLayer(nil), c.placed...)} |
| 184 | alive := map[WindowID]bool{} | 198 | alive := map[WindowID]bool{} | ||
| 185 | focus := c.keyboardFocus() | 199 | focus := c.keyboardFocus() | ||
| 186 | for _, p := range c.ws.Layout() { | 200 | for _, p := range c.ws.Layout() { | ||
| 187 | alive[p.ID] = true | 201 | alive[p.ID] = true | ||
| 188 | - | scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: p.Focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden}) | 202 | + | scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: p.Focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Borderless: p.Borderless}) |
| 189 | if p.Hidden { | 203 | if p.Hidden { | ||
| 190 | if old, ok := c.sent[p.ID]; ok && old.Activated { | 204 | if old, ok := c.sent[p.ID]; ok && old.Activated { | ||
| 191 | old.Activated = false | 205 | old.Activated = false | ||
| @@ -210,6 +224,16 @@ func (c *Core) publish(ctx context.Context) error { | |||||
| 210 | delete(c.sent, id) | 224 | delete(c.sent, id) | ||
| 211 | } | 225 | } | ||
| 212 | } | 226 | } | ||
| 227 | + | // A workspace switch can hide the window under the pointer; it must not get | |||
| 228 | + | // clicks. The next motion re-runs hit-testing. | |||
| 229 | + | if c.pointer != 0 && !c.visible(c.pointer) { | |||
| 230 | + | c.pointer = 0 | |||
| 231 | + | if c.grab == 0 { | |||
| 232 | + | if err := c.command(ctx, ports.PointerFocus{}); err != nil { | |||
| 233 | + | return err | |||
| 234 | + | } | |||
| 235 | + | } | |||
| 236 | + | } | |||
| 213 | if focus != c.focus { | 237 | if focus != c.focus { | ||
| 214 | if err := c.command(ctx, ports.FocusWindow{ID: focus}); err != nil { | 238 | if err := c.command(ctx, ports.FocusWindow{ID: focus}); err != nil { | ||
| 215 | return err | 239 | return err | ||
| @@ -246,7 +270,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 246 | c.layers = append([]ports.LayerSurface(nil), v.Layers...) | 270 | c.layers = append([]ports.LayerSurface(nil), v.Layers...) | ||
| 247 | c.layerChanged = true | 271 | c.layerChanged = true | ||
| 248 | var usable ports.Rect | 272 | var usable ports.Rect | ||
| 249 | - | c.placed, usable = arrangeLayers(c.ws.Output.W, c.ws.Output.H, c.layers) | 273 | + | c.placed, usable = arrangeLayers(c.ws.Output().W, c.ws.Output().H, c.layers) |
| 250 | c.ws.SetUsable(usable) | 274 | c.ws.SetUsable(usable) | ||
| 251 | case ports.WindowMapped: | 275 | case ports.WindowMapped: | ||
| 252 | c.ws.AddWindow(v.ID) | 276 | c.ws.AddWindow(v.ID) | ||
| @@ -274,7 +298,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 274 | c.ws.SetOutput(v.Width, v.Height) | 298 | c.ws.SetOutput(v.Width, v.Height) | ||
| 275 | if c.layerChanged { | 299 | if c.layerChanged { | ||
| 276 | var usable ports.Rect | 300 | var usable ports.Rect | ||
| 277 | - | c.placed, usable = arrangeLayers(c.ws.Output.W, c.ws.Output.H, c.layers) | 301 | + | c.placed, usable = arrangeLayers(c.ws.Output().W, c.ws.Output().H, c.layers) |
| 278 | c.ws.SetUsable(usable) | 302 | c.ws.SetUsable(usable) | ||
| 279 | } | 303 | } | ||
| 280 | case ports.OutputUsable: | 304 | case ports.OutputUsable: | ||
| @@ -308,8 +332,8 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 308 | } | 332 | } | ||
| 309 | switch v := ev.(type) { | 333 | switch v := ev.(type) { | ||
| 310 | case ports.PointerMotion: | 334 | case ports.PointerMotion: | ||
| 311 | - | c.cursorX = min(max(v.X, 0), float64(max(c.ws.Output.W-1, 0))) | 335 | + | c.cursorX = min(max(v.X, 0), float64(max(c.ws.Output().W-1, 0))) |
| 312 | - | c.cursorY = min(max(v.Y, 0), float64(max(c.ws.Output.H-1, 0))) | 336 | + | c.cursorY = min(max(v.Y, 0), float64(max(c.ws.Output().H-1, 0))) |
| 313 | var id WindowID | 337 | var id WindowID | ||
| 314 | var x, y float64 | 338 | var x, y float64 | ||
| 315 | // Fullscreen wins; otherwise the last visible placement is topmost. | 339 | // Fullscreen wins; otherwise the last visible placement is topmost. | ||
| @@ -354,7 +378,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 354 | if err := c.command(ctx, ports.PointerButtonTo{ID: id, Button: v.Button, Pressed: v.Pressed, TimeMsec: v.TimeMsec}); err != nil { | 378 | if err := c.command(ctx, ports.PointerButtonTo{ID: id, Button: v.Button, Pressed: v.Pressed, TimeMsec: v.TimeMsec}); err != nil { | ||
| 355 | return nil | 379 | return nil | ||
| 356 | } | 380 | } | ||
| 357 | - | if v.Pressed && c.focus != id && c.ws.FocusID(id) { | 381 | + | if v.Pressed && c.focus != id && c.ws.Current().FocusID(id) { |
| 358 | if err := c.publish(ctx); err != nil { | 382 | if err := c.publish(ctx); err != nil { | ||
| 359 | return nil | 383 | return nil | ||
| 360 | } | 384 | } | ||
| @@ -370,16 +394,31 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 370 | continue | 394 | continue | ||
| 371 | } | 395 | } | ||
| 372 | name := keyName(key.Keysym) | 396 | name := keyName(key.Keysym) | ||
| 397 | + | action, bound := c.binds[binding{key: name, mods: key.Mods}] | |||
| 398 | + | // Cmd+Shift+1 on US prints exclam: match the unshifted keysym. | |||
| 399 | + | if !bound && key.Base != "" { | |||
| 400 | + | action, bound = c.binds[binding{key: keyName(key.Base), mods: key.Mods}] | |||
| 401 | + | } | |||
| 402 | + | // AZERTY puts digits on the shifted level: Cmd+1 is Super+ampersand. | |||
| 403 | + | // Only digits fall back this way, so cmd+plus stays off Cmd+=. | |||
| 404 | + | if !bound && len(key.Shifted) == 1 && key.Shifted[0] >= '0' && key.Shifted[0] <= '9' { | |||
| 405 | + | action, bound = c.binds[binding{key: key.Shifted, mods: key.Mods}] | |||
| 406 | + | } | |||
| 407 | + | // Track presses by physical key: Shift may be released before the key. | |||
| 408 | + | held := name | |||
| 409 | + | if key.Keycode != 0 { | |||
| 410 | + | held = "#" + strconv.FormatUint(uint64(key.Keycode), 10) | |||
| 411 | + | } | |||
| 373 | if !key.Pressed { | 412 | if !key.Pressed { | ||
| 374 | - | consumed := c.pressed[name] | 413 | + | consumed := c.pressed[held] |
| 375 | - | delete(c.pressed, name) | 414 | + | delete(c.pressed, held) |
| 376 | if consumed { | 415 | if consumed { | ||
| 377 | continue | 416 | continue | ||
| 378 | } | 417 | } | ||
| 379 | } | 418 | } | ||
| 380 | if key.Pressed { | 419 | if key.Pressed { | ||
| 381 | - | if action, ok := c.binds[binding{key: name, mods: key.Mods}]; ok { | 420 | + | if bound { |
| 382 | - | c.pressed[name] = true | 421 | + | c.pressed[held] = true |
| 383 | effect := c.ws.Apply(action) | 422 | effect := c.ws.Apply(action) | ||
| 384 | if effect.Quit { | 423 | if effect.Quit { | ||
| 385 | return ErrQuit | 424 | return ErrQuit | ||
| @@ -405,7 +444,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 405 | } | 444 | } | ||
| 406 | continue | 445 | continue | ||
| 407 | } | 446 | } | ||
| 408 | - | c.pressed[name] = false | 447 | + | c.pressed[held] = false |
| 409 | } | 448 | } | ||
| 410 | if id := c.keyboardFocus(); id != 0 { | 449 | if id := c.keyboardFocus(); id != 0 { | ||
| 411 | if err := c.command(ctx, ports.ForwardKey{ID: id, Key: key}); err != nil { | 450 | if err := c.command(ctx, ports.ForwardKey{ID: id, Key: key}); err != nil { | ||
M internal/core/core_test.go +154 -9
| @@ -49,10 +49,11 @@ func TestOwner(t *testing.T) { | |||
| 49 | 49 | } | |
| 50 | 50 | client <- ports.WindowMapped{ID: 1} | |
| 51 | 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 | 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 | 57 | t.Fatal(v) | |
| 57 | 58 | } | |
| 58 | 59 | receive(t, commands) | |
| @@ -93,13 +94,13 @@ func TestOwner(t *testing.T) { | |||
| 93 | 94 | cfg.Layout.Gaps = 4 | |
| 94 | 95 | reload <- ports.ConfigChanged{Config: cfg} | |
| 95 | 96 | s = receive(t, scenes) | |
| 96 | - | if s.Windows[0].Rect.W != 44 { | |
| 97 | + | if s.Windows[0].Rect.W != 92 { | |
| 97 | 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 | 101 | t.Fatal(v) | |
| 101 | 102 | } | |
| 102 | - | cfg.Layout.DefaultColumnWidth = "bad" | |
| 103 | + | cfg.Layout.MaxColumns = 0 | |
| 103 | 104 | reload <- ports.ConfigChanged{Config: cfg} | |
| 104 | 105 | receive(t, errs) | |
| 105 | 106 | for i := 0; i < 100; i++ { | |
| @@ -268,17 +269,32 @@ func TestBorderInset(t *testing.T) { | |||
| 268 | 269 | go c.Run(ctx) | |
| 269 | 270 | output <- ports.OutputMode{Width: 100, Height: 80} | |
| 270 | 271 | receive(t, scenes) | |
| 272 | + | // Alone, the window is borderless and gets the full rect. | |
| 271 | 273 | client <- ports.WindowMapped{ID: 1} | |
| 272 | 274 | 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}) { | |
| 275 | 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 | 288 | 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) | |
| 279 | 295 | } | |
| 280 | 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 | 298 | t.Fatal(v) | |
| 283 | 299 | } | |
| 284 | 300 | } | |
| @@ -335,3 +351,132 @@ func TestLayerKeyboardFocus(t *testing.T) { | |||
| 335 | 351 | t.Fatalf("focus not restored: %d", id) | |
| 336 | 352 | } | |
| 337 | 353 | } | |
| 354 | + | ||
| 355 | + | func TestWorkspaceSwitch(t *testing.T) { | |
| 356 | + | cfg := config.Defaults() | |
| 357 | + | client := make(chan ports.ClientEvent, 8) | |
| 358 | + | input := make(chan ports.InputEvent, 8) | |
| 359 | + | output := make(chan ports.OutputEvent, 8) | |
| 360 | + | commands := make(chan ports.ClientCommand, 64) | |
| 361 | + | scenes := make(chan ports.Scene, 1) | |
| 362 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |
| 363 | + | if err != nil { | |
| 364 | + | t.Fatal(err) | |
| 365 | + | } | |
| 366 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 367 | + | defer cancel() | |
| 368 | + | go c.Run(ctx) | |
| 369 | + | output <- ports.OutputMode{Width: 100, Height: 80} | |
| 370 | + | receive(t, scenes) | |
| 371 | + | client <- ports.WindowMapped{ID: 1} | |
| 372 | + | receive(t, scenes) | |
| 373 | + | for len(commands) > 0 { | |
| 374 | + | <-commands | |
| 375 | + | } | |
| 376 | + | // AZERTY: the 2 key is eacute unshifted; Cmd+2 still matches through its shifted level. | |
| 377 | + | input <- ports.KeyEvent{Keysym: "eacute", Base: "eacute", Shifted: "2", Mods: ports.ModSuper, Pressed: true} | |
| 378 | + | s := receive(t, scenes) | |
| 379 | + | if len(s.Windows) != 1 || !s.Windows[0].Hidden { | |
| 380 | + | t.Fatal(s) | |
| 381 | + | } | |
| 382 | + | // Window 1 is deactivated and loses keyboard focus. | |
| 383 | + | var sawDeactivate, sawFocus bool | |
| 384 | + | for len(commands) > 0 { | |
| 385 | + | switch v := (<-commands).(type) { | |
| 386 | + | case ports.ConfigureWindow: | |
| 387 | + | sawDeactivate = sawDeactivate || (v.ID == 1 && !v.Activated) | |
| 388 | + | case ports.FocusWindow: | |
| 389 | + | sawFocus = sawFocus || v.ID == 0 | |
| 390 | + | } | |
| 391 | + | } | |
| 392 | + | if !sawDeactivate || !sawFocus { | |
| 393 | + | t.Fatal(sawDeactivate, sawFocus) | |
| 394 | + | } | |
| 395 | + | // A new window opens on workspace 2, alone and borderless. | |
| 396 | + | client <- ports.WindowMapped{ID: 2} | |
| 397 | + | s = receive(t, scenes) | |
| 398 | + | if len(s.Windows) != 2 || s.Windows[1].ID != 2 || s.Windows[1].Hidden || !s.Windows[1].Borderless { | |
| 399 | + | t.Fatal(s) | |
| 400 | + | } | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | func TestClickAfterWorkspaceSwitch(t *testing.T) { | |
| 404 | + | cfg := config.Defaults() | |
| 405 | + | client := make(chan ports.ClientEvent, 8) | |
| 406 | + | input := make(chan ports.InputEvent, 8) | |
| 407 | + | output := make(chan ports.OutputEvent, 8) | |
| 408 | + | commands := make(chan ports.ClientCommand, 64) | |
| 409 | + | scenes := make(chan ports.Scene, 1) | |
| 410 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |
| 411 | + | if err != nil { | |
| 412 | + | t.Fatal(err) | |
| 413 | + | } | |
| 414 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 415 | + | defer cancel() | |
| 416 | + | go c.Run(ctx) | |
| 417 | + | output <- ports.OutputMode{Width: 100, Height: 80} | |
| 418 | + | receive(t, scenes) | |
| 419 | + | client <- ports.WindowMapped{ID: 1} | |
| 420 | + | receive(t, scenes) | |
| 421 | + | input <- ports.PointerMotion{X: 50, Y: 40} | |
| 422 | + | for len(commands) < 3 { | |
| 423 | + | time.Sleep(time.Millisecond) | |
| 424 | + | } | |
| 425 | + | for len(commands) > 0 { | |
| 426 | + | <-commands | |
| 427 | + | } | |
| 428 | + | // Cmd+2 hides window 1; the pointer must leave it. | |
| 429 | + | input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModSuper, Pressed: true} | |
| 430 | + | receive(t, scenes) | |
| 431 | + | var cleared bool | |
| 432 | + | for len(commands) > 0 { | |
| 433 | + | if v, ok := (<-commands).(ports.PointerFocus); ok && v.ID == 0 { | |
| 434 | + | cleared = true | |
| 435 | + | } | |
| 436 | + | } | |
| 437 | + | if !cleared { | |
| 438 | + | t.Fatal("pointer focus kept on hidden window") | |
| 439 | + | } | |
| 440 | + | // A click without moving does not reach window 1 nor switch back. | |
| 441 | + | input <- ports.PointerButton{Button: 0x110, Pressed: true} | |
| 442 | + | input <- ports.PointerButton{Button: 0x110} | |
| 443 | + | client <- ports.WindowMapped{ID: 2} | |
| 444 | + | s := receive(t, scenes) | |
| 445 | + | if s.Windows[0].ID != 1 || !s.Windows[0].Hidden || s.Windows[1].Hidden { | |
| 446 | + | t.Fatal(s) | |
| 447 | + | } | |
| 448 | + | for len(commands) > 0 { | |
| 449 | + | if v, ok := (<-commands).(ports.PointerButtonTo); ok { | |
| 450 | + | t.Fatal(v) | |
| 451 | + | } | |
| 452 | + | } | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | func TestShiftReleasedFirst(t *testing.T) { | |
| 456 | + | cfg := config.Defaults() | |
| 457 | + | input := make(chan ports.InputEvent, 8) | |
| 458 | + | client := make(chan ports.ClientEvent, 2) | |
| 459 | + | commands := make(chan ports.ClientCommand, 16) | |
| 460 | + | scenes := make(chan ports.Scene, 1) | |
| 461 | + | c, err := core.New(cfg, core.Channels{Input: input, Client: client, Commands: commands, Scenes: scenes}) | |
| 462 | + | if err != nil { | |
| 463 | + | t.Fatal(err) | |
| 464 | + | } | |
| 465 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 466 | + | defer cancel() | |
| 467 | + | go c.Run(ctx) | |
| 468 | + | client <- ports.WindowMapped{ID: 1} | |
| 469 | + | receive(t, scenes) | |
| 470 | + | for len(commands) > 0 { | |
| 471 | + | <-commands | |
| 472 | + | } | |
| 473 | + | // US Cmd+Shift+1 prints exclam; its base level 1 matches move-to-workspace 1. | |
| 474 | + | input <- ports.KeyEvent{Keysym: "exclam", Base: "1", Shifted: "exclam", Keycode: 2, Mods: ports.ModSuper | ports.ModShift, Pressed: true} | |
| 475 | + | receive(t, scenes) | |
| 476 | + | // Shift goes up first, so the release reports "1": still swallowed. | |
| 477 | + | input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModSuper} | |
| 478 | + | input <- ports.KeyEvent{Keysym: "x", Keycode: 45, Pressed: true} | |
| 479 | + | if v := receive(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "x", Keycode: 45, Pressed: true}}) { | |
| 480 | + | t.Fatal(v) | |
| 481 | + | } | |
| 482 | + | } | |
| @@ -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 = 4 | 94 | 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].Rect | 274 | 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 | - | <-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) | |||
| 279 | } | 295 | } | ||
| 280 | input <- ports.PointerMotion{X: float64(r.X + 2), Y: float64(r.Y + 5), TimeMsec: 1} | 296 | input <- ports.PointerMotion{X: float64(r.X + 2), Y: float64(r.Y + 5), TimeMsec: 1} | ||
| 281 | - | if v := receive(t, commands); v != (ports.PointerFocus{ID: 1, X: 0, Y: 3}) { | 297 | + | if v := receive(t, commands); v != (ports.PointerFocus{ID: 2, X: 0, Y: 3}) { |
| 282 | t.Fatal(v) | 298 | t.Fatal(v) | ||
| 283 | } | 299 | } | ||
| 284 | } | 300 | } | ||
| @@ -335,3 +351,132 @@ func TestLayerKeyboardFocus(t *testing.T) { | |||||
| 335 | t.Fatalf("focus not restored: %d", id) | 351 | t.Fatalf("focus not restored: %d", id) | ||
| 336 | } | 352 | } | ||
| 337 | } | 353 | } | ||
| 354 | + | ||||
| 355 | + | func TestWorkspaceSwitch(t *testing.T) { | |||
| 356 | + | cfg := config.Defaults() | |||
| 357 | + | client := make(chan ports.ClientEvent, 8) | |||
| 358 | + | input := make(chan ports.InputEvent, 8) | |||
| 359 | + | output := make(chan ports.OutputEvent, 8) | |||
| 360 | + | commands := make(chan ports.ClientCommand, 64) | |||
| 361 | + | scenes := make(chan ports.Scene, 1) | |||
| 362 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |||
| 363 | + | if err != nil { | |||
| 364 | + | t.Fatal(err) | |||
| 365 | + | } | |||
| 366 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 367 | + | defer cancel() | |||
| 368 | + | go c.Run(ctx) | |||
| 369 | + | output <- ports.OutputMode{Width: 100, Height: 80} | |||
| 370 | + | receive(t, scenes) | |||
| 371 | + | client <- ports.WindowMapped{ID: 1} | |||
| 372 | + | receive(t, scenes) | |||
| 373 | + | for len(commands) > 0 { | |||
| 374 | + | <-commands | |||
| 375 | + | } | |||
| 376 | + | // AZERTY: the 2 key is eacute unshifted; Cmd+2 still matches through its shifted level. | |||
| 377 | + | input <- ports.KeyEvent{Keysym: "eacute", Base: "eacute", Shifted: "2", Mods: ports.ModSuper, Pressed: true} | |||
| 378 | + | s := receive(t, scenes) | |||
| 379 | + | if len(s.Windows) != 1 || !s.Windows[0].Hidden { | |||
| 380 | + | t.Fatal(s) | |||
| 381 | + | } | |||
| 382 | + | // Window 1 is deactivated and loses keyboard focus. | |||
| 383 | + | var sawDeactivate, sawFocus bool | |||
| 384 | + | for len(commands) > 0 { | |||
| 385 | + | switch v := (<-commands).(type) { | |||
| 386 | + | case ports.ConfigureWindow: | |||
| 387 | + | sawDeactivate = sawDeactivate || (v.ID == 1 && !v.Activated) | |||
| 388 | + | case ports.FocusWindow: | |||
| 389 | + | sawFocus = sawFocus || v.ID == 0 | |||
| 390 | + | } | |||
| 391 | + | } | |||
| 392 | + | if !sawDeactivate || !sawFocus { | |||
| 393 | + | t.Fatal(sawDeactivate, sawFocus) | |||
| 394 | + | } | |||
| 395 | + | // A new window opens on workspace 2, alone and borderless. | |||
| 396 | + | client <- ports.WindowMapped{ID: 2} | |||
| 397 | + | s = receive(t, scenes) | |||
| 398 | + | if len(s.Windows) != 2 || s.Windows[1].ID != 2 || s.Windows[1].Hidden || !s.Windows[1].Borderless { | |||
| 399 | + | t.Fatal(s) | |||
| 400 | + | } | |||
| 401 | + | } | |||
| 402 | + | ||||
| 403 | + | func TestClickAfterWorkspaceSwitch(t *testing.T) { | |||
| 404 | + | cfg := config.Defaults() | |||
| 405 | + | client := make(chan ports.ClientEvent, 8) | |||
| 406 | + | input := make(chan ports.InputEvent, 8) | |||
| 407 | + | output := make(chan ports.OutputEvent, 8) | |||
| 408 | + | commands := make(chan ports.ClientCommand, 64) | |||
| 409 | + | scenes := make(chan ports.Scene, 1) | |||
| 410 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |||
| 411 | + | if err != nil { | |||
| 412 | + | t.Fatal(err) | |||
| 413 | + | } | |||
| 414 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 415 | + | defer cancel() | |||
| 416 | + | go c.Run(ctx) | |||
| 417 | + | output <- ports.OutputMode{Width: 100, Height: 80} | |||
| 418 | + | receive(t, scenes) | |||
| 419 | + | client <- ports.WindowMapped{ID: 1} | |||
| 420 | + | receive(t, scenes) | |||
| 421 | + | input <- ports.PointerMotion{X: 50, Y: 40} | |||
| 422 | + | for len(commands) < 3 { | |||
| 423 | + | time.Sleep(time.Millisecond) | |||
| 424 | + | } | |||
| 425 | + | for len(commands) > 0 { | |||
| 426 | + | <-commands | |||
| 427 | + | } | |||
| 428 | + | // Cmd+2 hides window 1; the pointer must leave it. | |||
| 429 | + | input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModSuper, Pressed: true} | |||
| 430 | + | receive(t, scenes) | |||
| 431 | + | var cleared bool | |||
| 432 | + | for len(commands) > 0 { | |||
| 433 | + | if v, ok := (<-commands).(ports.PointerFocus); ok && v.ID == 0 { | |||
| 434 | + | cleared = true | |||
| 435 | + | } | |||
| 436 | + | } | |||
| 437 | + | if !cleared { | |||
| 438 | + | t.Fatal("pointer focus kept on hidden window") | |||
| 439 | + | } | |||
| 440 | + | // A click without moving does not reach window 1 nor switch back. | |||
| 441 | + | input <- ports.PointerButton{Button: 0x110, Pressed: true} | |||
| 442 | + | input <- ports.PointerButton{Button: 0x110} | |||
| 443 | + | client <- ports.WindowMapped{ID: 2} | |||
| 444 | + | s := receive(t, scenes) | |||
| 445 | + | if s.Windows[0].ID != 1 || !s.Windows[0].Hidden || s.Windows[1].Hidden { | |||
| 446 | + | t.Fatal(s) | |||
| 447 | + | } | |||
| 448 | + | for len(commands) > 0 { | |||
| 449 | + | if v, ok := (<-commands).(ports.PointerButtonTo); ok { | |||
| 450 | + | t.Fatal(v) | |||
| 451 | + | } | |||
| 452 | + | } | |||
| 453 | + | } | |||
| 454 | + | ||||
| 455 | + | func TestShiftReleasedFirst(t *testing.T) { | |||
| 456 | + | cfg := config.Defaults() | |||
| 457 | + | input := make(chan ports.InputEvent, 8) | |||
| 458 | + | client := make(chan ports.ClientEvent, 2) | |||
| 459 | + | commands := make(chan ports.ClientCommand, 16) | |||
| 460 | + | scenes := make(chan ports.Scene, 1) | |||
| 461 | + | c, err := core.New(cfg, core.Channels{Input: input, Client: client, Commands: commands, Scenes: scenes}) | |||
| 462 | + | if err != nil { | |||
| 463 | + | t.Fatal(err) | |||
| 464 | + | } | |||
| 465 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 466 | + | defer cancel() | |||
| 467 | + | go c.Run(ctx) | |||
| 468 | + | client <- ports.WindowMapped{ID: 1} | |||
| 469 | + | receive(t, scenes) | |||
| 470 | + | for len(commands) > 0 { | |||
| 471 | + | <-commands | |||
| 472 | + | } | |||
| 473 | + | // US Cmd+Shift+1 prints exclam; its base level 1 matches move-to-workspace 1. | |||
| 474 | + | input <- ports.KeyEvent{Keysym: "exclam", Base: "1", Shifted: "exclam", Keycode: 2, Mods: ports.ModSuper | ports.ModShift, Pressed: true} | |||
| 475 | + | receive(t, scenes) | |||
| 476 | + | // Shift goes up first, so the release reports "1": still swallowed. | |||
| 477 | + | input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModSuper} | |||
| 478 | + | input <- ports.KeyEvent{Keysym: "x", Keycode: 45, Pressed: true} | |||
| 479 | + | if v := receive(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "x", Keycode: 45, Pressed: true}}) { | |||
| 480 | + | t.Fatal(v) | |||
| 481 | + | } | |||
| 482 | + | } | |||
M internal/core/layers_test.go +1 -1
| @@ -56,7 +56,7 @@ func TestLayerChangedChannels(t *testing.T) { | |||
| 56 | 56 | scenes := make(chan ports.Scene, 1) | |
| 57 | 57 | cfg := ports.Config{} | |
| 58 | 58 | cfg.Keyboard.CmdKey = "super" | |
| 59 | - | cfg.Layout.DefaultColumnWidth = "1/2" | |
| 59 | + | cfg.Layout.MaxColumns = 2 | |
| 60 | 60 | c, err := New(cfg, Channels{Client: client, Output: output, Commands: commands, Scenes: scenes}) | |
| 61 | 61 | if err != nil { | |
| 62 | 62 | t.Fatal(err) | |
| @@ -56,7 +56,7 @@ func TestLayerChangedChannels(t *testing.T) { | |||||
| 56 | scenes := make(chan ports.Scene, 1) | 56 | scenes := make(chan ports.Scene, 1) | ||
| 57 | cfg := ports.Config{} | 57 | cfg := ports.Config{} | ||
| 58 | cfg.Keyboard.CmdKey = "super" | 58 | cfg.Keyboard.CmdKey = "super" | ||
| 59 | - | cfg.Layout.DefaultColumnWidth = "1/2" | 59 | + | cfg.Layout.MaxColumns = 2 |
| 60 | c, err := New(cfg, Channels{Client: client, Output: output, Commands: commands, Scenes: scenes}) | 60 | c, err := New(cfg, Channels{Client: client, Output: output, Commands: commands, Scenes: scenes}) | ||
| 61 | if err != nil { | 61 | if err != nil { | ||
| 62 | t.Fatal(err) | 62 | t.Fatal(err) | ||
M internal/core/layout.go +64 -33
| @@ -70,6 +70,8 @@ func (v Width) Resolve(usableW, gaps int) int { | |||
| 70 | 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 | 75 | type Column struct { | |
| 74 | 76 | Windows []WindowID | |
| 75 | 77 | Width Width | |
| @@ -79,17 +81,34 @@ type Placement struct { | |||
| 79 | 81 | ID WindowID | |
| 80 | 82 | Rect Rect | |
| 81 | 83 | 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 | 88 | 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 | |
| 93 | 112 | } | |
| 94 | 113 | ||
| 95 | 114 | func (w *Workspace) Focused() (WindowID, bool) { | |
| @@ -103,23 +122,16 @@ func (w *Workspace) Focused() (WindowID, bool) { | |||
| 103 | 122 | return c.Windows[c.Focus], true | |
| 104 | 123 | } | |
| 105 | 124 | func (w *Workspace) AddWindow(id WindowID) { | |
| 106 | - | if id == 0 { | |
| 125 | + | if id == 0 || w.has(id) { | |
| 107 | 126 | 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 | 128 | at := 0 | |
| 117 | 129 | if len(w.Columns) > 0 { | |
| 118 | 130 | at = w.Focus + 1 | |
| 119 | 131 | } | |
| 120 | 132 | w.Columns = append(w.Columns, Column{}) | |
| 121 | 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 | 135 | w.Focus = at | |
| 124 | 136 | w.scroll() | |
| 125 | 137 | } | |
| @@ -177,15 +189,19 @@ func (w *Workspace) FocusColumn(dir int) { | |||
| 177 | 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 | 195 | if len(w.Columns) == 0 || (dir != -1 && dir != 1) { | |
| 182 | - | return | |
| 196 | + | return false | |
| 183 | 197 | } | |
| 184 | 198 | c := &w.Columns[w.Focus] | |
| 185 | 199 | if c.Focus+dir >= 0 && c.Focus+dir < len(c.Windows) { | |
| 186 | 200 | c.Focus += dir | |
| 187 | 201 | w.scroll() | |
| 202 | + | return true | |
| 188 | 203 | } | |
| 204 | + | return false | |
| 189 | 205 | } | |
| 190 | 206 | func (w *Workspace) MoveColumn(dir int) { | |
| 191 | 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 | 215 | if len(w.Columns) == 0 || len(w.presets) == 0 { | |
| 200 | 216 | return | |
| 201 | 217 | } | |
| 218 | + | // auto → presets in order → auto. | |
| 202 | 219 | c := &w.Columns[w.Focus] | |
| 203 | - | next := 0 | |
| 220 | + | next := Width{} | |
| 221 | + | if c.Width == next { | |
| 222 | + | next = w.presets[0] | |
| 223 | + | } | |
| 204 | 224 | 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] | |
| 207 | 227 | break | |
| 208 | 228 | } | |
| 209 | 229 | } | |
| 210 | - | c.Width = w.presets[next] | |
| 230 | + | c.Width = next | |
| 211 | 231 | w.scroll() | |
| 212 | 232 | } | |
| 213 | 233 | func (w *Workspace) ToggleFullscreen() { | |
| @@ -223,13 +243,12 @@ func (w *Workspace) ToggleFullscreen() { | |||
| 223 | 243 | w.scroll() | |
| 224 | 244 | } | |
| 225 | 245 | ||
| 226 | - | // SetFullscreen focuses an existing window and applies the requested state. | |
| 246 | + | // SetFullscreen applies a client request. It never moves focus: client | |
| 247 | + | // requests are automatic events (ADR 011 golden rule). | |
| 227 | 248 | func (w *Workspace) SetFullscreen(id WindowID, on bool) { | |
| 228 | 249 | for i := range w.Columns { | |
| 229 | - | for j, v := range w.Columns[i].Windows { | |
| 250 | + | for _, v := range w.Columns[i].Windows { | |
| 230 | 251 | if v == id { | |
| 231 | - | w.Focus = i | |
| 232 | - | w.Columns[i].Focus = j | |
| 233 | 252 | if on { | |
| 234 | 253 | w.fullscreen = id | |
| 235 | 254 | } else if w.fullscreen == id { | |
| @@ -273,9 +292,12 @@ func (w *Workspace) SetGaps(g int) { | |||
| 273 | 292 | w.Gaps = g | |
| 274 | 293 | w.scroll() | |
| 275 | 294 | } | |
| 276 | - | func (w *Workspace) SetDefaultWidth(v Width) { w.DefaultWidth = v } | |
| 277 | - | func (w *Workspace) SetPresets(v []Width) { w.presets = append([]Width(nil), v...) } | |
| 278 | - | func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) } | |
| 295 | + | func (w *Workspace) SetPresets(v []Width) { w.presets = append([]Width(nil), v...) } | |
| 296 | + | func (w *Workspace) SetMaxColumns(n int) { | |
| 297 | + | w.MaxColumns = n | |
| 298 | + | w.scroll() | |
| 299 | + | } | |
| 300 | + | func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) } | |
| 279 | 301 | func (w *Workspace) fullscreenColumn(i int) bool { | |
| 280 | 302 | for _, id := range w.Columns[i].Windows { | |
| 281 | 303 | if id == w.fullscreen && id != 0 { | |
| @@ -288,7 +310,16 @@ func (w *Workspace) columnWidth(i int) int { | |||
| 288 | 310 | if w.fullscreenColumn(i) { | |
| 289 | 311 | return w.Output.W | |
| 290 | 312 | } | |
| 291 | - | return w.Columns[i].Width.Resolve(w.Usable.W, w.gap()) | |
| 313 | + | g := w.gap() | |
| 314 | + | if w.Columns[i].Width == (Width{}) { | |
| 315 | + | if len(w.Columns) == 1 { | |
| 316 | + | return max(w.Usable.W-2*g, 0) | |
| 317 | + | } | |
| 318 | + | // Equal shares of the width left after gaps; a remainder under k pixels stays empty. | |
| 319 | + | k := min(len(w.Columns), max(w.MaxColumns, 1)) | |
| 320 | + | return max((w.Usable.W-g*(k+1))/k, 0) | |
| 321 | + | } | |
| 322 | + | return w.Columns[i].Width.Resolve(w.Usable.W, g) | |
| 292 | 323 | } | |
| 293 | 324 | func (w *Workspace) columnX(i int) int { | |
| 294 | 325 | x := w.Usable.X + w.gap() | |
| @@ -347,7 +378,7 @@ func (w *Workspace) Layout() []Placement { | |||
| 347 | 378 | if hidden { | |
| 348 | 379 | r = Rect{} | |
| 349 | 380 | } | |
| 350 | - | result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: i == w.Focus && j == c.Focus, Hidden: hidden}) | |
| 381 | + | result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: i == w.Focus && j == c.Focus, Hidden: hidden, Borderless: width >= w.Usable.W-2*gap}) | |
| 351 | 382 | y += h + gap | |
| 352 | 383 | } | |
| 353 | 384 | } | |
| @@ -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 []WindowID | 76 | Windows []WindowID | ||
| 75 | Width Width | 77 | Width Width | ||
| @@ -79,17 +81,34 @@ type Placement struct { | |||||
| 79 | ID WindowID | 81 | ID WindowID | ||
| 80 | Rect Rect | 82 | Rect Rect | ||
| 81 | Fullscreen, Focused, Hidden bool | 83 | 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 []Column | 89 | + | Columns []Column |
| 85 | - | Focus int | 90 | + | Focus int |
| 86 | - | ViewX int | 91 | + | ViewX int |
| 87 | - | Output Rect | 92 | + | Output Rect |
| 88 | - | Usable Rect | 93 | + | Usable Rect |
| 89 | - | Gaps int | 94 | + | Gaps int |
| 90 | - | DefaultWidth Width | 95 | + | MaxColumns int |
| 91 | - | presets []Width | 96 | + | presets []Width |
| 92 | - | fullscreen WindowID | 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 | |||
| 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], true | 122 | 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 | return | 126 | 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 := 0 | 128 | at := 0 | ||
| 117 | if len(w.Columns) > 0 { | 129 | if len(w.Columns) > 0 { | ||
| 118 | at = w.Focus + 1 | 130 | 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 = at | 135 | 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 | - | return | 196 | + | 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 += dir | 200 | 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 | return | 216 | return | ||
| 201 | } | 217 | } | ||
| 218 | + | // auto → presets in order → auto. | |||
| 202 | c := &w.Columns[w.Focus] | 219 | c := &w.Columns[w.Focus] | ||
| 203 | - | next := 0 | 220 | + | 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 | break | 227 | break | ||
| 208 | } | 228 | } | ||
| 209 | } | 229 | } | ||
| 210 | - | c.Width = w.presets[next] | 230 | + | c.Width = next |
| 211 | w.scroll() | 231 | w.scroll() | ||
| 212 | } | 232 | } | ||
| 213 | func (w *Workspace) ToggleFullscreen() { | 233 | func (w *Workspace) ToggleFullscreen() { | ||
| @@ -223,13 +243,12 @@ func (w *Workspace) ToggleFullscreen() { | |||||
| 223 | w.scroll() | 243 | w.scroll() | ||
| 224 | } | 244 | } | ||
| 225 | 245 | ||||
| 226 | - | // SetFullscreen focuses an existing window and applies the requested state. | 246 | + | // SetFullscreen applies a client request. It never moves focus: client |
| 247 | + | // requests are automatic events (ADR 011 golden rule). | |||
| 227 | func (w *Workspace) SetFullscreen(id WindowID, on bool) { | 248 | func (w *Workspace) SetFullscreen(id WindowID, on bool) { | ||
| 228 | for i := range w.Columns { | 249 | for i := range w.Columns { | ||
| 229 | - | for j, v := range w.Columns[i].Windows { | 250 | + | for _, v := range w.Columns[i].Windows { |
| 230 | if v == id { | 251 | if v == id { | ||
| 231 | - | w.Focus = i | |||
| 232 | - | w.Columns[i].Focus = j | |||
| 233 | if on { | 252 | if on { | ||
| 234 | w.fullscreen = id | 253 | w.fullscreen = id | ||
| 235 | } else if w.fullscreen == id { | 254 | } else if w.fullscreen == id { | ||
| @@ -273,9 +292,12 @@ func (w *Workspace) SetGaps(g int) { | |||||
| 273 | w.Gaps = g | 292 | w.Gaps = g | ||
| 274 | w.scroll() | 293 | w.scroll() | ||
| 275 | } | 294 | } | ||
| 276 | - | func (w *Workspace) SetDefaultWidth(v Width) { w.DefaultWidth = v } | 295 | + | func (w *Workspace) SetPresets(v []Width) { w.presets = append([]Width(nil), v...) } |
| 277 | - | func (w *Workspace) SetPresets(v []Width) { w.presets = append([]Width(nil), v...) } | 296 | + | func (w *Workspace) SetMaxColumns(n int) { |
| 278 | - | func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) } | 297 | + | w.MaxColumns = n |
| 298 | + | w.scroll() | |||
| 299 | + | } | |||
| 300 | + | func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) } | |||
| 279 | func (w *Workspace) fullscreenColumn(i int) bool { | 301 | func (w *Workspace) fullscreenColumn(i int) bool { | ||
| 280 | for _, id := range w.Columns[i].Windows { | 302 | for _, id := range w.Columns[i].Windows { | ||
| 281 | if id == w.fullscreen && id != 0 { | 303 | if id == w.fullscreen && id != 0 { | ||
| @@ -288,7 +310,16 @@ func (w *Workspace) columnWidth(i int) int { | |||||
| 288 | if w.fullscreenColumn(i) { | 310 | if w.fullscreenColumn(i) { | ||
| 289 | return w.Output.W | 311 | return w.Output.W | ||
| 290 | } | 312 | } | ||
| 291 | - | return w.Columns[i].Width.Resolve(w.Usable.W, w.gap()) | 313 | + | g := w.gap() |
| 314 | + | if w.Columns[i].Width == (Width{}) { | |||
| 315 | + | if len(w.Columns) == 1 { | |||
| 316 | + | return max(w.Usable.W-2*g, 0) | |||
| 317 | + | } | |||
| 318 | + | // Equal shares of the width left after gaps; a remainder under k pixels stays empty. | |||
| 319 | + | k := min(len(w.Columns), max(w.MaxColumns, 1)) | |||
| 320 | + | return max((w.Usable.W-g*(k+1))/k, 0) | |||
| 321 | + | } | |||
| 322 | + | return w.Columns[i].Width.Resolve(w.Usable.W, g) | |||
| 292 | } | 323 | } | ||
| 293 | func (w *Workspace) columnX(i int) int { | 324 | func (w *Workspace) columnX(i int) int { | ||
| 294 | x := w.Usable.X + w.gap() | 325 | x := w.Usable.X + w.gap() | ||
| @@ -347,7 +378,7 @@ func (w *Workspace) Layout() []Placement { | |||||
| 347 | if hidden { | 378 | if hidden { | ||
| 348 | r = Rect{} | 379 | r = Rect{} | ||
| 349 | } | 380 | } | ||
| 350 | - | result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: i == w.Focus && j == c.Focus, Hidden: hidden}) | 381 | + | result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: i == w.Focus && j == c.Focus, Hidden: hidden, Borderless: width >= w.Usable.W-2*gap}) |
| 351 | y += h + gap | 382 | y += h + gap | ||
| 352 | } | 383 | } | ||
| 353 | } | 384 | } | ||
M internal/core/layout_test.go +16 -12
| @@ -10,7 +10,7 @@ func workspace() *Workspace { | |||
| 10 | 10 | w := &Workspace{} | |
| 11 | 11 | w.SetOutput(100, 81) | |
| 12 | 12 | w.SetGaps(5) | |
| 13 | - | w.SetDefaultWidth(Width{Num: 1, Den: 2}) | |
| 13 | + | w.SetMaxColumns(2) | |
| 14 | 14 | return w | |
| 15 | 15 | } | |
| 16 | 16 | func TestWidths(t *testing.T) { | |
| @@ -45,11 +45,12 @@ func TestOperations(t *testing.T) { | |||
| 45 | 45 | w.AddWindow(1) | |
| 46 | 46 | w.AddWindow(2) | |
| 47 | 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 | 50 | t.Fatalf("scroll: %+v", w) | |
| 50 | 51 | } | |
| 51 | 52 | w.FocusColumn(-1) | |
| 52 | - | if w.ViewX != 48 { | |
| 53 | + | if w.ViewX != 46 { | |
| 53 | 54 | t.Fatal(w.ViewX) | |
| 54 | 55 | } | |
| 55 | 56 | w.FocusColumn(-1) | |
| @@ -72,7 +73,7 @@ func TestOperations(t *testing.T) { | |||
| 72 | 73 | } | |
| 73 | 74 | func TestStackAndLayout(t *testing.T) { | |
| 74 | 75 | w := workspace() | |
| 75 | - | w.Columns = []Column{{Windows: []WindowID{1, 2, 3}, Width: Width{Num: 1, Den: 2}, Focus: 1}} | |
| 76 | + | w.Columns = []Column{{Windows: []WindowID{1, 2, 3}, Focus: 1}} | |
| 76 | 77 | w.RemoveWindow(2) | |
| 77 | 78 | if id, _ := w.Focused(); id != 1 { | |
| 78 | 79 | t.Fatal(id) | |
| @@ -83,7 +84,7 @@ func TestStackAndLayout(t *testing.T) { | |||
| 83 | 84 | } | |
| 84 | 85 | w.FocusWindow(-1) | |
| 85 | 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 | 88 | t.Fatal(p) | |
| 88 | 89 | } | |
| 89 | 90 | w.ToggleFullscreen() | |
| @@ -110,18 +111,21 @@ func TestPresetsAndActions(t *testing.T) { | |||
| 110 | 111 | p := []Width{{Pixels: 30}, {Pixels: 40}} | |
| 111 | 112 | w.SetPresets(p) | |
| 112 | 113 | p[0].Pixels = 99 | |
| 114 | + | w.AddWindow(2) | |
| 113 | 115 | w.Apply(ActionCycleColumnWidth) | |
| 114 | - | if w.Columns[0].Width.Pixels != 30 { | |
| 116 | + | if w.Columns[1].Width.Pixels != 30 { | |
| 115 | 117 | t.Fatal(w.Columns) | |
| 116 | 118 | } | |
| 117 | 119 | w.CycleWidth() | |
| 118 | - | if w.Columns[0].Width.Pixels != 40 { | |
| 120 | + | if w.Columns[1].Width.Pixels != 40 { | |
| 119 | 121 | t.Fatal(w.Columns) | |
| 120 | 122 | } | |
| 123 | + | // After the last preset, back to the automatic share. | |
| 121 | 124 | w.CycleWidth() | |
| 122 | - | if w.Columns[0].Width.Pixels != 30 { | |
| 125 | + | if w.Columns[1].Width != (Width{}) { | |
| 123 | 126 | t.Fatal(w.Columns) | |
| 124 | 127 | } | |
| 128 | + | w.RemoveWindow(2) | |
| 125 | 129 | for _, tc := range []struct { | |
| 126 | 130 | a Action | |
| 127 | 131 | want Effect | |
| @@ -181,9 +185,8 @@ func FuzzWorkspaceOps(f *testing.F) { | |||
| 181 | 185 | stackFocused(w) | |
| 182 | 186 | } | |
| 183 | 187 | case 15: | |
| 184 | - | w.SetDefaultWidth(Width{Num: math.MaxInt, Den: math.MaxInt}) | |
| 185 | 188 | 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} | |
| 187 | 190 | w.scroll() | |
| 188 | 191 | } | |
| 189 | 192 | case 16: | |
| @@ -335,10 +338,11 @@ func TestFullscreenPersistsAndScrolls(t *testing.T) { | |||
| 335 | 338 | ||
| 336 | 339 | func TestSetFullscreenStackDeactivation(t *testing.T) { | |
| 337 | 340 | w := workspace() | |
| 338 | - | w.Columns = []Column{{Windows: []WindowID{1, 2}, Width: w.DefaultWidth, Focus: 0}} | |
| 341 | + | w.Columns = []Column{{Windows: []WindowID{1, 2}, Focus: 0}} | |
| 342 | + | // A client request does not take focus; window 1 keeps it but is covered. | |
| 339 | 343 | w.SetFullscreen(2, true) | |
| 340 | 344 | p := w.Layout() | |
| 341 | - | if !p[0].Hidden || !p[1].Fullscreen || !p[1].Focused { | |
| 345 | + | if !p[0].Hidden || !p[1].Fullscreen || p[1].Focused || !p[0].Focused { | |
| 342 | 346 | t.Fatal(p) | |
| 343 | 347 | } | |
| 344 | 348 | w.SetFullscreen(2, false) | |
| @@ -10,7 +10,7 @@ func workspace() *Workspace { | |||||
| 10 | w := &Workspace{} | 10 | w := &Workspace{} | ||
| 11 | w.SetOutput(100, 81) | 11 | w.SetOutput(100, 81) | ||
| 12 | w.SetGaps(5) | 12 | w.SetGaps(5) | ||
| 13 | - | w.SetDefaultWidth(Width{Num: 1, Den: 2}) | 13 | + | w.SetMaxColumns(2) |
| 14 | return w | 14 | return w | ||
| 15 | } | 15 | } | ||
| 16 | func TestWidths(t *testing.T) { | 16 | func TestWidths(t *testing.T) { | ||
| @@ -45,11 +45,12 @@ func TestOperations(t *testing.T) { | |||||
| 45 | w.AddWindow(1) | 45 | w.AddWindow(1) | ||
| 46 | w.AddWindow(2) | 46 | w.AddWindow(2) | ||
| 47 | w.AddWindow(3) | 47 | w.AddWindow(3) | ||
| 48 | - | if w.Focus != 2 || w.ViewX != 49 { | 48 | + | // Three auto columns, two visible: each (100-3*5)/2 = 42 wide. |
| 49 | + | if w.Focus != 2 || w.ViewX != 46 || w.columnWidth(0) != 42 { | |||
| 49 | t.Fatalf("scroll: %+v", w) | 50 | t.Fatalf("scroll: %+v", w) | ||
| 50 | } | 51 | } | ||
| 51 | w.FocusColumn(-1) | 52 | w.FocusColumn(-1) | ||
| 52 | - | if w.ViewX != 48 { | 53 | + | if w.ViewX != 46 { |
| 53 | t.Fatal(w.ViewX) | 54 | t.Fatal(w.ViewX) | ||
| 54 | } | 55 | } | ||
| 55 | w.FocusColumn(-1) | 56 | w.FocusColumn(-1) | ||
| @@ -72,7 +73,7 @@ func TestOperations(t *testing.T) { | |||||
| 72 | } | 73 | } | ||
| 73 | func TestStackAndLayout(t *testing.T) { | 74 | func TestStackAndLayout(t *testing.T) { | ||
| 74 | w := workspace() | 75 | w := workspace() | ||
| 75 | - | w.Columns = []Column{{Windows: []WindowID{1, 2, 3}, Width: Width{Num: 1, Den: 2}, Focus: 1}} | 76 | + | w.Columns = []Column{{Windows: []WindowID{1, 2, 3}, Focus: 1}} |
| 76 | w.RemoveWindow(2) | 77 | w.RemoveWindow(2) | ||
| 77 | if id, _ := w.Focused(); id != 1 { | 78 | if id, _ := w.Focused(); id != 1 { | ||
| 78 | t.Fatal(id) | 79 | t.Fatal(id) | ||
| @@ -83,7 +84,7 @@ func TestStackAndLayout(t *testing.T) { | |||||
| 83 | } | 84 | } | ||
| 84 | w.FocusWindow(-1) | 85 | w.FocusWindow(-1) | ||
| 85 | p := w.Layout() | 86 | p := w.Layout() | ||
| 86 | - | if p[0].Rect != (Rect{X: 5, Y: 5, W: 43, H: 33}) || p[1].Rect != (Rect{X: 5, Y: 43, W: 43, H: 33}) { | 87 | + | if p[0].Rect != (Rect{X: 5, Y: 5, W: 90, H: 33}) || p[1].Rect != (Rect{X: 5, Y: 43, W: 90, H: 33}) || !p[0].Borderless { |
| 87 | t.Fatal(p) | 88 | t.Fatal(p) | ||
| 88 | } | 89 | } | ||
| 89 | w.ToggleFullscreen() | 90 | w.ToggleFullscreen() | ||
| @@ -110,18 +111,21 @@ func TestPresetsAndActions(t *testing.T) { | |||||
| 110 | p := []Width{{Pixels: 30}, {Pixels: 40}} | 111 | p := []Width{{Pixels: 30}, {Pixels: 40}} | ||
| 111 | w.SetPresets(p) | 112 | w.SetPresets(p) | ||
| 112 | p[0].Pixels = 99 | 113 | 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 Action | 130 | a Action | ||
| 127 | want Effect | 131 | 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.DefaultWidth | 189 | + | w.Columns[w.Focus].Width = Width{Num: math.MaxInt, Den: math.MaxInt} |
| 187 | w.scroll() | 190 | w.scroll() | ||
| 188 | } | 191 | } | ||
| 189 | case 16: | 192 | case 16: | ||
| @@ -335,10 +338,11 @@ func TestFullscreenPersistsAndScrolls(t *testing.T) { | |||||
| 335 | 338 | ||||
| 336 | func TestSetFullscreenStackDeactivation(t *testing.T) { | 339 | func TestSetFullscreenStackDeactivation(t *testing.T) { | ||
| 337 | w := workspace() | 340 | w := workspace() | ||
| 338 | - | w.Columns = []Column{{Windows: []WindowID{1, 2}, Width: w.DefaultWidth, Focus: 0}} | 341 | + | w.Columns = []Column{{Windows: []WindowID{1, 2}, Focus: 0}} |
| 342 | + | // A client request does not take focus; window 1 keeps it but is covered. | |||
| 339 | w.SetFullscreen(2, true) | 343 | w.SetFullscreen(2, true) | ||
| 340 | p := w.Layout() | 344 | p := w.Layout() | ||
| 341 | - | if !p[0].Hidden || !p[1].Fullscreen || !p[1].Focused { | 345 | + | if !p[0].Hidden || !p[1].Fullscreen || p[1].Focused || !p[0].Focused { |
| 342 | t.Fatal(p) | 346 | t.Fatal(p) | ||
| 343 | } | 347 | } | ||
| 344 | w.SetFullscreen(2, false) | 348 | w.SetFullscreen(2, false) | ||
A internal/core/monitor.go +150 -0
| @@ -0,0 +1,150 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | // Monitor owns an ordered list of workspaces for one output (ADR 011). Numbers | |
| 4 | + | // are positions: Cmd+N targets Workspaces[N-1]. An empty workspace always sits | |
| 5 | + | // below the last one, and an empty workspace is removed once left, except the | |
| 6 | + | // first. Output-wide settings apply to every workspace. | |
| 7 | + | type Monitor struct { | |
| 8 | + | Workspaces []*Workspace | |
| 9 | + | Active int | |
| 10 | + | template Workspace | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | func NewMonitor() *Monitor { | |
| 14 | + | m := &Monitor{} | |
| 15 | + | m.normalize() | |
| 16 | + | return m | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // Current is the workspace on screen. | |
| 20 | + | func (m *Monitor) Current() *Workspace { return m.Workspaces[m.Active] } | |
| 21 | + | ||
| 22 | + | func (m *Monitor) newWorkspace() *Workspace { | |
| 23 | + | w := m.template | |
| 24 | + | w.presets = append([]Width(nil), m.template.presets...) | |
| 25 | + | return &w | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // normalize keeps one trailing empty workspace and drops other empty ones that | |
| 29 | + | // are neither the first nor active. The active workspace never changes. | |
| 30 | + | func (m *Monitor) normalize() { | |
| 31 | + | for i := len(m.Workspaces) - 2; i >= 1; i-- { | |
| 32 | + | if m.Workspaces[i].empty() && i != m.Active { | |
| 33 | + | m.Workspaces = append(m.Workspaces[:i], m.Workspaces[i+1:]...) | |
| 34 | + | if i < m.Active { | |
| 35 | + | m.Active-- | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | for n := len(m.Workspaces); n >= 2 && m.Workspaces[n-1].empty() && m.Workspaces[n-2].empty() && m.Active != n-1; n-- { | |
| 40 | + | m.Workspaces = m.Workspaces[:n-1] | |
| 41 | + | } | |
| 42 | + | if n := len(m.Workspaces); n == 0 || !m.Workspaces[n-1].empty() { | |
| 43 | + | m.Workspaces = append(m.Workspaces, m.newWorkspace()) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // Focus switches to the workspace at index i, clamped to the list. | |
| 48 | + | func (m *Monitor) Focus(i int) { | |
| 49 | + | i = min(max(i, 0), len(m.Workspaces)-1) | |
| 50 | + | m.Active = i | |
| 51 | + | m.normalize() | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // FocusNumber switches to workspace n (1-based); past the end means the last. | |
| 55 | + | func (m *Monitor) FocusNumber(n int) { m.Focus(n - 1) } | |
| 56 | + | ||
| 57 | + | // find returns the workspace holding the window. | |
| 58 | + | func (m *Monitor) find(id WindowID) (*Workspace, int) { | |
| 59 | + | for i, w := range m.Workspaces { | |
| 60 | + | if w.has(id) { | |
| 61 | + | return w, i | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | return nil, -1 | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // AddWindow places a new window on the active workspace. | |
| 68 | + | func (m *Monitor) AddWindow(id WindowID) { | |
| 69 | + | if w, _ := m.find(id); w != nil { | |
| 70 | + | return | |
| 71 | + | } | |
| 72 | + | m.Current().AddWindow(id) | |
| 73 | + | m.normalize() | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // RemoveWindow drops the window wherever it is; focus stays on the active workspace. | |
| 77 | + | func (m *Monitor) RemoveWindow(id WindowID) { | |
| 78 | + | if w, _ := m.find(id); w != nil { | |
| 79 | + | w.RemoveWindow(id) | |
| 80 | + | m.normalize() | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // FocusID shows the window's workspace and focuses it. | |
| 85 | + | func (m *Monitor) FocusID(id WindowID) bool { | |
| 86 | + | w, i := m.find(id) | |
| 87 | + | if w == nil { | |
| 88 | + | return false | |
| 89 | + | } | |
| 90 | + | m.Active = i | |
| 91 | + | w.FocusID(id) | |
| 92 | + | m.normalize() | |
| 93 | + | return true | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // SetFullscreen applies a client request on the window's own workspace. It does | |
| 97 | + | // not switch workspaces: client requests never move the user. | |
| 98 | + | func (m *Monitor) SetFullscreen(id WindowID, on bool) { | |
| 99 | + | if w, _ := m.find(id); w != nil { | |
| 100 | + | w.SetFullscreen(id, on) | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // MoveToWorkspace moves the focused window to workspace index i; focus stays here. | |
| 105 | + | func (m *Monitor) MoveToWorkspace(i int) { | |
| 106 | + | i = min(max(i, 0), len(m.Workspaces)-1) | |
| 107 | + | cur := m.Current() | |
| 108 | + | id, ok := cur.Focused() | |
| 109 | + | if !ok || i == m.Active { | |
| 110 | + | return | |
| 111 | + | } | |
| 112 | + | cur.RemoveWindow(id) | |
| 113 | + | m.Workspaces[i].AddWindow(id) | |
| 114 | + | m.normalize() | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func (m *Monitor) Focused() (WindowID, bool) { return m.Current().Focused() } | |
| 118 | + | ||
| 119 | + | // Layout places every window: the active workspace on screen, others hidden. | |
| 120 | + | func (m *Monitor) Layout() []Placement { | |
| 121 | + | var result []Placement | |
| 122 | + | for i, w := range m.Workspaces { | |
| 123 | + | if i == m.Active { | |
| 124 | + | result = append(result, w.Layout()...) | |
| 125 | + | continue | |
| 126 | + | } | |
| 127 | + | for _, c := range w.Columns { | |
| 128 | + | for _, id := range c.Windows { | |
| 129 | + | result = append(result, Placement{ID: id, Hidden: true}) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | return result | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func (m *Monitor) Output() Rect { return m.template.Output } | |
| 137 | + | ||
| 138 | + | func (m *Monitor) each(f func(*Workspace)) { | |
| 139 | + | f(&m.template) | |
| 140 | + | for _, w := range m.Workspaces { | |
| 141 | + | f(w) | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | func (m *Monitor) SetOutput(width, height int) { | |
| 145 | + | m.each(func(w *Workspace) { w.SetOutput(width, height) }) | |
| 146 | + | } | |
| 147 | + | func (m *Monitor) SetUsable(r Rect) { m.each(func(w *Workspace) { w.SetUsable(r) }) } | |
| 148 | + | func (m *Monitor) SetGaps(g int) { m.each(func(w *Workspace) { w.SetGaps(g) }) } | |
| 149 | + | func (m *Monitor) SetPresets(v []Width) { m.each(func(w *Workspace) { w.SetPresets(v) }) } | |
| 150 | + | func (m *Monitor) SetMaxColumns(n int) { m.each(func(w *Workspace) { w.SetMaxColumns(n) }) } | |
| @@ -0,0 +1,150 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | // Monitor owns an ordered list of workspaces for one output (ADR 011). Numbers | |||
| 4 | + | // are positions: Cmd+N targets Workspaces[N-1]. An empty workspace always sits | |||
| 5 | + | // below the last one, and an empty workspace is removed once left, except the | |||
| 6 | + | // first. Output-wide settings apply to every workspace. | |||
| 7 | + | type Monitor struct { | |||
| 8 | + | Workspaces []*Workspace | |||
| 9 | + | Active int | |||
| 10 | + | template Workspace | |||
| 11 | + | } | |||
| 12 | + | ||||
| 13 | + | func NewMonitor() *Monitor { | |||
| 14 | + | m := &Monitor{} | |||
| 15 | + | m.normalize() | |||
| 16 | + | return m | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // Current is the workspace on screen. | |||
| 20 | + | func (m *Monitor) Current() *Workspace { return m.Workspaces[m.Active] } | |||
| 21 | + | ||||
| 22 | + | func (m *Monitor) newWorkspace() *Workspace { | |||
| 23 | + | w := m.template | |||
| 24 | + | w.presets = append([]Width(nil), m.template.presets...) | |||
| 25 | + | return &w | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // normalize keeps one trailing empty workspace and drops other empty ones that | |||
| 29 | + | // are neither the first nor active. The active workspace never changes. | |||
| 30 | + | func (m *Monitor) normalize() { | |||
| 31 | + | for i := len(m.Workspaces) - 2; i >= 1; i-- { | |||
| 32 | + | if m.Workspaces[i].empty() && i != m.Active { | |||
| 33 | + | m.Workspaces = append(m.Workspaces[:i], m.Workspaces[i+1:]...) | |||
| 34 | + | if i < m.Active { | |||
| 35 | + | m.Active-- | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | for n := len(m.Workspaces); n >= 2 && m.Workspaces[n-1].empty() && m.Workspaces[n-2].empty() && m.Active != n-1; n-- { | |||
| 40 | + | m.Workspaces = m.Workspaces[:n-1] | |||
| 41 | + | } | |||
| 42 | + | if n := len(m.Workspaces); n == 0 || !m.Workspaces[n-1].empty() { | |||
| 43 | + | m.Workspaces = append(m.Workspaces, m.newWorkspace()) | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // Focus switches to the workspace at index i, clamped to the list. | |||
| 48 | + | func (m *Monitor) Focus(i int) { | |||
| 49 | + | i = min(max(i, 0), len(m.Workspaces)-1) | |||
| 50 | + | m.Active = i | |||
| 51 | + | m.normalize() | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // FocusNumber switches to workspace n (1-based); past the end means the last. | |||
| 55 | + | func (m *Monitor) FocusNumber(n int) { m.Focus(n - 1) } | |||
| 56 | + | ||||
| 57 | + | // find returns the workspace holding the window. | |||
| 58 | + | func (m *Monitor) find(id WindowID) (*Workspace, int) { | |||
| 59 | + | for i, w := range m.Workspaces { | |||
| 60 | + | if w.has(id) { | |||
| 61 | + | return w, i | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | return nil, -1 | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // AddWindow places a new window on the active workspace. | |||
| 68 | + | func (m *Monitor) AddWindow(id WindowID) { | |||
| 69 | + | if w, _ := m.find(id); w != nil { | |||
| 70 | + | return | |||
| 71 | + | } | |||
| 72 | + | m.Current().AddWindow(id) | |||
| 73 | + | m.normalize() | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // RemoveWindow drops the window wherever it is; focus stays on the active workspace. | |||
| 77 | + | func (m *Monitor) RemoveWindow(id WindowID) { | |||
| 78 | + | if w, _ := m.find(id); w != nil { | |||
| 79 | + | w.RemoveWindow(id) | |||
| 80 | + | m.normalize() | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // FocusID shows the window's workspace and focuses it. | |||
| 85 | + | func (m *Monitor) FocusID(id WindowID) bool { | |||
| 86 | + | w, i := m.find(id) | |||
| 87 | + | if w == nil { | |||
| 88 | + | return false | |||
| 89 | + | } | |||
| 90 | + | m.Active = i | |||
| 91 | + | w.FocusID(id) | |||
| 92 | + | m.normalize() | |||
| 93 | + | return true | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | // SetFullscreen applies a client request on the window's own workspace. It does | |||
| 97 | + | // not switch workspaces: client requests never move the user. | |||
| 98 | + | func (m *Monitor) SetFullscreen(id WindowID, on bool) { | |||
| 99 | + | if w, _ := m.find(id); w != nil { | |||
| 100 | + | w.SetFullscreen(id, on) | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // MoveToWorkspace moves the focused window to workspace index i; focus stays here. | |||
| 105 | + | func (m *Monitor) MoveToWorkspace(i int) { | |||
| 106 | + | i = min(max(i, 0), len(m.Workspaces)-1) | |||
| 107 | + | cur := m.Current() | |||
| 108 | + | id, ok := cur.Focused() | |||
| 109 | + | if !ok || i == m.Active { | |||
| 110 | + | return | |||
| 111 | + | } | |||
| 112 | + | cur.RemoveWindow(id) | |||
| 113 | + | m.Workspaces[i].AddWindow(id) | |||
| 114 | + | m.normalize() | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func (m *Monitor) Focused() (WindowID, bool) { return m.Current().Focused() } | |||
| 118 | + | ||||
| 119 | + | // Layout places every window: the active workspace on screen, others hidden. | |||
| 120 | + | func (m *Monitor) Layout() []Placement { | |||
| 121 | + | var result []Placement | |||
| 122 | + | for i, w := range m.Workspaces { | |||
| 123 | + | if i == m.Active { | |||
| 124 | + | result = append(result, w.Layout()...) | |||
| 125 | + | continue | |||
| 126 | + | } | |||
| 127 | + | for _, c := range w.Columns { | |||
| 128 | + | for _, id := range c.Windows { | |||
| 129 | + | result = append(result, Placement{ID: id, Hidden: true}) | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | return result | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func (m *Monitor) Output() Rect { return m.template.Output } | |||
| 137 | + | ||||
| 138 | + | func (m *Monitor) each(f func(*Workspace)) { | |||
| 139 | + | f(&m.template) | |||
| 140 | + | for _, w := range m.Workspaces { | |||
| 141 | + | f(w) | |||
| 142 | + | } | |||
| 143 | + | } | |||
| 144 | + | func (m *Monitor) SetOutput(width, height int) { | |||
| 145 | + | m.each(func(w *Workspace) { w.SetOutput(width, height) }) | |||
| 146 | + | } | |||
| 147 | + | func (m *Monitor) SetUsable(r Rect) { m.each(func(w *Workspace) { w.SetUsable(r) }) } | |||
| 148 | + | func (m *Monitor) SetGaps(g int) { m.each(func(w *Workspace) { w.SetGaps(g) }) } | |||
| 149 | + | func (m *Monitor) SetPresets(v []Width) { m.each(func(w *Workspace) { w.SetPresets(v) }) } | |||
| 150 | + | func (m *Monitor) SetMaxColumns(n int) { m.each(func(w *Workspace) { w.SetMaxColumns(n) }) } | |||
A internal/core/monitor_test.go +241 -0
| @@ -0,0 +1,241 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "reflect" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func monitor() *Monitor { | |
| 9 | + | m := NewMonitor() | |
| 10 | + | m.SetOutput(100, 80) | |
| 11 | + | m.SetMaxColumns(2) | |
| 12 | + | return m | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | // windows lists each workspace's windows, in column order. | |
| 16 | + | func windows(m *Monitor) [][]WindowID { | |
| 17 | + | var out [][]WindowID | |
| 18 | + | for _, w := range m.Workspaces { | |
| 19 | + | list := []WindowID{} | |
| 20 | + | for _, c := range w.Columns { | |
| 21 | + | list = append(list, c.Windows...) | |
| 22 | + | } | |
| 23 | + | out = append(out, list) | |
| 24 | + | } | |
| 25 | + | return out | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestMonitorKeepsOneEmptyWorkspaceBelow(t *testing.T) { | |
| 29 | + | m := monitor() | |
| 30 | + | if len(m.Workspaces) != 1 || m.Active != 0 { | |
| 31 | + | t.Fatal(windows(m)) | |
| 32 | + | } | |
| 33 | + | m.AddWindow(1) | |
| 34 | + | if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {}}) { | |
| 35 | + | t.Fatal(windows(m)) | |
| 36 | + | } | |
| 37 | + | // Cmd+9 past the end lands on the trailing empty workspace. | |
| 38 | + | m.FocusNumber(9) | |
| 39 | + | if m.Active != 1 { | |
| 40 | + | t.Fatal(m.Active) | |
| 41 | + | } | |
| 42 | + | m.AddWindow(2) | |
| 43 | + | if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {2}, {}}) { | |
| 44 | + | t.Fatal(windows(m)) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func TestMonitorRemovesEmptyWorkspaceOnceLeft(t *testing.T) { | |
| 49 | + | m := monitor() | |
| 50 | + | m.AddWindow(1) | |
| 51 | + | m.FocusNumber(2) | |
| 52 | + | m.AddWindow(2) | |
| 53 | + | m.FocusNumber(3) | |
| 54 | + | m.AddWindow(3) | |
| 55 | + | // [1:{1}] [2:{2}] [3:{3}] [4:{}] | |
| 56 | + | m.FocusNumber(2) | |
| 57 | + | m.RemoveWindow(2) | |
| 58 | + | // Still on the now-empty workspace 2: it stays while active. | |
| 59 | + | if len(m.Workspaces) != 4 || m.Active != 1 { | |
| 60 | + | t.Fatal(windows(m), m.Active) | |
| 61 | + | } | |
| 62 | + | m.FocusNumber(1) | |
| 63 | + | // Once left it is removed: workspace 3 becomes 2. | |
| 64 | + | if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {3}, {}}) || m.Active != 0 { | |
| 65 | + | t.Fatal(windows(m), m.Active) | |
| 66 | + | } | |
| 67 | + | m.FocusNumber(2) | |
| 68 | + | if id, _ := m.Focused(); id != 3 { | |
| 69 | + | t.Fatal(id) | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestMonitorFirstWorkspaceStays(t *testing.T) { | |
| 74 | + | m := monitor() | |
| 75 | + | m.AddWindow(1) | |
| 76 | + | m.RemoveWindow(1) | |
| 77 | + | m.FocusNumber(2) | |
| 78 | + | if len(m.Workspaces) != 1 || m.Active != 0 { | |
| 79 | + | t.Fatal(windows(m), m.Active) | |
| 80 | + | } | |
| 81 | + | // Leaving an empty first workspace does not remove it. | |
| 82 | + | m.AddWindow(1) | |
| 83 | + | m.FocusNumber(2) | |
| 84 | + | m.AddWindow(2) | |
| 85 | + | m.FocusNumber(1) | |
| 86 | + | m.RemoveWindow(1) | |
| 87 | + | m.FocusNumber(2) | |
| 88 | + | if !reflect.DeepEqual(windows(m), [][]WindowID{{}, {2}, {}}) { | |
| 89 | + | t.Fatal(windows(m)) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestMonitorRemoveElsewhereKeepsActive(t *testing.T) { | |
| 94 | + | m := monitor() | |
| 95 | + | m.AddWindow(1) | |
| 96 | + | m.FocusNumber(2) | |
| 97 | + | m.AddWindow(2) | |
| 98 | + | m.FocusNumber(3) | |
| 99 | + | m.AddWindow(3) | |
| 100 | + | m.FocusNumber(3) | |
| 101 | + | // A window on another workspace closes: the user stays on workspace 3's windows. | |
| 102 | + | m.RemoveWindow(2) | |
| 103 | + | if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {3}, {}}) || m.Active != 1 { | |
| 104 | + | t.Fatal(windows(m), m.Active) | |
| 105 | + | } | |
| 106 | + | if id, _ := m.Focused(); id != 3 { | |
| 107 | + | t.Fatal(id) | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func TestMonitorLayoutHidesOtherWorkspaces(t *testing.T) { | |
| 112 | + | m := monitor() | |
| 113 | + | m.AddWindow(1) | |
| 114 | + | m.FocusNumber(2) | |
| 115 | + | m.AddWindow(2) | |
| 116 | + | for _, p := range m.Layout() { | |
| 117 | + | if hidden := p.ID == 1; p.Hidden != hidden { | |
| 118 | + | t.Fatal(m.Layout()) | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | if !m.FocusID(1) || m.Active != 0 { | |
| 122 | + | t.Fatal(m.Active) | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func TestMonitorFullscreenRequestStaysPut(t *testing.T) { | |
| 127 | + | m := monitor() | |
| 128 | + | m.AddWindow(1) | |
| 129 | + | m.FocusNumber(2) | |
| 130 | + | m.AddWindow(2) | |
| 131 | + | m.SetFullscreen(1, true) | |
| 132 | + | if m.Active != 1 { | |
| 133 | + | t.Fatal("client request switched workspace") | |
| 134 | + | } | |
| 135 | + | if m.Workspaces[0].fullscreen != 1 { | |
| 136 | + | t.Fatal("fullscreen not applied") | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func TestMonitorActions(t *testing.T) { | |
| 141 | + | m := monitor() | |
| 142 | + | m.AddWindow(1) | |
| 143 | + | m.AddWindow(2) | |
| 144 | + | // Down past the only window of the column goes to the workspace below. | |
| 145 | + | m.Apply(ActionFocusWindowDown) | |
| 146 | + | if m.Active != 1 { | |
| 147 | + | t.Fatal(m.Active) | |
| 148 | + | } | |
| 149 | + | m.Apply(ActionFocusWindowUp) | |
| 150 | + | if m.Active != 0 { | |
| 151 | + | t.Fatal(m.Active) | |
| 152 | + | } | |
| 153 | + | // Up at the top workspace stays. | |
| 154 | + | m.Apply(ActionFocusWorkspaceUp) | |
| 155 | + | if m.Active != 0 { | |
| 156 | + | t.Fatal(m.Active) | |
| 157 | + | } | |
| 158 | + | // Move the focused window (2) down: focus stays on workspace 1. | |
| 159 | + | m.Apply(ActionMoveToWorkspaceDown) | |
| 160 | + | if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {2}, {}}) || m.Active != 0 { | |
| 161 | + | t.Fatal(windows(m), m.Active) | |
| 162 | + | } | |
| 163 | + | m.Apply("move-to-workspace 3") | |
| 164 | + | if !reflect.DeepEqual(windows(m), [][]WindowID{{}, {2}, {1}, {}}) || m.Active != 0 { | |
| 165 | + | t.Fatal(windows(m), m.Active) | |
| 166 | + | } | |
| 167 | + | m.Apply("focus-workspace 3") | |
| 168 | + | if id, _ := m.Focused(); id != 1 { | |
| 169 | + | t.Fatal(id) | |
| 170 | + | } | |
| 171 | + | // Workspace 1 is empty but first: it stays. | |
| 172 | + | if len(m.Workspaces) != 4 { | |
| 173 | + | t.Fatal(windows(m)) | |
| 174 | + | } | |
| 175 | + | if e := m.Apply("spawn foot"); !e.Spawn { | |
| 176 | + | t.Fatal(e) | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func TestEqualSharesAndBorderless(t *testing.T) { | |
| 181 | + | m := monitor() | |
| 182 | + | m.SetMaxColumns(3) | |
| 183 | + | m.AddWindow(1) | |
| 184 | + | p := m.Layout() | |
| 185 | + | if p[0].Rect.W != 100 || !p[0].Borderless { | |
| 186 | + | t.Fatal(p) | |
| 187 | + | } | |
| 188 | + | m.AddWindow(2) | |
| 189 | + | m.AddWindow(3) | |
| 190 | + | p = m.Layout() | |
| 191 | + | if p[0].Rect.W != 33 || p[1].Rect.W != 33 || p[2].Rect.W != 33 || p[0].Borderless { | |
| 192 | + | t.Fatal(p) | |
| 193 | + | } | |
| 194 | + | // A fourth column scrolls: still 1/3 each. | |
| 195 | + | m.AddWindow(4) | |
| 196 | + | if w := m.Current(); w.columnWidth(3) != 33 || w.ViewX == 0 { | |
| 197 | + | t.Fatal(w.ViewX) | |
| 198 | + | } | |
| 199 | + | // A preset on one column is kept; auto columns keep their share. | |
| 200 | + | m.SetPresets([]Width{{Num: 1, Den: 2}}) | |
| 201 | + | m.Apply(ActionCycleColumnWidth) | |
| 202 | + | if w := m.Current(); w.columnWidth(w.Focus) != 50 || w.columnWidth(0) != 33 { | |
| 203 | + | t.Fatal(w.columnWidth(w.Focus), w.columnWidth(0)) | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | func TestWorkspaceArg(t *testing.T) { | |
| 208 | + | for _, tc := range []struct { | |
| 209 | + | a Action | |
| 210 | + | n int | |
| 211 | + | move, ok bool | |
| 212 | + | }{{"focus-workspace 3", 3, false, true}, {"move-to-workspace 12", 12, true, true}, {"focus-workspace 0", 0, false, false}, {"focus-workspace x", 0, false, false}, {"quit", 0, false, false}} { | |
| 213 | + | n, move, ok := WorkspaceArg(tc.a) | |
| 214 | + | if n != tc.n || move != tc.move || ok != tc.ok { | |
| 215 | + | t.Errorf("%s: %d %v %v", tc.a, n, move, ok) | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | func TestFullscreenRequestKeepsFocus(t *testing.T) { | |
| 221 | + | m := monitor() | |
| 222 | + | m.AddWindow(1) | |
| 223 | + | m.AddWindow(2) | |
| 224 | + | m.SetFullscreen(1, true) | |
| 225 | + | if id, _ := m.Focused(); id != 2 { | |
| 226 | + | t.Fatal(id) | |
| 227 | + | } | |
| 228 | + | if m.Current().fullscreen != 1 { | |
| 229 | + | t.Fatal("not fullscreen") | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | func TestLonePresetColumnKeepsWidth(t *testing.T) { | |
| 234 | + | m := monitor() | |
| 235 | + | m.SetPresets([]Width{{Num: 1, Den: 2}}) | |
| 236 | + | m.AddWindow(1) | |
| 237 | + | m.Apply(ActionCycleColumnWidth) | |
| 238 | + | if p := m.Layout()[0]; p.Rect.W != 50 || p.Borderless { | |
| 239 | + | t.Fatal(p) | |
| 240 | + | } | |
| 241 | + | } | |
| @@ -0,0 +1,241 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "reflect" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func monitor() *Monitor { | |||
| 9 | + | m := NewMonitor() | |||
| 10 | + | m.SetOutput(100, 80) | |||
| 11 | + | m.SetMaxColumns(2) | |||
| 12 | + | return m | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | // windows lists each workspace's windows, in column order. | |||
| 16 | + | func windows(m *Monitor) [][]WindowID { | |||
| 17 | + | var out [][]WindowID | |||
| 18 | + | for _, w := range m.Workspaces { | |||
| 19 | + | list := []WindowID{} | |||
| 20 | + | for _, c := range w.Columns { | |||
| 21 | + | list = append(list, c.Windows...) | |||
| 22 | + | } | |||
| 23 | + | out = append(out, list) | |||
| 24 | + | } | |||
| 25 | + | return out | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestMonitorKeepsOneEmptyWorkspaceBelow(t *testing.T) { | |||
| 29 | + | m := monitor() | |||
| 30 | + | if len(m.Workspaces) != 1 || m.Active != 0 { | |||
| 31 | + | t.Fatal(windows(m)) | |||
| 32 | + | } | |||
| 33 | + | m.AddWindow(1) | |||
| 34 | + | if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {}}) { | |||
| 35 | + | t.Fatal(windows(m)) | |||
| 36 | + | } | |||
| 37 | + | // Cmd+9 past the end lands on the trailing empty workspace. | |||
| 38 | + | m.FocusNumber(9) | |||
| 39 | + | if m.Active != 1 { | |||
| 40 | + | t.Fatal(m.Active) | |||
| 41 | + | } | |||
| 42 | + | m.AddWindow(2) | |||
| 43 | + | if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {2}, {}}) { | |||
| 44 | + | t.Fatal(windows(m)) | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func TestMonitorRemovesEmptyWorkspaceOnceLeft(t *testing.T) { | |||
| 49 | + | m := monitor() | |||
| 50 | + | m.AddWindow(1) | |||
| 51 | + | m.FocusNumber(2) | |||
| 52 | + | m.AddWindow(2) | |||
| 53 | + | m.FocusNumber(3) | |||
| 54 | + | m.AddWindow(3) | |||
| 55 | + | // [1:{1}] [2:{2}] [3:{3}] [4:{}] | |||
| 56 | + | m.FocusNumber(2) | |||
| 57 | + | m.RemoveWindow(2) | |||
| 58 | + | // Still on the now-empty workspace 2: it stays while active. | |||
| 59 | + | if len(m.Workspaces) != 4 || m.Active != 1 { | |||
| 60 | + | t.Fatal(windows(m), m.Active) | |||
| 61 | + | } | |||
| 62 | + | m.FocusNumber(1) | |||
| 63 | + | // Once left it is removed: workspace 3 becomes 2. | |||
| 64 | + | if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {3}, {}}) || m.Active != 0 { | |||
| 65 | + | t.Fatal(windows(m), m.Active) | |||
| 66 | + | } | |||
| 67 | + | m.FocusNumber(2) | |||
| 68 | + | if id, _ := m.Focused(); id != 3 { | |||
| 69 | + | t.Fatal(id) | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func TestMonitorFirstWorkspaceStays(t *testing.T) { | |||
| 74 | + | m := monitor() | |||
| 75 | + | m.AddWindow(1) | |||
| 76 | + | m.RemoveWindow(1) | |||
| 77 | + | m.FocusNumber(2) | |||
| 78 | + | if len(m.Workspaces) != 1 || m.Active != 0 { | |||
| 79 | + | t.Fatal(windows(m), m.Active) | |||
| 80 | + | } | |||
| 81 | + | // Leaving an empty first workspace does not remove it. | |||
| 82 | + | m.AddWindow(1) | |||
| 83 | + | m.FocusNumber(2) | |||
| 84 | + | m.AddWindow(2) | |||
| 85 | + | m.FocusNumber(1) | |||
| 86 | + | m.RemoveWindow(1) | |||
| 87 | + | m.FocusNumber(2) | |||
| 88 | + | if !reflect.DeepEqual(windows(m), [][]WindowID{{}, {2}, {}}) { | |||
| 89 | + | t.Fatal(windows(m)) | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func TestMonitorRemoveElsewhereKeepsActive(t *testing.T) { | |||
| 94 | + | m := monitor() | |||
| 95 | + | m.AddWindow(1) | |||
| 96 | + | m.FocusNumber(2) | |||
| 97 | + | m.AddWindow(2) | |||
| 98 | + | m.FocusNumber(3) | |||
| 99 | + | m.AddWindow(3) | |||
| 100 | + | m.FocusNumber(3) | |||
| 101 | + | // A window on another workspace closes: the user stays on workspace 3's windows. | |||
| 102 | + | m.RemoveWindow(2) | |||
| 103 | + | if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {3}, {}}) || m.Active != 1 { | |||
| 104 | + | t.Fatal(windows(m), m.Active) | |||
| 105 | + | } | |||
| 106 | + | if id, _ := m.Focused(); id != 3 { | |||
| 107 | + | t.Fatal(id) | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func TestMonitorLayoutHidesOtherWorkspaces(t *testing.T) { | |||
| 112 | + | m := monitor() | |||
| 113 | + | m.AddWindow(1) | |||
| 114 | + | m.FocusNumber(2) | |||
| 115 | + | m.AddWindow(2) | |||
| 116 | + | for _, p := range m.Layout() { | |||
| 117 | + | if hidden := p.ID == 1; p.Hidden != hidden { | |||
| 118 | + | t.Fatal(m.Layout()) | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | if !m.FocusID(1) || m.Active != 0 { | |||
| 122 | + | t.Fatal(m.Active) | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func TestMonitorFullscreenRequestStaysPut(t *testing.T) { | |||
| 127 | + | m := monitor() | |||
| 128 | + | m.AddWindow(1) | |||
| 129 | + | m.FocusNumber(2) | |||
| 130 | + | m.AddWindow(2) | |||
| 131 | + | m.SetFullscreen(1, true) | |||
| 132 | + | if m.Active != 1 { | |||
| 133 | + | t.Fatal("client request switched workspace") | |||
| 134 | + | } | |||
| 135 | + | if m.Workspaces[0].fullscreen != 1 { | |||
| 136 | + | t.Fatal("fullscreen not applied") | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func TestMonitorActions(t *testing.T) { | |||
| 141 | + | m := monitor() | |||
| 142 | + | m.AddWindow(1) | |||
| 143 | + | m.AddWindow(2) | |||
| 144 | + | // Down past the only window of the column goes to the workspace below. | |||
| 145 | + | m.Apply(ActionFocusWindowDown) | |||
| 146 | + | if m.Active != 1 { | |||
| 147 | + | t.Fatal(m.Active) | |||
| 148 | + | } | |||
| 149 | + | m.Apply(ActionFocusWindowUp) | |||
| 150 | + | if m.Active != 0 { | |||
| 151 | + | t.Fatal(m.Active) | |||
| 152 | + | } | |||
| 153 | + | // Up at the top workspace stays. | |||
| 154 | + | m.Apply(ActionFocusWorkspaceUp) | |||
| 155 | + | if m.Active != 0 { | |||
| 156 | + | t.Fatal(m.Active) | |||
| 157 | + | } | |||
| 158 | + | // Move the focused window (2) down: focus stays on workspace 1. | |||
| 159 | + | m.Apply(ActionMoveToWorkspaceDown) | |||
| 160 | + | if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {2}, {}}) || m.Active != 0 { | |||
| 161 | + | t.Fatal(windows(m), m.Active) | |||
| 162 | + | } | |||
| 163 | + | m.Apply("move-to-workspace 3") | |||
| 164 | + | if !reflect.DeepEqual(windows(m), [][]WindowID{{}, {2}, {1}, {}}) || m.Active != 0 { | |||
| 165 | + | t.Fatal(windows(m), m.Active) | |||
| 166 | + | } | |||
| 167 | + | m.Apply("focus-workspace 3") | |||
| 168 | + | if id, _ := m.Focused(); id != 1 { | |||
| 169 | + | t.Fatal(id) | |||
| 170 | + | } | |||
| 171 | + | // Workspace 1 is empty but first: it stays. | |||
| 172 | + | if len(m.Workspaces) != 4 { | |||
| 173 | + | t.Fatal(windows(m)) | |||
| 174 | + | } | |||
| 175 | + | if e := m.Apply("spawn foot"); !e.Spawn { | |||
| 176 | + | t.Fatal(e) | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | func TestEqualSharesAndBorderless(t *testing.T) { | |||
| 181 | + | m := monitor() | |||
| 182 | + | m.SetMaxColumns(3) | |||
| 183 | + | m.AddWindow(1) | |||
| 184 | + | p := m.Layout() | |||
| 185 | + | if p[0].Rect.W != 100 || !p[0].Borderless { | |||
| 186 | + | t.Fatal(p) | |||
| 187 | + | } | |||
| 188 | + | m.AddWindow(2) | |||
| 189 | + | m.AddWindow(3) | |||
| 190 | + | p = m.Layout() | |||
| 191 | + | if p[0].Rect.W != 33 || p[1].Rect.W != 33 || p[2].Rect.W != 33 || p[0].Borderless { | |||
| 192 | + | t.Fatal(p) | |||
| 193 | + | } | |||
| 194 | + | // A fourth column scrolls: still 1/3 each. | |||
| 195 | + | m.AddWindow(4) | |||
| 196 | + | if w := m.Current(); w.columnWidth(3) != 33 || w.ViewX == 0 { | |||
| 197 | + | t.Fatal(w.ViewX) | |||
| 198 | + | } | |||
| 199 | + | // A preset on one column is kept; auto columns keep their share. | |||
| 200 | + | m.SetPresets([]Width{{Num: 1, Den: 2}}) | |||
| 201 | + | m.Apply(ActionCycleColumnWidth) | |||
| 202 | + | if w := m.Current(); w.columnWidth(w.Focus) != 50 || w.columnWidth(0) != 33 { | |||
| 203 | + | t.Fatal(w.columnWidth(w.Focus), w.columnWidth(0)) | |||
| 204 | + | } | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | func TestWorkspaceArg(t *testing.T) { | |||
| 208 | + | for _, tc := range []struct { | |||
| 209 | + | a Action | |||
| 210 | + | n int | |||
| 211 | + | move, ok bool | |||
| 212 | + | }{{"focus-workspace 3", 3, false, true}, {"move-to-workspace 12", 12, true, true}, {"focus-workspace 0", 0, false, false}, {"focus-workspace x", 0, false, false}, {"quit", 0, false, false}} { | |||
| 213 | + | n, move, ok := WorkspaceArg(tc.a) | |||
| 214 | + | if n != tc.n || move != tc.move || ok != tc.ok { | |||
| 215 | + | t.Errorf("%s: %d %v %v", tc.a, n, move, ok) | |||
| 216 | + | } | |||
| 217 | + | } | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | func TestFullscreenRequestKeepsFocus(t *testing.T) { | |||
| 221 | + | m := monitor() | |||
| 222 | + | m.AddWindow(1) | |||
| 223 | + | m.AddWindow(2) | |||
| 224 | + | m.SetFullscreen(1, true) | |||
| 225 | + | if id, _ := m.Focused(); id != 2 { | |||
| 226 | + | t.Fatal(id) | |||
| 227 | + | } | |||
| 228 | + | if m.Current().fullscreen != 1 { | |||
| 229 | + | t.Fatal("not fullscreen") | |||
| 230 | + | } | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | func TestLonePresetColumnKeepsWidth(t *testing.T) { | |||
| 234 | + | m := monitor() | |||
| 235 | + | m.SetPresets([]Width{{Num: 1, Den: 2}}) | |||
| 236 | + | m.AddWindow(1) | |||
| 237 | + | m.Apply(ActionCycleColumnWidth) | |||
| 238 | + | if p := m.Layout()[0]; p.Rect.W != 50 || p.Borderless { | |||
| 239 | + | t.Fatal(p) | |||
| 240 | + | } | |||
| 241 | + | } | |||
M internal/ports/config.go +4 -3
| @@ -28,9 +28,10 @@ type Config struct { | |||
| 28 | 28 | Inactive string | |
| 29 | 29 | } | |
| 30 | 30 | 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 | |
| 34 | 35 | } | |
| 35 | 36 | // Outputs selects and configures physical displays (drm backend). | |
| 36 | 37 | Outputs []OutputConfig | |
| @@ -28,9 +28,10 @@ type Config struct { | |||||
| 28 | Inactive string | 28 | Inactive string | ||
| 29 | } | 29 | } | ||
| 30 | Layout struct { | 30 | Layout struct { | ||
| 31 | - | Gaps int | 31 | + | Gaps int |
| 32 | - | DefaultColumnWidth string | 32 | + | // MaxColumns is how many columns share the screen before scrolling. |
| 33 | - | Presets []string | 33 | + | 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 []OutputConfig | 37 | Outputs []OutputConfig | ||
M internal/ports/messages.go +11 -6
| @@ -80,12 +80,15 @@ type InputEvent interface{ inputEvent() } | |||
| 80 | 80 | // Keycode is the evdev code (xkb keycode - 8) and State the xkb modifier state | |
| 81 | 81 | // after this transition; wayland forwards both to clients unchanged. | |
| 82 | 82 | 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 | |
| 89 | 92 | } | |
| 90 | 93 | ||
| 91 | 94 | // ModState is the serialized xkb modifier state sent in wl_keyboard.modifiers. | |
| @@ -235,4 +238,6 @@ type SceneWindow struct { | |||
| 235 | 238 | ID WindowID | |
| 236 | 239 | Rect Rect | |
| 237 | 240 | Focused, Fullscreen, Hidden bool | |
| 241 | + | // Borderless windows fill the usable width alone; no border is drawn. | |
| 242 | + | Borderless bool | |
| 238 | 243 | } | |
| @@ -80,12 +80,15 @@ type InputEvent interface{ inputEvent() } | |||||
| 80 | // Keycode is the evdev code (xkb keycode - 8) and State the xkb modifier state | 80 | // 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 string | 83 | + | Keysym string |
| 84 | - | Mods Mods | 84 | + | // Base and Shifted are the key's level 1 and 2 keysyms in the active layout, |
| 85 | - | Pressed bool | 85 | + | // so binds like Cmd+1 also match on layouts where 1 needs Shift (AZERTY). |
| 86 | - | TimeMsec uint32 | 86 | + | Base, Shifted string |
| 87 | - | Keycode uint32 | 87 | + | Mods Mods |
| 88 | - | State ModState | 88 | + | 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 WindowID | 238 | ID WindowID | ||
| 236 | Rect Rect | 239 | Rect Rect | ||
| 237 | Focused, Fullscreen, Hidden bool | 240 | Focused, Fullscreen, Hidden bool | ||
| 241 | + | // Borderless windows fill the usable width alone; no border is drawn. | |||
| 242 | + | Borderless bool | |||
| 238 | } | 243 | } | ||