feat(scale): per-output HiDPI scale with live zoom and physical-key binds
output.<name>.scale (default 1, decimals or fractions) makes the layout logical; the renderer maps it to physical pixels and resamples buffers drawn at another size. Clients get wp_fractional_scale_v1 (exact 1/120 scale), wp_viewporter, wl_output.scale and wl_surface.preferred_buffer_scale rounded up, and the logical size through xdg-output and layer-shell. scale-up/scale-down step through the output's clean scales (whole logical pixels) and apply live. Binds accept code:N physical keys; the digit row and zoom defaults use them so they work on every layout.
- commit
- 8b505f81c24c23b75ca4b0904c5a2aca94513b55
- author
- brice · · 2026-09-24 14:42 UTC
- parent
- 777707c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
21 files · +944 -162
M examples/config +28 -18
| @@ -32,6 +32,9 @@ layout.presets = 1/3, 1/2, 2/3, 1 | |||
| 32 | 32 | # The first listed display that is connected and not off is used. | |
| 33 | 33 | # output.DP-2 = 5120x2160@165.058 | |
| 34 | 34 | # output.HDMI-A-1 = off | |
| 35 | + | # Scale (HiDPI), 1 to 4, like 1.5 or 4/3. Default 1. scale-up and scale-down | |
| 36 | + | # step through the scales where the display divides into whole pixels. | |
| 37 | + | # output.DP-2.scale = 4/3 | |
| 35 | 38 | ||
| 36 | 39 | render.direct-scanout = on | |
| 37 | 40 | ||
| @@ -40,6 +43,9 @@ log.level = info | |||
| 40 | 43 | log.debug = | |
| 41 | 44 | ||
| 42 | 45 | # Binds: bind.<keys> = <action>. Modifiers: cmd, shift, ctrl, alt, super. | |
| 46 | + | # code:N is a physical key (evdev code), the same key on every layout: | |
| 47 | + | # code:2 to code:10 are the digit row 1 to 9, code:12 and code:13 the two | |
| 48 | + | # keys right of 0 (- and = on US). | |
| 43 | 49 | # Keys are what your layout prints (cmd+é on AZERTY). Write = # and space | |
| 44 | 50 | # by name: cmd+equal, cmd+numbersign, cmd+space. `none` removes a default. | |
| 45 | 51 | # `spawn <command>` runs a program; arguments split on spaces, no shell. | |
| @@ -62,21 +68,25 @@ bind.cmd+pageup = focus-workspace-up | |||
| 62 | 68 | bind.cmd+pagedown = focus-workspace-down | |
| 63 | 69 | bind.cmd+shift+pageup = move-to-workspace-up | |
| 64 | 70 | 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 | |
| 71 | + | bind.cmd+code:2 = focus-workspace 1 | |
| 72 | + | bind.cmd+code:3 = focus-workspace 2 | |
| 73 | + | bind.cmd+code:4 = focus-workspace 3 | |
| 74 | + | bind.cmd+code:5 = focus-workspace 4 | |
| 75 | + | bind.cmd+code:6 = focus-workspace 5 | |
| 76 | + | bind.cmd+code:7 = focus-workspace 6 | |
| 77 | + | bind.cmd+code:8 = focus-workspace 7 | |
| 78 | + | bind.cmd+code:9 = focus-workspace 8 | |
| 79 | + | bind.cmd+code:10 = focus-workspace 9 | |
| 80 | + | bind.cmd+shift+code:2 = move-to-workspace 1 | |
| 81 | + | bind.cmd+shift+code:3 = move-to-workspace 2 | |
| 82 | + | bind.cmd+shift+code:4 = move-to-workspace 3 | |
| 83 | + | bind.cmd+shift+code:5 = move-to-workspace 4 | |
| 84 | + | bind.cmd+shift+code:6 = move-to-workspace 5 | |
| 85 | + | bind.cmd+shift+code:7 = move-to-workspace 6 | |
| 86 | + | bind.cmd+shift+code:8 = move-to-workspace 7 | |
| 87 | + | bind.cmd+shift+code:9 = move-to-workspace 8 | |
| 88 | + | bind.cmd+shift+code:10 = move-to-workspace 9 | |
| 89 | + | ||
| 90 | + | # Zoom: the whole display, live. | |
| 91 | + | bind.cmd+code:13 = scale-up | |
| 92 | + | bind.cmd+code:12 = scale-down | |
| @@ -32,6 +32,9 @@ layout.presets = 1/3, 1/2, 2/3, 1 | |||||
| 32 | # The first listed display that is connected and not off is used. | 32 | # The first listed display that is connected and not off is used. | ||
| 33 | # output.DP-2 = 5120x2160@165.058 | 33 | # output.DP-2 = 5120x2160@165.058 | ||
| 34 | # output.HDMI-A-1 = off | 34 | # output.HDMI-A-1 = off | ||
| 35 | + | # Scale (HiDPI), 1 to 4, like 1.5 or 4/3. Default 1. scale-up and scale-down | |||
| 36 | + | # step through the scales where the display divides into whole pixels. | |||
| 37 | + | # output.DP-2.scale = 4/3 | |||
| 35 | 38 | ||||
| 36 | render.direct-scanout = on | 39 | render.direct-scanout = on | ||
| 37 | 40 | ||||
| @@ -40,6 +43,9 @@ log.level = info | |||||
| 40 | log.debug = | 43 | log.debug = | ||
| 41 | 44 | ||||
| 42 | # Binds: bind.<keys> = <action>. Modifiers: cmd, shift, ctrl, alt, super. | 45 | # Binds: bind.<keys> = <action>. Modifiers: cmd, shift, ctrl, alt, super. | ||
| 46 | + | # code:N is a physical key (evdev code), the same key on every layout: | |||
| 47 | + | # code:2 to code:10 are the digit row 1 to 9, code:12 and code:13 the two | |||
| 48 | + | # keys right of 0 (- and = on US). | |||
| 43 | # Keys are what your layout prints (cmd+é on AZERTY). Write = # and space | 49 | # Keys are what your layout prints (cmd+é on AZERTY). Write = # and space | ||
| 44 | # by name: cmd+equal, cmd+numbersign, cmd+space. `none` removes a default. | 50 | # by name: cmd+equal, cmd+numbersign, cmd+space. `none` removes a default. | ||
| 45 | # `spawn <command>` runs a program; arguments split on spaces, no shell. | 51 | # `spawn <command>` runs a program; arguments split on spaces, no shell. | ||
| @@ -62,21 +68,25 @@ bind.cmd+pageup = focus-workspace-up | |||||
| 62 | bind.cmd+pagedown = focus-workspace-down | 68 | bind.cmd+pagedown = focus-workspace-down | ||
| 63 | bind.cmd+shift+pageup = move-to-workspace-up | 69 | bind.cmd+shift+pageup = move-to-workspace-up | ||
| 64 | bind.cmd+shift+pagedown = move-to-workspace-down | 70 | bind.cmd+shift+pagedown = move-to-workspace-down | ||
| 65 | - | bind.cmd+1 = focus-workspace 1 | 71 | + | bind.cmd+code:2 = focus-workspace 1 |
| 66 | - | bind.cmd+2 = focus-workspace 2 | 72 | + | bind.cmd+code:3 = focus-workspace 2 |
| 67 | - | bind.cmd+3 = focus-workspace 3 | 73 | + | bind.cmd+code:4 = focus-workspace 3 |
| 68 | - | bind.cmd+4 = focus-workspace 4 | 74 | + | bind.cmd+code:5 = focus-workspace 4 |
| 69 | - | bind.cmd+5 = focus-workspace 5 | 75 | + | bind.cmd+code:6 = focus-workspace 5 |
| 70 | - | bind.cmd+6 = focus-workspace 6 | 76 | + | bind.cmd+code:7 = focus-workspace 6 |
| 71 | - | bind.cmd+7 = focus-workspace 7 | 77 | + | bind.cmd+code:8 = focus-workspace 7 |
| 72 | - | bind.cmd+8 = focus-workspace 8 | 78 | + | bind.cmd+code:9 = focus-workspace 8 |
| 73 | - | bind.cmd+9 = focus-workspace 9 | 79 | + | bind.cmd+code:10 = focus-workspace 9 |
| 74 | - | bind.cmd+shift+1 = move-to-workspace 1 | 80 | + | bind.cmd+shift+code:2 = move-to-workspace 1 |
| 75 | - | bind.cmd+shift+2 = move-to-workspace 2 | 81 | + | bind.cmd+shift+code:3 = move-to-workspace 2 |
| 76 | - | bind.cmd+shift+3 = move-to-workspace 3 | 82 | + | bind.cmd+shift+code:4 = move-to-workspace 3 |
| 77 | - | bind.cmd+shift+4 = move-to-workspace 4 | 83 | + | bind.cmd+shift+code:5 = move-to-workspace 4 |
| 78 | - | bind.cmd+shift+5 = move-to-workspace 5 | 84 | + | bind.cmd+shift+code:6 = move-to-workspace 5 |
| 79 | - | bind.cmd+shift+6 = move-to-workspace 6 | 85 | + | bind.cmd+shift+code:7 = move-to-workspace 6 |
| 80 | - | bind.cmd+shift+7 = move-to-workspace 7 | 86 | + | bind.cmd+shift+code:8 = move-to-workspace 7 |
| 81 | - | bind.cmd+shift+8 = move-to-workspace 8 | 87 | + | bind.cmd+shift+code:9 = move-to-workspace 8 |
| 82 | - | bind.cmd+shift+9 = move-to-workspace 9 | 88 | + | bind.cmd+shift+code:10 = move-to-workspace 9 |
| 89 | + | ||||
| 90 | + | # Zoom: the whole display, live. | |||
| 91 | + | bind.cmd+code:13 = scale-up | |||
| 92 | + | bind.cmd+code:12 = scale-down | |||
M internal/adapters/config/config.go +60 -9
| @@ -36,7 +36,7 @@ var defaultBinds = []struct{ combo, action string }{ | |||
| 36 | 36 | {"cmd+shift+pageup", "move-to-workspace-up"}, | |
| 37 | 37 | {"cmd+shift+pagedown", "move-to-workspace-down"}, | |
| 38 | 38 | } | |
| 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} | |
| 39 | + | var actions = map[string]bool{"scale-up": true, "scale-down": true, "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} | |
| 40 | 40 | var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true} | |
| 41 | 41 | var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) | |
| 42 | 42 | ||
| @@ -86,9 +86,15 @@ func Defaults() ports.Config { | |||
| 86 | 86 | } | |
| 87 | 87 | c.Binds[combo] = b.action | |
| 88 | 88 | } | |
| 89 | + | // Digits and zoom use physical keys (evdev codes 2-10, 12, 13), so they | |
| 90 | + | // work the same on AZERTY, QWERTZ or Dvorak. | |
| 91 | + | for combo, action := range map[string]string{"cmd+code:13": "scale-up", "cmd+code:12": "scale-down"} { | |
| 92 | + | canon, _ := parseCombo(combo) | |
| 93 | + | c.Binds[canon] = action | |
| 94 | + | } | |
| 89 | 95 | for n := 1; n <= 9; n++ { | |
| 90 | - | focus, _ := parseCombo(fmt.Sprintf("cmd+%d", n)) | |
| 91 | - | move, _ := parseCombo(fmt.Sprintf("cmd+shift+%d", n)) | |
| 96 | + | focus, _ := parseCombo(fmt.Sprintf("cmd+code:%d", n+1)) | |
| 97 | + | move, _ := parseCombo(fmt.Sprintf("cmd+shift+code:%d", n+1)) | |
| 92 | 98 | c.Binds[focus] = fmt.Sprintf("focus-workspace %d", n) | |
| 93 | 99 | c.Binds[move] = fmt.Sprintf("move-to-workspace %d", n) | |
| 94 | 100 | } | |
| @@ -176,6 +182,25 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||
| 176 | 182 | raw[key] = value | |
| 177 | 183 | } | |
| 178 | 184 | if name, ok := strings.CutPrefix(key, "output."); ok { | |
| 185 | + | entry := func() *ports.OutputConfig { | |
| 186 | + | if i, ok := outputs[name]; ok { | |
| 187 | + | return &c.Outputs[i] | |
| 188 | + | } | |
| 189 | + | outputs[name] = len(c.Outputs) | |
| 190 | + | c.Outputs = append(c.Outputs, ports.OutputConfig{Name: name}) | |
| 191 | + | return &c.Outputs[len(c.Outputs)-1] | |
| 192 | + | } | |
| 193 | + | if base, ok := strings.CutSuffix(name, ".scale"); ok { | |
| 194 | + | name = base | |
| 195 | + | s, err := parseScale(value) | |
| 196 | + | if err != nil { | |
| 197 | + | warn("%s: %v", key, err) | |
| 198 | + | continue | |
| 199 | + | } | |
| 200 | + | override() | |
| 201 | + | entry().Scale = s | |
| 202 | + | continue | |
| 203 | + | } | |
| 179 | 204 | o := ports.OutputConfig{Name: name} | |
| 180 | 205 | if value == "off" { | |
| 181 | 206 | o.Off = true | |
| @@ -186,12 +211,9 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||
| 186 | 211 | o.Mode = value | |
| 187 | 212 | } | |
| 188 | 213 | override() | |
| 189 | - | if i, ok := outputs[name]; ok { | |
| 190 | - | c.Outputs[i] = o | |
| 191 | - | } else { | |
| 192 | - | outputs[name] = len(c.Outputs) | |
| 193 | - | c.Outputs = append(c.Outputs, o) | |
| 194 | - | } | |
| 214 | + | e := entry() | |
| 215 | + | o.Scale = e.Scale | |
| 216 | + | *e = o | |
| 195 | 217 | continue | |
| 196 | 218 | } | |
| 197 | 219 | if err := set(&c, key, value); err != nil { | |
| @@ -472,6 +494,13 @@ func parseCombo(s string) (string, error) { | |||
| 472 | 494 | ||
| 473 | 495 | // keysym maps a written key to its xkb keysym name. | |
| 474 | 496 | func keysym(k string) (string, error) { | |
| 497 | + | // code:N is the physical key (evdev code), the same on every layout. | |
| 498 | + | if rest, ok := strings.CutPrefix(strings.ToLower(k), "code:"); ok { | |
| 499 | + | if n, err := strconv.Atoi(rest); err == nil && n > 0 && n < 768 { | |
| 500 | + | return "code:" + rest, nil | |
| 501 | + | } | |
| 502 | + | return "", fmt.Errorf("code must be an evdev key code like code:2") | |
| 503 | + | } | |
| 475 | 504 | if r := []rune(k); len(r) == 1 { | |
| 476 | 505 | // Binds match the unshifted keysym, so É means é. | |
| 477 | 506 | if name, ok := runeKeysyms[unicode.ToLower(r[0])]; ok { | |
| @@ -493,6 +522,28 @@ func keysym(k string) (string, error) { | |||
| 493 | 522 | return k, nil | |
| 494 | 523 | } | |
| 495 | 524 | ||
| 525 | + | // parseScale accepts a decimal (1.5) or a fraction (4/3) between 1 and 4. | |
| 526 | + | func parseScale(v string) (float64, error) { | |
| 527 | + | var s float64 | |
| 528 | + | var err error | |
| 529 | + | if num, den, ok := strings.Cut(v, "/"); ok { | |
| 530 | + | var n, d float64 | |
| 531 | + | n, err = strconv.ParseFloat(strings.TrimSpace(num), 64) | |
| 532 | + | if err == nil { | |
| 533 | + | d, err = strconv.ParseFloat(strings.TrimSpace(den), 64) | |
| 534 | + | } | |
| 535 | + | if err == nil && d != 0 { | |
| 536 | + | s = n / d | |
| 537 | + | } | |
| 538 | + | } else { | |
| 539 | + | s, err = strconv.ParseFloat(v, 64) | |
| 540 | + | } | |
| 541 | + | if err != nil || !(s >= 1 && s <= 4) { | |
| 542 | + | return 0, fmt.Errorf("must be between 1 and 4, like 1.5 or 4/3") | |
| 543 | + | } | |
| 544 | + | return s, nil | |
| 545 | + | } | |
| 546 | + | ||
| 496 | 547 | // ParseMode parses "WxH" or "WxH@Hz" ("preferred" is accepted); hz is 0 when omitted. | |
| 497 | 548 | func ParseMode(s string) (w, h int, hz float64, err error) { | |
| 498 | 549 | if s == "preferred" { | |
| @@ -36,7 +36,7 @@ var defaultBinds = []struct{ combo, action string }{ | |||||
| 36 | {"cmd+shift+pageup", "move-to-workspace-up"}, | 36 | {"cmd+shift+pageup", "move-to-workspace-up"}, | ||
| 37 | {"cmd+shift+pagedown", "move-to-workspace-down"}, | 37 | {"cmd+shift+pagedown", "move-to-workspace-down"}, | ||
| 38 | } | 38 | } | ||
| 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} | 39 | + | var actions = map[string]bool{"scale-up": true, "scale-down": true, "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} |
| 40 | 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} | ||
| 41 | var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) | 41 | var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) | ||
| 42 | 42 | ||||
| @@ -86,9 +86,15 @@ func Defaults() ports.Config { | |||||
| 86 | } | 86 | } | ||
| 87 | c.Binds[combo] = b.action | 87 | c.Binds[combo] = b.action | ||
| 88 | } | 88 | } | ||
| 89 | + | // Digits and zoom use physical keys (evdev codes 2-10, 12, 13), so they | |||
| 90 | + | // work the same on AZERTY, QWERTZ or Dvorak. | |||
| 91 | + | for combo, action := range map[string]string{"cmd+code:13": "scale-up", "cmd+code:12": "scale-down"} { | |||
| 92 | + | canon, _ := parseCombo(combo) | |||
| 93 | + | c.Binds[canon] = action | |||
| 94 | + | } | |||
| 89 | for n := 1; n <= 9; n++ { | 95 | for n := 1; n <= 9; n++ { | ||
| 90 | - | focus, _ := parseCombo(fmt.Sprintf("cmd+%d", n)) | 96 | + | focus, _ := parseCombo(fmt.Sprintf("cmd+code:%d", n+1)) |
| 91 | - | move, _ := parseCombo(fmt.Sprintf("cmd+shift+%d", n)) | 97 | + | move, _ := parseCombo(fmt.Sprintf("cmd+shift+code:%d", n+1)) |
| 92 | c.Binds[focus] = fmt.Sprintf("focus-workspace %d", n) | 98 | c.Binds[focus] = fmt.Sprintf("focus-workspace %d", n) | ||
| 93 | c.Binds[move] = fmt.Sprintf("move-to-workspace %d", n) | 99 | c.Binds[move] = fmt.Sprintf("move-to-workspace %d", n) | ||
| 94 | } | 100 | } | ||
| @@ -176,6 +182,25 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||||
| 176 | raw[key] = value | 182 | raw[key] = value | ||
| 177 | } | 183 | } | ||
| 178 | if name, ok := strings.CutPrefix(key, "output."); ok { | 184 | if name, ok := strings.CutPrefix(key, "output."); ok { | ||
| 185 | + | entry := func() *ports.OutputConfig { | |||
| 186 | + | if i, ok := outputs[name]; ok { | |||
| 187 | + | return &c.Outputs[i] | |||
| 188 | + | } | |||
| 189 | + | outputs[name] = len(c.Outputs) | |||
| 190 | + | c.Outputs = append(c.Outputs, ports.OutputConfig{Name: name}) | |||
| 191 | + | return &c.Outputs[len(c.Outputs)-1] | |||
| 192 | + | } | |||
| 193 | + | if base, ok := strings.CutSuffix(name, ".scale"); ok { | |||
| 194 | + | name = base | |||
| 195 | + | s, err := parseScale(value) | |||
| 196 | + | if err != nil { | |||
| 197 | + | warn("%s: %v", key, err) | |||
| 198 | + | continue | |||
| 199 | + | } | |||
| 200 | + | override() | |||
| 201 | + | entry().Scale = s | |||
| 202 | + | continue | |||
| 203 | + | } | |||
| 179 | o := ports.OutputConfig{Name: name} | 204 | o := ports.OutputConfig{Name: name} | ||
| 180 | if value == "off" { | 205 | if value == "off" { | ||
| 181 | o.Off = true | 206 | o.Off = true | ||
| @@ -186,12 +211,9 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||||
| 186 | o.Mode = value | 211 | o.Mode = value | ||
| 187 | } | 212 | } | ||
| 188 | override() | 213 | override() | ||
| 189 | - | if i, ok := outputs[name]; ok { | 214 | + | e := entry() |
| 190 | - | c.Outputs[i] = o | 215 | + | o.Scale = e.Scale |
| 191 | - | } else { | 216 | + | *e = o |
| 192 | - | outputs[name] = len(c.Outputs) | |||
| 193 | - | c.Outputs = append(c.Outputs, o) | |||
| 194 | - | } | |||
| 195 | continue | 217 | continue | ||
| 196 | } | 218 | } | ||
| 197 | if err := set(&c, key, value); err != nil { | 219 | if err := set(&c, key, value); err != nil { | ||
| @@ -472,6 +494,13 @@ func parseCombo(s string) (string, error) { | |||||
| 472 | 494 | ||||
| 473 | // keysym maps a written key to its xkb keysym name. | 495 | // keysym maps a written key to its xkb keysym name. | ||
| 474 | func keysym(k string) (string, error) { | 496 | func keysym(k string) (string, error) { | ||
| 497 | + | // code:N is the physical key (evdev code), the same on every layout. | |||
| 498 | + | if rest, ok := strings.CutPrefix(strings.ToLower(k), "code:"); ok { | |||
| 499 | + | if n, err := strconv.Atoi(rest); err == nil && n > 0 && n < 768 { | |||
| 500 | + | return "code:" + rest, nil | |||
| 501 | + | } | |||
| 502 | + | return "", fmt.Errorf("code must be an evdev key code like code:2") | |||
| 503 | + | } | |||
| 475 | if r := []rune(k); len(r) == 1 { | 504 | if r := []rune(k); len(r) == 1 { | ||
| 476 | // Binds match the unshifted keysym, so É means é. | 505 | // Binds match the unshifted keysym, so É means é. | ||
| 477 | if name, ok := runeKeysyms[unicode.ToLower(r[0])]; ok { | 506 | if name, ok := runeKeysyms[unicode.ToLower(r[0])]; ok { | ||
| @@ -493,6 +522,28 @@ func keysym(k string) (string, error) { | |||||
| 493 | return k, nil | 522 | return k, nil | ||
| 494 | } | 523 | } | ||
| 495 | 524 | ||||
| 525 | + | // parseScale accepts a decimal (1.5) or a fraction (4/3) between 1 and 4. | |||
| 526 | + | func parseScale(v string) (float64, error) { | |||
| 527 | + | var s float64 | |||
| 528 | + | var err error | |||
| 529 | + | if num, den, ok := strings.Cut(v, "/"); ok { | |||
| 530 | + | var n, d float64 | |||
| 531 | + | n, err = strconv.ParseFloat(strings.TrimSpace(num), 64) | |||
| 532 | + | if err == nil { | |||
| 533 | + | d, err = strconv.ParseFloat(strings.TrimSpace(den), 64) | |||
| 534 | + | } | |||
| 535 | + | if err == nil && d != 0 { | |||
| 536 | + | s = n / d | |||
| 537 | + | } | |||
| 538 | + | } else { | |||
| 539 | + | s, err = strconv.ParseFloat(v, 64) | |||
| 540 | + | } | |||
| 541 | + | if err != nil || !(s >= 1 && s <= 4) { | |||
| 542 | + | return 0, fmt.Errorf("must be between 1 and 4, like 1.5 or 4/3") | |||
| 543 | + | } | |||
| 544 | + | return s, nil | |||
| 545 | + | } | |||
| 546 | + | ||||
| 496 | // ParseMode parses "WxH" or "WxH@Hz" ("preferred" is accepted); hz is 0 when omitted. | 547 | // ParseMode parses "WxH" or "WxH@Hz" ("preferred" is accepted); hz is 0 when omitted. | ||
| 497 | func ParseMode(s string) (w, h int, hz float64, err error) { | 548 | func ParseMode(s string) (w, h int, hz float64, err error) { | ||
| 498 | if s == "preferred" { | 549 | if s == "preferred" { | ||
M internal/adapters/config/config_test.go +12 -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) != 34 || d.Layout.MaxColumns != 2 { | |
| 24 | + | if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 36 || 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" { | |
| @@ -54,9 +54,12 @@ background = #000000 | |||
| 54 | 54 | border.inactive = | |
| 55 | 55 | layout.presets = 1/3, 1/2 ,1 | |
| 56 | 56 | log.debug = core, input | |
| 57 | + | output.DP-2.scale = 4/3 | |
| 57 | 58 | output.DP-2 = 5120x2160@165.058 | |
| 58 | 59 | output.HDMI-A-1 = off | |
| 59 | 60 | output.DP-1 = preferred | |
| 61 | + | output.DP-1.scale = 1.5 | |
| 62 | + | bind.cmd+code:30 = quit | |
| 60 | 63 | render.direct-scanout = off | |
| 61 | 64 | `) | |
| 62 | 65 | if len(w) != 0 { | |
| @@ -68,9 +71,13 @@ render.direct-scanout = off | |||
| 68 | 71 | if !reflect.DeepEqual(c.Layout.Presets, []string{"1/3", "1/2", "1"}) || !reflect.DeepEqual(c.Log.Debug, []string{"core", "input"}) { | |
| 69 | 72 | t.Fatalf("%+v", c) | |
| 70 | 73 | } | |
| 71 | - | if len(c.Outputs) != 3 || c.Outputs[0].Mode != "5120x2160@165.058" || !c.Outputs[1].Off || c.Outputs[2].Mode != "" || c.Outputs[2].Off { | |
| 74 | + | // Scale and mode lines combine in either order. | |
| 75 | + | if len(c.Outputs) != 3 || c.Outputs[0].Mode != "5120x2160@165.058" || c.Outputs[0].Scale != 4.0/3 || !c.Outputs[1].Off || c.Outputs[2].Mode != "" || c.Outputs[2].Off || c.Outputs[2].Scale != 1.5 { | |
| 72 | 76 | t.Fatalf("%+v", c.Outputs) | |
| 73 | 77 | } | |
| 78 | + | if c.Binds["Cmd+code:30"] != "quit" { | |
| 79 | + | t.Fatal(c.Binds) | |
| 80 | + | } | |
| 74 | 81 | } | |
| 75 | 82 | ||
| 76 | 83 | func TestWarningsKeepDefaults(t *testing.T) { | |
| @@ -87,6 +94,9 @@ func TestWarningsKeepDefaults(t *testing.T) { | |||
| 87 | 94 | {"log.debug = nope", "log.debug"}, | |
| 88 | 95 | {"output.DP-2 = big", "output.DP-2"}, | |
| 89 | 96 | {"output.DP-2 = 1920x1080@0", "output.DP-2"}, | |
| 97 | + | {"output.DP-2.scale = 0.5", "between 1 and 4"}, | |
| 98 | + | {"output.DP-2.scale = 3/0", "between 1 and 4"}, | |
| 99 | + | {"bind.cmd+code:x = quit", "evdev key code"}, | |
| 90 | 100 | {"nope = 1", "nope"}, | |
| 91 | 101 | {"just text", "expected key = value"}, | |
| 92 | 102 | {"bind.cmd+ = quit", "missing key"}, | |
| @@ -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) != 34 || d.Layout.MaxColumns != 2 { | 24 | + | if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 36 || 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" { | ||
| @@ -54,9 +54,12 @@ background = #000000 | |||||
| 54 | border.inactive = | 54 | border.inactive = | ||
| 55 | layout.presets = 1/3, 1/2 ,1 | 55 | layout.presets = 1/3, 1/2 ,1 | ||
| 56 | log.debug = core, input | 56 | log.debug = core, input | ||
| 57 | + | output.DP-2.scale = 4/3 | |||
| 57 | output.DP-2 = 5120x2160@165.058 | 58 | output.DP-2 = 5120x2160@165.058 | ||
| 58 | output.HDMI-A-1 = off | 59 | output.HDMI-A-1 = off | ||
| 59 | output.DP-1 = preferred | 60 | output.DP-1 = preferred | ||
| 61 | + | output.DP-1.scale = 1.5 | |||
| 62 | + | bind.cmd+code:30 = quit | |||
| 60 | render.direct-scanout = off | 63 | render.direct-scanout = off | ||
| 61 | `) | 64 | `) | ||
| 62 | if len(w) != 0 { | 65 | if len(w) != 0 { | ||
| @@ -68,9 +71,13 @@ render.direct-scanout = off | |||||
| 68 | if !reflect.DeepEqual(c.Layout.Presets, []string{"1/3", "1/2", "1"}) || !reflect.DeepEqual(c.Log.Debug, []string{"core", "input"}) { | 71 | if !reflect.DeepEqual(c.Layout.Presets, []string{"1/3", "1/2", "1"}) || !reflect.DeepEqual(c.Log.Debug, []string{"core", "input"}) { | ||
| 69 | t.Fatalf("%+v", c) | 72 | t.Fatalf("%+v", c) | ||
| 70 | } | 73 | } | ||
| 71 | - | if len(c.Outputs) != 3 || c.Outputs[0].Mode != "5120x2160@165.058" || !c.Outputs[1].Off || c.Outputs[2].Mode != "" || c.Outputs[2].Off { | 74 | + | // Scale and mode lines combine in either order. |
| 75 | + | if len(c.Outputs) != 3 || c.Outputs[0].Mode != "5120x2160@165.058" || c.Outputs[0].Scale != 4.0/3 || !c.Outputs[1].Off || c.Outputs[2].Mode != "" || c.Outputs[2].Off || c.Outputs[2].Scale != 1.5 { | |||
| 72 | t.Fatalf("%+v", c.Outputs) | 76 | t.Fatalf("%+v", c.Outputs) | ||
| 73 | } | 77 | } | ||
| 78 | + | if c.Binds["Cmd+code:30"] != "quit" { | |||
| 79 | + | t.Fatal(c.Binds) | |||
| 80 | + | } | |||
| 74 | } | 81 | } | ||
| 75 | 82 | ||||
| 76 | func TestWarningsKeepDefaults(t *testing.T) { | 83 | func TestWarningsKeepDefaults(t *testing.T) { | ||
| @@ -87,6 +94,9 @@ func TestWarningsKeepDefaults(t *testing.T) { | |||||
| 87 | {"log.debug = nope", "log.debug"}, | 94 | {"log.debug = nope", "log.debug"}, | ||
| 88 | {"output.DP-2 = big", "output.DP-2"}, | 95 | {"output.DP-2 = big", "output.DP-2"}, | ||
| 89 | {"output.DP-2 = 1920x1080@0", "output.DP-2"}, | 96 | {"output.DP-2 = 1920x1080@0", "output.DP-2"}, | ||
| 97 | + | {"output.DP-2.scale = 0.5", "between 1 and 4"}, | |||
| 98 | + | {"output.DP-2.scale = 3/0", "between 1 and 4"}, | |||
| 99 | + | {"bind.cmd+code:x = quit", "evdev key code"}, | |||
| 90 | {"nope = 1", "nope"}, | 100 | {"nope = 1", "nope"}, | ||
| 91 | {"just text", "expected key = value"}, | 101 | {"just text", "expected key = value"}, | ||
| 92 | {"bind.cmd+ = quit", "missing key"}, | 102 | {"bind.cmd+ = quit", "missing key"}, | ||
M internal/adapters/vulkan/renderer.go +70 -31
| @@ -315,30 +315,60 @@ func (r *Renderer) Clear(rgb [3]uint8) error { | |||
| 315 | 315 | } | |
| 316 | 316 | ||
| 317 | 317 | func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.SurfaceContent) error { | |
| 318 | + | // Scene rects are logical; everything below works in physical pixels. | |
| 319 | + | scale := s.Scale | |
| 320 | + | if scale <= 0 { | |
| 321 | + | scale = 1 | |
| 322 | + | } | |
| 323 | + | phys := func(v int) int { return int(math.Round(float64(v) * scale)) } | |
| 324 | + | physRect := func(x, y, w, h int) image.Rectangle { return image.Rect(phys(x), phys(y), phys(x+w), phys(y+h)) } | |
| 318 | 325 | type upload struct { | |
| 319 | - | rect image.Rectangle | |
| 320 | - | offset int | |
| 321 | - | pixels []byte | |
| 322 | - | stride, sourceX, sourceY int | |
| 323 | - | color [3]uint8 | |
| 324 | - | opaque bool | |
| 326 | + | rect image.Rectangle | |
| 327 | + | offset int | |
| 328 | + | pixels []byte | |
| 329 | + | stride int | |
| 330 | + | // dst covers src; they differ in size when the buffer is not drawn at | |
| 331 | + | // the physical size (integer-scale clients, rounding). | |
| 332 | + | dst, src image.Rectangle | |
| 333 | + | color [3]uint8 | |
| 334 | + | opaque bool | |
| 325 | 335 | } | |
| 326 | 336 | var uploads []upload | |
| 327 | 337 | used := 0 | |
| 328 | 338 | bounds := image.Rect(0, 0, r.width, r.height) | |
| 329 | - | add := func(rect image.Rectangle, c [3]uint8, content *ports.SurfaceContent, origin image.Point) { | |
| 339 | + | add := func(rect image.Rectangle, c [3]uint8) { | |
| 330 | 340 | rect = rect.Intersect(bounds) | |
| 331 | 341 | if rect.Empty() { | |
| 332 | 342 | return | |
| 333 | 343 | } | |
| 334 | - | n := rect.Dx() * rect.Dy() * 4 | |
| 335 | - | u := upload{rect: rect, offset: used, color: c} | |
| 336 | - | if content != nil { | |
| 337 | - | u.pixels, u.stride, u.opaque = content.Pixels, content.Stride, content.Opaque | |
| 338 | - | u.sourceX, u.sourceY = rect.Min.X-origin.X, rect.Min.Y-origin.Y | |
| 344 | + | uploads = append(uploads, upload{rect: rect, offset: used, color: c}) | |
| 345 | + | used += rect.Dx() * rect.Dy() * 4 | |
| 346 | + | } | |
| 347 | + | // addContent maps src (buffer pixels) onto dst (physical pixels). | |
| 348 | + | addContent := func(dst, src image.Rectangle, content *ports.SurfaceContent) { | |
| 349 | + | rect := dst.Intersect(bounds) | |
| 350 | + | if rect.Empty() || src.Empty() { | |
| 351 | + | return | |
| 352 | + | } | |
| 353 | + | uploads = append(uploads, upload{rect: rect, offset: used, pixels: content.Pixels, stride: content.Stride, opaque: content.Opaque, dst: dst, src: src}) | |
| 354 | + | used += rect.Dx() * rect.Dy() * 4 | |
| 355 | + | } | |
| 356 | + | // place draws content with its top-left at (x, y) logical, clipped to w×h logical. | |
| 357 | + | place := func(content *ports.SurfaceContent, x, y, w, h int) { | |
| 358 | + | lw, lh := content.LogicalW, content.LogicalH | |
| 359 | + | if lw <= 0 || lh <= 0 { | |
| 360 | + | lw, lh = content.Width, content.Height | |
| 361 | + | } | |
| 362 | + | if content.Pixels == nil || lw <= 0 || lh <= 0 || content.Stride < content.Width*4 || len(content.Pixels) < (content.Height-1)*content.Stride+content.Width*4 { | |
| 363 | + | return | |
| 364 | + | } | |
| 365 | + | vw, vh := min(lw, w), min(lh, h) | |
| 366 | + | if vw <= 0 || vh <= 0 { | |
| 367 | + | return | |
| 339 | 368 | } | |
| 340 | - | uploads = append(uploads, u) | |
| 341 | - | used += n | |
| 369 | + | // Buffer pixels per logical pixel on each axis. | |
| 370 | + | src := image.Rect(0, 0, vw*content.Width/lw, vh*content.Height/lh) | |
| 371 | + | addContent(physRect(x, y, vw, vh), src, content) | |
| 342 | 372 | } | |
| 343 | 373 | fullscreen := false | |
| 344 | 374 | for _, w := range s.Windows { | |
| @@ -352,12 +382,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||
| 352 | 382 | continue | |
| 353 | 383 | } | |
| 354 | 384 | content := contents[layer.ID] | |
| 355 | - | width, height := min(content.Width, layer.Rect.W), min(content.Height, layer.Rect.H) | |
| 356 | - | if content.Pixels == nil || width <= 0 || height <= 0 || content.Stride < width*4 || len(content.Pixels) < (height-1)*content.Stride+width*4 { | |
| 357 | - | continue | |
| 358 | - | } | |
| 359 | - | x, y := layer.Rect.X, layer.Rect.Y | |
| 360 | - | add(image.Rect(x, y, x+width, y+height), [3]uint8{}, &content, image.Pt(x, y)) | |
| 385 | + | place(&content, layer.Rect.X, layer.Rect.Y, layer.Rect.W, layer.Rect.H) | |
| 361 | 386 | } | |
| 362 | 387 | } | |
| 363 | 388 | addLayers(false) | |
| @@ -372,16 +397,13 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||
| 372 | 397 | b = min(max(s.Border.Width, 0), w.Rect.W/2, w.Rect.H/2) | |
| 373 | 398 | } | |
| 374 | 399 | cx, cy, cw, ch := x+b, y+b, w.Rect.W-2*b, w.Rect.H-2*b | |
| 375 | - | body := image.Rect(cx, cy, cx+cw, cy+ch) | |
| 400 | + | body := physRect(cx, cy, cw, ch) | |
| 376 | 401 | content := contents[w.ID] | |
| 377 | 402 | if content.Pixels == nil { | |
| 378 | - | add(body, windowColor(w.ID), nil, image.Point{}) | |
| 403 | + | add(body, windowColor(w.ID)) | |
| 379 | 404 | } else { | |
| 380 | - | add(body, parseColor(s.Background), nil, image.Point{}) | |
| 381 | - | width, height := min(content.Width, cw), min(content.Height, ch) | |
| 382 | - | if width > 0 && height > 0 && content.Stride >= width*4 && len(content.Pixels) >= (height-1)*content.Stride+width*4 { | |
| 383 | - | add(image.Rect(cx, cy, cx+width, cy+height), [3]uint8{}, &content, image.Pt(cx, cy)) | |
| 384 | - | } | |
| 405 | + | add(body, parseColor(s.Background)) | |
| 406 | + | place(&content, cx, cy, cw, ch) | |
| 385 | 407 | } | |
| 386 | 408 | borderColor := s.Border.Inactive | |
| 387 | 409 | if w.Focused { | |
| @@ -389,8 +411,14 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||
| 389 | 411 | } | |
| 390 | 412 | if b > 0 && borderColor != "" { | |
| 391 | 413 | rgb := parseColor(borderColor) | |
| 392 | - | for _, strip := range []image.Rectangle{image.Rect(x, y, x+w.Rect.W, y+b), image.Rect(x, y+w.Rect.H-b, x+w.Rect.W, y+w.Rect.H), image.Rect(x, y+b, x+b, y+w.Rect.H-b), image.Rect(x+w.Rect.W-b, y+b, x+w.Rect.W, y+w.Rect.H-b)} { | |
| 393 | - | add(strip, rgb, nil, image.Point{}) | |
| 414 | + | outer, inner := physRect(x, y, w.Rect.W, w.Rect.H), physRect(cx, cy, cw, ch) | |
| 415 | + | for _, strip := range []image.Rectangle{ | |
| 416 | + | image.Rect(outer.Min.X, outer.Min.Y, outer.Max.X, inner.Min.Y), | |
| 417 | + | image.Rect(outer.Min.X, inner.Max.Y, outer.Max.X, outer.Max.Y), | |
| 418 | + | image.Rect(outer.Min.X, inner.Min.Y, inner.Min.X, inner.Max.Y), | |
| 419 | + | image.Rect(inner.Max.X, inner.Min.Y, outer.Max.X, inner.Max.Y), | |
| 420 | + | } { | |
| 421 | + | add(strip, rgb) | |
| 394 | 422 | } | |
| 395 | 423 | } | |
| 396 | 424 | } | |
| @@ -404,8 +432,19 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||
| 404 | 432 | for row := 0; row < u.rect.Dy(); row++ { | |
| 405 | 433 | line := dst[row*u.rect.Dx()*4 : (row+1)*u.rect.Dx()*4] | |
| 406 | 434 | if u.pixels != nil { | |
| 407 | - | start := (u.sourceY+row)*u.stride + u.sourceX*4 | |
| 408 | - | copy(line, u.pixels[start:start+len(line)]) | |
| 435 | + | y := u.rect.Min.Y + row | |
| 436 | + | if u.dst.Size() == u.src.Size() { | |
| 437 | + | start := (u.src.Min.Y+y-u.dst.Min.Y)*u.stride + (u.src.Min.X+u.rect.Min.X-u.dst.Min.X)*4 | |
| 438 | + | copy(line, u.pixels[start:start+len(line)]) | |
| 439 | + | } else { | |
| 440 | + | // Nearest-neighbour resample, sampling pixel centres. | |
| 441 | + | sy := u.src.Min.Y + ((y-u.dst.Min.Y)*2+1)*u.src.Dy()/(u.dst.Dy()*2) | |
| 442 | + | src := u.pixels[sy*u.stride:] | |
| 443 | + | for i := 0; i < u.rect.Dx(); i++ { | |
| 444 | + | sx := u.src.Min.X + ((u.rect.Min.X+i-u.dst.Min.X)*2+1)*u.src.Dx()/(u.dst.Dx()*2) | |
| 445 | + | copy(line[i*4:i*4+4], src[sx*4:sx*4+4]) | |
| 446 | + | } | |
| 447 | + | } | |
| 409 | 448 | if u.opaque { | |
| 410 | 449 | for i := 3; i < len(line); i += 4 { | |
| 411 | 450 | line[i] = 255 | |
| @@ -315,30 +315,60 @@ func (r *Renderer) Clear(rgb [3]uint8) error { | |||||
| 315 | } | 315 | } | ||
| 316 | 316 | ||||
| 317 | func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.SurfaceContent) error { | 317 | func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.SurfaceContent) error { | ||
| 318 | + | // Scene rects are logical; everything below works in physical pixels. | |||
| 319 | + | scale := s.Scale | |||
| 320 | + | if scale <= 0 { | |||
| 321 | + | scale = 1 | |||
| 322 | + | } | |||
| 323 | + | phys := func(v int) int { return int(math.Round(float64(v) * scale)) } | |||
| 324 | + | physRect := func(x, y, w, h int) image.Rectangle { return image.Rect(phys(x), phys(y), phys(x+w), phys(y+h)) } | |||
| 318 | type upload struct { | 325 | type upload struct { | ||
| 319 | - | rect image.Rectangle | 326 | + | rect image.Rectangle |
| 320 | - | offset int | 327 | + | offset int |
| 321 | - | pixels []byte | 328 | + | pixels []byte |
| 322 | - | stride, sourceX, sourceY int | 329 | + | stride int |
| 323 | - | color [3]uint8 | 330 | + | // dst covers src; they differ in size when the buffer is not drawn at |
| 324 | - | opaque bool | 331 | + | // the physical size (integer-scale clients, rounding). |
| 332 | + | dst, src image.Rectangle | |||
| 333 | + | color [3]uint8 | |||
| 334 | + | opaque bool | |||
| 325 | } | 335 | } | ||
| 326 | var uploads []upload | 336 | var uploads []upload | ||
| 327 | used := 0 | 337 | used := 0 | ||
| 328 | bounds := image.Rect(0, 0, r.width, r.height) | 338 | bounds := image.Rect(0, 0, r.width, r.height) | ||
| 329 | - | add := func(rect image.Rectangle, c [3]uint8, content *ports.SurfaceContent, origin image.Point) { | 339 | + | add := func(rect image.Rectangle, c [3]uint8) { |
| 330 | rect = rect.Intersect(bounds) | 340 | rect = rect.Intersect(bounds) | ||
| 331 | if rect.Empty() { | 341 | if rect.Empty() { | ||
| 332 | return | 342 | return | ||
| 333 | } | 343 | } | ||
| 334 | - | n := rect.Dx() * rect.Dy() * 4 | 344 | + | uploads = append(uploads, upload{rect: rect, offset: used, color: c}) |
| 335 | - | u := upload{rect: rect, offset: used, color: c} | 345 | + | used += rect.Dx() * rect.Dy() * 4 |
| 336 | - | if content != nil { | 346 | + | } |
| 337 | - | u.pixels, u.stride, u.opaque = content.Pixels, content.Stride, content.Opaque | 347 | + | // addContent maps src (buffer pixels) onto dst (physical pixels). |
| 338 | - | u.sourceX, u.sourceY = rect.Min.X-origin.X, rect.Min.Y-origin.Y | 348 | + | addContent := func(dst, src image.Rectangle, content *ports.SurfaceContent) { |
| 349 | + | rect := dst.Intersect(bounds) | |||
| 350 | + | if rect.Empty() || src.Empty() { | |||
| 351 | + | return | |||
| 352 | + | } | |||
| 353 | + | uploads = append(uploads, upload{rect: rect, offset: used, pixels: content.Pixels, stride: content.Stride, opaque: content.Opaque, dst: dst, src: src}) | |||
| 354 | + | used += rect.Dx() * rect.Dy() * 4 | |||
| 355 | + | } | |||
| 356 | + | // place draws content with its top-left at (x, y) logical, clipped to w×h logical. | |||
| 357 | + | place := func(content *ports.SurfaceContent, x, y, w, h int) { | |||
| 358 | + | lw, lh := content.LogicalW, content.LogicalH | |||
| 359 | + | if lw <= 0 || lh <= 0 { | |||
| 360 | + | lw, lh = content.Width, content.Height | |||
| 361 | + | } | |||
| 362 | + | if content.Pixels == nil || lw <= 0 || lh <= 0 || content.Stride < content.Width*4 || len(content.Pixels) < (content.Height-1)*content.Stride+content.Width*4 { | |||
| 363 | + | return | |||
| 364 | + | } | |||
| 365 | + | vw, vh := min(lw, w), min(lh, h) | |||
| 366 | + | if vw <= 0 || vh <= 0 { | |||
| 367 | + | return | |||
| 339 | } | 368 | } | ||
| 340 | - | uploads = append(uploads, u) | 369 | + | // Buffer pixels per logical pixel on each axis. |
| 341 | - | used += n | 370 | + | src := image.Rect(0, 0, vw*content.Width/lw, vh*content.Height/lh) |
| 371 | + | addContent(physRect(x, y, vw, vh), src, content) | |||
| 342 | } | 372 | } | ||
| 343 | fullscreen := false | 373 | fullscreen := false | ||
| 344 | for _, w := range s.Windows { | 374 | for _, w := range s.Windows { | ||
| @@ -352,12 +382,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||||
| 352 | continue | 382 | continue | ||
| 353 | } | 383 | } | ||
| 354 | content := contents[layer.ID] | 384 | content := contents[layer.ID] | ||
| 355 | - | width, height := min(content.Width, layer.Rect.W), min(content.Height, layer.Rect.H) | 385 | + | place(&content, layer.Rect.X, layer.Rect.Y, layer.Rect.W, layer.Rect.H) |
| 356 | - | if content.Pixels == nil || width <= 0 || height <= 0 || content.Stride < width*4 || len(content.Pixels) < (height-1)*content.Stride+width*4 { | |||
| 357 | - | continue | |||
| 358 | - | } | |||
| 359 | - | x, y := layer.Rect.X, layer.Rect.Y | |||
| 360 | - | add(image.Rect(x, y, x+width, y+height), [3]uint8{}, &content, image.Pt(x, y)) | |||
| 361 | } | 386 | } | ||
| 362 | } | 387 | } | ||
| 363 | addLayers(false) | 388 | addLayers(false) | ||
| @@ -372,16 +397,13 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||||
| 372 | b = min(max(s.Border.Width, 0), w.Rect.W/2, w.Rect.H/2) | 397 | b = min(max(s.Border.Width, 0), w.Rect.W/2, w.Rect.H/2) | ||
| 373 | } | 398 | } | ||
| 374 | cx, cy, cw, ch := x+b, y+b, w.Rect.W-2*b, w.Rect.H-2*b | 399 | cx, cy, cw, ch := x+b, y+b, w.Rect.W-2*b, w.Rect.H-2*b | ||
| 375 | - | body := image.Rect(cx, cy, cx+cw, cy+ch) | 400 | + | body := physRect(cx, cy, cw, ch) |
| 376 | content := contents[w.ID] | 401 | content := contents[w.ID] | ||
| 377 | if content.Pixels == nil { | 402 | if content.Pixels == nil { | ||
| 378 | - | add(body, windowColor(w.ID), nil, image.Point{}) | 403 | + | add(body, windowColor(w.ID)) |
| 379 | } else { | 404 | } else { | ||
| 380 | - | add(body, parseColor(s.Background), nil, image.Point{}) | 405 | + | add(body, parseColor(s.Background)) |
| 381 | - | width, height := min(content.Width, cw), min(content.Height, ch) | 406 | + | place(&content, cx, cy, cw, ch) |
| 382 | - | if width > 0 && height > 0 && content.Stride >= width*4 && len(content.Pixels) >= (height-1)*content.Stride+width*4 { | |||
| 383 | - | add(image.Rect(cx, cy, cx+width, cy+height), [3]uint8{}, &content, image.Pt(cx, cy)) | |||
| 384 | - | } | |||
| 385 | } | 407 | } | ||
| 386 | borderColor := s.Border.Inactive | 408 | borderColor := s.Border.Inactive | ||
| 387 | if w.Focused { | 409 | if w.Focused { | ||
| @@ -389,8 +411,14 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||||
| 389 | } | 411 | } | ||
| 390 | if b > 0 && borderColor != "" { | 412 | if b > 0 && borderColor != "" { | ||
| 391 | rgb := parseColor(borderColor) | 413 | rgb := parseColor(borderColor) | ||
| 392 | - | for _, strip := range []image.Rectangle{image.Rect(x, y, x+w.Rect.W, y+b), image.Rect(x, y+w.Rect.H-b, x+w.Rect.W, y+w.Rect.H), image.Rect(x, y+b, x+b, y+w.Rect.H-b), image.Rect(x+w.Rect.W-b, y+b, x+w.Rect.W, y+w.Rect.H-b)} { | 414 | + | outer, inner := physRect(x, y, w.Rect.W, w.Rect.H), physRect(cx, cy, cw, ch) |
| 393 | - | add(strip, rgb, nil, image.Point{}) | 415 | + | for _, strip := range []image.Rectangle{ |
| 416 | + | image.Rect(outer.Min.X, outer.Min.Y, outer.Max.X, inner.Min.Y), | |||
| 417 | + | image.Rect(outer.Min.X, inner.Max.Y, outer.Max.X, outer.Max.Y), | |||
| 418 | + | image.Rect(outer.Min.X, inner.Min.Y, inner.Min.X, inner.Max.Y), | |||
| 419 | + | image.Rect(inner.Max.X, inner.Min.Y, outer.Max.X, inner.Max.Y), | |||
| 420 | + | } { | |||
| 421 | + | add(strip, rgb) | |||
| 394 | } | 422 | } | ||
| 395 | } | 423 | } | ||
| 396 | } | 424 | } | ||
| @@ -404,8 +432,19 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||||
| 404 | for row := 0; row < u.rect.Dy(); row++ { | 432 | for row := 0; row < u.rect.Dy(); row++ { | ||
| 405 | line := dst[row*u.rect.Dx()*4 : (row+1)*u.rect.Dx()*4] | 433 | line := dst[row*u.rect.Dx()*4 : (row+1)*u.rect.Dx()*4] | ||
| 406 | if u.pixels != nil { | 434 | if u.pixels != nil { | ||
| 407 | - | start := (u.sourceY+row)*u.stride + u.sourceX*4 | 435 | + | y := u.rect.Min.Y + row |
| 408 | - | copy(line, u.pixels[start:start+len(line)]) | 436 | + | if u.dst.Size() == u.src.Size() { |
| 437 | + | start := (u.src.Min.Y+y-u.dst.Min.Y)*u.stride + (u.src.Min.X+u.rect.Min.X-u.dst.Min.X)*4 | |||
| 438 | + | copy(line, u.pixels[start:start+len(line)]) | |||
| 439 | + | } else { | |||
| 440 | + | // Nearest-neighbour resample, sampling pixel centres. | |||
| 441 | + | sy := u.src.Min.Y + ((y-u.dst.Min.Y)*2+1)*u.src.Dy()/(u.dst.Dy()*2) | |||
| 442 | + | src := u.pixels[sy*u.stride:] | |||
| 443 | + | for i := 0; i < u.rect.Dx(); i++ { | |||
| 444 | + | sx := u.src.Min.X + ((u.rect.Min.X+i-u.dst.Min.X)*2+1)*u.src.Dx()/(u.dst.Dx()*2) | |||
| 445 | + | copy(line[i*4:i*4+4], src[sx*4:sx*4+4]) | |||
| 446 | + | } | |||
| 447 | + | } | |||
| 409 | if u.opaque { | 448 | if u.opaque { | ||
| 410 | for i := 3; i < len(line); i += 4 { | 449 | for i := 3; i < len(line); i += 4 { | ||
| 411 | line[i] = 255 | 450 | line[i] = 255 | ||
M internal/adapters/wayland/globals.go +9 -3
| @@ -12,8 +12,9 @@ func registerGlobals(d *server.Display, o Options, s *Server) error { | |||
| 12 | 12 | for _, register := range []func() error{ | |
| 13 | 13 | func() error { return registerXDG(d, s) }, | |
| 14 | 14 | func() error { return registerLayer(d, o, s) }, | |
| 15 | - | func() error { return registerXDGOutput(d, o) }, | |
| 15 | + | func() error { return registerXDGOutput(d, o, s) }, | |
| 16 | 16 | func() error { return registerDecoration(d, s) }, | |
| 17 | + | func() error { return registerScale(d, s) }, | |
| 17 | 18 | func() error { | |
| 18 | 19 | return wayland.NewCompositorGlobal(d, 6, func(c server.Client, v, id uint32) { wayland.NewCompositor(c, int32(v), id, compositor{s}) }) | |
| 19 | 20 | }, | |
| @@ -37,11 +38,14 @@ func registerGlobals(d *server.Display, o Options, s *Server) error { | |||
| 37 | 38 | if e != nil { | |
| 38 | 39 | return | |
| 39 | 40 | } | |
| 41 | + | s.scale.outputs = append(s.scale.outputs, r) | |
| 42 | + | r.OnDestroy = func() { s.scale.outputs = removeItem(s.scale.outputs, r) } | |
| 40 | 43 | info := o.output() | |
| 41 | 44 | r.SendGeometry(0, 0, int32(info.PhysicalW), int32(info.PhysicalH), 0, info.Make, info.Model, 0) | |
| 45 | + | // The mode stays physical; the scale tells clients how to divide it. | |
| 42 | 46 | r.SendMode(3, int32(o.OutputWidth), int32(o.OutputHeight), int32(info.RefreshMilli)) | |
| 43 | 47 | if v >= 2 { | |
| 44 | - | r.SendScale(1) | |
| 48 | + | r.SendScale(s.scale.integerScale()) | |
| 45 | 49 | } | |
| 46 | 50 | if v >= 4 { | |
| 47 | 51 | r.SendName(info.Name) | |
| @@ -81,9 +85,11 @@ func registerGlobals(d *server.Display, o Options, s *Server) error { | |||
| 81 | 85 | type compositor struct{ server *Server } | |
| 82 | 86 | ||
| 83 | 87 | func (c compositor) CreateSurface(r *wayland.Compositor, id uint32) { | |
| 84 | - | state := &surface{server: c.server} | |
| 88 | + | state := &surface{server: c.server, bufferScale: 1} | |
| 85 | 89 | if w, err := wayland.NewSurface(r.Client(), r.Version(), id, state); err == nil { | |
| 90 | + | state.wl = w | |
| 86 | 91 | c.server.surfaces[w.Resource] = state | |
| 92 | + | c.server.sendSurfaceScale(w) | |
| 87 | 93 | w.OnDestroy = func() { delete(c.server.surfaces, w.Resource); state.Destroy(w) } | |
| 88 | 94 | } | |
| 89 | 95 | } | |
| @@ -12,8 +12,9 @@ func registerGlobals(d *server.Display, o Options, s *Server) error { | |||||
| 12 | for _, register := range []func() error{ | 12 | for _, register := range []func() error{ | ||
| 13 | func() error { return registerXDG(d, s) }, | 13 | func() error { return registerXDG(d, s) }, | ||
| 14 | func() error { return registerLayer(d, o, s) }, | 14 | func() error { return registerLayer(d, o, s) }, | ||
| 15 | - | func() error { return registerXDGOutput(d, o) }, | 15 | + | func() error { return registerXDGOutput(d, o, s) }, |
| 16 | func() error { return registerDecoration(d, s) }, | 16 | func() error { return registerDecoration(d, s) }, | ||
| 17 | + | func() error { return registerScale(d, s) }, | |||
| 17 | func() error { | 18 | func() error { | ||
| 18 | return wayland.NewCompositorGlobal(d, 6, func(c server.Client, v, id uint32) { wayland.NewCompositor(c, int32(v), id, compositor{s}) }) | 19 | return wayland.NewCompositorGlobal(d, 6, func(c server.Client, v, id uint32) { wayland.NewCompositor(c, int32(v), id, compositor{s}) }) | ||
| 19 | }, | 20 | }, | ||
| @@ -37,11 +38,14 @@ func registerGlobals(d *server.Display, o Options, s *Server) error { | |||||
| 37 | if e != nil { | 38 | if e != nil { | ||
| 38 | return | 39 | return | ||
| 39 | } | 40 | } | ||
| 41 | + | s.scale.outputs = append(s.scale.outputs, r) | |||
| 42 | + | r.OnDestroy = func() { s.scale.outputs = removeItem(s.scale.outputs, r) } | |||
| 40 | info := o.output() | 43 | info := o.output() | ||
| 41 | r.SendGeometry(0, 0, int32(info.PhysicalW), int32(info.PhysicalH), 0, info.Make, info.Model, 0) | 44 | r.SendGeometry(0, 0, int32(info.PhysicalW), int32(info.PhysicalH), 0, info.Make, info.Model, 0) | ||
| 45 | + | // The mode stays physical; the scale tells clients how to divide it. | |||
| 42 | r.SendMode(3, int32(o.OutputWidth), int32(o.OutputHeight), int32(info.RefreshMilli)) | 46 | r.SendMode(3, int32(o.OutputWidth), int32(o.OutputHeight), int32(info.RefreshMilli)) | ||
| 43 | if v >= 2 { | 47 | if v >= 2 { | ||
| 44 | - | r.SendScale(1) | 48 | + | r.SendScale(s.scale.integerScale()) |
| 45 | } | 49 | } | ||
| 46 | if v >= 4 { | 50 | if v >= 4 { | ||
| 47 | r.SendName(info.Name) | 51 | r.SendName(info.Name) | ||
| @@ -81,9 +85,11 @@ func registerGlobals(d *server.Display, o Options, s *Server) error { | |||||
| 81 | type compositor struct{ server *Server } | 85 | type compositor struct{ server *Server } | ||
| 82 | 86 | ||||
| 83 | func (c compositor) CreateSurface(r *wayland.Compositor, id uint32) { | 87 | func (c compositor) CreateSurface(r *wayland.Compositor, id uint32) { | ||
| 84 | - | state := &surface{server: c.server} | 88 | + | state := &surface{server: c.server, bufferScale: 1} |
| 85 | if w, err := wayland.NewSurface(r.Client(), r.Version(), id, state); err == nil { | 89 | if w, err := wayland.NewSurface(r.Client(), r.Version(), id, state); err == nil { | ||
| 90 | + | state.wl = w | |||
| 86 | c.server.surfaces[w.Resource] = state | 91 | c.server.surfaces[w.Resource] = state | ||
| 92 | + | c.server.sendSurfaceScale(w) | |||
| 87 | w.OnDestroy = func() { delete(c.server.surfaces, w.Resource); state.Destroy(w) } | 93 | w.OnDestroy = func() { delete(c.server.surfaces, w.Resource); state.Destroy(w) } | ||
| 88 | } | 94 | } | ||
| 89 | } | 95 | } | ||
M internal/adapters/wayland/layer.go +6 -8
| @@ -9,10 +9,7 @@ import ( | |||
| 9 | 9 | "sort" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | - | type layerShell struct { | |
| 13 | - | server *Server | |
| 14 | - | width, height uint32 | |
| 15 | - | } | |
| 12 | + | type layerShell struct{ server *Server } | |
| 16 | 13 | type layerState struct { | |
| 17 | 14 | width, height, anchor uint32 | |
| 18 | 15 | zone int32 | |
| @@ -33,7 +30,7 @@ type layerSurface struct { | |||
| 33 | 30 | ||
| 34 | 31 | func registerLayer(d *server.Display, o Options, s *Server) error { | |
| 35 | 32 | return wlrlayershell.NewZwlrLayerShellV1Global(d, 4, func(c server.Client, v, id uint32) { | |
| 36 | - | _, _ = wlrlayershell.NewZwlrLayerShellV1(c, int32(v), id, layerShell{s, uint32(o.OutputWidth), uint32(o.OutputHeight)}) | |
| 33 | + | _, _ = wlrlayershell.NewZwlrLayerShellV1(c, int32(v), id, layerShell{s}) | |
| 37 | 34 | }) | |
| 38 | 35 | } | |
| 39 | 36 | func (h layerShell) Destroy(*wlrlayershell.ZwlrLayerShellV1) {} | |
| @@ -142,7 +139,7 @@ func (s *Server) layerChanged() { | |||
| 142 | 139 | v := ports.LayerSurface{ID: l.id, Layer: l.current.layer, Anchor: l.current.anchor, ExclusiveZone: l.current.zone, Margin: l.current.margin, Namespace: l.namespace, Keyboard: l.current.keyboard} | |
| 143 | 140 | if l.surface.current != nil { | |
| 144 | 141 | if b := s.buffers[l.surface.current.Resource]; b != nil { | |
| 145 | - | v.Width, v.Height = b.width, b.height | |
| 142 | + | v.Width, v.Height = l.surface.logicalSize(b.width, b.height) | |
| 146 | 143 | } | |
| 147 | 144 | } | |
| 148 | 145 | list = append(list, v) | |
| @@ -165,12 +162,13 @@ func (l *layerSurface) commit(buffer bool) { | |||
| 165 | 162 | sizeChanged := p.width != l.current.width || p.height != l.current.height | |
| 166 | 163 | l.current = p | |
| 167 | 164 | if !l.configured || sizeChanged { | |
| 165 | + | // Zero means "fill": the logical output size. | |
| 168 | 166 | w, h := p.width, p.height | |
| 169 | 167 | if w == 0 { | |
| 170 | - | w = l.shell.width | |
| 168 | + | w = uint32(l.shell.server.scale.width) | |
| 171 | 169 | } | |
| 172 | 170 | if h == 0 { | |
| 173 | - | h = l.shell.height | |
| 171 | + | h = uint32(l.shell.server.scale.height) | |
| 174 | 172 | } | |
| 175 | 173 | l.shell.server.serial++ | |
| 176 | 174 | l.resource.SendConfigure(l.shell.server.serial, w, h) | |
| @@ -9,10 +9,7 @@ import ( | |||||
| 9 | "sort" | 9 | "sort" | ||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | - | type layerShell struct { | 12 | + | type layerShell struct{ server *Server } |
| 13 | - | server *Server | |||
| 14 | - | width, height uint32 | |||
| 15 | - | } | |||
| 16 | type layerState struct { | 13 | type layerState struct { | ||
| 17 | width, height, anchor uint32 | 14 | width, height, anchor uint32 | ||
| 18 | zone int32 | 15 | zone int32 | ||
| @@ -33,7 +30,7 @@ type layerSurface struct { | |||||
| 33 | 30 | ||||
| 34 | func registerLayer(d *server.Display, o Options, s *Server) error { | 31 | func registerLayer(d *server.Display, o Options, s *Server) error { | ||
| 35 | return wlrlayershell.NewZwlrLayerShellV1Global(d, 4, func(c server.Client, v, id uint32) { | 32 | return wlrlayershell.NewZwlrLayerShellV1Global(d, 4, func(c server.Client, v, id uint32) { | ||
| 36 | - | _, _ = wlrlayershell.NewZwlrLayerShellV1(c, int32(v), id, layerShell{s, uint32(o.OutputWidth), uint32(o.OutputHeight)}) | 33 | + | _, _ = wlrlayershell.NewZwlrLayerShellV1(c, int32(v), id, layerShell{s}) |
| 37 | }) | 34 | }) | ||
| 38 | } | 35 | } | ||
| 39 | func (h layerShell) Destroy(*wlrlayershell.ZwlrLayerShellV1) {} | 36 | func (h layerShell) Destroy(*wlrlayershell.ZwlrLayerShellV1) {} | ||
| @@ -142,7 +139,7 @@ func (s *Server) layerChanged() { | |||||
| 142 | v := ports.LayerSurface{ID: l.id, Layer: l.current.layer, Anchor: l.current.anchor, ExclusiveZone: l.current.zone, Margin: l.current.margin, Namespace: l.namespace, Keyboard: l.current.keyboard} | 139 | v := ports.LayerSurface{ID: l.id, Layer: l.current.layer, Anchor: l.current.anchor, ExclusiveZone: l.current.zone, Margin: l.current.margin, Namespace: l.namespace, Keyboard: l.current.keyboard} | ||
| 143 | if l.surface.current != nil { | 140 | if l.surface.current != nil { | ||
| 144 | if b := s.buffers[l.surface.current.Resource]; b != nil { | 141 | if b := s.buffers[l.surface.current.Resource]; b != nil { | ||
| 145 | - | v.Width, v.Height = b.width, b.height | 142 | + | v.Width, v.Height = l.surface.logicalSize(b.width, b.height) |
| 146 | } | 143 | } | ||
| 147 | } | 144 | } | ||
| 148 | list = append(list, v) | 145 | list = append(list, v) | ||
| @@ -165,12 +162,13 @@ func (l *layerSurface) commit(buffer bool) { | |||||
| 165 | sizeChanged := p.width != l.current.width || p.height != l.current.height | 162 | sizeChanged := p.width != l.current.width || p.height != l.current.height | ||
| 166 | l.current = p | 163 | l.current = p | ||
| 167 | if !l.configured || sizeChanged { | 164 | if !l.configured || sizeChanged { | ||
| 165 | + | // Zero means "fill": the logical output size. | |||
| 168 | w, h := p.width, p.height | 166 | w, h := p.width, p.height | ||
| 169 | if w == 0 { | 167 | if w == 0 { | ||
| 170 | - | w = l.shell.width | 168 | + | w = uint32(l.shell.server.scale.width) |
| 171 | } | 169 | } | ||
| 172 | if h == 0 { | 170 | if h == 0 { | ||
| 173 | - | h = l.shell.height | 171 | + | h = uint32(l.shell.server.scale.height) |
| 174 | } | 172 | } | ||
| 175 | l.shell.server.serial++ | 173 | l.shell.server.serial++ | ||
| 176 | l.resource.SendConfigure(l.shell.server.serial, w, h) | 174 | l.resource.SendConfigure(l.shell.server.serial, w, h) | ||
A internal/adapters/wayland/scale.go +171 -0
| @@ -0,0 +1,171 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/nefertty/internal/ports" | |
| 7 | + | "github.com/bnema/purego-libwayland/protocol/fractionalscale" | |
| 8 | + | "github.com/bnema/purego-libwayland/protocol/viewporter" | |
| 9 | + | "github.com/bnema/purego-libwayland/protocol/wayland" | |
| 10 | + | "github.com/bnema/purego-libwayland/protocol/xdgoutput" | |
| 11 | + | "github.com/bnema/purego-libwayland/server" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // Output scale (HiDPI). Clients that bind wp_fractional_scale_v1 get the exact | |
| 15 | + | // scale in 1/120 steps and draw at physical size through wp_viewporter. Others | |
| 16 | + | // get wl_output.scale rounded up, draw at that integer, and the renderer | |
| 17 | + | // downscales. Every size sent to clients (configures, xdg-output, layer | |
| 18 | + | // configures, pointer coordinates) stays logical. | |
| 19 | + | ||
| 20 | + | // scaleState is what clients were last told about the output. | |
| 21 | + | type scaleState struct { | |
| 22 | + | scale float64 | |
| 23 | + | width, height int // logical | |
| 24 | + | outputs []*wayland.Output | |
| 25 | + | xdgOutputs []*xdgoutput.ZxdgOutputV1 | |
| 26 | + | fractions map[*surface]*fractionalscale.WpFractionalScaleV1 | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // integerScale is wl_output.scale: the fractional scale rounded up. | |
| 30 | + | func (st *scaleState) integerScale() int32 { return int32(math.Ceil(st.scale - 1e-9)) } | |
| 31 | + | ||
| 32 | + | func (s *Server) setOutputScale(c ports.SetOutputScale) { | |
| 33 | + | st := &s.scale | |
| 34 | + | scaleChanged := c.Scale != st.scale | |
| 35 | + | st.scale, st.width, st.height = c.Scale, c.Width, c.Height | |
| 36 | + | s.log.Info().Float64("scale", c.Scale).Int("w", c.Width).Int("h", c.Height).Msg("output scale") | |
| 37 | + | for _, x := range st.xdgOutputs { | |
| 38 | + | x.SendLogicalSize(int32(c.Width), int32(c.Height)) | |
| 39 | + | if x.Version() < 3 { | |
| 40 | + | x.SendDone() | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | for _, o := range st.outputs { | |
| 44 | + | if o.Version() >= 2 { | |
| 45 | + | o.SendScale(st.integerScale()) | |
| 46 | + | o.SendDone() | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | if scaleChanged { | |
| 50 | + | for _, surf := range s.surfaces { | |
| 51 | + | s.sendSurfaceScale(surf.wl) | |
| 52 | + | } | |
| 53 | + | for surf, f := range st.fractions { | |
| 54 | + | if !surf.destroyed { | |
| 55 | + | f.SendPreferredScale(uint32(math.Round(st.scale * 120))) | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | // Layer surfaces sized from the output follow its new logical size. | |
| 60 | + | for _, l := range s.layers { | |
| 61 | + | if l.configured && (l.current.width == 0 || l.current.height == 0) { | |
| 62 | + | l.configured = false | |
| 63 | + | l.commit(l.surface.current != nil) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // sendSurfaceScale tells a wl_surface v6+ which integer buffer scale to use. | |
| 69 | + | func (s *Server) sendSurfaceScale(w *wayland.Surface) { | |
| 70 | + | if w != nil && w.Version() >= 6 { | |
| 71 | + | w.SendPreferredBufferScale(s.scale.integerScale()) | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func registerScale(d *server.Display, s *Server) error { | |
| 76 | + | if err := fractionalscale.NewWpFractionalScaleManagerV1Global(d, 1, func(c server.Client, v, id uint32) { | |
| 77 | + | _, _ = fractionalscale.NewWpFractionalScaleManagerV1(c, int32(v), id, fractionManager{s}) | |
| 78 | + | }); err != nil { | |
| 79 | + | return err | |
| 80 | + | } | |
| 81 | + | return viewporter.NewWpViewporterGlobal(d, 1, func(c server.Client, v, id uint32) { | |
| 82 | + | _, _ = viewporter.NewWpViewporter(c, int32(v), id, viewporterHandler{s}) | |
| 83 | + | }) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | type fractionManager struct{ server *Server } | |
| 87 | + | ||
| 88 | + | func (fractionManager) Destroy(*fractionalscale.WpFractionalScaleManagerV1) {} | |
| 89 | + | func (m fractionManager) GetFractionalScale(r *fractionalscale.WpFractionalScaleManagerV1, id uint32, w *wayland.Surface) { | |
| 90 | + | s := m.server | |
| 91 | + | surf := s.surfaces[w.GetResource()] | |
| 92 | + | if surf == nil { | |
| 93 | + | return | |
| 94 | + | } | |
| 95 | + | if _, dup := s.scale.fractions[surf]; dup { | |
| 96 | + | r.PostError(uint32(fractionalscale.WpFractionalScaleManagerV1ErrorFractionalScaleExists), "fractional scale already exists") | |
| 97 | + | return | |
| 98 | + | } | |
| 99 | + | f, err := fractionalscale.NewWpFractionalScaleV1(r.Client(), r.Version(), id, fraction{}) | |
| 100 | + | if err != nil { | |
| 101 | + | return | |
| 102 | + | } | |
| 103 | + | s.scale.fractions[surf] = f | |
| 104 | + | f.OnDestroy = func() { delete(s.scale.fractions, surf) } | |
| 105 | + | f.SendPreferredScale(uint32(math.Round(s.scale.scale * 120))) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | type fraction struct{} | |
| 109 | + | ||
| 110 | + | func (fraction) Destroy(*fractionalscale.WpFractionalScaleV1) {} | |
| 111 | + | ||
| 112 | + | type viewporterHandler struct{ server *Server } | |
| 113 | + | ||
| 114 | + | func (viewporterHandler) Destroy(*viewporter.WpViewporter) {} | |
| 115 | + | func (h viewporterHandler) GetViewport(r *viewporter.WpViewporter, id uint32, w *wayland.Surface) { | |
| 116 | + | surf := h.server.surfaces[w.GetResource()] | |
| 117 | + | if surf == nil { | |
| 118 | + | return | |
| 119 | + | } | |
| 120 | + | if surf.viewport != nil { | |
| 121 | + | r.PostError(uint32(viewporter.WpViewporterErrorViewportExists), "viewport already exists") | |
| 122 | + | return | |
| 123 | + | } | |
| 124 | + | vp := &viewport{surface: surf} | |
| 125 | + | res, err := viewporter.NewWpViewport(r.Client(), r.Version(), id, vp) | |
| 126 | + | if err != nil { | |
| 127 | + | return | |
| 128 | + | } | |
| 129 | + | surf.viewport = vp | |
| 130 | + | res.OnDestroy = func() { | |
| 131 | + | // Destroying the viewport resets the size on the next commit. | |
| 132 | + | if surf.viewport == vp { | |
| 133 | + | surf.viewport = nil | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // viewport holds the pending and committed wp_viewport destination; the | |
| 139 | + | // source crop is accepted and ignored (clients use it for video crops). | |
| 140 | + | type viewport struct { | |
| 141 | + | surface *surface | |
| 142 | + | pendingW, destW int32 | |
| 143 | + | pendingH, destH int32 | |
| 144 | + | pendingSet, dest bool | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func (*viewport) Destroy(*viewporter.WpViewport) {} | |
| 148 | + | func (*viewport) SetSource(*viewporter.WpViewport, server.Fixed, server.Fixed, server.Fixed, server.Fixed) { | |
| 149 | + | } | |
| 150 | + | func (v *viewport) SetDestination(r *viewporter.WpViewport, w, h int32) { | |
| 151 | + | if v.surface.destroyed { | |
| 152 | + | r.PostError(uint32(viewporter.WpViewportErrorNoSurface), "surface destroyed") | |
| 153 | + | return | |
| 154 | + | } | |
| 155 | + | if (w == -1) != (h == -1) || (w != -1 && (w <= 0 || h <= 0)) { | |
| 156 | + | r.PostError(uint32(viewporter.WpViewportErrorBadValue), "invalid destination size") | |
| 157 | + | return | |
| 158 | + | } | |
| 159 | + | v.pendingW, v.pendingH, v.pendingSet = w, h, w != -1 | |
| 160 | + | } | |
| 161 | + | func (v *viewport) commit() { v.destW, v.destH, v.dest = v.pendingW, v.pendingH, v.pendingSet } | |
| 162 | + | ||
| 163 | + | // logicalSize is the surface size in logical pixels: the viewport | |
| 164 | + | // destination, else the buffer divided by its integer buffer scale. | |
| 165 | + | func (s *surface) logicalSize(bw, bh int) (int, int) { | |
| 166 | + | if s.viewport != nil && s.viewport.dest { | |
| 167 | + | return int(s.viewport.destW), int(s.viewport.destH) | |
| 168 | + | } | |
| 169 | + | scale := max(s.bufferScale, 1) | |
| 170 | + | return bw / scale, bh / scale | |
| 171 | + | } | |
| @@ -0,0 +1,171 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "math" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 7 | + | "github.com/bnema/purego-libwayland/protocol/fractionalscale" | |||
| 8 | + | "github.com/bnema/purego-libwayland/protocol/viewporter" | |||
| 9 | + | "github.com/bnema/purego-libwayland/protocol/wayland" | |||
| 10 | + | "github.com/bnema/purego-libwayland/protocol/xdgoutput" | |||
| 11 | + | "github.com/bnema/purego-libwayland/server" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // Output scale (HiDPI). Clients that bind wp_fractional_scale_v1 get the exact | |||
| 15 | + | // scale in 1/120 steps and draw at physical size through wp_viewporter. Others | |||
| 16 | + | // get wl_output.scale rounded up, draw at that integer, and the renderer | |||
| 17 | + | // downscales. Every size sent to clients (configures, xdg-output, layer | |||
| 18 | + | // configures, pointer coordinates) stays logical. | |||
| 19 | + | ||||
| 20 | + | // scaleState is what clients were last told about the output. | |||
| 21 | + | type scaleState struct { | |||
| 22 | + | scale float64 | |||
| 23 | + | width, height int // logical | |||
| 24 | + | outputs []*wayland.Output | |||
| 25 | + | xdgOutputs []*xdgoutput.ZxdgOutputV1 | |||
| 26 | + | fractions map[*surface]*fractionalscale.WpFractionalScaleV1 | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // integerScale is wl_output.scale: the fractional scale rounded up. | |||
| 30 | + | func (st *scaleState) integerScale() int32 { return int32(math.Ceil(st.scale - 1e-9)) } | |||
| 31 | + | ||||
| 32 | + | func (s *Server) setOutputScale(c ports.SetOutputScale) { | |||
| 33 | + | st := &s.scale | |||
| 34 | + | scaleChanged := c.Scale != st.scale | |||
| 35 | + | st.scale, st.width, st.height = c.Scale, c.Width, c.Height | |||
| 36 | + | s.log.Info().Float64("scale", c.Scale).Int("w", c.Width).Int("h", c.Height).Msg("output scale") | |||
| 37 | + | for _, x := range st.xdgOutputs { | |||
| 38 | + | x.SendLogicalSize(int32(c.Width), int32(c.Height)) | |||
| 39 | + | if x.Version() < 3 { | |||
| 40 | + | x.SendDone() | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | for _, o := range st.outputs { | |||
| 44 | + | if o.Version() >= 2 { | |||
| 45 | + | o.SendScale(st.integerScale()) | |||
| 46 | + | o.SendDone() | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | if scaleChanged { | |||
| 50 | + | for _, surf := range s.surfaces { | |||
| 51 | + | s.sendSurfaceScale(surf.wl) | |||
| 52 | + | } | |||
| 53 | + | for surf, f := range st.fractions { | |||
| 54 | + | if !surf.destroyed { | |||
| 55 | + | f.SendPreferredScale(uint32(math.Round(st.scale * 120))) | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | // Layer surfaces sized from the output follow its new logical size. | |||
| 60 | + | for _, l := range s.layers { | |||
| 61 | + | if l.configured && (l.current.width == 0 || l.current.height == 0) { | |||
| 62 | + | l.configured = false | |||
| 63 | + | l.commit(l.surface.current != nil) | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // sendSurfaceScale tells a wl_surface v6+ which integer buffer scale to use. | |||
| 69 | + | func (s *Server) sendSurfaceScale(w *wayland.Surface) { | |||
| 70 | + | if w != nil && w.Version() >= 6 { | |||
| 71 | + | w.SendPreferredBufferScale(s.scale.integerScale()) | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func registerScale(d *server.Display, s *Server) error { | |||
| 76 | + | if err := fractionalscale.NewWpFractionalScaleManagerV1Global(d, 1, func(c server.Client, v, id uint32) { | |||
| 77 | + | _, _ = fractionalscale.NewWpFractionalScaleManagerV1(c, int32(v), id, fractionManager{s}) | |||
| 78 | + | }); err != nil { | |||
| 79 | + | return err | |||
| 80 | + | } | |||
| 81 | + | return viewporter.NewWpViewporterGlobal(d, 1, func(c server.Client, v, id uint32) { | |||
| 82 | + | _, _ = viewporter.NewWpViewporter(c, int32(v), id, viewporterHandler{s}) | |||
| 83 | + | }) | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | type fractionManager struct{ server *Server } | |||
| 87 | + | ||||
| 88 | + | func (fractionManager) Destroy(*fractionalscale.WpFractionalScaleManagerV1) {} | |||
| 89 | + | func (m fractionManager) GetFractionalScale(r *fractionalscale.WpFractionalScaleManagerV1, id uint32, w *wayland.Surface) { | |||
| 90 | + | s := m.server | |||
| 91 | + | surf := s.surfaces[w.GetResource()] | |||
| 92 | + | if surf == nil { | |||
| 93 | + | return | |||
| 94 | + | } | |||
| 95 | + | if _, dup := s.scale.fractions[surf]; dup { | |||
| 96 | + | r.PostError(uint32(fractionalscale.WpFractionalScaleManagerV1ErrorFractionalScaleExists), "fractional scale already exists") | |||
| 97 | + | return | |||
| 98 | + | } | |||
| 99 | + | f, err := fractionalscale.NewWpFractionalScaleV1(r.Client(), r.Version(), id, fraction{}) | |||
| 100 | + | if err != nil { | |||
| 101 | + | return | |||
| 102 | + | } | |||
| 103 | + | s.scale.fractions[surf] = f | |||
| 104 | + | f.OnDestroy = func() { delete(s.scale.fractions, surf) } | |||
| 105 | + | f.SendPreferredScale(uint32(math.Round(s.scale.scale * 120))) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | type fraction struct{} | |||
| 109 | + | ||||
| 110 | + | func (fraction) Destroy(*fractionalscale.WpFractionalScaleV1) {} | |||
| 111 | + | ||||
| 112 | + | type viewporterHandler struct{ server *Server } | |||
| 113 | + | ||||
| 114 | + | func (viewporterHandler) Destroy(*viewporter.WpViewporter) {} | |||
| 115 | + | func (h viewporterHandler) GetViewport(r *viewporter.WpViewporter, id uint32, w *wayland.Surface) { | |||
| 116 | + | surf := h.server.surfaces[w.GetResource()] | |||
| 117 | + | if surf == nil { | |||
| 118 | + | return | |||
| 119 | + | } | |||
| 120 | + | if surf.viewport != nil { | |||
| 121 | + | r.PostError(uint32(viewporter.WpViewporterErrorViewportExists), "viewport already exists") | |||
| 122 | + | return | |||
| 123 | + | } | |||
| 124 | + | vp := &viewport{surface: surf} | |||
| 125 | + | res, err := viewporter.NewWpViewport(r.Client(), r.Version(), id, vp) | |||
| 126 | + | if err != nil { | |||
| 127 | + | return | |||
| 128 | + | } | |||
| 129 | + | surf.viewport = vp | |||
| 130 | + | res.OnDestroy = func() { | |||
| 131 | + | // Destroying the viewport resets the size on the next commit. | |||
| 132 | + | if surf.viewport == vp { | |||
| 133 | + | surf.viewport = nil | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | // viewport holds the pending and committed wp_viewport destination; the | |||
| 139 | + | // source crop is accepted and ignored (clients use it for video crops). | |||
| 140 | + | type viewport struct { | |||
| 141 | + | surface *surface | |||
| 142 | + | pendingW, destW int32 | |||
| 143 | + | pendingH, destH int32 | |||
| 144 | + | pendingSet, dest bool | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | func (*viewport) Destroy(*viewporter.WpViewport) {} | |||
| 148 | + | func (*viewport) SetSource(*viewporter.WpViewport, server.Fixed, server.Fixed, server.Fixed, server.Fixed) { | |||
| 149 | + | } | |||
| 150 | + | func (v *viewport) SetDestination(r *viewporter.WpViewport, w, h int32) { | |||
| 151 | + | if v.surface.destroyed { | |||
| 152 | + | r.PostError(uint32(viewporter.WpViewportErrorNoSurface), "surface destroyed") | |||
| 153 | + | return | |||
| 154 | + | } | |||
| 155 | + | if (w == -1) != (h == -1) || (w != -1 && (w <= 0 || h <= 0)) { | |||
| 156 | + | r.PostError(uint32(viewporter.WpViewportErrorBadValue), "invalid destination size") | |||
| 157 | + | return | |||
| 158 | + | } | |||
| 159 | + | v.pendingW, v.pendingH, v.pendingSet = w, h, w != -1 | |||
| 160 | + | } | |||
| 161 | + | func (v *viewport) commit() { v.destW, v.destH, v.dest = v.pendingW, v.pendingH, v.pendingSet } | |||
| 162 | + | ||||
| 163 | + | // logicalSize is the surface size in logical pixels: the viewport | |||
| 164 | + | // destination, else the buffer divided by its integer buffer scale. | |||
| 165 | + | func (s *surface) logicalSize(bw, bh int) (int, int) { | |||
| 166 | + | if s.viewport != nil && s.viewport.dest { | |||
| 167 | + | return int(s.viewport.destW), int(s.viewport.destH) | |||
| 168 | + | } | |||
| 169 | + | scale := max(s.bufferScale, 1) | |||
| 170 | + | return bw / scale, bh / scale | |||
| 171 | + | } | |||
A internal/adapters/wayland/scale_test.go +195 -0
| @@ -0,0 +1,195 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/nefertty/internal/logging" | |
| 9 | + | "github.com/bnema/nefertty/internal/ports" | |
| 10 | + | "github.com/bnema/purego-libwayland/protocol/fractionalscale" | |
| 11 | + | "github.com/bnema/purego-libwayland/protocol/viewporter" | |
| 12 | + | "github.com/bnema/purego-libwayland/protocol/wayland" | |
| 13 | + | "github.com/bnema/purego-libwayland/protocol/xdgshell" | |
| 14 | + | "github.com/bnema/wlturbo" | |
| 15 | + | "golang.org/x/sys/unix" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | type scaleProxy struct { | |
| 19 | + | wlturbo.BaseProxy | |
| 20 | + | values chan uint32 | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func (p *scaleProxy) Dispatch(e *wlturbo.Event) { | |
| 24 | + | if e.Opcode == 0 { | |
| 25 | + | p.values <- e.Uint32() | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // surfaceScaleProxy records wl_surface.preferred_buffer_scale (opcode 2). | |
| 30 | + | type surfaceScaleProxy struct { | |
| 31 | + | wlturbo.BaseProxy | |
| 32 | + | scales chan int32 | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func (p *surfaceScaleProxy) Dispatch(e *wlturbo.Event) { | |
| 36 | + | if e.Opcode == 2 { | |
| 37 | + | p.scales <- e.Int32() | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func contentServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, chan ports.SurfaceContent, string) { | |
| 42 | + | t.Helper() | |
| 43 | + | dir := t.TempDir() | |
| 44 | + | events := make(chan ports.ClientEvent, 16) | |
| 45 | + | commands := make(chan ports.ClientCommand, 16) | |
| 46 | + | contents := make(chan ports.SurfaceContent, 16) | |
| 47 | + | s, err := New(Options{RuntimeDir: dir, OutputWidth: 1920, OutputHeight: 1080}, Channels{Events: events, Commands: commands, Contents: contents}, logging.For(context.Background(), "wayland")) | |
| 48 | + | if err != nil { | |
| 49 | + | t.Fatal(err) | |
| 50 | + | } | |
| 51 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 52 | + | done := make(chan error, 1) | |
| 53 | + | go func() { done <- s.Run(ctx) }() | |
| 54 | + | t.Cleanup(func() { cancel(); <-done }) | |
| 55 | + | return s, events, commands, contents, dir | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestFractionalScaleAndViewport(t *testing.T) { | |
| 59 | + | s, events, commands, contents, dir := contentServer(t) | |
| 60 | + | c := protocolClient(t, s, dir) | |
| 61 | + | // wl_surface.preferred_buffer_scale needs wl_compositor v6. | |
| 62 | + | g, _ := c.Registry().FindGlobal("wl_compositor") | |
| 63 | + | comp, err := c.Registry().BindID(g.Name, g.Interface, 6) | |
| 64 | + | if err != nil { | |
| 65 | + | t.Fatal(err) | |
| 66 | + | } | |
| 67 | + | wm := bindProtocol(t, c, "xdg_wm_base") | |
| 68 | + | shm := bindProtocol(t, c, "wl_shm") | |
| 69 | + | fm := bindProtocol(t, c, "wp_fractional_scale_manager_v1") | |
| 70 | + | vpm := bindProtocol(t, c, "wp_viewporter") | |
| 71 | + | registerProtocol(t, c, shm) // wl_shm sends its formats right away | |
| 72 | + | // New IDs must be used in allocation order. | |
| 73 | + | surf := c.AllocateID() | |
| 74 | + | bufferScales := make(chan int32, 4) | |
| 75 | + | sp := &surfaceScaleProxy{scales: bufferScales} | |
| 76 | + | sp.SetID(surf) | |
| 77 | + | c.Context().Register(sp) | |
| 78 | + | requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf) | |
| 79 | + | frac := c.AllocateID() | |
| 80 | + | values := make(chan uint32, 4) | |
| 81 | + | proxy := &scaleProxy{values: values} | |
| 82 | + | proxy.SetID(frac) | |
| 83 | + | c.Context().Register(proxy) | |
| 84 | + | requestProtocol(t, c, fm, fractionalscale.WpFractionalScaleManagerV1RequestGetFractionalScale, frac, surf) | |
| 85 | + | vp := c.AllocateID() | |
| 86 | + | requestProtocol(t, c, vpm, viewporter.WpViewporterRequestGetViewport, vp, surf) | |
| 87 | + | registerProtocol(t, c, vp) | |
| 88 | + | if err := c.Roundtrip(); err != nil { | |
| 89 | + | t.Fatal(err) | |
| 90 | + | } | |
| 91 | + | if v := <-values; v != 120 { | |
| 92 | + | t.Fatalf("initial scale %d", v) | |
| 93 | + | } | |
| 94 | + | // Core switches to 1.5: the surface gets 180/120. | |
| 95 | + | commands <- ports.SetOutputScale{Scale: 1.5, Width: 1280, Height: 720} | |
| 96 | + | // Events are read by Roundtrip; the command is applied asynchronously. | |
| 97 | + | var got uint32 | |
| 98 | + | for deadline := time.Now().Add(2 * time.Second); got == 0 && time.Now().Before(deadline); { | |
| 99 | + | if err := c.Roundtrip(); err != nil { | |
| 100 | + | t.Fatal(err) | |
| 101 | + | } | |
| 102 | + | select { | |
| 103 | + | case got = <-values: | |
| 104 | + | case <-time.After(10 * time.Millisecond): | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | if got != 180 { | |
| 108 | + | t.Fatalf("scale %d", got) | |
| 109 | + | } | |
| 110 | + | // Clients without fractional scale get the integer scale rounded up. | |
| 111 | + | var last int32 | |
| 112 | + | for len(bufferScales) > 0 { | |
| 113 | + | last = <-bufferScales | |
| 114 | + | } | |
| 115 | + | if last != 2 { | |
| 116 | + | t.Fatalf("preferred_buffer_scale %d", last) | |
| 117 | + | } | |
| 118 | + | // A 30x15 buffer shown at 20x10 logical through the viewport. | |
| 119 | + | xdg := c.AllocateID() | |
| 120 | + | requestProtocol(t, c, wm, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf) | |
| 121 | + | serials := make(chan uint32, 4) | |
| 122 | + | cp := &configureProxy{serial: serials} | |
| 123 | + | cp.SetID(xdg) | |
| 124 | + | c.Context().Register(cp) | |
| 125 | + | top := c.AllocateID() | |
| 126 | + | registerProtocol(t, c, top) | |
| 127 | + | requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top) | |
| 128 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | |
| 129 | + | if err := c.Roundtrip(); err != nil { | |
| 130 | + | t.Fatal(err) | |
| 131 | + | } | |
| 132 | + | requestProtocol(t, c, xdg, xdgshell.SurfaceRequestAckConfigure, <-serials) | |
| 133 | + | fd, err := unix.MemfdCreate("scaled", 0) | |
| 134 | + | if err != nil { | |
| 135 | + | t.Fatal(err) | |
| 136 | + | } | |
| 137 | + | defer unix.Close(fd) | |
| 138 | + | size := int32(30 * 15 * 4) | |
| 139 | + | if err := unix.Ftruncate(fd, int64(size)); err != nil { | |
| 140 | + | t.Fatal(err) | |
| 141 | + | } | |
| 142 | + | pool, buffer := c.AllocateID(), c.AllocateID() | |
| 143 | + | registerProtocol(t, c, pool) | |
| 144 | + | registerProtocol(t, c, buffer) | |
| 145 | + | if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, size); err != nil { | |
| 146 | + | t.Fatal(err) | |
| 147 | + | } | |
| 148 | + | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(30), int32(15), int32(120), uint32(0)) | |
| 149 | + | requestProtocol(t, c, vp, viewporter.WpViewportRequestSetDestination, int32(20), int32(10)) | |
| 150 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buffer, int32(0), int32(0)) | |
| 151 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | |
| 152 | + | if err := c.Roundtrip(); err != nil { | |
| 153 | + | t.Fatal(err) | |
| 154 | + | } | |
| 155 | + | mapped(t, events, 2*time.Second) | |
| 156 | + | deadline := time.After(2 * time.Second) | |
| 157 | + | for { | |
| 158 | + | select { | |
| 159 | + | case got := <-contents: | |
| 160 | + | if got.Pixels == nil { | |
| 161 | + | continue | |
| 162 | + | } | |
| 163 | + | if got.Width != 30 || got.Height != 15 || got.LogicalW != 20 || got.LogicalH != 10 { | |
| 164 | + | t.Fatalf("%+v", got) | |
| 165 | + | } | |
| 166 | + | return | |
| 167 | + | case <-deadline: | |
| 168 | + | t.Fatal("no content") | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func TestBufferScaleLogicalSize(t *testing.T) { | |
| 174 | + | st := &surface{bufferScale: 2} | |
| 175 | + | if w, h := st.logicalSize(200, 100); w != 100 || h != 50 { | |
| 176 | + | t.Fatal(w, h) | |
| 177 | + | } | |
| 178 | + | st.viewport = &viewport{surface: st, destW: 7, destH: 3, dest: true} | |
| 179 | + | if w, h := st.logicalSize(200, 100); w != 7 || h != 3 { | |
| 180 | + | t.Fatal(w, h) | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func TestViewportBadDestination(t *testing.T) { | |
| 185 | + | s, _, _, _, dir := contentServer(t) | |
| 186 | + | c := protocolClient(t, s, dir) | |
| 187 | + | comp := bindProtocol(t, c, "wl_compositor") | |
| 188 | + | vpm := bindProtocol(t, c, "wp_viewporter") | |
| 189 | + | surf, vp := c.AllocateID(), c.AllocateID() | |
| 190 | + | requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf) | |
| 191 | + | requestProtocol(t, c, vpm, viewporter.WpViewporterRequestGetViewport, vp, surf) | |
| 192 | + | registerProtocol(t, c, vp) | |
| 193 | + | requestProtocol(t, c, vp, viewporter.WpViewportRequestSetDestination, int32(0), int32(10)) | |
| 194 | + | expectProtocolError(t, c, vp, uint32(viewporter.WpViewportErrorBadValue)) | |
| 195 | + | } | |
| @@ -0,0 +1,195 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/nefertty/internal/logging" | |||
| 9 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 10 | + | "github.com/bnema/purego-libwayland/protocol/fractionalscale" | |||
| 11 | + | "github.com/bnema/purego-libwayland/protocol/viewporter" | |||
| 12 | + | "github.com/bnema/purego-libwayland/protocol/wayland" | |||
| 13 | + | "github.com/bnema/purego-libwayland/protocol/xdgshell" | |||
| 14 | + | "github.com/bnema/wlturbo" | |||
| 15 | + | "golang.org/x/sys/unix" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | type scaleProxy struct { | |||
| 19 | + | wlturbo.BaseProxy | |||
| 20 | + | values chan uint32 | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func (p *scaleProxy) Dispatch(e *wlturbo.Event) { | |||
| 24 | + | if e.Opcode == 0 { | |||
| 25 | + | p.values <- e.Uint32() | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // surfaceScaleProxy records wl_surface.preferred_buffer_scale (opcode 2). | |||
| 30 | + | type surfaceScaleProxy struct { | |||
| 31 | + | wlturbo.BaseProxy | |||
| 32 | + | scales chan int32 | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func (p *surfaceScaleProxy) Dispatch(e *wlturbo.Event) { | |||
| 36 | + | if e.Opcode == 2 { | |||
| 37 | + | p.scales <- e.Int32() | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func contentServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, chan ports.SurfaceContent, string) { | |||
| 42 | + | t.Helper() | |||
| 43 | + | dir := t.TempDir() | |||
| 44 | + | events := make(chan ports.ClientEvent, 16) | |||
| 45 | + | commands := make(chan ports.ClientCommand, 16) | |||
| 46 | + | contents := make(chan ports.SurfaceContent, 16) | |||
| 47 | + | s, err := New(Options{RuntimeDir: dir, OutputWidth: 1920, OutputHeight: 1080}, Channels{Events: events, Commands: commands, Contents: contents}, logging.For(context.Background(), "wayland")) | |||
| 48 | + | if err != nil { | |||
| 49 | + | t.Fatal(err) | |||
| 50 | + | } | |||
| 51 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 52 | + | done := make(chan error, 1) | |||
| 53 | + | go func() { done <- s.Run(ctx) }() | |||
| 54 | + | t.Cleanup(func() { cancel(); <-done }) | |||
| 55 | + | return s, events, commands, contents, dir | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func TestFractionalScaleAndViewport(t *testing.T) { | |||
| 59 | + | s, events, commands, contents, dir := contentServer(t) | |||
| 60 | + | c := protocolClient(t, s, dir) | |||
| 61 | + | // wl_surface.preferred_buffer_scale needs wl_compositor v6. | |||
| 62 | + | g, _ := c.Registry().FindGlobal("wl_compositor") | |||
| 63 | + | comp, err := c.Registry().BindID(g.Name, g.Interface, 6) | |||
| 64 | + | if err != nil { | |||
| 65 | + | t.Fatal(err) | |||
| 66 | + | } | |||
| 67 | + | wm := bindProtocol(t, c, "xdg_wm_base") | |||
| 68 | + | shm := bindProtocol(t, c, "wl_shm") | |||
| 69 | + | fm := bindProtocol(t, c, "wp_fractional_scale_manager_v1") | |||
| 70 | + | vpm := bindProtocol(t, c, "wp_viewporter") | |||
| 71 | + | registerProtocol(t, c, shm) // wl_shm sends its formats right away | |||
| 72 | + | // New IDs must be used in allocation order. | |||
| 73 | + | surf := c.AllocateID() | |||
| 74 | + | bufferScales := make(chan int32, 4) | |||
| 75 | + | sp := &surfaceScaleProxy{scales: bufferScales} | |||
| 76 | + | sp.SetID(surf) | |||
| 77 | + | c.Context().Register(sp) | |||
| 78 | + | requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf) | |||
| 79 | + | frac := c.AllocateID() | |||
| 80 | + | values := make(chan uint32, 4) | |||
| 81 | + | proxy := &scaleProxy{values: values} | |||
| 82 | + | proxy.SetID(frac) | |||
| 83 | + | c.Context().Register(proxy) | |||
| 84 | + | requestProtocol(t, c, fm, fractionalscale.WpFractionalScaleManagerV1RequestGetFractionalScale, frac, surf) | |||
| 85 | + | vp := c.AllocateID() | |||
| 86 | + | requestProtocol(t, c, vpm, viewporter.WpViewporterRequestGetViewport, vp, surf) | |||
| 87 | + | registerProtocol(t, c, vp) | |||
| 88 | + | if err := c.Roundtrip(); err != nil { | |||
| 89 | + | t.Fatal(err) | |||
| 90 | + | } | |||
| 91 | + | if v := <-values; v != 120 { | |||
| 92 | + | t.Fatalf("initial scale %d", v) | |||
| 93 | + | } | |||
| 94 | + | // Core switches to 1.5: the surface gets 180/120. | |||
| 95 | + | commands <- ports.SetOutputScale{Scale: 1.5, Width: 1280, Height: 720} | |||
| 96 | + | // Events are read by Roundtrip; the command is applied asynchronously. | |||
| 97 | + | var got uint32 | |||
| 98 | + | for deadline := time.Now().Add(2 * time.Second); got == 0 && time.Now().Before(deadline); { | |||
| 99 | + | if err := c.Roundtrip(); err != nil { | |||
| 100 | + | t.Fatal(err) | |||
| 101 | + | } | |||
| 102 | + | select { | |||
| 103 | + | case got = <-values: | |||
| 104 | + | case <-time.After(10 * time.Millisecond): | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | if got != 180 { | |||
| 108 | + | t.Fatalf("scale %d", got) | |||
| 109 | + | } | |||
| 110 | + | // Clients without fractional scale get the integer scale rounded up. | |||
| 111 | + | var last int32 | |||
| 112 | + | for len(bufferScales) > 0 { | |||
| 113 | + | last = <-bufferScales | |||
| 114 | + | } | |||
| 115 | + | if last != 2 { | |||
| 116 | + | t.Fatalf("preferred_buffer_scale %d", last) | |||
| 117 | + | } | |||
| 118 | + | // A 30x15 buffer shown at 20x10 logical through the viewport. | |||
| 119 | + | xdg := c.AllocateID() | |||
| 120 | + | requestProtocol(t, c, wm, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf) | |||
| 121 | + | serials := make(chan uint32, 4) | |||
| 122 | + | cp := &configureProxy{serial: serials} | |||
| 123 | + | cp.SetID(xdg) | |||
| 124 | + | c.Context().Register(cp) | |||
| 125 | + | top := c.AllocateID() | |||
| 126 | + | registerProtocol(t, c, top) | |||
| 127 | + | requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top) | |||
| 128 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | |||
| 129 | + | if err := c.Roundtrip(); err != nil { | |||
| 130 | + | t.Fatal(err) | |||
| 131 | + | } | |||
| 132 | + | requestProtocol(t, c, xdg, xdgshell.SurfaceRequestAckConfigure, <-serials) | |||
| 133 | + | fd, err := unix.MemfdCreate("scaled", 0) | |||
| 134 | + | if err != nil { | |||
| 135 | + | t.Fatal(err) | |||
| 136 | + | } | |||
| 137 | + | defer unix.Close(fd) | |||
| 138 | + | size := int32(30 * 15 * 4) | |||
| 139 | + | if err := unix.Ftruncate(fd, int64(size)); err != nil { | |||
| 140 | + | t.Fatal(err) | |||
| 141 | + | } | |||
| 142 | + | pool, buffer := c.AllocateID(), c.AllocateID() | |||
| 143 | + | registerProtocol(t, c, pool) | |||
| 144 | + | registerProtocol(t, c, buffer) | |||
| 145 | + | if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, size); err != nil { | |||
| 146 | + | t.Fatal(err) | |||
| 147 | + | } | |||
| 148 | + | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(30), int32(15), int32(120), uint32(0)) | |||
| 149 | + | requestProtocol(t, c, vp, viewporter.WpViewportRequestSetDestination, int32(20), int32(10)) | |||
| 150 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buffer, int32(0), int32(0)) | |||
| 151 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | |||
| 152 | + | if err := c.Roundtrip(); err != nil { | |||
| 153 | + | t.Fatal(err) | |||
| 154 | + | } | |||
| 155 | + | mapped(t, events, 2*time.Second) | |||
| 156 | + | deadline := time.After(2 * time.Second) | |||
| 157 | + | for { | |||
| 158 | + | select { | |||
| 159 | + | case got := <-contents: | |||
| 160 | + | if got.Pixels == nil { | |||
| 161 | + | continue | |||
| 162 | + | } | |||
| 163 | + | if got.Width != 30 || got.Height != 15 || got.LogicalW != 20 || got.LogicalH != 10 { | |||
| 164 | + | t.Fatalf("%+v", got) | |||
| 165 | + | } | |||
| 166 | + | return | |||
| 167 | + | case <-deadline: | |||
| 168 | + | t.Fatal("no content") | |||
| 169 | + | } | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func TestBufferScaleLogicalSize(t *testing.T) { | |||
| 174 | + | st := &surface{bufferScale: 2} | |||
| 175 | + | if w, h := st.logicalSize(200, 100); w != 100 || h != 50 { | |||
| 176 | + | t.Fatal(w, h) | |||
| 177 | + | } | |||
| 178 | + | st.viewport = &viewport{surface: st, destW: 7, destH: 3, dest: true} | |||
| 179 | + | if w, h := st.logicalSize(200, 100); w != 7 || h != 3 { | |||
| 180 | + | t.Fatal(w, h) | |||
| 181 | + | } | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | func TestViewportBadDestination(t *testing.T) { | |||
| 185 | + | s, _, _, _, dir := contentServer(t) | |||
| 186 | + | c := protocolClient(t, s, dir) | |||
| 187 | + | comp := bindProtocol(t, c, "wl_compositor") | |||
| 188 | + | vpm := bindProtocol(t, c, "wp_viewporter") | |||
| 189 | + | surf, vp := c.AllocateID(), c.AllocateID() | |||
| 190 | + | requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf) | |||
| 191 | + | requestProtocol(t, c, vpm, viewporter.WpViewporterRequestGetViewport, vp, surf) | |||
| 192 | + | registerProtocol(t, c, vp) | |||
| 193 | + | requestProtocol(t, c, vp, viewporter.WpViewportRequestSetDestination, int32(0), int32(10)) | |||
| 194 | + | expectProtocolError(t, c, vp, uint32(viewporter.WpViewportErrorBadValue)) | |||
| 195 | + | } | |||
M internal/adapters/wayland/server.go +14 -0
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "time" | |
| 10 | 10 | ||
| 11 | 11 | "github.com/bnema/nefertty/internal/ports" | |
| 12 | + | "github.com/bnema/purego-libwayland/protocol/fractionalscale" | |
| 12 | 13 | "github.com/bnema/purego-libwayland/protocol/wayland" | |
| 13 | 14 | "github.com/bnema/purego-libwayland/server" | |
| 14 | 15 | "github.com/bnema/zerowrap" | |
| @@ -90,6 +91,16 @@ type Server struct { | |||
| 90 | 91 | contentSeq map[ports.WindowID]uint64 | |
| 91 | 92 | contentNotify chan struct{} | |
| 92 | 93 | contentReady chan struct{} | |
| 94 | + | scale scaleState | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func removeItem[T comparable](list []T, v T) []T { | |
| 98 | + | for i, x := range list { | |
| 99 | + | if x == v { | |
| 100 | + | return append(list[:i], list[i+1:]...) | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | return list | |
| 93 | 104 | } | |
| 94 | 105 | ||
| 95 | 106 | func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |
| @@ -115,6 +126,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||
| 115 | 126 | return nil, err | |
| 116 | 127 | } | |
| 117 | 128 | s := &Server{display: d, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]*buffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay} | |
| 129 | + | s.scale = scaleState{scale: 1, width: opts.OutputWidth, height: opts.OutputHeight, fractions: map[*surface]*fractionalscale.WpFractionalScaleV1{}} | |
| 118 | 130 | if opts.Keymap != "" { | |
| 119 | 131 | fd, size, e := keymapFile(opts.Keymap) | |
| 120 | 132 | if e != nil { | |
| @@ -371,6 +383,8 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||
| 371 | 383 | s.log.Info().Uint64("id", uint64(c.ID)).Int("w", c.Width).Int("h", c.Height).Bool("fullscreen", c.Fullscreen).Bool("activated", c.Activated).Msg("configure") | |
| 372 | 384 | w.last, w.hasLast = c, true | |
| 373 | 385 | w.sendConfigure() | |
| 386 | + | case ports.SetOutputScale: | |
| 387 | + | s.setOutputScale(c) | |
| 374 | 388 | case ports.CloseWindow: | |
| 375 | 389 | w := s.windows[c.ID] | |
| 376 | 390 | if w == nil || !w.toplevel.Resource.Alive() { | |
| @@ -9,6 +9,7 @@ import ( | |||||
| 9 | "time" | 9 | "time" | ||
| 10 | 10 | ||||
| 11 | "github.com/bnema/nefertty/internal/ports" | 11 | "github.com/bnema/nefertty/internal/ports" | ||
| 12 | + | "github.com/bnema/purego-libwayland/protocol/fractionalscale" | |||
| 12 | "github.com/bnema/purego-libwayland/protocol/wayland" | 13 | "github.com/bnema/purego-libwayland/protocol/wayland" | ||
| 13 | "github.com/bnema/purego-libwayland/server" | 14 | "github.com/bnema/purego-libwayland/server" | ||
| 14 | "github.com/bnema/zerowrap" | 15 | "github.com/bnema/zerowrap" | ||
| @@ -90,6 +91,16 @@ type Server struct { | |||||
| 90 | contentSeq map[ports.WindowID]uint64 | 91 | contentSeq map[ports.WindowID]uint64 | ||
| 91 | contentNotify chan struct{} | 92 | contentNotify chan struct{} | ||
| 92 | contentReady chan struct{} | 93 | contentReady chan struct{} | ||
| 94 | + | scale scaleState | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func removeItem[T comparable](list []T, v T) []T { | |||
| 98 | + | for i, x := range list { | |||
| 99 | + | if x == v { | |||
| 100 | + | return append(list[:i], list[i+1:]...) | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | return list | |||
| 93 | } | 104 | } | ||
| 94 | 105 | ||||
| 95 | func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | 106 | func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | ||
| @@ -115,6 +126,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||||
| 115 | return nil, err | 126 | return nil, err | ||
| 116 | } | 127 | } | ||
| 117 | s := &Server{display: d, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]*buffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay} | 128 | s := &Server{display: d, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]*buffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay} | ||
| 129 | + | s.scale = scaleState{scale: 1, width: opts.OutputWidth, height: opts.OutputHeight, fractions: map[*surface]*fractionalscale.WpFractionalScaleV1{}} | |||
| 118 | if opts.Keymap != "" { | 130 | if opts.Keymap != "" { | ||
| 119 | fd, size, e := keymapFile(opts.Keymap) | 131 | fd, size, e := keymapFile(opts.Keymap) | ||
| 120 | if e != nil { | 132 | if e != nil { | ||
| @@ -371,6 +383,8 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||||
| 371 | s.log.Info().Uint64("id", uint64(c.ID)).Int("w", c.Width).Int("h", c.Height).Bool("fullscreen", c.Fullscreen).Bool("activated", c.Activated).Msg("configure") | 383 | s.log.Info().Uint64("id", uint64(c.ID)).Int("w", c.Width).Int("h", c.Height).Bool("fullscreen", c.Fullscreen).Bool("activated", c.Activated).Msg("configure") | ||
| 372 | w.last, w.hasLast = c, true | 384 | w.last, w.hasLast = c, true | ||
| 373 | w.sendConfigure() | 385 | w.sendConfigure() | ||
| 386 | + | case ports.SetOutputScale: | |||
| 387 | + | s.setOutputScale(c) | |||
| 374 | case ports.CloseWindow: | 388 | case ports.CloseWindow: | ||
| 375 | w := s.windows[c.ID] | 389 | w := s.windows[c.ID] | ||
| 376 | if w == nil || !w.toplevel.Resource.Alive() { | 390 | if w == nil || !w.toplevel.Resource.Alive() { | ||
M internal/adapters/wayland/surface.go +31 -14
| @@ -17,17 +17,21 @@ const ( | |||
| 17 | 17 | ) | |
| 18 | 18 | ||
| 19 | 19 | type surface struct { | |
| 20 | - | kind roleKind | |
| 21 | - | xdg *xdgSurface | |
| 22 | - | layer *layerSurface | |
| 23 | - | server *Server | |
| 24 | - | current, pending *wayland.Buffer | |
| 25 | - | attached bool | |
| 26 | - | lastW, lastH int // last logged buffer size | |
| 27 | - | callbacks []*wayland.Callback | |
| 28 | - | role func(bool) | |
| 29 | - | destroyed bool | |
| 30 | - | released bool | |
| 20 | + | wl *wayland.Surface | |
| 21 | + | viewport *viewport | |
| 22 | + | // bufferScale is committed state; pendingScale is set by set_buffer_scale. | |
| 23 | + | bufferScale, pendingScale int | |
| 24 | + | kind roleKind | |
| 25 | + | xdg *xdgSurface | |
| 26 | + | layer *layerSurface | |
| 27 | + | server *Server | |
| 28 | + | current, pending *wayland.Buffer | |
| 29 | + | attached bool | |
| 30 | + | lastW, lastH int // last logged buffer size | |
| 31 | + | callbacks []*wayland.Callback | |
| 32 | + | role func(bool) | |
| 33 | + | destroyed bool | |
| 34 | + | released bool | |
| 31 | 35 | } | |
| 32 | 36 | ||
| 33 | 37 | func (s *surface) Destroy(*wayland.Surface) { | |
| @@ -61,6 +65,12 @@ func (s *surface) Commit(*wayland.Surface) { | |||
| 61 | 65 | s.xdg.resource.PostError(uint32(xdgshell.SurfaceErrorUnconfiguredBuffer), "buffer before initial configure ack") | |
| 62 | 66 | return | |
| 63 | 67 | } | |
| 68 | + | if s.pendingScale > 0 { | |
| 69 | + | s.bufferScale = s.pendingScale | |
| 70 | + | } | |
| 71 | + | if s.viewport != nil { | |
| 72 | + | s.viewport.commit() | |
| 73 | + | } | |
| 64 | 74 | if s.attached { | |
| 65 | 75 | if s.current != nil && (s.pending == nil || s.current.Resource != s.pending.Resource) { | |
| 66 | 76 | if !s.released && s.current.Resource.Alive() { | |
| @@ -88,6 +98,7 @@ func (s *surface) Commit(*wayland.Surface) { | |||
| 88 | 98 | b := s.current | |
| 89 | 99 | if state, ok := s.server.buffers[b.Resource]; ok { | |
| 90 | 100 | if c, ok := state.content(id); ok { | |
| 101 | + | c.LogicalW, c.LogicalH = s.logicalSize(c.Width, c.Height) | |
| 91 | 102 | if c.Width != s.lastW || c.Height != s.lastH { | |
| 92 | 103 | s.lastW, s.lastH = c.Width, c.Height | |
| 93 | 104 | s.server.log.Info().Uint64("id", uint64(id)).Int("w", c.Width).Int("h", c.Height).Msg("buffer size") | |
| @@ -108,6 +119,12 @@ func (*surface) DamageBuffer(*wayland.Surface, int32, int32, int32, int32) {} | |||
| 108 | 119 | func (*surface) SetOpaqueRegion(*wayland.Surface, *wayland.Region) {} | |
| 109 | 120 | func (*surface) SetInputRegion(*wayland.Surface, *wayland.Region) {} | |
| 110 | 121 | func (*surface) SetBufferTransform(*wayland.Surface, int32) {} | |
| 111 | - | func (*surface) SetBufferScale(*wayland.Surface, int32) {} | |
| 112 | - | func (*surface) Offset(*wayland.Surface, int32, int32) {} | |
| 113 | - | func (*surface) GetRelease(*wayland.Surface, uint32) {} | |
| 122 | + | func (s *surface) SetBufferScale(r *wayland.Surface, v int32) { | |
| 123 | + | if v < 1 { | |
| 124 | + | r.PostError(uint32(wayland.SurfaceErrorInvalidScale), "buffer scale must be positive") | |
| 125 | + | return | |
| 126 | + | } | |
| 127 | + | s.pendingScale = int(v) | |
| 128 | + | } | |
| 129 | + | func (*surface) Offset(*wayland.Surface, int32, int32) {} | |
| 130 | + | func (*surface) GetRelease(*wayland.Surface, uint32) {} | |
| @@ -17,17 +17,21 @@ const ( | |||||
| 17 | ) | 17 | ) | ||
| 18 | 18 | ||||
| 19 | type surface struct { | 19 | type surface struct { | ||
| 20 | - | kind roleKind | 20 | + | wl *wayland.Surface |
| 21 | - | xdg *xdgSurface | 21 | + | viewport *viewport |
| 22 | - | layer *layerSurface | 22 | + | // bufferScale is committed state; pendingScale is set by set_buffer_scale. |
| 23 | - | server *Server | 23 | + | bufferScale, pendingScale int |
| 24 | - | current, pending *wayland.Buffer | 24 | + | kind roleKind |
| 25 | - | attached bool | 25 | + | xdg *xdgSurface |
| 26 | - | lastW, lastH int // last logged buffer size | 26 | + | layer *layerSurface |
| 27 | - | callbacks []*wayland.Callback | 27 | + | server *Server |
| 28 | - | role func(bool) | 28 | + | current, pending *wayland.Buffer |
| 29 | - | destroyed bool | 29 | + | attached bool |
| 30 | - | released bool | 30 | + | lastW, lastH int // last logged buffer size |
| 31 | + | callbacks []*wayland.Callback | |||
| 32 | + | role func(bool) | |||
| 33 | + | destroyed bool | |||
| 34 | + | released bool | |||
| 31 | } | 35 | } | ||
| 32 | 36 | ||||
| 33 | func (s *surface) Destroy(*wayland.Surface) { | 37 | func (s *surface) Destroy(*wayland.Surface) { | ||
| @@ -61,6 +65,12 @@ func (s *surface) Commit(*wayland.Surface) { | |||||
| 61 | s.xdg.resource.PostError(uint32(xdgshell.SurfaceErrorUnconfiguredBuffer), "buffer before initial configure ack") | 65 | s.xdg.resource.PostError(uint32(xdgshell.SurfaceErrorUnconfiguredBuffer), "buffer before initial configure ack") | ||
| 62 | return | 66 | return | ||
| 63 | } | 67 | } | ||
| 68 | + | if s.pendingScale > 0 { | |||
| 69 | + | s.bufferScale = s.pendingScale | |||
| 70 | + | } | |||
| 71 | + | if s.viewport != nil { | |||
| 72 | + | s.viewport.commit() | |||
| 73 | + | } | |||
| 64 | if s.attached { | 74 | if s.attached { | ||
| 65 | if s.current != nil && (s.pending == nil || s.current.Resource != s.pending.Resource) { | 75 | if s.current != nil && (s.pending == nil || s.current.Resource != s.pending.Resource) { | ||
| 66 | if !s.released && s.current.Resource.Alive() { | 76 | if !s.released && s.current.Resource.Alive() { | ||
| @@ -88,6 +98,7 @@ func (s *surface) Commit(*wayland.Surface) { | |||||
| 88 | b := s.current | 98 | b := s.current | ||
| 89 | if state, ok := s.server.buffers[b.Resource]; ok { | 99 | if state, ok := s.server.buffers[b.Resource]; ok { | ||
| 90 | if c, ok := state.content(id); ok { | 100 | if c, ok := state.content(id); ok { | ||
| 101 | + | c.LogicalW, c.LogicalH = s.logicalSize(c.Width, c.Height) | |||
| 91 | if c.Width != s.lastW || c.Height != s.lastH { | 102 | if c.Width != s.lastW || c.Height != s.lastH { | ||
| 92 | s.lastW, s.lastH = c.Width, c.Height | 103 | s.lastW, s.lastH = c.Width, c.Height | ||
| 93 | s.server.log.Info().Uint64("id", uint64(id)).Int("w", c.Width).Int("h", c.Height).Msg("buffer size") | 104 | s.server.log.Info().Uint64("id", uint64(id)).Int("w", c.Width).Int("h", c.Height).Msg("buffer size") | ||
| @@ -108,6 +119,12 @@ func (*surface) DamageBuffer(*wayland.Surface, int32, int32, int32, int32) {} | |||||
| 108 | func (*surface) SetOpaqueRegion(*wayland.Surface, *wayland.Region) {} | 119 | func (*surface) SetOpaqueRegion(*wayland.Surface, *wayland.Region) {} | ||
| 109 | func (*surface) SetInputRegion(*wayland.Surface, *wayland.Region) {} | 120 | func (*surface) SetInputRegion(*wayland.Surface, *wayland.Region) {} | ||
| 110 | func (*surface) SetBufferTransform(*wayland.Surface, int32) {} | 121 | func (*surface) SetBufferTransform(*wayland.Surface, int32) {} | ||
| 111 | - | func (*surface) SetBufferScale(*wayland.Surface, int32) {} | 122 | + | func (s *surface) SetBufferScale(r *wayland.Surface, v int32) { |
| 112 | - | func (*surface) Offset(*wayland.Surface, int32, int32) {} | 123 | + | if v < 1 { |
| 113 | - | func (*surface) GetRelease(*wayland.Surface, uint32) {} | 124 | + | r.PostError(uint32(wayland.SurfaceErrorInvalidScale), "buffer scale must be positive") |
| 125 | + | return | |||
| 126 | + | } | |||
| 127 | + | s.pendingScale = int(v) | |||
| 128 | + | } | |||
| 129 | + | func (*surface) Offset(*wayland.Surface, int32, int32) {} | |||
| 130 | + | func (*surface) GetRelease(*wayland.Surface, uint32) {} | |||
M internal/adapters/wayland/xdgoutput.go +8 -5
| @@ -7,13 +7,13 @@ import ( | |||
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | 9 | type xdgOutputManager struct { | |
| 10 | - | width, height int32 | |
| 11 | - | info OutputInfo | |
| 10 | + | server *Server | |
| 11 | + | info OutputInfo | |
| 12 | 12 | } | |
| 13 | 13 | ||
| 14 | - | func registerXDGOutput(d *server.Display, o Options) error { | |
| 14 | + | func registerXDGOutput(d *server.Display, o Options, s *Server) error { | |
| 15 | 15 | return xdgoutput.NewZxdgOutputManagerV1Global(d, 3, func(c server.Client, v, id uint32) { | |
| 16 | - | _, _ = xdgoutput.NewZxdgOutputManagerV1(c, int32(v), id, xdgOutputManager{int32(o.OutputWidth), int32(o.OutputHeight), o.output()}) | |
| 16 | + | _, _ = xdgoutput.NewZxdgOutputManagerV1(c, int32(v), id, xdgOutputManager{s, o.output()}) | |
| 17 | 17 | }) | |
| 18 | 18 | } | |
| 19 | 19 | func (xdgOutputManager) Destroy(*xdgoutput.ZxdgOutputManagerV1) {} | |
| @@ -22,8 +22,11 @@ func (m xdgOutputManager) GetXdgOutput(r *xdgoutput.ZxdgOutputManagerV1, id uint | |||
| 22 | 22 | if err != nil { | |
| 23 | 23 | return | |
| 24 | 24 | } | |
| 25 | + | st := &m.server.scale | |
| 26 | + | st.xdgOutputs = append(st.xdgOutputs, x) | |
| 27 | + | x.OnDestroy = func() { st.xdgOutputs = removeItem(st.xdgOutputs, x) } | |
| 25 | 28 | x.SendLogicalPosition(0, 0) | |
| 26 | - | x.SendLogicalSize(m.width, m.height) | |
| 29 | + | x.SendLogicalSize(int32(st.width), int32(st.height)) | |
| 27 | 30 | if r.Version() >= 2 { | |
| 28 | 31 | x.SendName(m.info.Name) | |
| 29 | 32 | x.SendDescription(m.info.Description) | |
| @@ -7,13 +7,13 @@ import ( | |||||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | type xdgOutputManager struct { | 9 | type xdgOutputManager struct { | ||
| 10 | - | width, height int32 | 10 | + | server *Server |
| 11 | - | info OutputInfo | 11 | + | info OutputInfo |
| 12 | } | 12 | } | ||
| 13 | 13 | ||||
| 14 | - | func registerXDGOutput(d *server.Display, o Options) error { | 14 | + | func registerXDGOutput(d *server.Display, o Options, s *Server) error { |
| 15 | return xdgoutput.NewZxdgOutputManagerV1Global(d, 3, func(c server.Client, v, id uint32) { | 15 | return xdgoutput.NewZxdgOutputManagerV1Global(d, 3, func(c server.Client, v, id uint32) { | ||
| 16 | - | _, _ = xdgoutput.NewZxdgOutputManagerV1(c, int32(v), id, xdgOutputManager{int32(o.OutputWidth), int32(o.OutputHeight), o.output()}) | 16 | + | _, _ = xdgoutput.NewZxdgOutputManagerV1(c, int32(v), id, xdgOutputManager{s, o.output()}) |
| 17 | }) | 17 | }) | ||
| 18 | } | 18 | } | ||
| 19 | func (xdgOutputManager) Destroy(*xdgoutput.ZxdgOutputManagerV1) {} | 19 | func (xdgOutputManager) Destroy(*xdgoutput.ZxdgOutputManagerV1) {} | ||
| @@ -22,8 +22,11 @@ func (m xdgOutputManager) GetXdgOutput(r *xdgoutput.ZxdgOutputManagerV1, id uint | |||||
| 22 | if err != nil { | 22 | if err != nil { | ||
| 23 | return | 23 | return | ||
| 24 | } | 24 | } | ||
| 25 | + | st := &m.server.scale | |||
| 26 | + | st.xdgOutputs = append(st.xdgOutputs, x) | |||
| 27 | + | x.OnDestroy = func() { st.xdgOutputs = removeItem(st.xdgOutputs, x) } | |||
| 25 | x.SendLogicalPosition(0, 0) | 28 | x.SendLogicalPosition(0, 0) | ||
| 26 | - | x.SendLogicalSize(m.width, m.height) | 29 | + | x.SendLogicalSize(int32(st.width), int32(st.height)) |
| 27 | if r.Version() >= 2 { | 30 | if r.Version() >= 2 { | ||
| 28 | x.SendName(m.info.Name) | 31 | x.SendName(m.info.Name) | ||
| 29 | x.SendDescription(m.info.Description) | 32 | x.SendDescription(m.info.Description) | ||
M internal/adapters/xkb/xkb.go +1 -1
| @@ -141,7 +141,7 @@ func (k *Keymap) Key(evdevCode uint32, pressed bool, timeMsec uint32) ports.KeyE | |||
| 141 | 141 | event := ports.KeyEvent{Keysym: name, Pressed: pressed, TimeMsec: timeMsec, Keycode: evdevCode} | |
| 142 | 142 | if pressed { | |
| 143 | 143 | layout := k.serializeLayout(k.state, layoutEffective) | |
| 144 | - | event.Base, event.Shifted = k.levelName(code, layout, 0), k.levelName(code, layout, 1) | |
| 144 | + | event.Base = k.levelName(code, layout, 0) | |
| 145 | 145 | } | |
| 146 | 146 | event.State = ports.ModState{ | |
| 147 | 147 | Depressed: k.serializeMods(k.state, modsDepressed), Latched: k.serializeMods(k.state, modsLatched), | |
| @@ -141,7 +141,7 @@ func (k *Keymap) Key(evdevCode uint32, pressed bool, timeMsec uint32) ports.KeyE | |||||
| 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 { | 142 | if pressed { | ||
| 143 | layout := k.serializeLayout(k.state, layoutEffective) | 143 | layout := k.serializeLayout(k.state, layoutEffective) | ||
| 144 | - | event.Base, event.Shifted = k.levelName(code, layout, 0), k.levelName(code, layout, 1) | 144 | + | event.Base = k.levelName(code, layout, 0) |
| 145 | } | 145 | } | ||
| 146 | event.State = ports.ModState{ | 146 | event.State = ports.ModState{ | ||
| 147 | 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), | ||
M internal/adapters/xkb/xkb_test.go +5 -4
| @@ -54,10 +54,11 @@ func TestKeymap(t *testing.T) { | |||
| 54 | 54 | } | |
| 55 | 55 | ||
| 56 | 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) | |
| 57 | + | // With Shift held, Base still reports the unshifted keysym. | |
| 58 | + | k := newTest(t, "us") | |
| 59 | + | k.Key(42, true, 0) | |
| 60 | + | if ev := k.Key(2, true, 0); ev.Keysym != "exclam" || ev.Base != "1" { | |
| 61 | + | t.Fatalf("shift 1: %+v", ev) | |
| 61 | 62 | } | |
| 62 | 63 | if ev := k.Key(2, false, 0); ev.Base != "" { | |
| 63 | 64 | t.Fatalf("release carries levels: %+v", ev) | |
| @@ -54,10 +54,11 @@ func TestKeymap(t *testing.T) { | |||||
| 54 | } | 54 | } | ||
| 55 | 55 | ||||
| 56 | func TestKeyLevels(t *testing.T) { | 56 | func TestKeyLevels(t *testing.T) { | ||
| 57 | - | // On AZERTY the digit row prints & é " ... unshifted and 1 2 3 with Shift. | 57 | + | // With Shift held, Base still reports the unshifted keysym. |
| 58 | - | k := newTest(t, "fr") | 58 | + | k := newTest(t, "us") |
| 59 | - | if ev := k.Key(2, true, 0); ev.Keysym != "ampersand" || ev.Base != "ampersand" || ev.Shifted != "1" { | 59 | + | k.Key(42, true, 0) |
| 60 | - | t.Fatalf("fr 1 key: %+v", ev) | 60 | + | if ev := k.Key(2, true, 0); ev.Keysym != "exclam" || ev.Base != "1" { |
| 61 | + | t.Fatalf("shift 1: %+v", ev) | |||
| 61 | } | 62 | } | ||
| 62 | if ev := k.Key(2, false, 0); ev.Base != "" { | 63 | if ev := k.Key(2, false, 0); ev.Base != "" { | ||
| 63 | t.Fatalf("release carries levels: %+v", ev) | 64 | t.Fatalf("release carries levels: %+v", ev) | ||
M internal/app/app.go +6 -2
| @@ -83,7 +83,11 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 83 | 83 | if err != nil { | |
| 84 | 84 | return err | |
| 85 | 85 | } | |
| 86 | - | output <- ports.OutputMode{Width: width, Height: height} | |
| 86 | + | outName := outInfo.Name | |
| 87 | + | if outName == "" { | |
| 88 | + | outName = "HEADLESS-1" | |
| 89 | + | } | |
| 90 | + | output <- ports.OutputMode{Width: width, Height: height, Name: outName} | |
| 87 | 91 | km, err := xkb.New(xkb.RMLVO{Layout: opts.Config.Keyboard.Layout, Variant: opts.Config.Keyboard.Variant, Options: opts.Config.Keyboard.Options}) | |
| 88 | 92 | if err != nil { | |
| 89 | 93 | return err | |
| @@ -296,7 +300,7 @@ func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors | |||
| 296 | 300 | default: | |
| 297 | 301 | } | |
| 298 | 302 | } | |
| 299 | - | ev := log.Debug().Uint64("seq", s.Seq).Int("out_w", s.OutputWidth).Int("out_h", s.OutputHeight) | |
| 303 | + | ev := log.Debug().Uint64("seq", s.Seq).Int("out_w", s.OutputWidth).Int("out_h", s.OutputHeight).Float64("scale", s.Scale) | |
| 300 | 304 | rects := make([]string, 0, len(s.Windows)) | |
| 301 | 305 | for _, w := range s.Windows { | |
| 302 | 306 | rects = append(rects, fmt.Sprintf("%d:%d,%d %dx%d", w.ID, w.Rect.X, w.Rect.Y, w.Rect.W, w.Rect.H)) | |
| @@ -83,7 +83,11 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 83 | if err != nil { | 83 | if err != nil { | ||
| 84 | return err | 84 | return err | ||
| 85 | } | 85 | } | ||
| 86 | - | output <- ports.OutputMode{Width: width, Height: height} | 86 | + | outName := outInfo.Name |
| 87 | + | if outName == "" { | |||
| 88 | + | outName = "HEADLESS-1" | |||
| 89 | + | } | |||
| 90 | + | output <- ports.OutputMode{Width: width, Height: height, Name: outName} | |||
| 87 | km, err := xkb.New(xkb.RMLVO{Layout: opts.Config.Keyboard.Layout, Variant: opts.Config.Keyboard.Variant, Options: opts.Config.Keyboard.Options}) | 91 | km, err := xkb.New(xkb.RMLVO{Layout: opts.Config.Keyboard.Layout, Variant: opts.Config.Keyboard.Variant, Options: opts.Config.Keyboard.Options}) | ||
| 88 | if err != nil { | 92 | if err != nil { | ||
| 89 | return err | 93 | return err | ||
| @@ -296,7 +300,7 @@ func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors | |||||
| 296 | default: | 300 | default: | ||
| 297 | } | 301 | } | ||
| 298 | } | 302 | } | ||
| 299 | - | ev := log.Debug().Uint64("seq", s.Seq).Int("out_w", s.OutputWidth).Int("out_h", s.OutputHeight) | 303 | + | ev := log.Debug().Uint64("seq", s.Seq).Int("out_w", s.OutputWidth).Int("out_h", s.OutputHeight).Float64("scale", s.Scale) |
| 300 | rects := make([]string, 0, len(s.Windows)) | 304 | rects := make([]string, 0, len(s.Windows)) | ||
| 301 | for _, w := range s.Windows { | 305 | for _, w := range s.Windows { | ||
| 302 | rects = append(rects, fmt.Sprintf("%d:%d,%d %dx%d", w.ID, w.Rect.X, w.Rect.Y, w.Rect.W, w.Rect.H)) | 306 | rects = append(rects, fmt.Sprintf("%d:%d,%d %dx%d", w.ID, w.Rect.X, w.Rect.Y, w.Rect.W, w.Rect.H)) | ||
M internal/core/action.go +5 -2
| @@ -24,8 +24,11 @@ const ( | |||
| 24 | 24 | ActionFocusWorkspaceDown Action = "focus-workspace-down" | |
| 25 | 25 | ActionMoveToWorkspaceUp Action = "move-to-workspace-up" | |
| 26 | 26 | ActionMoveToWorkspaceDown Action = "move-to-workspace-down" | |
| 27 | - | actionFocusWorkspace = "focus-workspace " | |
| 28 | - | actionMoveToWorkspace = "move-to-workspace " | |
| 27 | + | // Scale steps through the clean scales of the output (see CleanScales). | |
| 28 | + | ActionScaleUp Action = "scale-up" | |
| 29 | + | ActionScaleDown Action = "scale-down" | |
| 30 | + | actionFocusWorkspace = "focus-workspace " | |
| 31 | + | actionMoveToWorkspace = "move-to-workspace " | |
| 29 | 32 | ) | |
| 30 | 33 | ||
| 31 | 34 | // WorkspaceArg parses "focus-workspace N" and "move-to-workspace N" (N from 1). | |
| @@ -24,8 +24,11 @@ const ( | |||||
| 24 | ActionFocusWorkspaceDown Action = "focus-workspace-down" | 24 | ActionFocusWorkspaceDown Action = "focus-workspace-down" | ||
| 25 | ActionMoveToWorkspaceUp Action = "move-to-workspace-up" | 25 | ActionMoveToWorkspaceUp Action = "move-to-workspace-up" | ||
| 26 | ActionMoveToWorkspaceDown Action = "move-to-workspace-down" | 26 | ActionMoveToWorkspaceDown Action = "move-to-workspace-down" | ||
| 27 | - | actionFocusWorkspace = "focus-workspace " | 27 | + | // Scale steps through the clean scales of the output (see CleanScales). |
| 28 | - | actionMoveToWorkspace = "move-to-workspace " | 28 | + | ActionScaleUp Action = "scale-up" |
| 29 | + | ActionScaleDown Action = "scale-down" | |||
| 30 | + | actionFocusWorkspace = "focus-workspace " | |||
| 31 | + | actionMoveToWorkspace = "move-to-workspace " | |||
| 29 | ) | 32 | ) | ||
| 30 | 33 | ||||
| 31 | // WorkspaceArg parses "focus-workspace N" and "move-to-workspace N" (N from 1). | 34 | // WorkspaceArg parses "focus-workspace N" and "move-to-workspace N" (N from 1). | ||
M internal/core/core.go +70 -14
| @@ -44,6 +44,12 @@ type Core struct { | |||
| 44 | 44 | layers []ports.LayerSurface | |
| 45 | 45 | placed []ports.SceneLayer | |
| 46 | 46 | layerChanged bool | |
| 47 | + | // Output in physical pixels. scale comes from output.<name>.scale and | |
| 48 | + | // changes live with scale-up/scale-down until the config value changes. | |
| 49 | + | outName string | |
| 50 | + | physW, physH int | |
| 51 | + | scale, cfgScale float64 | |
| 52 | + | sentScale ports.SetOutputScale | |
| 47 | 53 | } | |
| 48 | 54 | ||
| 49 | 55 | func keyName(s string) string { | |
| @@ -117,6 +123,10 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 117 | 123 | binds[b] = Action(a) | |
| 118 | 124 | continue | |
| 119 | 125 | } | |
| 126 | + | if Action(a) == ActionScaleUp || Action(a) == ActionScaleDown { | |
| 127 | + | binds[b] = Action(a) | |
| 128 | + | continue | |
| 129 | + | } | |
| 120 | 130 | if _, _, ok := WorkspaceArg(Action(a)); ok { | |
| 121 | 131 | binds[b] = Action(a) | |
| 122 | 132 | continue | |
| @@ -130,6 +140,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 130 | 140 | } | |
| 131 | 141 | c.cfg = cfg | |
| 132 | 142 | c.binds = binds | |
| 143 | + | c.applyConfigScale() | |
| 133 | 144 | c.ws.SetMaxColumns(cfg.Layout.MaxColumns) | |
| 134 | 145 | c.ws.SetPresets(presets) | |
| 135 | 146 | c.ws.SetGaps(cfg.Layout.Gaps) | |
| @@ -139,7 +150,7 @@ func New(cfg ports.Config, ch Channels) (*Core, error) { | |||
| 139 | 150 | if cap(ch.Scenes) != 1 { | |
| 140 | 151 | return nil, fmt.Errorf("scenes must have capacity 1") | |
| 141 | 152 | } | |
| 142 | - | c := &Core{ws: NewMonitor(), ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}} | |
| 153 | + | c := &Core{scale: 1, ws: NewMonitor(), ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}} | |
| 143 | 154 | if err := c.apply(cfg); err != nil { | |
| 144 | 155 | return nil, err | |
| 145 | 156 | } | |
| @@ -154,6 +165,33 @@ func (c *Core) command(ctx context.Context, v ports.ClientCommand) error { | |||
| 154 | 165 | } | |
| 155 | 166 | } | |
| 156 | 167 | ||
| 168 | + | // applyConfigScale picks up output.<name>.scale when it changed in the config, | |
| 169 | + | // replacing any live scale-up/scale-down adjustment. | |
| 170 | + | func (c *Core) applyConfigScale() { | |
| 171 | + | s := 1.0 | |
| 172 | + | for _, o := range c.cfg.Outputs { | |
| 173 | + | if o.Name == c.outName && o.Scale != 0 { | |
| 174 | + | s = o.Scale | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | s = SnapScale(s) | |
| 178 | + | if s != c.cfgScale { | |
| 179 | + | c.cfgScale = s | |
| 180 | + | c.setScale(s) | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | // setScale resizes the logical output; layer placement follows. | |
| 185 | + | func (c *Core) setScale(s float64) { | |
| 186 | + | c.scale = SnapScale(s) | |
| 187 | + | c.ws.SetOutput(logical(c.physW, c.scale), logical(c.physH, c.scale)) | |
| 188 | + | if c.layerChanged { | |
| 189 | + | var usable ports.Rect | |
| 190 | + | c.placed, usable = arrangeLayers(c.ws.Output().W, c.ws.Output().H, c.layers) | |
| 191 | + | c.ws.SetUsable(usable) | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | ||
| 157 | 195 | // clientRect is the placement minus the border drawn around the client. | |
| 158 | 196 | // The renderer applies the same inset (ports.Border). | |
| 159 | 197 | func (c *Core) clientRect(p Placement) Rect { | |
| @@ -193,8 +231,15 @@ func (c *Core) keyboardFocus() WindowID { | |||
| 193 | 231 | } | |
| 194 | 232 | ||
| 195 | 233 | func (c *Core) publish(ctx context.Context) error { | |
| 234 | + | // Clients learn the new scale before the configures sized for it. | |
| 235 | + | if v := (ports.SetOutputScale{Scale: c.scale, Width: c.ws.Output().W, Height: c.ws.Output().H}); v != c.sentScale { | |
| 236 | + | if err := c.command(ctx, v); err != nil { | |
| 237 | + | return err | |
| 238 | + | } | |
| 239 | + | c.sentScale = v | |
| 240 | + | } | |
| 196 | 241 | c.seq++ | |
| 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...)} | |
| 242 | + | scene := ports.Scene{Seq: c.seq, OutputWidth: c.ws.Output().W, OutputHeight: c.ws.Output().H, Scale: c.scale, 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...)} | |
| 198 | 243 | alive := map[WindowID]bool{} | |
| 199 | 244 | focus := c.keyboardFocus() | |
| 200 | 245 | for _, p := range c.ws.Layout() { | |
| @@ -295,12 +340,12 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 295 | 340 | } | |
| 296 | 341 | switch v := ev.(type) { | |
| 297 | 342 | case ports.OutputMode: | |
| 298 | - | c.ws.SetOutput(v.Width, v.Height) | |
| 299 | - | if c.layerChanged { | |
| 300 | - | var usable ports.Rect | |
| 301 | - | c.placed, usable = arrangeLayers(c.ws.Output().W, c.ws.Output().H, c.layers) | |
| 302 | - | c.ws.SetUsable(usable) | |
| 343 | + | c.physW, c.physH = v.Width, v.Height | |
| 344 | + | if v.Name != c.outName { | |
| 345 | + | c.outName, c.cfgScale = v.Name, 0 | |
| 303 | 346 | } | |
| 347 | + | c.applyConfigScale() | |
| 348 | + | c.setScale(c.scale) | |
| 304 | 349 | case ports.OutputUsable: | |
| 305 | 350 | // Once layer state arrives, its exclusive zones take precedence. | |
| 306 | 351 | if !c.layerChanged { | |
| @@ -332,8 +377,9 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 332 | 377 | } | |
| 333 | 378 | switch v := ev.(type) { | |
| 334 | 379 | case ports.PointerMotion: | |
| 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))) | |
| 380 | + | // Input reports physical pixels; hit-testing is logical. | |
| 381 | + | c.cursorX = min(max(v.X/c.scale, 0), float64(max(c.ws.Output().W-1, 0))) | |
| 382 | + | c.cursorY = min(max(v.Y/c.scale, 0), float64(max(c.ws.Output().H-1, 0))) | |
| 337 | 383 | var id WindowID | |
| 338 | 384 | var x, y float64 | |
| 339 | 385 | // Fullscreen wins; otherwise the last visible placement is topmost. | |
| @@ -394,15 +440,14 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 394 | 440 | continue | |
| 395 | 441 | } | |
| 396 | 442 | name := keyName(key.Keysym) | |
| 443 | + | // A bind on the keysym wins; then the unshifted keysym (Cmd+Shift+1 | |
| 444 | + | // prints exclam on US); then the physical key (code:N). | |
| 397 | 445 | action, bound := c.binds[binding{key: name, mods: key.Mods}] | |
| 398 | - | // Cmd+Shift+1 on US prints exclam: match the unshifted keysym. | |
| 399 | 446 | if !bound && key.Base != "" { | |
| 400 | 447 | action, bound = c.binds[binding{key: keyName(key.Base), mods: key.Mods}] | |
| 401 | 448 | } | |
| 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}] | |
| 449 | + | if !bound && key.Keycode != 0 { | |
| 450 | + | action, bound = c.binds[binding{key: "code:" + strconv.FormatUint(uint64(key.Keycode), 10), mods: key.Mods}] | |
| 406 | 451 | } | |
| 407 | 452 | // Track presses by physical key: Shift may be released before the key. | |
| 408 | 453 | held := name | |
| @@ -419,6 +464,17 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 419 | 464 | if key.Pressed { | |
| 420 | 465 | if bound { | |
| 421 | 466 | c.pressed[held] = true | |
| 467 | + | if action == ActionScaleUp || action == ActionScaleDown { | |
| 468 | + | dir := 1 | |
| 469 | + | if action == ActionScaleDown { | |
| 470 | + | dir = -1 | |
| 471 | + | } | |
| 472 | + | c.setScale(StepScale(c.physW, c.physH, c.scale, dir)) | |
| 473 | + | if err := c.publish(ctx); err != nil { | |
| 474 | + | return nil | |
| 475 | + | } | |
| 476 | + | continue | |
| 477 | + | } | |
| 422 | 478 | effect := c.ws.Apply(action) | |
| 423 | 479 | if effect.Quit { | |
| 424 | 480 | return ErrQuit | |
| @@ -44,6 +44,12 @@ type Core struct { | |||||
| 44 | layers []ports.LayerSurface | 44 | layers []ports.LayerSurface | ||
| 45 | placed []ports.SceneLayer | 45 | placed []ports.SceneLayer | ||
| 46 | layerChanged bool | 46 | layerChanged bool | ||
| 47 | + | // Output in physical pixels. scale comes from output.<name>.scale and | |||
| 48 | + | // changes live with scale-up/scale-down until the config value changes. | |||
| 49 | + | outName string | |||
| 50 | + | physW, physH int | |||
| 51 | + | scale, cfgScale float64 | |||
| 52 | + | sentScale ports.SetOutputScale | |||
| 47 | } | 53 | } | ||
| 48 | 54 | ||||
| 49 | func keyName(s string) string { | 55 | func keyName(s string) string { | ||
| @@ -117,6 +123,10 @@ func (c *Core) apply(cfg ports.Config) error { | |||||
| 117 | binds[b] = Action(a) | 123 | binds[b] = Action(a) | ||
| 118 | continue | 124 | continue | ||
| 119 | } | 125 | } | ||
| 126 | + | if Action(a) == ActionScaleUp || Action(a) == ActionScaleDown { | |||
| 127 | + | binds[b] = Action(a) | |||
| 128 | + | continue | |||
| 129 | + | } | |||
| 120 | if _, _, ok := WorkspaceArg(Action(a)); ok { | 130 | if _, _, ok := WorkspaceArg(Action(a)); ok { | ||
| 121 | binds[b] = Action(a) | 131 | binds[b] = Action(a) | ||
| 122 | continue | 132 | continue | ||
| @@ -130,6 +140,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||||
| 130 | } | 140 | } | ||
| 131 | c.cfg = cfg | 141 | c.cfg = cfg | ||
| 132 | c.binds = binds | 142 | c.binds = binds | ||
| 143 | + | c.applyConfigScale() | |||
| 133 | c.ws.SetMaxColumns(cfg.Layout.MaxColumns) | 144 | c.ws.SetMaxColumns(cfg.Layout.MaxColumns) | ||
| 134 | c.ws.SetPresets(presets) | 145 | c.ws.SetPresets(presets) | ||
| 135 | c.ws.SetGaps(cfg.Layout.Gaps) | 146 | c.ws.SetGaps(cfg.Layout.Gaps) | ||
| @@ -139,7 +150,7 @@ func New(cfg ports.Config, ch Channels) (*Core, error) { | |||||
| 139 | if cap(ch.Scenes) != 1 { | 150 | if cap(ch.Scenes) != 1 { | ||
| 140 | return nil, fmt.Errorf("scenes must have capacity 1") | 151 | return nil, fmt.Errorf("scenes must have capacity 1") | ||
| 141 | } | 152 | } | ||
| 142 | - | c := &Core{ws: NewMonitor(), ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}} | 153 | + | c := &Core{scale: 1, ws: NewMonitor(), ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}} |
| 143 | if err := c.apply(cfg); err != nil { | 154 | if err := c.apply(cfg); err != nil { | ||
| 144 | return nil, err | 155 | return nil, err | ||
| 145 | } | 156 | } | ||
| @@ -154,6 +165,33 @@ func (c *Core) command(ctx context.Context, v ports.ClientCommand) error { | |||||
| 154 | } | 165 | } | ||
| 155 | } | 166 | } | ||
| 156 | 167 | ||||
| 168 | + | // applyConfigScale picks up output.<name>.scale when it changed in the config, | |||
| 169 | + | // replacing any live scale-up/scale-down adjustment. | |||
| 170 | + | func (c *Core) applyConfigScale() { | |||
| 171 | + | s := 1.0 | |||
| 172 | + | for _, o := range c.cfg.Outputs { | |||
| 173 | + | if o.Name == c.outName && o.Scale != 0 { | |||
| 174 | + | s = o.Scale | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | s = SnapScale(s) | |||
| 178 | + | if s != c.cfgScale { | |||
| 179 | + | c.cfgScale = s | |||
| 180 | + | c.setScale(s) | |||
| 181 | + | } | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | // setScale resizes the logical output; layer placement follows. | |||
| 185 | + | func (c *Core) setScale(s float64) { | |||
| 186 | + | c.scale = SnapScale(s) | |||
| 187 | + | c.ws.SetOutput(logical(c.physW, c.scale), logical(c.physH, c.scale)) | |||
| 188 | + | if c.layerChanged { | |||
| 189 | + | var usable ports.Rect | |||
| 190 | + | c.placed, usable = arrangeLayers(c.ws.Output().W, c.ws.Output().H, c.layers) | |||
| 191 | + | c.ws.SetUsable(usable) | |||
| 192 | + | } | |||
| 193 | + | } | |||
| 194 | + | ||||
| 157 | // clientRect is the placement minus the border drawn around the client. | 195 | // clientRect is the placement minus the border drawn around the client. | ||
| 158 | // The renderer applies the same inset (ports.Border). | 196 | // The renderer applies the same inset (ports.Border). | ||
| 159 | func (c *Core) clientRect(p Placement) Rect { | 197 | func (c *Core) clientRect(p Placement) Rect { | ||
| @@ -193,8 +231,15 @@ func (c *Core) keyboardFocus() WindowID { | |||||
| 193 | } | 231 | } | ||
| 194 | 232 | ||||
| 195 | func (c *Core) publish(ctx context.Context) error { | 233 | func (c *Core) publish(ctx context.Context) error { | ||
| 234 | + | // Clients learn the new scale before the configures sized for it. | |||
| 235 | + | if v := (ports.SetOutputScale{Scale: c.scale, Width: c.ws.Output().W, Height: c.ws.Output().H}); v != c.sentScale { | |||
| 236 | + | if err := c.command(ctx, v); err != nil { | |||
| 237 | + | return err | |||
| 238 | + | } | |||
| 239 | + | c.sentScale = v | |||
| 240 | + | } | |||
| 196 | c.seq++ | 241 | c.seq++ | ||
| 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...)} | 242 | + | scene := ports.Scene{Seq: c.seq, OutputWidth: c.ws.Output().W, OutputHeight: c.ws.Output().H, Scale: c.scale, 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...)} |
| 198 | alive := map[WindowID]bool{} | 243 | alive := map[WindowID]bool{} | ||
| 199 | focus := c.keyboardFocus() | 244 | focus := c.keyboardFocus() | ||
| 200 | for _, p := range c.ws.Layout() { | 245 | for _, p := range c.ws.Layout() { | ||
| @@ -295,12 +340,12 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 295 | } | 340 | } | ||
| 296 | switch v := ev.(type) { | 341 | switch v := ev.(type) { | ||
| 297 | case ports.OutputMode: | 342 | case ports.OutputMode: | ||
| 298 | - | c.ws.SetOutput(v.Width, v.Height) | 343 | + | c.physW, c.physH = v.Width, v.Height |
| 299 | - | if c.layerChanged { | 344 | + | if v.Name != c.outName { |
| 300 | - | var usable ports.Rect | 345 | + | c.outName, c.cfgScale = v.Name, 0 |
| 301 | - | c.placed, usable = arrangeLayers(c.ws.Output().W, c.ws.Output().H, c.layers) | |||
| 302 | - | c.ws.SetUsable(usable) | |||
| 303 | } | 346 | } | ||
| 347 | + | c.applyConfigScale() | |||
| 348 | + | c.setScale(c.scale) | |||
| 304 | case ports.OutputUsable: | 349 | case ports.OutputUsable: | ||
| 305 | // Once layer state arrives, its exclusive zones take precedence. | 350 | // Once layer state arrives, its exclusive zones take precedence. | ||
| 306 | if !c.layerChanged { | 351 | if !c.layerChanged { | ||
| @@ -332,8 +377,9 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 332 | } | 377 | } | ||
| 333 | switch v := ev.(type) { | 378 | switch v := ev.(type) { | ||
| 334 | case ports.PointerMotion: | 379 | case ports.PointerMotion: | ||
| 335 | - | c.cursorX = min(max(v.X, 0), float64(max(c.ws.Output().W-1, 0))) | 380 | + | // Input reports physical pixels; hit-testing is logical. |
| 336 | - | c.cursorY = min(max(v.Y, 0), float64(max(c.ws.Output().H-1, 0))) | 381 | + | c.cursorX = min(max(v.X/c.scale, 0), float64(max(c.ws.Output().W-1, 0))) |
| 382 | + | c.cursorY = min(max(v.Y/c.scale, 0), float64(max(c.ws.Output().H-1, 0))) | |||
| 337 | var id WindowID | 383 | var id WindowID | ||
| 338 | var x, y float64 | 384 | var x, y float64 | ||
| 339 | // Fullscreen wins; otherwise the last visible placement is topmost. | 385 | // Fullscreen wins; otherwise the last visible placement is topmost. | ||
| @@ -394,15 +440,14 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 394 | continue | 440 | continue | ||
| 395 | } | 441 | } | ||
| 396 | name := keyName(key.Keysym) | 442 | name := keyName(key.Keysym) | ||
| 443 | + | // A bind on the keysym wins; then the unshifted keysym (Cmd+Shift+1 | |||
| 444 | + | // prints exclam on US); then the physical key (code:N). | |||
| 397 | action, bound := c.binds[binding{key: name, mods: key.Mods}] | 445 | 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 != "" { | 446 | if !bound && key.Base != "" { | ||
| 400 | action, bound = c.binds[binding{key: keyName(key.Base), mods: key.Mods}] | 447 | action, bound = c.binds[binding{key: keyName(key.Base), mods: key.Mods}] | ||
| 401 | } | 448 | } | ||
| 402 | - | // AZERTY puts digits on the shifted level: Cmd+1 is Super+ampersand. | 449 | + | if !bound && key.Keycode != 0 { |
| 403 | - | // Only digits fall back this way, so cmd+plus stays off Cmd+=. | 450 | + | action, bound = c.binds[binding{key: "code:" + strconv.FormatUint(uint64(key.Keycode), 10), mods: key.Mods}] |
| 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 | } | 451 | } | ||
| 407 | // Track presses by physical key: Shift may be released before the key. | 452 | // Track presses by physical key: Shift may be released before the key. | ||
| 408 | held := name | 453 | held := name | ||
| @@ -419,6 +464,17 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 419 | if key.Pressed { | 464 | if key.Pressed { | ||
| 420 | if bound { | 465 | if bound { | ||
| 421 | c.pressed[held] = true | 466 | c.pressed[held] = true | ||
| 467 | + | if action == ActionScaleUp || action == ActionScaleDown { | |||
| 468 | + | dir := 1 | |||
| 469 | + | if action == ActionScaleDown { | |||
| 470 | + | dir = -1 | |||
| 471 | + | } | |||
| 472 | + | c.setScale(StepScale(c.physW, c.physH, c.scale, dir)) | |||
| 473 | + | if err := c.publish(ctx); err != nil { | |||
| 474 | + | return nil | |||
| 475 | + | } | |||
| 476 | + | continue | |||
| 477 | + | } | |||
| 422 | effect := c.ws.Apply(action) | 478 | effect := c.ws.Apply(action) | ||
| 423 | if effect.Quit { | 479 | if effect.Quit { | ||
| 424 | return ErrQuit | 480 | return ErrQuit | ||
M internal/core/core_test.go +112 -28
| @@ -21,6 +21,19 @@ func receive[T any](t *testing.T, ch <-chan T) T { | |||
| 21 | 21 | return zero | |
| 22 | 22 | } | |
| 23 | 23 | } | |
| 24 | + | ||
| 25 | + | // command receives the next client command, skipping output scale updates. | |
| 26 | + | func command(t *testing.T, ch <-chan ports.ClientCommand) ports.ClientCommand { | |
| 27 | + | t.Helper() | |
| 28 | + | for { | |
| 29 | + | if v := receive(t, ch); v != nil { | |
| 30 | + | if _, ok := v.(ports.SetOutputScale); !ok { | |
| 31 | + | return v | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 24 | 37 | func TestOwner(t *testing.T) { | |
| 25 | 38 | cfg := config.Defaults() | |
| 26 | 39 | cfg.Keyboard.CmdKey = "alt" | |
| @@ -53,10 +66,10 @@ func TestOwner(t *testing.T) { | |||
| 53 | 66 | if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || !s.Windows[0].Borderless { | |
| 54 | 67 | t.Fatal(s) | |
| 55 | 68 | } | |
| 56 | - | if v := receive(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 84, Height: 64, Activated: true}) { | |
| 69 | + | if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 84, Height: 64, Activated: true}) { | |
| 57 | 70 | t.Fatal(v) | |
| 58 | 71 | } | |
| 59 | - | receive(t, commands) | |
| 72 | + | command(t, commands) | |
| 60 | 73 | client <- ports.WindowMapped{ID: 2} | |
| 61 | 74 | s = receive(t, scenes) | |
| 62 | 75 | if len(s.Windows) != 2 { | |
| @@ -78,12 +91,12 @@ func TestOwner(t *testing.T) { | |||
| 78 | 91 | } | |
| 79 | 92 | input <- ports.KeyEvent{Keysym: "Return", Mods: ports.ModAlt} | |
| 80 | 93 | input <- ports.KeyEvent{Keysym: "x", Pressed: true} | |
| 81 | - | if v := receive(t, commands); v != (ports.ForwardKey{ID: 2, Key: ports.KeyEvent{Keysym: "x", Pressed: true}}) { | |
| 94 | + | if v := command(t, commands); v != (ports.ForwardKey{ID: 2, Key: ports.KeyEvent{Keysym: "x", Pressed: true}}) { | |
| 82 | 95 | t.Fatal(v) | |
| 83 | 96 | } | |
| 84 | 97 | input <- ports.KeyEvent{Keysym: "Q", Mods: ports.ModAlt, Pressed: true} | |
| 85 | 98 | receive(t, scenes) | |
| 86 | - | if v := receive(t, commands); v != (ports.CloseWindow{ID: 2}) { | |
| 99 | + | if v := command(t, commands); v != (ports.CloseWindow{ID: 2}) { | |
| 87 | 100 | t.Fatal(v) | |
| 88 | 101 | } | |
| 89 | 102 | client <- ports.WindowUnmapped{ID: 2} | |
| @@ -97,7 +110,7 @@ func TestOwner(t *testing.T) { | |||
| 97 | 110 | if s.Windows[0].Rect.W != 92 { | |
| 98 | 111 | t.Fatal(s) | |
| 99 | 112 | } | |
| 100 | - | if v := receive(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 92, Height: 72, Activated: true}) { | |
| 113 | + | if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 92, Height: 72, Activated: true}) { | |
| 101 | 114 | t.Fatal(v) | |
| 102 | 115 | } | |
| 103 | 116 | cfg.Layout.MaxColumns = 0 | |
| @@ -145,26 +158,26 @@ func TestKeyPressState(t *testing.T) { | |||
| 145 | 158 | defer func() { cancel(); receive(t, done) }() | |
| 146 | 159 | client <- ports.WindowMapped{ID: 1} | |
| 147 | 160 | receive(t, scenes) | |
| 148 | - | receive(t, commands) | |
| 149 | - | receive(t, commands) | |
| 161 | + | command(t, commands) | |
| 162 | + | command(t, commands) | |
| 150 | 163 | press := ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true} | |
| 151 | 164 | input <- press | |
| 152 | 165 | receive(t, scenes) | |
| 153 | 166 | input <- ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper} | |
| 154 | 167 | input <- ports.KeyEvent{Keysym: "Left", Pressed: true} | |
| 155 | - | if v := receive(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "Left", Pressed: true}}) { | |
| 168 | + | if v := command(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "Left", Pressed: true}}) { | |
| 156 | 169 | t.Fatal(v) | |
| 157 | 170 | } | |
| 158 | 171 | input <- ports.KeyEvent{Keysym: "Left"} | |
| 159 | - | if v := receive(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "Left"}}) { | |
| 172 | + | if v := command(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "Left"}}) { | |
| 160 | 173 | t.Fatal(v) | |
| 161 | 174 | } | |
| 162 | 175 | input <- press | |
| 163 | 176 | receive(t, scenes) | |
| 164 | 177 | input <- ports.KeyEvent{Keysym: "Left", Pressed: true} | |
| 165 | - | receive(t, commands) | |
| 178 | + | command(t, commands) | |
| 166 | 179 | input <- ports.KeyEvent{Keysym: "Left"} | |
| 167 | - | if v := receive(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "Left"}}) { | |
| 180 | + | if v := command(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "Left"}}) { | |
| 168 | 181 | t.Fatal(v) | |
| 169 | 182 | } | |
| 170 | 183 | } | |
| @@ -185,13 +198,13 @@ func TestBoundReleaseSwallowed(t *testing.T) { | |||
| 185 | 198 | defer func() { cancel(); receive(t, done) }() | |
| 186 | 199 | client <- ports.WindowMapped{ID: 1} | |
| 187 | 200 | receive(t, scenes) | |
| 188 | - | receive(t, commands) | |
| 189 | - | receive(t, commands) | |
| 201 | + | command(t, commands) | |
| 202 | + | command(t, commands) | |
| 190 | 203 | input <- ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true} | |
| 191 | 204 | receive(t, scenes) | |
| 192 | 205 | input <- ports.KeyEvent{Keysym: "Left"} | |
| 193 | 206 | input <- ports.KeyEvent{Keysym: "x", Pressed: true} | |
| 194 | - | if v := receive(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "x", Pressed: true}}) { | |
| 207 | + | if v := command(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "x", Pressed: true}}) { | |
| 195 | 208 | t.Fatal(v) | |
| 196 | 209 | } | |
| 197 | 210 | } | |
| @@ -222,14 +235,14 @@ func TestPointerFocusAndGrab(t *testing.T) { | |||
| 222 | 235 | <-commands | |
| 223 | 236 | } | |
| 224 | 237 | input <- ports.PointerMotion{X: float64(first.X + 2), Y: float64(first.Y + 3), TimeMsec: 1} | |
| 225 | - | if v := receive(t, commands); v != (ports.PointerFocus{ID: 1, X: 2, Y: 3}) { | |
| 238 | + | if v := command(t, commands); v != (ports.PointerFocus{ID: 1, X: 2, Y: 3}) { | |
| 226 | 239 | t.Fatal(v) | |
| 227 | 240 | } | |
| 228 | - | if v := receive(t, commands); v != (ports.PointerMotionTo{ID: 1, X: 2, Y: 3, TimeMsec: 1}) { | |
| 241 | + | if v := command(t, commands); v != (ports.PointerMotionTo{ID: 1, X: 2, Y: 3, TimeMsec: 1}) { | |
| 229 | 242 | t.Fatal(v) | |
| 230 | 243 | } | |
| 231 | 244 | input <- ports.PointerButton{Button: 0x110, Pressed: true} | |
| 232 | - | if v := receive(t, commands); v != (ports.PointerButtonTo{ID: 1, Button: 0x110, Pressed: true}) { | |
| 245 | + | if v := command(t, commands); v != (ports.PointerButtonTo{ID: 1, Button: 0x110, Pressed: true}) { | |
| 233 | 246 | t.Fatal(v) | |
| 234 | 247 | } | |
| 235 | 248 | receive(t, scenes) | |
| @@ -243,11 +256,11 @@ func TestPointerFocusAndGrab(t *testing.T) { | |||
| 243 | 256 | t.Fatal("missing keyboard focus") | |
| 244 | 257 | } | |
| 245 | 258 | input <- ports.PointerMotion{X: 0, Y: 0} | |
| 246 | - | if v := receive(t, commands); v != (ports.PointerFocus{}) { | |
| 259 | + | if v := command(t, commands); v != (ports.PointerFocus{}) { | |
| 247 | 260 | t.Fatal(v) | |
| 248 | 261 | } | |
| 249 | 262 | input <- ports.PointerButton{Button: 0x110} | |
| 250 | - | if v := receive(t, commands); v != (ports.PointerButtonTo{ID: 1, Button: 0x110}) { | |
| 263 | + | if v := command(t, commands); v != (ports.PointerButtonTo{ID: 1, Button: 0x110}) { | |
| 251 | 264 | t.Fatal(v) | |
| 252 | 265 | } | |
| 253 | 266 | } | |
| @@ -272,7 +285,7 @@ func TestBorderInset(t *testing.T) { | |||
| 272 | 285 | // Alone, the window is borderless and gets the full rect. | |
| 273 | 286 | client <- ports.WindowMapped{ID: 1} | |
| 274 | 287 | r := receive(t, scenes).Windows[0].Rect | |
| 275 | - | if v := receive(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W, Height: r.H, Activated: true}) { | |
| 288 | + | if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W, Height: r.H, Activated: true}) { | |
| 276 | 289 | t.Fatalf("%v for outer %v", v, r) | |
| 277 | 290 | } | |
| 278 | 291 | // With a second column, the scene keeps the outer rect and the client is | |
| @@ -294,7 +307,7 @@ func TestBorderInset(t *testing.T) { | |||
| 294 | 307 | t.Fatalf("no %v for outer %v", want, r) | |
| 295 | 308 | } | |
| 296 | 309 | input <- ports.PointerMotion{X: float64(r.X + 2), Y: float64(r.Y + 5), TimeMsec: 1} | |
| 297 | - | if v := receive(t, commands); v != (ports.PointerFocus{ID: 2, X: 0, Y: 3}) { | |
| 310 | + | if v := command(t, commands); v != (ports.PointerFocus{ID: 2, X: 0, Y: 3}) { | |
| 298 | 311 | t.Fatal(v) | |
| 299 | 312 | } | |
| 300 | 313 | } | |
| @@ -320,7 +333,7 @@ func TestLayerKeyboardFocus(t *testing.T) { | |||
| 320 | 333 | focusOf := func() ports.WindowID { | |
| 321 | 334 | t.Helper() | |
| 322 | 335 | for { | |
| 323 | - | if v, ok := receive(t, commands).(ports.FocusWindow); ok { | |
| 336 | + | if v, ok := command(t, commands).(ports.FocusWindow); ok { | |
| 324 | 337 | return v.ID | |
| 325 | 338 | } | |
| 326 | 339 | } | |
| @@ -338,7 +351,7 @@ func TestLayerKeyboardFocus(t *testing.T) { | |||
| 338 | 351 | } | |
| 339 | 352 | input <- ports.KeyEvent{Keysym: "a", Keycode: 30, Pressed: true} | |
| 340 | 353 | for { | |
| 341 | - | if v, ok := receive(t, commands).(ports.ForwardKey); ok { | |
| 354 | + | if v, ok := command(t, commands).(ports.ForwardKey); ok { | |
| 342 | 355 | if v.ID != 7 { | |
| 343 | 356 | t.Fatalf("key went to %d", v.ID) | |
| 344 | 357 | } | |
| @@ -373,8 +386,8 @@ func TestWorkspaceSwitch(t *testing.T) { | |||
| 373 | 386 | for len(commands) > 0 { | |
| 374 | 387 | <-commands | |
| 375 | 388 | } | |
| 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} | |
| 389 | + | // AZERTY: the 2 key prints eacute; the default bind is on its physical key (code 3). | |
| 390 | + | input <- ports.KeyEvent{Keysym: "eacute", Base: "eacute", Keycode: 3, Mods: ports.ModSuper, Pressed: true} | |
| 378 | 391 | s := receive(t, scenes) | |
| 379 | 392 | if len(s.Windows) != 1 || !s.Windows[0].Hidden { | |
| 380 | 393 | t.Fatal(s) | |
| @@ -470,13 +483,84 @@ func TestShiftReleasedFirst(t *testing.T) { | |||
| 470 | 483 | for len(commands) > 0 { | |
| 471 | 484 | <-commands | |
| 472 | 485 | } | |
| 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} | |
| 486 | + | // US Cmd+Shift+1 prints exclam; the physical key (code 2) matches move-to-workspace 1. | |
| 487 | + | input <- ports.KeyEvent{Keysym: "exclam", Base: "1", Keycode: 2, Mods: ports.ModSuper | ports.ModShift, Pressed: true} | |
| 475 | 488 | receive(t, scenes) | |
| 476 | 489 | // Shift goes up first, so the release reports "1": still swallowed. | |
| 477 | 490 | input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModSuper} | |
| 478 | 491 | 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}}) { | |
| 492 | + | if v := command(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "x", Keycode: 45, Pressed: true}}) { | |
| 493 | + | t.Fatal(v) | |
| 494 | + | } | |
| 495 | + | } | |
| 496 | + | ||
| 497 | + | func TestOutputScale(t *testing.T) { | |
| 498 | + | cfg := config.Defaults() | |
| 499 | + | cfg.Border.Width = 0 | |
| 500 | + | cfg.Outputs = []ports.OutputConfig{{Name: "DP-2", Scale: 2}} | |
| 501 | + | cfg.Binds["Cmd+equal"] = "scale-up" | |
| 502 | + | cfg.Binds["Cmd+minus"] = "scale-down" | |
| 503 | + | client := make(chan ports.ClientEvent, 8) | |
| 504 | + | input := make(chan ports.InputEvent, 8) | |
| 505 | + | output := make(chan ports.OutputEvent, 8) | |
| 506 | + | reload := make(chan ports.ConfigChanged, 1) | |
| 507 | + | commands := make(chan ports.ClientCommand, 64) | |
| 508 | + | scenes := make(chan ports.Scene, 1) | |
| 509 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Config: reload, Commands: commands, Scenes: scenes}) | |
| 510 | + | if err != nil { | |
| 511 | + | t.Fatal(err) | |
| 512 | + | } | |
| 513 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 514 | + | defer cancel() | |
| 515 | + | go c.Run(ctx) | |
| 516 | + | // 5120x2160 at scale 2 is 2560x1080 logical. | |
| 517 | + | output <- ports.OutputMode{Width: 5120, Height: 2160, Name: "DP-2"} | |
| 518 | + | s := receive(t, scenes) | |
| 519 | + | if s.Scale != 2 || s.OutputWidth != 2560 || s.OutputHeight != 1080 { | |
| 520 | + | t.Fatal(s.Scale, s.OutputWidth, s.OutputHeight) | |
| 521 | + | } | |
| 522 | + | if v := receive(t, commands); v != (ports.SetOutputScale{Scale: 2, Width: 2560, Height: 1080}) { | |
| 523 | + | t.Fatal(v) | |
| 524 | + | } | |
| 525 | + | client <- ports.WindowMapped{ID: 1} | |
| 526 | + | receive(t, scenes) | |
| 527 | + | if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 2560, Height: 1080, Activated: true}) { | |
| 528 | + | t.Fatal(v) | |
| 529 | + | } | |
| 530 | + | for len(commands) > 0 { | |
| 531 | + | <-commands | |
| 532 | + | } | |
| 533 | + | // The pointer arrives in physical pixels; the client sees logical ones. | |
| 534 | + | input <- ports.PointerMotion{X: 1000, Y: 400} | |
| 535 | + | if v := command(t, commands); v != (ports.PointerFocus{ID: 1, X: 500, Y: 200}) { | |
| 480 | 536 | t.Fatal(v) | |
| 481 | 537 | } | |
| 538 | + | // Cmd+- steps down through the clean scales of 5120x2160: 2 → 5/3. | |
| 539 | + | input <- ports.KeyEvent{Keysym: "minus", Keycode: 12, Mods: ports.ModSuper, Pressed: true} | |
| 540 | + | s = receive(t, scenes) | |
| 541 | + | if s.Scale != 5.0/3 || s.OutputWidth != 3072 || s.OutputHeight != 1296 { | |
| 542 | + | t.Fatal(s.Scale, s.OutputWidth, s.OutputHeight) | |
| 543 | + | } | |
| 544 | + | input <- ports.KeyEvent{Keysym: "minus", Keycode: 12, Mods: ports.ModSuper} | |
| 545 | + | input <- ports.KeyEvent{Keysym: "equal", Keycode: 13, Mods: ports.ModSuper, Pressed: true} | |
| 546 | + | if s = receive(t, scenes); s.Scale != 2 { | |
| 547 | + | t.Fatal(s.Scale) | |
| 548 | + | } | |
| 549 | + | // A reload that keeps the config scale keeps the live one too. | |
| 550 | + | input <- ports.KeyEvent{Keysym: "equal", Keycode: 13, Mods: ports.ModSuper} | |
| 551 | + | input <- ports.KeyEvent{Keysym: "equal", Keycode: 13, Mods: ports.ModSuper, Pressed: true} | |
| 552 | + | if s = receive(t, scenes); s.Scale != 2.5 { | |
| 553 | + | t.Fatal(s.Scale) | |
| 554 | + | } | |
| 555 | + | cfg.Layout.Gaps = 1 | |
| 556 | + | reload <- ports.ConfigChanged{Config: cfg} | |
| 557 | + | if s = receive(t, scenes); s.Scale != 2.5 { | |
| 558 | + | t.Fatal(s.Scale) | |
| 559 | + | } | |
| 560 | + | // Changing the config scale wins. | |
| 561 | + | cfg.Outputs = []ports.OutputConfig{{Name: "DP-2", Scale: 1.25}} | |
| 562 | + | reload <- ports.ConfigChanged{Config: cfg} | |
| 563 | + | if s = receive(t, scenes); s.Scale != 1.25 || s.OutputWidth != 4096 { | |
| 564 | + | t.Fatal(s.Scale, s.OutputWidth) | |
| 565 | + | } | |
| 482 | 566 | } | |
| @@ -21,6 +21,19 @@ func receive[T any](t *testing.T, ch <-chan T) T { | |||||
| 21 | return zero | 21 | return zero | ||
| 22 | } | 22 | } | ||
| 23 | } | 23 | } | ||
| 24 | + | ||||
| 25 | + | // command receives the next client command, skipping output scale updates. | |||
| 26 | + | func command(t *testing.T, ch <-chan ports.ClientCommand) ports.ClientCommand { | |||
| 27 | + | t.Helper() | |||
| 28 | + | for { | |||
| 29 | + | if v := receive(t, ch); v != nil { | |||
| 30 | + | if _, ok := v.(ports.SetOutputScale); !ok { | |||
| 31 | + | return v | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | ||||
| 24 | func TestOwner(t *testing.T) { | 37 | func TestOwner(t *testing.T) { | ||
| 25 | cfg := config.Defaults() | 38 | cfg := config.Defaults() | ||
| 26 | cfg.Keyboard.CmdKey = "alt" | 39 | cfg.Keyboard.CmdKey = "alt" | ||
| @@ -53,10 +66,10 @@ func TestOwner(t *testing.T) { | |||||
| 53 | if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || !s.Windows[0].Borderless { | 66 | if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || !s.Windows[0].Borderless { | ||
| 54 | t.Fatal(s) | 67 | t.Fatal(s) | ||
| 55 | } | 68 | } | ||
| 56 | - | if v := receive(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 84, Height: 64, Activated: true}) { | 69 | + | if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 84, Height: 64, Activated: true}) { |
| 57 | t.Fatal(v) | 70 | t.Fatal(v) | ||
| 58 | } | 71 | } | ||
| 59 | - | receive(t, commands) | 72 | + | command(t, commands) |
| 60 | client <- ports.WindowMapped{ID: 2} | 73 | client <- ports.WindowMapped{ID: 2} | ||
| 61 | s = receive(t, scenes) | 74 | s = receive(t, scenes) | ||
| 62 | if len(s.Windows) != 2 { | 75 | if len(s.Windows) != 2 { | ||
| @@ -78,12 +91,12 @@ func TestOwner(t *testing.T) { | |||||
| 78 | } | 91 | } | ||
| 79 | input <- ports.KeyEvent{Keysym: "Return", Mods: ports.ModAlt} | 92 | input <- ports.KeyEvent{Keysym: "Return", Mods: ports.ModAlt} | ||
| 80 | input <- ports.KeyEvent{Keysym: "x", Pressed: true} | 93 | input <- ports.KeyEvent{Keysym: "x", Pressed: true} | ||
| 81 | - | if v := receive(t, commands); v != (ports.ForwardKey{ID: 2, Key: ports.KeyEvent{Keysym: "x", Pressed: true}}) { | 94 | + | if v := command(t, commands); v != (ports.ForwardKey{ID: 2, Key: ports.KeyEvent{Keysym: "x", Pressed: true}}) { |
| 82 | t.Fatal(v) | 95 | t.Fatal(v) | ||
| 83 | } | 96 | } | ||
| 84 | input <- ports.KeyEvent{Keysym: "Q", Mods: ports.ModAlt, Pressed: true} | 97 | input <- ports.KeyEvent{Keysym: "Q", Mods: ports.ModAlt, Pressed: true} | ||
| 85 | receive(t, scenes) | 98 | receive(t, scenes) | ||
| 86 | - | if v := receive(t, commands); v != (ports.CloseWindow{ID: 2}) { | 99 | + | if v := command(t, commands); v != (ports.CloseWindow{ID: 2}) { |
| 87 | t.Fatal(v) | 100 | t.Fatal(v) | ||
| 88 | } | 101 | } | ||
| 89 | client <- ports.WindowUnmapped{ID: 2} | 102 | client <- ports.WindowUnmapped{ID: 2} | ||
| @@ -97,7 +110,7 @@ func TestOwner(t *testing.T) { | |||||
| 97 | if s.Windows[0].Rect.W != 92 { | 110 | if s.Windows[0].Rect.W != 92 { | ||
| 98 | t.Fatal(s) | 111 | t.Fatal(s) | ||
| 99 | } | 112 | } | ||
| 100 | - | if v := receive(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 92, Height: 72, Activated: true}) { | 113 | + | if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 92, Height: 72, Activated: true}) { |
| 101 | t.Fatal(v) | 114 | t.Fatal(v) | ||
| 102 | } | 115 | } | ||
| 103 | cfg.Layout.MaxColumns = 0 | 116 | cfg.Layout.MaxColumns = 0 | ||
| @@ -145,26 +158,26 @@ func TestKeyPressState(t *testing.T) { | |||||
| 145 | defer func() { cancel(); receive(t, done) }() | 158 | defer func() { cancel(); receive(t, done) }() | ||
| 146 | client <- ports.WindowMapped{ID: 1} | 159 | client <- ports.WindowMapped{ID: 1} | ||
| 147 | receive(t, scenes) | 160 | receive(t, scenes) | ||
| 148 | - | receive(t, commands) | 161 | + | command(t, commands) |
| 149 | - | receive(t, commands) | 162 | + | command(t, commands) |
| 150 | press := ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true} | 163 | press := ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true} | ||
| 151 | input <- press | 164 | input <- press | ||
| 152 | receive(t, scenes) | 165 | receive(t, scenes) | ||
| 153 | input <- ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper} | 166 | input <- ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper} | ||
| 154 | input <- ports.KeyEvent{Keysym: "Left", Pressed: true} | 167 | input <- ports.KeyEvent{Keysym: "Left", Pressed: true} | ||
| 155 | - | if v := receive(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "Left", Pressed: true}}) { | 168 | + | if v := command(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "Left", Pressed: true}}) { |
| 156 | t.Fatal(v) | 169 | t.Fatal(v) | ||
| 157 | } | 170 | } | ||
| 158 | input <- ports.KeyEvent{Keysym: "Left"} | 171 | input <- ports.KeyEvent{Keysym: "Left"} | ||
| 159 | - | if v := receive(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "Left"}}) { | 172 | + | if v := command(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "Left"}}) { |
| 160 | t.Fatal(v) | 173 | t.Fatal(v) | ||
| 161 | } | 174 | } | ||
| 162 | input <- press | 175 | input <- press | ||
| 163 | receive(t, scenes) | 176 | receive(t, scenes) | ||
| 164 | input <- ports.KeyEvent{Keysym: "Left", Pressed: true} | 177 | input <- ports.KeyEvent{Keysym: "Left", Pressed: true} | ||
| 165 | - | receive(t, commands) | 178 | + | command(t, commands) |
| 166 | input <- ports.KeyEvent{Keysym: "Left"} | 179 | input <- ports.KeyEvent{Keysym: "Left"} | ||
| 167 | - | if v := receive(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "Left"}}) { | 180 | + | if v := command(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "Left"}}) { |
| 168 | t.Fatal(v) | 181 | t.Fatal(v) | ||
| 169 | } | 182 | } | ||
| 170 | } | 183 | } | ||
| @@ -185,13 +198,13 @@ func TestBoundReleaseSwallowed(t *testing.T) { | |||||
| 185 | defer func() { cancel(); receive(t, done) }() | 198 | defer func() { cancel(); receive(t, done) }() | ||
| 186 | client <- ports.WindowMapped{ID: 1} | 199 | client <- ports.WindowMapped{ID: 1} | ||
| 187 | receive(t, scenes) | 200 | receive(t, scenes) | ||
| 188 | - | receive(t, commands) | 201 | + | command(t, commands) |
| 189 | - | receive(t, commands) | 202 | + | command(t, commands) |
| 190 | input <- ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true} | 203 | input <- ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true} | ||
| 191 | receive(t, scenes) | 204 | receive(t, scenes) | ||
| 192 | input <- ports.KeyEvent{Keysym: "Left"} | 205 | input <- ports.KeyEvent{Keysym: "Left"} | ||
| 193 | input <- ports.KeyEvent{Keysym: "x", Pressed: true} | 206 | input <- ports.KeyEvent{Keysym: "x", Pressed: true} | ||
| 194 | - | if v := receive(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "x", Pressed: true}}) { | 207 | + | if v := command(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "x", Pressed: true}}) { |
| 195 | t.Fatal(v) | 208 | t.Fatal(v) | ||
| 196 | } | 209 | } | ||
| 197 | } | 210 | } | ||
| @@ -222,14 +235,14 @@ func TestPointerFocusAndGrab(t *testing.T) { | |||||
| 222 | <-commands | 235 | <-commands | ||
| 223 | } | 236 | } | ||
| 224 | input <- ports.PointerMotion{X: float64(first.X + 2), Y: float64(first.Y + 3), TimeMsec: 1} | 237 | input <- ports.PointerMotion{X: float64(first.X + 2), Y: float64(first.Y + 3), TimeMsec: 1} | ||
| 225 | - | if v := receive(t, commands); v != (ports.PointerFocus{ID: 1, X: 2, Y: 3}) { | 238 | + | if v := command(t, commands); v != (ports.PointerFocus{ID: 1, X: 2, Y: 3}) { |
| 226 | t.Fatal(v) | 239 | t.Fatal(v) | ||
| 227 | } | 240 | } | ||
| 228 | - | if v := receive(t, commands); v != (ports.PointerMotionTo{ID: 1, X: 2, Y: 3, TimeMsec: 1}) { | 241 | + | if v := command(t, commands); v != (ports.PointerMotionTo{ID: 1, X: 2, Y: 3, TimeMsec: 1}) { |
| 229 | t.Fatal(v) | 242 | t.Fatal(v) | ||
| 230 | } | 243 | } | ||
| 231 | input <- ports.PointerButton{Button: 0x110, Pressed: true} | 244 | input <- ports.PointerButton{Button: 0x110, Pressed: true} | ||
| 232 | - | if v := receive(t, commands); v != (ports.PointerButtonTo{ID: 1, Button: 0x110, Pressed: true}) { | 245 | + | if v := command(t, commands); v != (ports.PointerButtonTo{ID: 1, Button: 0x110, Pressed: true}) { |
| 233 | t.Fatal(v) | 246 | t.Fatal(v) | ||
| 234 | } | 247 | } | ||
| 235 | receive(t, scenes) | 248 | receive(t, scenes) | ||
| @@ -243,11 +256,11 @@ func TestPointerFocusAndGrab(t *testing.T) { | |||||
| 243 | t.Fatal("missing keyboard focus") | 256 | t.Fatal("missing keyboard focus") | ||
| 244 | } | 257 | } | ||
| 245 | input <- ports.PointerMotion{X: 0, Y: 0} | 258 | input <- ports.PointerMotion{X: 0, Y: 0} | ||
| 246 | - | if v := receive(t, commands); v != (ports.PointerFocus{}) { | 259 | + | if v := command(t, commands); v != (ports.PointerFocus{}) { |
| 247 | t.Fatal(v) | 260 | t.Fatal(v) | ||
| 248 | } | 261 | } | ||
| 249 | input <- ports.PointerButton{Button: 0x110} | 262 | input <- ports.PointerButton{Button: 0x110} | ||
| 250 | - | if v := receive(t, commands); v != (ports.PointerButtonTo{ID: 1, Button: 0x110}) { | 263 | + | if v := command(t, commands); v != (ports.PointerButtonTo{ID: 1, Button: 0x110}) { |
| 251 | t.Fatal(v) | 264 | t.Fatal(v) | ||
| 252 | } | 265 | } | ||
| 253 | } | 266 | } | ||
| @@ -272,7 +285,7 @@ func TestBorderInset(t *testing.T) { | |||||
| 272 | // Alone, the window is borderless and gets the full rect. | 285 | // Alone, the window is borderless and gets the full rect. | ||
| 273 | client <- ports.WindowMapped{ID: 1} | 286 | client <- ports.WindowMapped{ID: 1} | ||
| 274 | r := receive(t, scenes).Windows[0].Rect | 287 | r := receive(t, scenes).Windows[0].Rect | ||
| 275 | - | if v := receive(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W, Height: r.H, Activated: true}) { | 288 | + | if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W, Height: r.H, Activated: true}) { |
| 276 | t.Fatalf("%v for outer %v", v, r) | 289 | t.Fatalf("%v for outer %v", v, r) | ||
| 277 | } | 290 | } | ||
| 278 | // With a second column, the scene keeps the outer rect and the client is | 291 | // With a second column, the scene keeps the outer rect and the client is | ||
| @@ -294,7 +307,7 @@ func TestBorderInset(t *testing.T) { | |||||
| 294 | t.Fatalf("no %v for outer %v", want, r) | 307 | t.Fatalf("no %v for outer %v", want, r) | ||
| 295 | } | 308 | } | ||
| 296 | input <- ports.PointerMotion{X: float64(r.X + 2), Y: float64(r.Y + 5), TimeMsec: 1} | 309 | input <- ports.PointerMotion{X: float64(r.X + 2), Y: float64(r.Y + 5), TimeMsec: 1} | ||
| 297 | - | if v := receive(t, commands); v != (ports.PointerFocus{ID: 2, X: 0, Y: 3}) { | 310 | + | if v := command(t, commands); v != (ports.PointerFocus{ID: 2, X: 0, Y: 3}) { |
| 298 | t.Fatal(v) | 311 | t.Fatal(v) | ||
| 299 | } | 312 | } | ||
| 300 | } | 313 | } | ||
| @@ -320,7 +333,7 @@ func TestLayerKeyboardFocus(t *testing.T) { | |||||
| 320 | focusOf := func() ports.WindowID { | 333 | focusOf := func() ports.WindowID { | ||
| 321 | t.Helper() | 334 | t.Helper() | ||
| 322 | for { | 335 | for { | ||
| 323 | - | if v, ok := receive(t, commands).(ports.FocusWindow); ok { | 336 | + | if v, ok := command(t, commands).(ports.FocusWindow); ok { |
| 324 | return v.ID | 337 | return v.ID | ||
| 325 | } | 338 | } | ||
| 326 | } | 339 | } | ||
| @@ -338,7 +351,7 @@ func TestLayerKeyboardFocus(t *testing.T) { | |||||
| 338 | } | 351 | } | ||
| 339 | input <- ports.KeyEvent{Keysym: "a", Keycode: 30, Pressed: true} | 352 | input <- ports.KeyEvent{Keysym: "a", Keycode: 30, Pressed: true} | ||
| 340 | for { | 353 | for { | ||
| 341 | - | if v, ok := receive(t, commands).(ports.ForwardKey); ok { | 354 | + | if v, ok := command(t, commands).(ports.ForwardKey); ok { |
| 342 | if v.ID != 7 { | 355 | if v.ID != 7 { | ||
| 343 | t.Fatalf("key went to %d", v.ID) | 356 | t.Fatalf("key went to %d", v.ID) | ||
| 344 | } | 357 | } | ||
| @@ -373,8 +386,8 @@ func TestWorkspaceSwitch(t *testing.T) { | |||||
| 373 | for len(commands) > 0 { | 386 | for len(commands) > 0 { | ||
| 374 | <-commands | 387 | <-commands | ||
| 375 | } | 388 | } | ||
| 376 | - | // AZERTY: the 2 key is eacute unshifted; Cmd+2 still matches through its shifted level. | 389 | + | // AZERTY: the 2 key prints eacute; the default bind is on its physical key (code 3). |
| 377 | - | input <- ports.KeyEvent{Keysym: "eacute", Base: "eacute", Shifted: "2", Mods: ports.ModSuper, Pressed: true} | 390 | + | input <- ports.KeyEvent{Keysym: "eacute", Base: "eacute", Keycode: 3, Mods: ports.ModSuper, Pressed: true} |
| 378 | s := receive(t, scenes) | 391 | s := receive(t, scenes) | ||
| 379 | if len(s.Windows) != 1 || !s.Windows[0].Hidden { | 392 | if len(s.Windows) != 1 || !s.Windows[0].Hidden { | ||
| 380 | t.Fatal(s) | 393 | t.Fatal(s) | ||
| @@ -470,13 +483,84 @@ func TestShiftReleasedFirst(t *testing.T) { | |||||
| 470 | for len(commands) > 0 { | 483 | for len(commands) > 0 { | ||
| 471 | <-commands | 484 | <-commands | ||
| 472 | } | 485 | } | ||
| 473 | - | // US Cmd+Shift+1 prints exclam; its base level 1 matches move-to-workspace 1. | 486 | + | // US Cmd+Shift+1 prints exclam; the physical key (code 2) matches move-to-workspace 1. |
| 474 | - | input <- ports.KeyEvent{Keysym: "exclam", Base: "1", Shifted: "exclam", Keycode: 2, Mods: ports.ModSuper | ports.ModShift, Pressed: true} | 487 | + | input <- ports.KeyEvent{Keysym: "exclam", Base: "1", Keycode: 2, Mods: ports.ModSuper | ports.ModShift, Pressed: true} |
| 475 | receive(t, scenes) | 488 | receive(t, scenes) | ||
| 476 | // Shift goes up first, so the release reports "1": still swallowed. | 489 | // Shift goes up first, so the release reports "1": still swallowed. | ||
| 477 | input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModSuper} | 490 | input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModSuper} | ||
| 478 | input <- ports.KeyEvent{Keysym: "x", Keycode: 45, Pressed: true} | 491 | 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}}) { | 492 | + | if v := command(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "x", Keycode: 45, Pressed: true}}) { |
| 493 | + | t.Fatal(v) | |||
| 494 | + | } | |||
| 495 | + | } | |||
| 496 | + | ||||
| 497 | + | func TestOutputScale(t *testing.T) { | |||
| 498 | + | cfg := config.Defaults() | |||
| 499 | + | cfg.Border.Width = 0 | |||
| 500 | + | cfg.Outputs = []ports.OutputConfig{{Name: "DP-2", Scale: 2}} | |||
| 501 | + | cfg.Binds["Cmd+equal"] = "scale-up" | |||
| 502 | + | cfg.Binds["Cmd+minus"] = "scale-down" | |||
| 503 | + | client := make(chan ports.ClientEvent, 8) | |||
| 504 | + | input := make(chan ports.InputEvent, 8) | |||
| 505 | + | output := make(chan ports.OutputEvent, 8) | |||
| 506 | + | reload := make(chan ports.ConfigChanged, 1) | |||
| 507 | + | commands := make(chan ports.ClientCommand, 64) | |||
| 508 | + | scenes := make(chan ports.Scene, 1) | |||
| 509 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Config: reload, Commands: commands, Scenes: scenes}) | |||
| 510 | + | if err != nil { | |||
| 511 | + | t.Fatal(err) | |||
| 512 | + | } | |||
| 513 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 514 | + | defer cancel() | |||
| 515 | + | go c.Run(ctx) | |||
| 516 | + | // 5120x2160 at scale 2 is 2560x1080 logical. | |||
| 517 | + | output <- ports.OutputMode{Width: 5120, Height: 2160, Name: "DP-2"} | |||
| 518 | + | s := receive(t, scenes) | |||
| 519 | + | if s.Scale != 2 || s.OutputWidth != 2560 || s.OutputHeight != 1080 { | |||
| 520 | + | t.Fatal(s.Scale, s.OutputWidth, s.OutputHeight) | |||
| 521 | + | } | |||
| 522 | + | if v := receive(t, commands); v != (ports.SetOutputScale{Scale: 2, Width: 2560, Height: 1080}) { | |||
| 523 | + | t.Fatal(v) | |||
| 524 | + | } | |||
| 525 | + | client <- ports.WindowMapped{ID: 1} | |||
| 526 | + | receive(t, scenes) | |||
| 527 | + | if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 2560, Height: 1080, Activated: true}) { | |||
| 528 | + | t.Fatal(v) | |||
| 529 | + | } | |||
| 530 | + | for len(commands) > 0 { | |||
| 531 | + | <-commands | |||
| 532 | + | } | |||
| 533 | + | // The pointer arrives in physical pixels; the client sees logical ones. | |||
| 534 | + | input <- ports.PointerMotion{X: 1000, Y: 400} | |||
| 535 | + | if v := command(t, commands); v != (ports.PointerFocus{ID: 1, X: 500, Y: 200}) { | |||
| 480 | t.Fatal(v) | 536 | t.Fatal(v) | ||
| 481 | } | 537 | } | ||
| 538 | + | // Cmd+- steps down through the clean scales of 5120x2160: 2 → 5/3. | |||
| 539 | + | input <- ports.KeyEvent{Keysym: "minus", Keycode: 12, Mods: ports.ModSuper, Pressed: true} | |||
| 540 | + | s = receive(t, scenes) | |||
| 541 | + | if s.Scale != 5.0/3 || s.OutputWidth != 3072 || s.OutputHeight != 1296 { | |||
| 542 | + | t.Fatal(s.Scale, s.OutputWidth, s.OutputHeight) | |||
| 543 | + | } | |||
| 544 | + | input <- ports.KeyEvent{Keysym: "minus", Keycode: 12, Mods: ports.ModSuper} | |||
| 545 | + | input <- ports.KeyEvent{Keysym: "equal", Keycode: 13, Mods: ports.ModSuper, Pressed: true} | |||
| 546 | + | if s = receive(t, scenes); s.Scale != 2 { | |||
| 547 | + | t.Fatal(s.Scale) | |||
| 548 | + | } | |||
| 549 | + | // A reload that keeps the config scale keeps the live one too. | |||
| 550 | + | input <- ports.KeyEvent{Keysym: "equal", Keycode: 13, Mods: ports.ModSuper} | |||
| 551 | + | input <- ports.KeyEvent{Keysym: "equal", Keycode: 13, Mods: ports.ModSuper, Pressed: true} | |||
| 552 | + | if s = receive(t, scenes); s.Scale != 2.5 { | |||
| 553 | + | t.Fatal(s.Scale) | |||
| 554 | + | } | |||
| 555 | + | cfg.Layout.Gaps = 1 | |||
| 556 | + | reload <- ports.ConfigChanged{Config: cfg} | |||
| 557 | + | if s = receive(t, scenes); s.Scale != 2.5 { | |||
| 558 | + | t.Fatal(s.Scale) | |||
| 559 | + | } | |||
| 560 | + | // Changing the config scale wins. | |||
| 561 | + | cfg.Outputs = []ports.OutputConfig{{Name: "DP-2", Scale: 1.25}} | |||
| 562 | + | reload <- ports.ConfigChanged{Config: cfg} | |||
| 563 | + | if s = receive(t, scenes); s.Scale != 1.25 || s.OutputWidth != 4096 { | |||
| 564 | + | t.Fatal(s.Scale, s.OutputWidth) | |||
| 565 | + | } | |||
| 482 | } | 566 | } | ||
A internal/core/scale.go +53 -0
| @@ -0,0 +1,53 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import "math" | |
| 4 | + | ||
| 5 | + | // Scales are in 1/120 steps, the wp_fractional_scale_v1 unit. | |
| 6 | + | const scaleDen = 120 | |
| 7 | + | ||
| 8 | + | // CleanScales lists scales between 1 and 3 where the output divides into whole | |
| 9 | + | // logical pixels, so window edges land on physical pixels. Scales below 1.1 | |
| 10 | + | // are skipped: 1.067 on 5120 wide outputs is too close to 1 to be useful. | |
| 11 | + | func CleanScales(width, height int) []float64 { | |
| 12 | + | out := []float64{1} | |
| 13 | + | for n := scaleDen + 1; n <= 3*scaleDen; n++ { | |
| 14 | + | if width*scaleDen%n != 0 || height*scaleDen%n != 0 { | |
| 15 | + | continue | |
| 16 | + | } | |
| 17 | + | if s := float64(n) / scaleDen; s >= 1.1 { | |
| 18 | + | out = append(out, s) | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | return out | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // StepScale returns the next clean scale above (dir 1) or below (dir -1) cur, | |
| 25 | + | // or cur at the ends. | |
| 26 | + | func StepScale(width, height int, cur float64, dir int) float64 { | |
| 27 | + | list := CleanScales(width, height) | |
| 28 | + | if dir > 0 { | |
| 29 | + | for _, s := range list { | |
| 30 | + | if s > cur+1e-6 { | |
| 31 | + | return s | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | return cur | |
| 35 | + | } | |
| 36 | + | for i := len(list) - 1; i >= 0; i-- { | |
| 37 | + | if list[i] < cur-1e-6 { | |
| 38 | + | return list[i] | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | return cur | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // SnapScale rounds to the nearest 1/120 and clamps to [1, 4]. | |
| 45 | + | func SnapScale(s float64) float64 { | |
| 46 | + | if !(s >= 1) { | |
| 47 | + | return 1 | |
| 48 | + | } | |
| 49 | + | return math.Round(min(s, 4)*scaleDen) / scaleDen | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // logical converts a physical size to logical pixels. | |
| 53 | + | func logical(v int, scale float64) int { return int(math.Round(float64(v) / scale)) } | |
| @@ -0,0 +1,53 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import "math" | |||
| 4 | + | ||||
| 5 | + | // Scales are in 1/120 steps, the wp_fractional_scale_v1 unit. | |||
| 6 | + | const scaleDen = 120 | |||
| 7 | + | ||||
| 8 | + | // CleanScales lists scales between 1 and 3 where the output divides into whole | |||
| 9 | + | // logical pixels, so window edges land on physical pixels. Scales below 1.1 | |||
| 10 | + | // are skipped: 1.067 on 5120 wide outputs is too close to 1 to be useful. | |||
| 11 | + | func CleanScales(width, height int) []float64 { | |||
| 12 | + | out := []float64{1} | |||
| 13 | + | for n := scaleDen + 1; n <= 3*scaleDen; n++ { | |||
| 14 | + | if width*scaleDen%n != 0 || height*scaleDen%n != 0 { | |||
| 15 | + | continue | |||
| 16 | + | } | |||
| 17 | + | if s := float64(n) / scaleDen; s >= 1.1 { | |||
| 18 | + | out = append(out, s) | |||
| 19 | + | } | |||
| 20 | + | } | |||
| 21 | + | return out | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // StepScale returns the next clean scale above (dir 1) or below (dir -1) cur, | |||
| 25 | + | // or cur at the ends. | |||
| 26 | + | func StepScale(width, height int, cur float64, dir int) float64 { | |||
| 27 | + | list := CleanScales(width, height) | |||
| 28 | + | if dir > 0 { | |||
| 29 | + | for _, s := range list { | |||
| 30 | + | if s > cur+1e-6 { | |||
| 31 | + | return s | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | return cur | |||
| 35 | + | } | |||
| 36 | + | for i := len(list) - 1; i >= 0; i-- { | |||
| 37 | + | if list[i] < cur-1e-6 { | |||
| 38 | + | return list[i] | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | return cur | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // SnapScale rounds to the nearest 1/120 and clamps to [1, 4]. | |||
| 45 | + | func SnapScale(s float64) float64 { | |||
| 46 | + | if !(s >= 1) { | |||
| 47 | + | return 1 | |||
| 48 | + | } | |||
| 49 | + | return math.Round(min(s, 4)*scaleDen) / scaleDen | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // logical converts a physical size to logical pixels. | |||
| 53 | + | func logical(v int, scale float64) int { return int(math.Round(float64(v) / scale)) } | |||
A internal/core/scale_test.go +47 -0
| @@ -0,0 +1,47 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "reflect" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestCleanScales(t *testing.T) { | |
| 9 | + | for _, tc := range []struct { | |
| 10 | + | w, h int | |
| 11 | + | want []float64 | |
| 12 | + | }{ | |
| 13 | + | {5120, 2160, []float64{1, 1.25, 4.0 / 3, 1.6, 5.0 / 3, 2, 2.5, 8.0 / 3}}, | |
| 14 | + | {3840, 2160, []float64{1, 1.2, 1.25, 4.0 / 3, 1.5, 1.6, 5.0 / 3, 1.875, 2, 2.4, 2.5, 8.0 / 3, 3}}, | |
| 15 | + | } { | |
| 16 | + | if got := CleanScales(tc.w, tc.h); !reflect.DeepEqual(got, tc.want) { | |
| 17 | + | t.Errorf("%dx%d: %v", tc.w, tc.h, got) | |
| 18 | + | } | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func TestStepScale(t *testing.T) { | |
| 23 | + | if s := StepScale(5120, 2160, 1, 1); s != 1.25 { | |
| 24 | + | t.Fatal(s) | |
| 25 | + | } | |
| 26 | + | // 1.5 is not clean on 5K: up goes to 1.6, down to 4/3. | |
| 27 | + | if s := StepScale(5120, 2160, 1.5, 1); s != 1.6 { | |
| 28 | + | t.Fatal(s) | |
| 29 | + | } | |
| 30 | + | if s := StepScale(5120, 2160, 1.5, -1); s != 4.0/3 { | |
| 31 | + | t.Fatal(s) | |
| 32 | + | } | |
| 33 | + | if s := StepScale(5120, 2160, 1, -1); s != 1 { | |
| 34 | + | t.Fatal(s) | |
| 35 | + | } | |
| 36 | + | if s := StepScale(1920, 1080, 3, 1); s != 3 { | |
| 37 | + | t.Fatal(s) | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func TestSnapScale(t *testing.T) { | |
| 42 | + | for in, want := range map[float64]float64{0: 1, -2: 1, 1.5: 1.5, 1.3333: 160.0 / 120, 9: 4} { | |
| 43 | + | if got := SnapScale(in); got != want { | |
| 44 | + | t.Errorf("%v: %v", in, got) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | } | |
| @@ -0,0 +1,47 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "reflect" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestCleanScales(t *testing.T) { | |||
| 9 | + | for _, tc := range []struct { | |||
| 10 | + | w, h int | |||
| 11 | + | want []float64 | |||
| 12 | + | }{ | |||
| 13 | + | {5120, 2160, []float64{1, 1.25, 4.0 / 3, 1.6, 5.0 / 3, 2, 2.5, 8.0 / 3}}, | |||
| 14 | + | {3840, 2160, []float64{1, 1.2, 1.25, 4.0 / 3, 1.5, 1.6, 5.0 / 3, 1.875, 2, 2.4, 2.5, 8.0 / 3, 3}}, | |||
| 15 | + | } { | |||
| 16 | + | if got := CleanScales(tc.w, tc.h); !reflect.DeepEqual(got, tc.want) { | |||
| 17 | + | t.Errorf("%dx%d: %v", tc.w, tc.h, got) | |||
| 18 | + | } | |||
| 19 | + | } | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func TestStepScale(t *testing.T) { | |||
| 23 | + | if s := StepScale(5120, 2160, 1, 1); s != 1.25 { | |||
| 24 | + | t.Fatal(s) | |||
| 25 | + | } | |||
| 26 | + | // 1.5 is not clean on 5K: up goes to 1.6, down to 4/3. | |||
| 27 | + | if s := StepScale(5120, 2160, 1.5, 1); s != 1.6 { | |||
| 28 | + | t.Fatal(s) | |||
| 29 | + | } | |||
| 30 | + | if s := StepScale(5120, 2160, 1.5, -1); s != 4.0/3 { | |||
| 31 | + | t.Fatal(s) | |||
| 32 | + | } | |||
| 33 | + | if s := StepScale(5120, 2160, 1, -1); s != 1 { | |||
| 34 | + | t.Fatal(s) | |||
| 35 | + | } | |||
| 36 | + | if s := StepScale(1920, 1080, 3, 1); s != 3 { | |||
| 37 | + | t.Fatal(s) | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func TestSnapScale(t *testing.T) { | |||
| 42 | + | for in, want := range map[float64]float64{0: 1, -2: 1, 1.5: 1.5, 1.3333: 160.0 / 120, 9: 4} { | |||
| 43 | + | if got := SnapScale(in); got != want { | |||
| 44 | + | t.Errorf("%v: %v", in, got) | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | } | |||
M internal/ports/config.go +6 -3
| @@ -5,10 +5,13 @@ package ports | |||
| 5 | 5 | // monitor's preferred mode. Off disables the connector. | |
| 6 | 6 | // The first listed, connected, enabled output is used; without a match, the first | |
| 7 | 7 | // connected output that is not off. | |
| 8 | + | // Scale is the output scale (0 means 1); layout works in logical pixels, | |
| 9 | + | // physical = logical × Scale. | |
| 8 | 10 | type OutputConfig struct { | |
| 9 | - | Name string | |
| 10 | - | Mode string | |
| 11 | - | Off bool | |
| 11 | + | Name string | |
| 12 | + | Mode string | |
| 13 | + | Off bool | |
| 14 | + | Scale float64 | |
| 12 | 15 | } | |
| 13 | 16 | ||
| 14 | 17 | // Config is the parsed compositor configuration (see the config adapter for keys). | |
| @@ -5,10 +5,13 @@ package ports | |||||
| 5 | // monitor's preferred mode. Off disables the connector. | 5 | // monitor's preferred mode. Off disables the connector. | ||
| 6 | // The first listed, connected, enabled output is used; without a match, the first | 6 | // The first listed, connected, enabled output is used; without a match, the first | ||
| 7 | // connected output that is not off. | 7 | // connected output that is not off. | ||
| 8 | + | // Scale is the output scale (0 means 1); layout works in logical pixels, | |||
| 9 | + | // physical = logical × Scale. | |||
| 8 | type OutputConfig struct { | 10 | type OutputConfig struct { | ||
| 9 | - | Name string | 11 | + | Name string |
| 10 | - | Mode string | 12 | + | Mode string |
| 11 | - | Off bool | 13 | + | Off bool |
| 14 | + | Scale float64 | |||
| 12 | } | 15 | } | ||
| 13 | 16 | ||||
| 14 | // Config is the parsed compositor configuration (see the config adapter for keys). | 17 | // Config is the parsed compositor configuration (see the config adapter for keys). | ||
M internal/ports/messages.go +35 -18
| @@ -43,7 +43,7 @@ const ( | |||
| 43 | 43 | ) | |
| 44 | 44 | ||
| 45 | 45 | // LayerSurface is the committed state of one mapped layer surface. Width and | |
| 46 | - | // Height are the committed buffer size. Margin is top, right, bottom, left. | |
| 46 | + | // Height are the committed surface size in logical pixels. Margin is top, right, bottom, left. | |
| 47 | 47 | // IDs share the WindowID space with windows and never collide. | |
| 48 | 48 | type LayerSurface struct { | |
| 49 | 49 | ID WindowID | |
| @@ -81,14 +81,14 @@ type InputEvent interface{ inputEvent() } | |||
| 81 | 81 | // after this transition; wayland forwards both to clients unchanged. | |
| 82 | 82 | type KeyEvent struct { | |
| 83 | 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 | |
| 84 | + | // Base is the key's unshifted keysym in the active layout, so Cmd+Shift+1 | |
| 85 | + | // still matches a cmd+shift+1 bind although it prints exclam. | |
| 86 | + | Base string | |
| 87 | + | Mods Mods | |
| 88 | + | Pressed bool | |
| 89 | + | TimeMsec uint32 | |
| 90 | + | Keycode uint32 | |
| 91 | + | State ModState | |
| 92 | 92 | } | |
| 93 | 93 | ||
| 94 | 94 | // ModState is the serialized xkb modifier state sent in wl_keyboard.modifiers. | |
| @@ -96,7 +96,7 @@ type ModState struct{ Depressed, Latched, Locked, Group uint32 } | |||
| 96 | 96 | ||
| 97 | 97 | func (KeyEvent) inputEvent() {} | |
| 98 | 98 | ||
| 99 | - | // PointerMotion uses absolute output coordinates. | |
| 99 | + | // PointerMotion uses absolute output coordinates in physical pixels. | |
| 100 | 100 | type PointerMotion struct { | |
| 101 | 101 | X, Y float64 | |
| 102 | 102 | TimeMsec uint32 | |
| @@ -116,8 +116,12 @@ func (PointerButton) inputEvent() {} | |||
| 116 | 116 | // OutputEvent carries output → core notifications. | |
| 117 | 117 | type OutputEvent interface{ outputEvent() } | |
| 118 | 118 | ||
| 119 | - | // OutputMode carries output → core resolution changes. | |
| 120 | - | type OutputMode struct{ Width, Height int } | |
| 119 | + | // OutputMode carries output → core resolution changes in physical pixels. | |
| 120 | + | // Name is the connector, matched against output.<name>.scale. | |
| 121 | + | type OutputMode struct { | |
| 122 | + | Width, Height int | |
| 123 | + | Name string | |
| 124 | + | } | |
| 121 | 125 | ||
| 122 | 126 | func (OutputMode) outputEvent() {} | |
| 123 | 127 | ||
| @@ -141,6 +145,15 @@ type ConfigureWindow struct { | |||
| 141 | 145 | ||
| 142 | 146 | func (ConfigureWindow) clientCommand() {} | |
| 143 | 147 | ||
| 148 | + | // SetOutputScale carries core → wayland the output scale and its logical size. | |
| 149 | + | // Window and pointer coordinates in other commands are logical. | |
| 150 | + | type SetOutputScale struct { | |
| 151 | + | Scale float64 | |
| 152 | + | Width, Height int | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func (SetOutputScale) clientCommand() {} | |
| 156 | + | ||
| 144 | 157 | // CloseWindow carries core → wayland close requests. | |
| 145 | 158 | type CloseWindow struct{ ID WindowID } | |
| 146 | 159 | ||
| @@ -197,9 +210,11 @@ func (SetKeymap) clientCommand() {} | |||
| 197 | 210 | type SpawnRequest struct{ Argv []string } | |
| 198 | 211 | ||
| 199 | 212 | // Scene carries core → renderer immutable snapshots with fresh Windows slices. | |
| 213 | + | // Rects and the output size are logical; the renderer multiplies by Scale. | |
| 200 | 214 | type Scene struct { | |
| 201 | 215 | Seq uint64 | |
| 202 | 216 | OutputWidth, OutputHeight int | |
| 217 | + | Scale float64 | |
| 203 | 218 | Background string | |
| 204 | 219 | Border Border | |
| 205 | 220 | Windows []SceneWindow | |
| @@ -224,13 +239,15 @@ type SceneLayer struct { | |||
| 224 | 239 | // SurfaceContent carries wayland → output the latest committed pixels of a | |
| 225 | 240 | // window. Pixels is a private copy in B8G8R8A8 (wl_shm argb8888/xrgb8888 | |
| 226 | 241 | // little-endian) with Stride bytes per row; receivers never modify it. | |
| 227 | - | // Pixels == nil means the window has no content. | |
| 242 | + | // Pixels == nil means the window has no content. LogicalW and LogicalH are | |
| 243 | + | // the surface size in logical pixels (buffer scale and viewport applied). | |
| 228 | 244 | type SurfaceContent struct { | |
| 229 | - | ID WindowID | |
| 230 | - | Width, Height int | |
| 231 | - | Stride int | |
| 232 | - | Opaque bool // xrgb8888: ignore the alpha byte | |
| 233 | - | Pixels []byte | |
| 245 | + | ID WindowID | |
| 246 | + | Width, Height int | |
| 247 | + | LogicalW, LogicalH int | |
| 248 | + | Stride int | |
| 249 | + | Opaque bool // xrgb8888: ignore the alpha byte | |
| 250 | + | Pixels []byte | |
| 234 | 251 | } | |
| 235 | 252 | ||
| 236 | 253 | // SceneWindow carries core → renderer window placement. | |
| @@ -43,7 +43,7 @@ const ( | |||||
| 43 | ) | 43 | ) | ||
| 44 | 44 | ||||
| 45 | // LayerSurface is the committed state of one mapped layer surface. Width and | 45 | // LayerSurface is the committed state of one mapped layer surface. Width and | ||
| 46 | - | // Height are the committed buffer size. Margin is top, right, bottom, left. | 46 | + | // Height are the committed surface size in logical pixels. Margin is top, right, bottom, left. |
| 47 | // IDs share the WindowID space with windows and never collide. | 47 | // IDs share the WindowID space with windows and never collide. | ||
| 48 | type LayerSurface struct { | 48 | type LayerSurface struct { | ||
| 49 | ID WindowID | 49 | ID WindowID | ||
| @@ -81,14 +81,14 @@ type InputEvent interface{ inputEvent() } | |||||
| 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 | - | // Base and Shifted are the key's level 1 and 2 keysyms in the active layout, | 84 | + | // Base is the key's unshifted keysym in the active layout, so Cmd+Shift+1 |
| 85 | - | // so binds like Cmd+1 also match on layouts where 1 needs Shift (AZERTY). | 85 | + | // still matches a cmd+shift+1 bind although it prints exclam. |
| 86 | - | Base, Shifted string | 86 | + | Base string |
| 87 | - | Mods Mods | 87 | + | Mods Mods |
| 88 | - | Pressed bool | 88 | + | Pressed bool |
| 89 | - | TimeMsec uint32 | 89 | + | TimeMsec uint32 |
| 90 | - | Keycode uint32 | 90 | + | Keycode uint32 |
| 91 | - | State ModState | 91 | + | State ModState |
| 92 | } | 92 | } | ||
| 93 | 93 | ||||
| 94 | // 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. | ||
| @@ -96,7 +96,7 @@ type ModState struct{ Depressed, Latched, Locked, Group uint32 } | |||||
| 96 | 96 | ||||
| 97 | func (KeyEvent) inputEvent() {} | 97 | func (KeyEvent) inputEvent() {} | ||
| 98 | 98 | ||||
| 99 | - | // PointerMotion uses absolute output coordinates. | 99 | + | // PointerMotion uses absolute output coordinates in physical pixels. |
| 100 | type PointerMotion struct { | 100 | type PointerMotion struct { | ||
| 101 | X, Y float64 | 101 | X, Y float64 | ||
| 102 | TimeMsec uint32 | 102 | TimeMsec uint32 | ||
| @@ -116,8 +116,12 @@ func (PointerButton) inputEvent() {} | |||||
| 116 | // OutputEvent carries output → core notifications. | 116 | // OutputEvent carries output → core notifications. | ||
| 117 | type OutputEvent interface{ outputEvent() } | 117 | type OutputEvent interface{ outputEvent() } | ||
| 118 | 118 | ||||
| 119 | - | // OutputMode carries output → core resolution changes. | 119 | + | // OutputMode carries output → core resolution changes in physical pixels. |
| 120 | - | type OutputMode struct{ Width, Height int } | 120 | + | // Name is the connector, matched against output.<name>.scale. |
| 121 | + | type OutputMode struct { | |||
| 122 | + | Width, Height int | |||
| 123 | + | Name string | |||
| 124 | + | } | |||
| 121 | 125 | ||||
| 122 | func (OutputMode) outputEvent() {} | 126 | func (OutputMode) outputEvent() {} | ||
| 123 | 127 | ||||
| @@ -141,6 +145,15 @@ type ConfigureWindow struct { | |||||
| 141 | 145 | ||||
| 142 | func (ConfigureWindow) clientCommand() {} | 146 | func (ConfigureWindow) clientCommand() {} | ||
| 143 | 147 | ||||
| 148 | + | // SetOutputScale carries core → wayland the output scale and its logical size. | |||
| 149 | + | // Window and pointer coordinates in other commands are logical. | |||
| 150 | + | type SetOutputScale struct { | |||
| 151 | + | Scale float64 | |||
| 152 | + | Width, Height int | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | func (SetOutputScale) clientCommand() {} | |||
| 156 | + | ||||
| 144 | // CloseWindow carries core → wayland close requests. | 157 | // CloseWindow carries core → wayland close requests. | ||
| 145 | type CloseWindow struct{ ID WindowID } | 158 | type CloseWindow struct{ ID WindowID } | ||
| 146 | 159 | ||||
| @@ -197,9 +210,11 @@ func (SetKeymap) clientCommand() {} | |||||
| 197 | type SpawnRequest struct{ Argv []string } | 210 | type SpawnRequest struct{ Argv []string } | ||
| 198 | 211 | ||||
| 199 | // Scene carries core → renderer immutable snapshots with fresh Windows slices. | 212 | // Scene carries core → renderer immutable snapshots with fresh Windows slices. | ||
| 213 | + | // Rects and the output size are logical; the renderer multiplies by Scale. | |||
| 200 | type Scene struct { | 214 | type Scene struct { | ||
| 201 | Seq uint64 | 215 | Seq uint64 | ||
| 202 | OutputWidth, OutputHeight int | 216 | OutputWidth, OutputHeight int | ||
| 217 | + | Scale float64 | |||
| 203 | Background string | 218 | Background string | ||
| 204 | Border Border | 219 | Border Border | ||
| 205 | Windows []SceneWindow | 220 | Windows []SceneWindow | ||
| @@ -224,13 +239,15 @@ type SceneLayer struct { | |||||
| 224 | // SurfaceContent carries wayland → output the latest committed pixels of a | 239 | // SurfaceContent carries wayland → output the latest committed pixels of a | ||
| 225 | // window. Pixels is a private copy in B8G8R8A8 (wl_shm argb8888/xrgb8888 | 240 | // window. Pixels is a private copy in B8G8R8A8 (wl_shm argb8888/xrgb8888 | ||
| 226 | // little-endian) with Stride bytes per row; receivers never modify it. | 241 | // little-endian) with Stride bytes per row; receivers never modify it. | ||
| 227 | - | // Pixels == nil means the window has no content. | 242 | + | // Pixels == nil means the window has no content. LogicalW and LogicalH are |
| 243 | + | // the surface size in logical pixels (buffer scale and viewport applied). | |||
| 228 | type SurfaceContent struct { | 244 | type SurfaceContent struct { | ||
| 229 | - | ID WindowID | 245 | + | ID WindowID |
| 230 | - | Width, Height int | 246 | + | Width, Height int |
| 231 | - | Stride int | 247 | + | LogicalW, LogicalH int |
| 232 | - | Opaque bool // xrgb8888: ignore the alpha byte | 248 | + | Stride int |
| 233 | - | Pixels []byte | 249 | + | Opaque bool // xrgb8888: ignore the alpha byte |
| 250 | + | Pixels []byte | |||
| 234 | } | 251 | } | ||
| 235 | 252 | ||||
| 236 | // SceneWindow carries core → renderer window placement. | 253 | // SceneWindow carries core → renderer window placement. | ||