detached 8b505f8

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
3232 # The first listed display that is connected and not off is used.
3333 # output.DP-2 = 5120x2160@165.058
3434 # 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
3538
3639 render.direct-scanout = on
3740
@@ -40,6 +43,9 @@ log.level = info
4043 log.debug =
4144
4245 # 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).
4349 # Keys are what your layout prints (cmd+é on AZERTY). Write = # and space
4450 # by name: cmd+equal, cmd+numbersign, cmd+space. `none` removes a default.
4551 # `spawn <command>` runs a program; arguments split on spaces, no shell.
@@ -62,21 +68,25 @@ bind.cmd+pageup = focus-workspace-up
6268 bind.cmd+pagedown = focus-workspace-down
6369 bind.cmd+shift+pageup = move-to-workspace-up
6470 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.05833 # output.DP-2 = 5120x2160@165.058
34 # output.HDMI-A-1 = off34 # 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 = on39 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 space49 # 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-down68 bind.cmd+pagedown = focus-workspace-down
63 bind.cmd+shift+pageup = move-to-workspace-up69 bind.cmd+shift+pageup = move-to-workspace-up
64 bind.cmd+shift+pagedown = move-to-workspace-down70 bind.cmd+shift+pagedown = move-to-workspace-down
65-bind.cmd+1 = focus-workspace 171+bind.cmd+code:2 = focus-workspace 1
66-bind.cmd+2 = focus-workspace 272+bind.cmd+code:3 = focus-workspace 2
67-bind.cmd+3 = focus-workspace 373+bind.cmd+code:4 = focus-workspace 3
68-bind.cmd+4 = focus-workspace 474+bind.cmd+code:5 = focus-workspace 4
69-bind.cmd+5 = focus-workspace 575+bind.cmd+code:6 = focus-workspace 5
70-bind.cmd+6 = focus-workspace 676+bind.cmd+code:7 = focus-workspace 6
71-bind.cmd+7 = focus-workspace 777+bind.cmd+code:8 = focus-workspace 7
72-bind.cmd+8 = focus-workspace 878+bind.cmd+code:9 = focus-workspace 8
73-bind.cmd+9 = focus-workspace 979+bind.cmd+code:10 = focus-workspace 9
74-bind.cmd+shift+1 = move-to-workspace 180+bind.cmd+shift+code:2 = move-to-workspace 1
75-bind.cmd+shift+2 = move-to-workspace 281+bind.cmd+shift+code:3 = move-to-workspace 2
76-bind.cmd+shift+3 = move-to-workspace 382+bind.cmd+shift+code:4 = move-to-workspace 3
77-bind.cmd+shift+4 = move-to-workspace 483+bind.cmd+shift+code:5 = move-to-workspace 4
78-bind.cmd+shift+5 = move-to-workspace 584+bind.cmd+shift+code:6 = move-to-workspace 5
79-bind.cmd+shift+6 = move-to-workspace 685+bind.cmd+shift+code:7 = move-to-workspace 6
80-bind.cmd+shift+7 = move-to-workspace 786+bind.cmd+shift+code:8 = move-to-workspace 7
81-bind.cmd+shift+8 = move-to-workspace 887+bind.cmd+shift+code:9 = move-to-workspace 8
82-bind.cmd+shift+9 = move-to-workspace 988+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 }{
3636 {"cmd+shift+pageup", "move-to-workspace-up"},
3737 {"cmd+shift+pagedown", "move-to-workspace-down"},
3838 }
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}
4040 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}
4141 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
4242
@@ -86,9 +86,15 @@ func Defaults() ports.Config {
8686 }
8787 c.Binds[combo] = b.action
8888 }
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+ }
8995 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))
9298 c.Binds[focus] = fmt.Sprintf("focus-workspace %d", n)
9399 c.Binds[move] = fmt.Sprintf("move-to-workspace %d", n)
94100 }
@@ -176,6 +182,25 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
176182 raw[key] = value
177183 }
178184 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+ }
179204 o := ports.OutputConfig{Name: name}
180205 if value == "off" {
181206 o.Off = true
@@ -186,12 +211,9 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
186211 o.Mode = value
187212 }
188213 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
195217 continue
196218 }
197219 if err := set(&c, key, value); err != nil {
@@ -472,6 +494,13 @@ func parseCombo(s string) (string, error) {
472494
473495 // keysym maps a written key to its xkb keysym name.
474496 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+ }
475504 if r := []rune(k); len(r) == 1 {
476505 // Binds match the unshifted keysym, so É means é.
477506 if name, ok := runeKeysyms[unicode.ToLower(r[0])]; ok {
@@ -493,6 +522,28 @@ func keysym(k string) (string, error) {
493522 return k, nil
494523 }
495524
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+
496547 // ParseMode parses "WxH" or "WxH@Hz" ("preferred" is accepted); hz is 0 when omitted.
497548 func ParseMode(s string) (w, h int, hz float64, err error) {
498549 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.action87 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] = value182 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 = true206 o.Off = true
@@ -186,12 +211,9 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
186 o.Mode = value211 o.Mode = value
187 }212 }
188 override()213 override()
189- if i, ok := outputs[name]; ok {214+ e := entry()
190- c.Outputs[i] = o215+ o.Scale = e.Scale
191- } else {216+ *e = o
192- outputs[name] = len(c.Outputs)
193- c.Outputs = append(c.Outputs, o)
194- }
195 continue217 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, nil522 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) {
2121
2222 func TestDefaultsAndLoad(t *testing.T) {
2323 d := Defaults()
24- if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 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 {
2525 t.Fatalf("defaults: %+v", d)
2626 }
2727 if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" {
@@ -54,9 +54,12 @@ background = #000000
5454 border.inactive =
5555 layout.presets = 1/3, 1/2 ,1
5656 log.debug = core, input
57+output.DP-2.scale = 4/3
5758 output.DP-2 = 5120x2160@165.058
5859 output.HDMI-A-1 = off
5960 output.DP-1 = preferred
61+output.DP-1.scale = 1.5
62+bind.cmd+code:30 = quit
6063 render.direct-scanout = off
6164 `)
6265 if len(w) != 0 {
@@ -68,9 +71,13 @@ render.direct-scanout = off
6871 if !reflect.DeepEqual(c.Layout.Presets, []string{"1/3", "1/2", "1"}) || !reflect.DeepEqual(c.Log.Debug, []string{"core", "input"}) {
6972 t.Fatalf("%+v", c)
7073 }
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 {
7276 t.Fatalf("%+v", c.Outputs)
7377 }
78+ if c.Binds["Cmd+code:30"] != "quit" {
79+ t.Fatal(c.Binds)
80+ }
7481 }
7582
7683 func TestWarningsKeepDefaults(t *testing.T) {
@@ -87,6 +94,9 @@ func TestWarningsKeepDefaults(t *testing.T) {
8794 {"log.debug = nope", "log.debug"},
8895 {"output.DP-2 = big", "output.DP-2"},
8996 {"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"},
90100 {"nope = 1", "nope"},
91101 {"just text", "expected key = value"},
92102 {"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 ,155 layout.presets = 1/3, 1/2 ,1
56 log.debug = core, input56 log.debug = core, input
57+output.DP-2.scale = 4/3
57 output.DP-2 = 5120x2160@165.05858 output.DP-2 = 5120x2160@165.058
58 output.HDMI-A-1 = off59 output.HDMI-A-1 = off
59 output.DP-1 = preferred60 output.DP-1 = preferred
61+output.DP-1.scale = 1.5
62+bind.cmd+code:30 = quit
60 render.direct-scanout = off63 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 {
315315 }
316316
317317 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)) }
318325 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
325335 }
326336 var uploads []upload
327337 used := 0
328338 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) {
330340 rect = rect.Intersect(bounds)
331341 if rect.Empty() {
332342 return
333343 }
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
339368 }
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)
342372 }
343373 fullscreen := false
344374 for _, w := range s.Windows {
@@ -352,12 +382,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
352382 continue
353383 }
354384 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)
361386 }
362387 }
363388 addLayers(false)
@@ -372,16 +397,13 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
372397 b = min(max(s.Border.Width, 0), w.Rect.W/2, w.Rect.H/2)
373398 }
374399 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)
376401 content := contents[w.ID]
377402 if content.Pixels == nil {
378- add(body, windowColor(w.ID), nil, image.Point{})
403+ add(body, windowColor(w.ID))
379404 } 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)
385407 }
386408 borderColor := s.Border.Inactive
387409 if w.Focused {
@@ -389,8 +411,14 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
389411 }
390412 if b > 0 && borderColor != "" {
391413 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)
394422 }
395423 }
396424 }
@@ -404,8 +432,19 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
404432 for row := 0; row < u.rect.Dy(); row++ {
405433 line := dst[row*u.rect.Dx()*4 : (row+1)*u.rect.Dx()*4]
406434 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+ }
409448 if u.opaque {
410449 for i := 3; i < len(line); i += 4 {
411450 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.Rectangle326+ rect image.Rectangle
320- offset int327+ offset int
321- pixels []byte328+ pixels []byte
322- stride, sourceX, sourceY int329+ stride int
323- color [3]uint8330+ // dst covers src; they differ in size when the buffer is not drawn at
324- opaque bool331+ // the physical size (integer-scale clients, rounding).
332+ dst, src image.Rectangle
333+ color [3]uint8
334+ opaque bool
325 }335 }
326 var uploads []upload336 var uploads []upload
327 used := 0337 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 return342 return
333 }343 }
334- n := rect.Dx() * rect.Dy() * 4344+ 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.Opaque347+ // addContent maps src (buffer pixels) onto dst (physical pixels).
338- u.sourceX, u.sourceY = rect.Min.X-origin.X, rect.Min.Y-origin.Y348+ 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 += n370+ 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 := false373 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 continue382 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*b399 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.Inactive408 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*4435+ 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] = 255450 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 {
1212 for _, register := range []func() error{
1313 func() error { return registerXDG(d, s) },
1414 func() error { return registerLayer(d, o, s) },
15- func() error { return registerXDGOutput(d, o) },
15+ func() error { return registerXDGOutput(d, o, s) },
1616 func() error { return registerDecoration(d, s) },
17+ func() error { return registerScale(d, s) },
1718 func() error {
1819 return wayland.NewCompositorGlobal(d, 6, func(c server.Client, v, id uint32) { wayland.NewCompositor(c, int32(v), id, compositor{s}) })
1920 },
@@ -37,11 +38,14 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
3738 if e != nil {
3839 return
3940 }
41+ s.scale.outputs = append(s.scale.outputs, r)
42+ r.OnDestroy = func() { s.scale.outputs = removeItem(s.scale.outputs, r) }
4043 info := o.output()
4144 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.
4246 r.SendMode(3, int32(o.OutputWidth), int32(o.OutputHeight), int32(info.RefreshMilli))
4347 if v >= 2 {
44- r.SendScale(1)
48+ r.SendScale(s.scale.integerScale())
4549 }
4650 if v >= 4 {
4751 r.SendName(info.Name)
@@ -81,9 +85,11 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
8185 type compositor struct{ server *Server }
8286
8387 func (c compositor) CreateSurface(r *wayland.Compositor, id uint32) {
84- state := &surface{server: c.server}
88+ state := &surface{server: c.server, bufferScale: 1}
8589 if w, err := wayland.NewSurface(r.Client(), r.Version(), id, state); err == nil {
90+ state.wl = w
8691 c.server.surfaces[w.Resource] = state
92+ c.server.sendSurfaceScale(w)
8793 w.OnDestroy = func() { delete(c.server.surfaces, w.Resource); state.Destroy(w) }
8894 }
8995 }
@@ -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 return39 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] = state91 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 (
99 "sort"
1010 )
1111
12-type layerShell struct {
13- server *Server
14- width, height uint32
15-}
12+type layerShell struct{ server *Server }
1613 type layerState struct {
1714 width, height, anchor uint32
1815 zone int32
@@ -33,7 +30,7 @@ type layerSurface struct {
3330
3431 func registerLayer(d *server.Display, o Options, s *Server) error {
3532 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})
3734 })
3835 }
3936 func (h layerShell) Destroy(*wlrlayershell.ZwlrLayerShellV1) {}
@@ -142,7 +139,7 @@ func (s *Server) layerChanged() {
142139 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}
143140 if l.surface.current != nil {
144141 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)
146143 }
147144 }
148145 list = append(list, v)
@@ -165,12 +162,13 @@ func (l *layerSurface) commit(buffer bool) {
165162 sizeChanged := p.width != l.current.width || p.height != l.current.height
166163 l.current = p
167164 if !l.configured || sizeChanged {
165+ // Zero means "fill": the logical output size.
168166 w, h := p.width, p.height
169167 if w == 0 {
170- w = l.shell.width
168+ w = uint32(l.shell.server.scale.width)
171169 }
172170 if h == 0 {
173- h = l.shell.height
171+ h = uint32(l.shell.server.scale.height)
174172 }
175173 l.shell.server.serial++
176174 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 uint3214 width, height, anchor uint32
18 zone int3215 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.height142+ 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.height162 sizeChanged := p.width != l.current.width || p.height != l.current.height
166 l.current = p163 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.height166 w, h := p.width, p.height
169 if w == 0 {167 if w == 0 {
170- w = l.shell.width168+ w = uint32(l.shell.server.scale.width)
171 }169 }
172 if h == 0 {170 if h == 0 {
173- h = l.shell.height171+ 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 (
99 "time"
1010
1111 "github.com/bnema/nefertty/internal/ports"
12+ "github.com/bnema/purego-libwayland/protocol/fractionalscale"
1213 "github.com/bnema/purego-libwayland/protocol/wayland"
1314 "github.com/bnema/purego-libwayland/server"
1415 "github.com/bnema/zerowrap"
@@ -90,6 +91,16 @@ type Server struct {
9091 contentSeq map[ports.WindowID]uint64
9192 contentNotify chan struct{}
9293 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
93104 }
94105
95106 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) {
115126 return nil, err
116127 }
117128 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{}}
118130 if opts.Keymap != "" {
119131 fd, size, e := keymapFile(opts.Keymap)
120132 if e != nil {
@@ -371,6 +383,8 @@ func (s *Server) apply(cmd ports.ClientCommand) {
371383 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")
372384 w.last, w.hasLast = c, true
373385 w.sendConfigure()
386+ case ports.SetOutputScale:
387+ s.setOutputScale(c)
374388 case ports.CloseWindow:
375389 w := s.windows[c.ID]
376390 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]uint6491 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, err126 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, true384 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 (
1717 )
1818
1919 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
3135 }
3236
3337 func (s *surface) Destroy(*wayland.Surface) {
@@ -61,6 +65,12 @@ func (s *surface) Commit(*wayland.Surface) {
6165 s.xdg.resource.PostError(uint32(xdgshell.SurfaceErrorUnconfiguredBuffer), "buffer before initial configure ack")
6266 return
6367 }
68+ if s.pendingScale > 0 {
69+ s.bufferScale = s.pendingScale
70+ }
71+ if s.viewport != nil {
72+ s.viewport.commit()
73+ }
6474 if s.attached {
6575 if s.current != nil && (s.pending == nil || s.current.Resource != s.pending.Resource) {
6676 if !s.released && s.current.Resource.Alive() {
@@ -88,6 +98,7 @@ func (s *surface) Commit(*wayland.Surface) {
8898 b := s.current
8999 if state, ok := s.server.buffers[b.Resource]; ok {
90100 if c, ok := state.content(id); ok {
101+ c.LogicalW, c.LogicalH = s.logicalSize(c.Width, c.Height)
91102 if c.Width != s.lastW || c.Height != s.lastH {
92103 s.lastW, s.lastH = c.Width, c.Height
93104 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) {}
108119 func (*surface) SetOpaqueRegion(*wayland.Surface, *wayland.Region) {}
109120 func (*surface) SetInputRegion(*wayland.Surface, *wayland.Region) {}
110121 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 roleKind20+ wl *wayland.Surface
21- xdg *xdgSurface21+ viewport *viewport
22- layer *layerSurface22+ // bufferScale is committed state; pendingScale is set by set_buffer_scale.
23- server *Server23+ bufferScale, pendingScale int
24- current, pending *wayland.Buffer24+ kind roleKind
25- attached bool25+ xdg *xdgSurface
26- lastW, lastH int // last logged buffer size26+ layer *layerSurface
27- callbacks []*wayland.Callback27+ server *Server
28- role func(bool)28+ current, pending *wayland.Buffer
29- destroyed bool29+ attached bool
30- released bool30+ 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 return66 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.current98 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.Height103 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 (
77 )
88
99 type xdgOutputManager struct {
10- width, height int32
11- info OutputInfo
10+ server *Server
11+ info OutputInfo
1212 }
1313
14-func registerXDGOutput(d *server.Display, o Options) error {
14+func registerXDGOutput(d *server.Display, o Options, s *Server) error {
1515 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()})
1717 })
1818 }
1919 func (xdgOutputManager) Destroy(*xdgoutput.ZxdgOutputManagerV1) {}
@@ -22,8 +22,11 @@ func (m xdgOutputManager) GetXdgOutput(r *xdgoutput.ZxdgOutputManagerV1, id uint
2222 if err != nil {
2323 return
2424 }
25+ st := &m.server.scale
26+ st.xdgOutputs = append(st.xdgOutputs, x)
27+ x.OnDestroy = func() { st.xdgOutputs = removeItem(st.xdgOutputs, x) }
2528 x.SendLogicalPosition(0, 0)
26- x.SendLogicalSize(m.width, m.height)
29+ x.SendLogicalSize(int32(st.width), int32(st.height))
2730 if r.Version() >= 2 {
2831 x.SendName(m.info.Name)
2932 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 int3210+ server *Server
11- info OutputInfo11+ 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 return23 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
141141 event := ports.KeyEvent{Keysym: name, Pressed: pressed, TimeMsec: timeMsec, Keycode: evdevCode}
142142 if pressed {
143143 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)
145145 }
146146 event.State = ports.ModState{
147147 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) {
5454 }
5555
5656 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)
6162 }
6263 if ev := k.Key(2, false, 0); ev.Base != "" {
6364 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)
8383 if err != nil {
8484 return err
8585 }
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}
8791 km, err := xkb.New(xkb.RMLVO{Layout: opts.Config.Keyboard.Layout, Variant: opts.Config.Keyboard.Variant, Options: opts.Config.Keyboard.Options})
8892 if err != nil {
8993 return err
@@ -296,7 +300,7 @@ func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors
296300 default:
297301 }
298302 }
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)
300304 rects := make([]string, 0, len(s.Windows))
301305 for _, w := range s.Windows {
302306 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 err84 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 err93 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 (
2424 ActionFocusWorkspaceDown Action = "focus-workspace-down"
2525 ActionMoveToWorkspaceUp Action = "move-to-workspace-up"
2626 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 "
2932 )
3033
3134 // 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 {
4444 layers []ports.LayerSurface
4545 placed []ports.SceneLayer
4646 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
4753 }
4854
4955 func keyName(s string) string {
@@ -117,6 +123,10 @@ func (c *Core) apply(cfg ports.Config) error {
117123 binds[b] = Action(a)
118124 continue
119125 }
126+ if Action(a) == ActionScaleUp || Action(a) == ActionScaleDown {
127+ binds[b] = Action(a)
128+ continue
129+ }
120130 if _, _, ok := WorkspaceArg(Action(a)); ok {
121131 binds[b] = Action(a)
122132 continue
@@ -130,6 +140,7 @@ func (c *Core) apply(cfg ports.Config) error {
130140 }
131141 c.cfg = cfg
132142 c.binds = binds
143+ c.applyConfigScale()
133144 c.ws.SetMaxColumns(cfg.Layout.MaxColumns)
134145 c.ws.SetPresets(presets)
135146 c.ws.SetGaps(cfg.Layout.Gaps)
@@ -139,7 +150,7 @@ func New(cfg ports.Config, ch Channels) (*Core, error) {
139150 if cap(ch.Scenes) != 1 {
140151 return nil, fmt.Errorf("scenes must have capacity 1")
141152 }
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{}}
143154 if err := c.apply(cfg); err != nil {
144155 return nil, err
145156 }
@@ -154,6 +165,33 @@ func (c *Core) command(ctx context.Context, v ports.ClientCommand) error {
154165 }
155166 }
156167
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+
157195 // clientRect is the placement minus the border drawn around the client.
158196 // The renderer applies the same inset (ports.Border).
159197 func (c *Core) clientRect(p Placement) Rect {
@@ -193,8 +231,15 @@ func (c *Core) keyboardFocus() WindowID {
193231 }
194232
195233 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+ }
196241 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...)}
198243 alive := map[WindowID]bool{}
199244 focus := c.keyboardFocus()
200245 for _, p := range c.ws.Layout() {
@@ -295,12 +340,12 @@ func (c *Core) Run(ctx context.Context) error {
295340 }
296341 switch v := ev.(type) {
297342 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
303346 }
347+ c.applyConfigScale()
348+ c.setScale(c.scale)
304349 case ports.OutputUsable:
305350 // Once layer state arrives, its exclusive zones take precedence.
306351 if !c.layerChanged {
@@ -332,8 +377,9 @@ func (c *Core) Run(ctx context.Context) error {
332377 }
333378 switch v := ev.(type) {
334379 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)))
337383 var id WindowID
338384 var x, y float64
339385 // Fullscreen wins; otherwise the last visible placement is topmost.
@@ -394,15 +440,14 @@ func (c *Core) Run(ctx context.Context) error {
394440 continue
395441 }
396442 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).
397445 action, bound := c.binds[binding{key: name, mods: key.Mods}]
398- // Cmd+Shift+1 on US prints exclam: match the unshifted keysym.
399446 if !bound && key.Base != "" {
400447 action, bound = c.binds[binding{key: keyName(key.Base), mods: key.Mods}]
401448 }
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}]
406451 }
407452 // Track presses by physical key: Shift may be released before the key.
408453 held := name
@@ -419,6 +464,17 @@ func (c *Core) Run(ctx context.Context) error {
419464 if key.Pressed {
420465 if bound {
421466 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+ }
422478 effect := c.ws.Apply(action)
423479 if effect.Quit {
424480 return ErrQuit
@@ -44,6 +44,12 @@ type Core struct {
44 layers []ports.LayerSurface44 layers []ports.LayerSurface
45 placed []ports.SceneLayer45 placed []ports.SceneLayer
46 layerChanged bool46 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 continue124 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 continue132 continue
@@ -130,6 +140,7 @@ func (c *Core) apply(cfg ports.Config) error {
130 }140 }
131 c.cfg = cfg141 c.cfg = cfg
132 c.binds = binds142 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, err155 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.Rect345+ 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 WindowID383 var id WindowID
338 var x, y float64384 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 continue440 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 := name453 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] = true466 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 ErrQuit480 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 {
2121 return zero
2222 }
2323 }
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+
2437 func TestOwner(t *testing.T) {
2538 cfg := config.Defaults()
2639 cfg.Keyboard.CmdKey = "alt"
@@ -53,10 +66,10 @@ func TestOwner(t *testing.T) {
5366 if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || !s.Windows[0].Borderless {
5467 t.Fatal(s)
5568 }
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}) {
5770 t.Fatal(v)
5871 }
59- receive(t, commands)
72+ command(t, commands)
6073 client <- ports.WindowMapped{ID: 2}
6174 s = receive(t, scenes)
6275 if len(s.Windows) != 2 {
@@ -78,12 +91,12 @@ func TestOwner(t *testing.T) {
7891 }
7992 input <- ports.KeyEvent{Keysym: "Return", Mods: ports.ModAlt}
8093 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}}) {
8295 t.Fatal(v)
8396 }
8497 input <- ports.KeyEvent{Keysym: "Q", Mods: ports.ModAlt, Pressed: true}
8598 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}) {
87100 t.Fatal(v)
88101 }
89102 client <- ports.WindowUnmapped{ID: 2}
@@ -97,7 +110,7 @@ func TestOwner(t *testing.T) {
97110 if s.Windows[0].Rect.W != 92 {
98111 t.Fatal(s)
99112 }
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}) {
101114 t.Fatal(v)
102115 }
103116 cfg.Layout.MaxColumns = 0
@@ -145,26 +158,26 @@ func TestKeyPressState(t *testing.T) {
145158 defer func() { cancel(); receive(t, done) }()
146159 client <- ports.WindowMapped{ID: 1}
147160 receive(t, scenes)
148- receive(t, commands)
149- receive(t, commands)
161+ command(t, commands)
162+ command(t, commands)
150163 press := ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true}
151164 input <- press
152165 receive(t, scenes)
153166 input <- ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper}
154167 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}}) {
156169 t.Fatal(v)
157170 }
158171 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"}}) {
160173 t.Fatal(v)
161174 }
162175 input <- press
163176 receive(t, scenes)
164177 input <- ports.KeyEvent{Keysym: "Left", Pressed: true}
165- receive(t, commands)
178+ command(t, commands)
166179 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"}}) {
168181 t.Fatal(v)
169182 }
170183 }
@@ -185,13 +198,13 @@ func TestBoundReleaseSwallowed(t *testing.T) {
185198 defer func() { cancel(); receive(t, done) }()
186199 client <- ports.WindowMapped{ID: 1}
187200 receive(t, scenes)
188- receive(t, commands)
189- receive(t, commands)
201+ command(t, commands)
202+ command(t, commands)
190203 input <- ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true}
191204 receive(t, scenes)
192205 input <- ports.KeyEvent{Keysym: "Left"}
193206 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}}) {
195208 t.Fatal(v)
196209 }
197210 }
@@ -222,14 +235,14 @@ func TestPointerFocusAndGrab(t *testing.T) {
222235 <-commands
223236 }
224237 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}) {
226239 t.Fatal(v)
227240 }
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}) {
229242 t.Fatal(v)
230243 }
231244 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}) {
233246 t.Fatal(v)
234247 }
235248 receive(t, scenes)
@@ -243,11 +256,11 @@ func TestPointerFocusAndGrab(t *testing.T) {
243256 t.Fatal("missing keyboard focus")
244257 }
245258 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{}) {
247260 t.Fatal(v)
248261 }
249262 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}) {
251264 t.Fatal(v)
252265 }
253266 }
@@ -272,7 +285,7 @@ func TestBorderInset(t *testing.T) {
272285 // Alone, the window is borderless and gets the full rect.
273286 client <- ports.WindowMapped{ID: 1}
274287 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}) {
276289 t.Fatalf("%v for outer %v", v, r)
277290 }
278291 // With a second column, the scene keeps the outer rect and the client is
@@ -294,7 +307,7 @@ func TestBorderInset(t *testing.T) {
294307 t.Fatalf("no %v for outer %v", want, r)
295308 }
296309 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}) {
298311 t.Fatal(v)
299312 }
300313 }
@@ -320,7 +333,7 @@ func TestLayerKeyboardFocus(t *testing.T) {
320333 focusOf := func() ports.WindowID {
321334 t.Helper()
322335 for {
323- if v, ok := receive(t, commands).(ports.FocusWindow); ok {
336+ if v, ok := command(t, commands).(ports.FocusWindow); ok {
324337 return v.ID
325338 }
326339 }
@@ -338,7 +351,7 @@ func TestLayerKeyboardFocus(t *testing.T) {
338351 }
339352 input <- ports.KeyEvent{Keysym: "a", Keycode: 30, Pressed: true}
340353 for {
341- if v, ok := receive(t, commands).(ports.ForwardKey); ok {
354+ if v, ok := command(t, commands).(ports.ForwardKey); ok {
342355 if v.ID != 7 {
343356 t.Fatalf("key went to %d", v.ID)
344357 }
@@ -373,8 +386,8 @@ func TestWorkspaceSwitch(t *testing.T) {
373386 for len(commands) > 0 {
374387 <-commands
375388 }
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}
378391 s := receive(t, scenes)
379392 if len(s.Windows) != 1 || !s.Windows[0].Hidden {
380393 t.Fatal(s)
@@ -470,13 +483,84 @@ func TestShiftReleasedFirst(t *testing.T) {
470483 for len(commands) > 0 {
471484 <-commands
472485 }
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}
475488 receive(t, scenes)
476489 // Shift goes up first, so the release reports "1": still swallowed.
477490 input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModSuper}
478491 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}) {
480536 t.Fatal(v)
481537 }
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+ }
482566 }
@@ -21,6 +21,19 @@ func receive[T any](t *testing.T, ch <-chan T) T {
21 return zero21 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 = 0116 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 <- press164 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 <- press175 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 <-commands235 <-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].Rect287 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 is291 // 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.ID337 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 <-commands387 <-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 <-commands484 <-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
55 // monitor's preferred mode. Off disables the connector.
66 // The first listed, connected, enabled output is used; without a match, the first
77 // connected output that is not off.
8+// Scale is the output scale (0 means 1); layout works in logical pixels,
9+// physical = logical × Scale.
810 type OutputConfig struct {
9- Name string
10- Mode string
11- Off bool
11+ Name string
12+ Mode string
13+ Off bool
14+ Scale float64
1215 }
1316
1417 // 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 first6 // 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 string11+ Name string
10- Mode string12+ Mode string
11- Off bool13+ 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 (
4343 )
4444
4545 // 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.
4747 // IDs share the WindowID space with windows and never collide.
4848 type LayerSurface struct {
4949 ID WindowID
@@ -81,14 +81,14 @@ type InputEvent interface{ inputEvent() }
8181 // after this transition; wayland forwards both to clients unchanged.
8282 type KeyEvent struct {
8383 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
9292 }
9393
9494 // ModState is the serialized xkb modifier state sent in wl_keyboard.modifiers.
@@ -96,7 +96,7 @@ type ModState struct{ Depressed, Latched, Locked, Group uint32 }
9696
9797 func (KeyEvent) inputEvent() {}
9898
99-// PointerMotion uses absolute output coordinates.
99+// PointerMotion uses absolute output coordinates in physical pixels.
100100 type PointerMotion struct {
101101 X, Y float64
102102 TimeMsec uint32
@@ -116,8 +116,12 @@ func (PointerButton) inputEvent() {}
116116 // OutputEvent carries output → core notifications.
117117 type OutputEvent interface{ outputEvent() }
118118
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+}
121125
122126 func (OutputMode) outputEvent() {}
123127
@@ -141,6 +145,15 @@ type ConfigureWindow struct {
141145
142146 func (ConfigureWindow) clientCommand() {}
143147
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+
144157 // CloseWindow carries core → wayland close requests.
145158 type CloseWindow struct{ ID WindowID }
146159
@@ -197,9 +210,11 @@ func (SetKeymap) clientCommand() {}
197210 type SpawnRequest struct{ Argv []string }
198211
199212 // Scene carries core → renderer immutable snapshots with fresh Windows slices.
213+// Rects and the output size are logical; the renderer multiplies by Scale.
200214 type Scene struct {
201215 Seq uint64
202216 OutputWidth, OutputHeight int
217+ Scale float64
203218 Background string
204219 Border Border
205220 Windows []SceneWindow
@@ -224,13 +239,15 @@ type SceneLayer struct {
224239 // SurfaceContent carries wayland → output the latest committed pixels of a
225240 // window. Pixels is a private copy in B8G8R8A8 (wl_shm argb8888/xrgb8888
226241 // 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).
228244 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
234251 }
235252
236253 // 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 and45 // 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 WindowID49 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 string83 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 string86+ Base string
87- Mods Mods87+ Mods Mods
88- Pressed bool88+ Pressed bool
89- TimeMsec uint3289+ TimeMsec uint32
90- Keycode uint3290+ Keycode uint32
91- State ModState91+ 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 float64101 X, Y float64
102 TimeMsec uint32102 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 uint64215 Seq uint64
202 OutputWidth, OutputHeight int216 OutputWidth, OutputHeight int
217+ Scale float64
203 Background string218 Background string
204 Border Border219 Border Border
205 Windows []SceneWindow220 Windows []SceneWindow
@@ -224,13 +239,15 @@ type SceneLayer struct {
224 // SurfaceContent carries wayland → output the latest committed pixels of a239 // SurfaceContent carries wayland → output the latest committed pixels of a
225 // window. Pixels is a private copy in B8G8R8A8 (wl_shm argb8888/xrgb8888240 // 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 WindowID245+ ID WindowID
230- Width, Height int246+ Width, Height int
231- Stride int247+ LogicalW, LogicalH int
232- Opaque bool // xrgb8888: ignore the alpha byte248+ Stride int
233- Pixels []byte249+ 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.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close