fix(scale): configure layers from committed state, keep output choice, copy near-size buffers
Scale changes re-configure fill-sized layers from committed state instead of replaying pending requests. A scale-only output line no longer selects the connector. Buffers within 1px of their physical slot are copied 1:1 and clipped instead of resampled. A user bind on a digit replaces the default bound to the same physical key.
- commit
- 777e1e0dedbf2ff7131dc1e647017e9b76f7b4c4
- author
- brice · · 2026-09-24 15:03 UTC
- parent
- 8b505f8
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +157 -22
M internal/adapters/config/config.go +21 -1
| @@ -187,7 +187,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||
| 187 | 187 | return &c.Outputs[i] | |
| 188 | 188 | } | |
| 189 | 189 | outputs[name] = len(c.Outputs) | |
| 190 | - | c.Outputs = append(c.Outputs, ports.OutputConfig{Name: name}) | |
| 190 | + | c.Outputs = append(c.Outputs, ports.OutputConfig{Name: name, ScaleOnly: true}) | |
| 191 | 191 | return &c.Outputs[len(c.Outputs)-1] | |
| 192 | 192 | } | |
| 193 | 193 | if base, ok := strings.CutSuffix(name, ".scale"); ok { | |
| @@ -225,6 +225,26 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||
| 225 | 225 | if err := scanner.Err(); err != nil { | |
| 226 | 226 | return c, raw, warnings, err | |
| 227 | 227 | } | |
| 228 | + | // A user bind on a digit (cmd+1, even "none") replaces the default bound | |
| 229 | + | // to the same physical key (cmd+code:2), so older configs keep working. | |
| 230 | + | for combo := range c.Binds { | |
| 231 | + | if seen["bind."+combo] == 0 { | |
| 232 | + | continue | |
| 233 | + | } | |
| 234 | + | parts := strings.Split(combo, "+") | |
| 235 | + | key := parts[len(parts)-1] | |
| 236 | + | if len(key) != 1 || key[0] < '0' || key[0] > '9' { | |
| 237 | + | continue | |
| 238 | + | } | |
| 239 | + | code := int(key[0]-'0') + 1 | |
| 240 | + | if key == "0" { | |
| 241 | + | code = 11 | |
| 242 | + | } | |
| 243 | + | parts[len(parts)-1] = "code:" + strconv.Itoa(code) | |
| 244 | + | if physical := strings.Join(parts, "+"); seen["bind."+physical] == 0 { | |
| 245 | + | delete(c.Binds, physical) | |
| 246 | + | } | |
| 247 | + | } | |
| 228 | 248 | // "none" only removes a default bind. | |
| 229 | 249 | for combo, a := range c.Binds { | |
| 230 | 250 | if a == "none" { | |
| @@ -187,7 +187,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||||
| 187 | return &c.Outputs[i] | 187 | return &c.Outputs[i] | ||
| 188 | } | 188 | } | ||
| 189 | outputs[name] = len(c.Outputs) | 189 | outputs[name] = len(c.Outputs) | ||
| 190 | - | c.Outputs = append(c.Outputs, ports.OutputConfig{Name: name}) | 190 | + | c.Outputs = append(c.Outputs, ports.OutputConfig{Name: name, ScaleOnly: true}) |
| 191 | return &c.Outputs[len(c.Outputs)-1] | 191 | return &c.Outputs[len(c.Outputs)-1] | ||
| 192 | } | 192 | } | ||
| 193 | if base, ok := strings.CutSuffix(name, ".scale"); ok { | 193 | if base, ok := strings.CutSuffix(name, ".scale"); ok { | ||
| @@ -225,6 +225,26 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||||
| 225 | if err := scanner.Err(); err != nil { | 225 | if err := scanner.Err(); err != nil { | ||
| 226 | return c, raw, warnings, err | 226 | return c, raw, warnings, err | ||
| 227 | } | 227 | } | ||
| 228 | + | // A user bind on a digit (cmd+1, even "none") replaces the default bound | |||
| 229 | + | // to the same physical key (cmd+code:2), so older configs keep working. | |||
| 230 | + | for combo := range c.Binds { | |||
| 231 | + | if seen["bind."+combo] == 0 { | |||
| 232 | + | continue | |||
| 233 | + | } | |||
| 234 | + | parts := strings.Split(combo, "+") | |||
| 235 | + | key := parts[len(parts)-1] | |||
| 236 | + | if len(key) != 1 || key[0] < '0' || key[0] > '9' { | |||
| 237 | + | continue | |||
| 238 | + | } | |||
| 239 | + | code := int(key[0]-'0') + 1 | |||
| 240 | + | if key == "0" { | |||
| 241 | + | code = 11 | |||
| 242 | + | } | |||
| 243 | + | parts[len(parts)-1] = "code:" + strconv.Itoa(code) | |||
| 244 | + | if physical := strings.Join(parts, "+"); seen["bind."+physical] == 0 { | |||
| 245 | + | delete(c.Binds, physical) | |||
| 246 | + | } | |||
| 247 | + | } | |||
| 228 | // "none" only removes a default bind. | 248 | // "none" only removes a default bind. | ||
| 229 | for combo, a := range c.Binds { | 249 | for combo, a := range c.Binds { | ||
| 230 | if a == "none" { | 250 | if a == "none" { | ||
M internal/adapters/config/config_test.go +16 -0
| @@ -217,3 +217,19 @@ func TestParseMode(t *testing.T) { | |||
| 217 | 217 | t.Fatal(hz, err) | |
| 218 | 218 | } | |
| 219 | 219 | } | |
| 220 | + | ||
| 221 | + | func TestDigitBindReplacesPhysicalDefault(t *testing.T) { | |
| 222 | + | c, w := parseString(t, "bind.cmd+1 = none\nbind.cmd+shift+2 = quit\n") | |
| 223 | + | if len(w) != 0 { | |
| 224 | + | t.Fatal(w) | |
| 225 | + | } | |
| 226 | + | if _, ok := c.Binds["Cmd+code:2"]; ok { | |
| 227 | + | t.Fatal("default cmd+code:2 kept") | |
| 228 | + | } | |
| 229 | + | if _, ok := c.Binds["Cmd+Shift+code:3"]; ok || c.Binds["Cmd+Shift+2"] != "quit" { | |
| 230 | + | t.Fatal(c.Binds) | |
| 231 | + | } | |
| 232 | + | if c.Binds["Cmd+code:4"] != "focus-workspace 3" { | |
| 233 | + | t.Fatal("other defaults must stay") | |
| 234 | + | } | |
| 235 | + | } | |
| @@ -217,3 +217,19 @@ func TestParseMode(t *testing.T) { | |||||
| 217 | t.Fatal(hz, err) | 217 | t.Fatal(hz, err) | ||
| 218 | } | 218 | } | ||
| 219 | } | 219 | } | ||
| 220 | + | ||||
| 221 | + | func TestDigitBindReplacesPhysicalDefault(t *testing.T) { | |||
| 222 | + | c, w := parseString(t, "bind.cmd+1 = none\nbind.cmd+shift+2 = quit\n") | |||
| 223 | + | if len(w) != 0 { | |||
| 224 | + | t.Fatal(w) | |||
| 225 | + | } | |||
| 226 | + | if _, ok := c.Binds["Cmd+code:2"]; ok { | |||
| 227 | + | t.Fatal("default cmd+code:2 kept") | |||
| 228 | + | } | |||
| 229 | + | if _, ok := c.Binds["Cmd+Shift+code:3"]; ok || c.Binds["Cmd+Shift+2"] != "quit" { | |||
| 230 | + | t.Fatal(c.Binds) | |||
| 231 | + | } | |||
| 232 | + | if c.Binds["Cmd+code:4"] != "focus-workspace 3" { | |||
| 233 | + | t.Fatal("other defaults must stay") | |||
| 234 | + | } | |||
| 235 | + | } | |||
M internal/adapters/vulkan/renderer.go +18 -1
| @@ -368,7 +368,24 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||
| 368 | 368 | } | |
| 369 | 369 | // Buffer pixels per logical pixel on each axis. | |
| 370 | 370 | src := image.Rect(0, 0, vw*content.Width/lw, vh*content.Height/lh) | |
| 371 | - | addContent(physRect(x, y, vw, vh), src, content) | |
| 371 | + | dst := physRect(x, y, vw, vh) | |
| 372 | + | // A buffer drawn at the physical size (fractional-scale clients round | |
| 373 | + | // w*scale, we round per edge) is copied 1:1, never resampled for 1px. | |
| 374 | + | near := func(a, b int) bool { return a-b <= 1 && b-a <= 1 } | |
| 375 | + | if near(src.Dx(), dst.Dx()) && near(src.Dy(), dst.Dy()) { | |
| 376 | + | dst.Max = dst.Min.Add(src.Size()) | |
| 377 | + | // Clip to the body: the buffer may be 1px wider than the slot. | |
| 378 | + | clip := physRect(x, y, w, h) | |
| 379 | + | if dst.Max.X > clip.Max.X { | |
| 380 | + | src.Max.X -= dst.Max.X - clip.Max.X | |
| 381 | + | dst.Max.X = clip.Max.X | |
| 382 | + | } | |
| 383 | + | if dst.Max.Y > clip.Max.Y { | |
| 384 | + | src.Max.Y -= dst.Max.Y - clip.Max.Y | |
| 385 | + | dst.Max.Y = clip.Max.Y | |
| 386 | + | } | |
| 387 | + | } | |
| 388 | + | addContent(dst, src, content) | |
| 372 | 389 | } | |
| 373 | 390 | fullscreen := false | |
| 374 | 391 | for _, w := range s.Windows { | |
| @@ -368,7 +368,24 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||||
| 368 | } | 368 | } | ||
| 369 | // Buffer pixels per logical pixel on each axis. | 369 | // Buffer pixels per logical pixel on each axis. | ||
| 370 | src := image.Rect(0, 0, vw*content.Width/lw, vh*content.Height/lh) | 370 | src := image.Rect(0, 0, vw*content.Width/lw, vh*content.Height/lh) | ||
| 371 | - | addContent(physRect(x, y, vw, vh), src, content) | 371 | + | dst := physRect(x, y, vw, vh) |
| 372 | + | // A buffer drawn at the physical size (fractional-scale clients round | |||
| 373 | + | // w*scale, we round per edge) is copied 1:1, never resampled for 1px. | |||
| 374 | + | near := func(a, b int) bool { return a-b <= 1 && b-a <= 1 } | |||
| 375 | + | if near(src.Dx(), dst.Dx()) && near(src.Dy(), dst.Dy()) { | |||
| 376 | + | dst.Max = dst.Min.Add(src.Size()) | |||
| 377 | + | // Clip to the body: the buffer may be 1px wider than the slot. | |||
| 378 | + | clip := physRect(x, y, w, h) | |||
| 379 | + | if dst.Max.X > clip.Max.X { | |||
| 380 | + | src.Max.X -= dst.Max.X - clip.Max.X | |||
| 381 | + | dst.Max.X = clip.Max.X | |||
| 382 | + | } | |||
| 383 | + | if dst.Max.Y > clip.Max.Y { | |||
| 384 | + | src.Max.Y -= dst.Max.Y - clip.Max.Y | |||
| 385 | + | dst.Max.Y = clip.Max.Y | |||
| 386 | + | } | |||
| 387 | + | } | |||
| 388 | + | addContent(dst, src, content) | |||
| 372 | } | 389 | } | ||
| 373 | fullscreen := false | 390 | fullscreen := false | ||
| 374 | for _, w := range s.Windows { | 391 | for _, w := range s.Windows { | ||
M internal/adapters/vulkan/renderer_test.go +60 -0
| @@ -187,3 +187,63 @@ func TestRendererLayers(t *testing.T) { | |||
| 187 | 187 | delete(contents, 2) | |
| 188 | 188 | check(color.RGBA{16, 32, 48, 255}) | |
| 189 | 189 | } | |
| 190 | + | ||
| 191 | + | // solid returns a w×h B8G8R8A8 buffer of one color. | |
| 192 | + | func solid(w, h int, c [3]uint8) []byte { | |
| 193 | + | p := make([]byte, w*h*4) | |
| 194 | + | for i := 0; i < len(p); i += 4 { | |
| 195 | + | p[i], p[i+1], p[i+2], p[i+3] = c[2], c[1], c[0], 255 | |
| 196 | + | } | |
| 197 | + | return p | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func TestRendererScale(t *testing.T) { | |
| 201 | + | r, err := New(64, 48) | |
| 202 | + | if err != nil { | |
| 203 | + | t.Skipf("Vulkan unavailable: %v", err) | |
| 204 | + | } | |
| 205 | + | defer r.Close() | |
| 206 | + | red := [3]uint8{200, 0, 0} | |
| 207 | + | // Scale 2: a 10x10 logical window at (4,4) covers physical (8,8)-(28,28); | |
| 208 | + | // its 20x20 buffer (fractional client) is copied 1:1. | |
| 209 | + | s := ports.Scene{Scale: 2, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: 10, H: 10}, Borderless: true}}} | |
| 210 | + | content := map[ports.WindowID]ports.SurfaceContent{1: {ID: 1, Width: 20, Height: 20, LogicalW: 10, LogicalH: 10, Stride: 80, Pixels: solid(20, 20, red)}} | |
| 211 | + | if err := r.Render(s, content); err != nil { | |
| 212 | + | t.Fatal(err) | |
| 213 | + | } | |
| 214 | + | px := r.Pixels() | |
| 215 | + | want := color.RGBA{200, 0, 0, 255} | |
| 216 | + | for _, p := range []image.Point{{8, 8}, {27, 27}} { | |
| 217 | + | if got := px.At(p.X, p.Y); got != want { | |
| 218 | + | t.Errorf("%v = %v", p, got) | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | for _, p := range []image.Point{{7, 7}, {28, 28}} { | |
| 222 | + | if got := px.At(p.X, p.Y); got != (color.RGBA{0, 0, 0, 255}) { | |
| 223 | + | t.Errorf("outside %v = %v", p, got) | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | // Scale 1.5 with an integer-scale client (buffer scale 2): the 20x20 | |
| 227 | + | // buffer is 10x10 logical and downscaled to 15x15 physical at (6,6). | |
| 228 | + | s.Scale = 1.5 | |
| 229 | + | if err := r.Render(s, content); err != nil { | |
| 230 | + | t.Fatal(err) | |
| 231 | + | } | |
| 232 | + | px = r.Pixels() | |
| 233 | + | for _, p := range []image.Point{{6, 6}, {20, 20}} { | |
| 234 | + | if got := px.At(p.X, p.Y); got != want { | |
| 235 | + | t.Errorf("1.5 %v = %v", p, got) | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | if got := px.At(21, 21); got != (color.RGBA{0, 0, 0, 255}) { | |
| 239 | + | t.Errorf("1.5 outside = %v", got) | |
| 240 | + | } | |
| 241 | + | // A buffer 1px wider than its slot is clipped, not resampled. | |
| 242 | + | content[1] = ports.SurfaceContent{ID: 1, Width: 16, Height: 15, LogicalW: 10, LogicalH: 10, Stride: 64, Pixels: solid(16, 15, red)} | |
| 243 | + | if err := r.Render(s, content); err != nil { | |
| 244 | + | t.Fatal(err) | |
| 245 | + | } | |
| 246 | + | if got := r.Pixels().At(21, 10); got != (color.RGBA{0, 0, 0, 255}) { | |
| 247 | + | t.Errorf("clipped column = %v", got) | |
| 248 | + | } | |
| 249 | + | } | |
| @@ -187,3 +187,63 @@ func TestRendererLayers(t *testing.T) { | |||||
| 187 | delete(contents, 2) | 187 | delete(contents, 2) | ||
| 188 | check(color.RGBA{16, 32, 48, 255}) | 188 | check(color.RGBA{16, 32, 48, 255}) | ||
| 189 | } | 189 | } | ||
| 190 | + | ||||
| 191 | + | // solid returns a w×h B8G8R8A8 buffer of one color. | |||
| 192 | + | func solid(w, h int, c [3]uint8) []byte { | |||
| 193 | + | p := make([]byte, w*h*4) | |||
| 194 | + | for i := 0; i < len(p); i += 4 { | |||
| 195 | + | p[i], p[i+1], p[i+2], p[i+3] = c[2], c[1], c[0], 255 | |||
| 196 | + | } | |||
| 197 | + | return p | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | func TestRendererScale(t *testing.T) { | |||
| 201 | + | r, err := New(64, 48) | |||
| 202 | + | if err != nil { | |||
| 203 | + | t.Skipf("Vulkan unavailable: %v", err) | |||
| 204 | + | } | |||
| 205 | + | defer r.Close() | |||
| 206 | + | red := [3]uint8{200, 0, 0} | |||
| 207 | + | // Scale 2: a 10x10 logical window at (4,4) covers physical (8,8)-(28,28); | |||
| 208 | + | // its 20x20 buffer (fractional client) is copied 1:1. | |||
| 209 | + | s := ports.Scene{Scale: 2, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: 10, H: 10}, Borderless: true}}} | |||
| 210 | + | content := map[ports.WindowID]ports.SurfaceContent{1: {ID: 1, Width: 20, Height: 20, LogicalW: 10, LogicalH: 10, Stride: 80, Pixels: solid(20, 20, red)}} | |||
| 211 | + | if err := r.Render(s, content); err != nil { | |||
| 212 | + | t.Fatal(err) | |||
| 213 | + | } | |||
| 214 | + | px := r.Pixels() | |||
| 215 | + | want := color.RGBA{200, 0, 0, 255} | |||
| 216 | + | for _, p := range []image.Point{{8, 8}, {27, 27}} { | |||
| 217 | + | if got := px.At(p.X, p.Y); got != want { | |||
| 218 | + | t.Errorf("%v = %v", p, got) | |||
| 219 | + | } | |||
| 220 | + | } | |||
| 221 | + | for _, p := range []image.Point{{7, 7}, {28, 28}} { | |||
| 222 | + | if got := px.At(p.X, p.Y); got != (color.RGBA{0, 0, 0, 255}) { | |||
| 223 | + | t.Errorf("outside %v = %v", p, got) | |||
| 224 | + | } | |||
| 225 | + | } | |||
| 226 | + | // Scale 1.5 with an integer-scale client (buffer scale 2): the 20x20 | |||
| 227 | + | // buffer is 10x10 logical and downscaled to 15x15 physical at (6,6). | |||
| 228 | + | s.Scale = 1.5 | |||
| 229 | + | if err := r.Render(s, content); err != nil { | |||
| 230 | + | t.Fatal(err) | |||
| 231 | + | } | |||
| 232 | + | px = r.Pixels() | |||
| 233 | + | for _, p := range []image.Point{{6, 6}, {20, 20}} { | |||
| 234 | + | if got := px.At(p.X, p.Y); got != want { | |||
| 235 | + | t.Errorf("1.5 %v = %v", p, got) | |||
| 236 | + | } | |||
| 237 | + | } | |||
| 238 | + | if got := px.At(21, 21); got != (color.RGBA{0, 0, 0, 255}) { | |||
| 239 | + | t.Errorf("1.5 outside = %v", got) | |||
| 240 | + | } | |||
| 241 | + | // A buffer 1px wider than its slot is clipped, not resampled. | |||
| 242 | + | content[1] = ports.SurfaceContent{ID: 1, Width: 16, Height: 15, LogicalW: 10, LogicalH: 10, Stride: 64, Pixels: solid(16, 15, red)} | |||
| 243 | + | if err := r.Render(s, content); err != nil { | |||
| 244 | + | t.Fatal(err) | |||
| 245 | + | } | |||
| 246 | + | if got := r.Pixels().At(21, 10); got != (color.RGBA{0, 0, 0, 255}) { | |||
| 247 | + | t.Errorf("clipped column = %v", got) | |||
| 248 | + | } | |||
| 249 | + | } | |||
M internal/adapters/wayland/layer.go +18 -13
| @@ -147,6 +147,23 @@ func (s *Server) layerChanged() { | |||
| 147 | 147 | sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID }) | |
| 148 | 148 | s.emit(ports.LayerChanged{Layers: list}) | |
| 149 | 149 | } | |
| 150 | + | ||
| 151 | + | // sendConfigure sizes the surface from committed state; zero means "fill", | |
| 152 | + | // the logical output size. | |
| 153 | + | func (l *layerSurface) sendConfigure() { | |
| 154 | + | w, h := l.current.width, l.current.height | |
| 155 | + | if w == 0 { | |
| 156 | + | w = uint32(l.shell.server.scale.width) | |
| 157 | + | } | |
| 158 | + | if h == 0 { | |
| 159 | + | h = uint32(l.shell.server.scale.height) | |
| 160 | + | } | |
| 161 | + | l.shell.server.serial++ | |
| 162 | + | l.resource.SendConfigure(l.shell.server.serial, w, h) | |
| 163 | + | l.serials = append(l.serials, l.shell.server.serial) | |
| 164 | + | l.configured = true | |
| 165 | + | } | |
| 166 | + | ||
| 150 | 167 | func (l *layerSurface) commit(buffer bool) { | |
| 151 | 168 | if l.surface.destroyed { | |
| 152 | 169 | l.unmap() | |
| @@ -162,19 +179,7 @@ func (l *layerSurface) commit(buffer bool) { | |||
| 162 | 179 | sizeChanged := p.width != l.current.width || p.height != l.current.height | |
| 163 | 180 | l.current = p | |
| 164 | 181 | if !l.configured || sizeChanged { | |
| 165 | - | // Zero means "fill": the logical output size. | |
| 166 | - | w, h := p.width, p.height | |
| 167 | - | if w == 0 { | |
| 168 | - | w = uint32(l.shell.server.scale.width) | |
| 169 | - | } | |
| 170 | - | if h == 0 { | |
| 171 | - | h = uint32(l.shell.server.scale.height) | |
| 172 | - | } | |
| 173 | - | l.shell.server.serial++ | |
| 174 | - | l.resource.SendConfigure(l.shell.server.serial, w, h) | |
| 175 | - | l.serials = append(l.serials, l.shell.server.serial) | |
| 176 | - | l.configured = true | |
| 177 | - | ||
| 182 | + | l.sendConfigure() | |
| 178 | 183 | } | |
| 179 | 184 | if !buffer { | |
| 180 | 185 | if l.mapped { | |
| @@ -147,6 +147,23 @@ func (s *Server) layerChanged() { | |||||
| 147 | sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID }) | 147 | sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID }) | ||
| 148 | s.emit(ports.LayerChanged{Layers: list}) | 148 | s.emit(ports.LayerChanged{Layers: list}) | ||
| 149 | } | 149 | } | ||
| 150 | + | ||||
| 151 | + | // sendConfigure sizes the surface from committed state; zero means "fill", | |||
| 152 | + | // the logical output size. | |||
| 153 | + | func (l *layerSurface) sendConfigure() { | |||
| 154 | + | w, h := l.current.width, l.current.height | |||
| 155 | + | if w == 0 { | |||
| 156 | + | w = uint32(l.shell.server.scale.width) | |||
| 157 | + | } | |||
| 158 | + | if h == 0 { | |||
| 159 | + | h = uint32(l.shell.server.scale.height) | |||
| 160 | + | } | |||
| 161 | + | l.shell.server.serial++ | |||
| 162 | + | l.resource.SendConfigure(l.shell.server.serial, w, h) | |||
| 163 | + | l.serials = append(l.serials, l.shell.server.serial) | |||
| 164 | + | l.configured = true | |||
| 165 | + | } | |||
| 166 | + | ||||
| 150 | func (l *layerSurface) commit(buffer bool) { | 167 | func (l *layerSurface) commit(buffer bool) { | ||
| 151 | if l.surface.destroyed { | 168 | if l.surface.destroyed { | ||
| 152 | l.unmap() | 169 | l.unmap() | ||
| @@ -162,19 +179,7 @@ func (l *layerSurface) commit(buffer bool) { | |||||
| 162 | sizeChanged := p.width != l.current.width || p.height != l.current.height | 179 | sizeChanged := p.width != l.current.width || p.height != l.current.height | ||
| 163 | l.current = p | 180 | l.current = p | ||
| 164 | if !l.configured || sizeChanged { | 181 | if !l.configured || sizeChanged { | ||
| 165 | - | // Zero means "fill": the logical output size. | 182 | + | l.sendConfigure() |
| 166 | - | w, h := p.width, p.height | |||
| 167 | - | if w == 0 { | |||
| 168 | - | w = uint32(l.shell.server.scale.width) | |||
| 169 | - | } | |||
| 170 | - | if h == 0 { | |||
| 171 | - | h = uint32(l.shell.server.scale.height) | |||
| 172 | - | } | |||
| 173 | - | l.shell.server.serial++ | |||
| 174 | - | l.resource.SendConfigure(l.shell.server.serial, w, h) | |||
| 175 | - | l.serials = append(l.serials, l.shell.server.serial) | |||
| 176 | - | l.configured = true | |||
| 177 | - | ||||
| 178 | } | 183 | } | ||
| 179 | if !buffer { | 184 | if !buffer { | ||
| 180 | if l.mapped { | 185 | if l.mapped { | ||
M internal/adapters/wayland/scale.go +2 -2
| @@ -58,9 +58,9 @@ func (s *Server) setOutputScale(c ports.SetOutputScale) { | |||
| 58 | 58 | } | |
| 59 | 59 | // Layer surfaces sized from the output follow its new logical size. | |
| 60 | 60 | for _, l := range s.layers { | |
| 61 | + | // Uses committed state only: pending requests wait for the client's commit. | |
| 61 | 62 | if l.configured && (l.current.width == 0 || l.current.height == 0) { | |
| 62 | - | l.configured = false | |
| 63 | - | l.commit(l.surface.current != nil) | |
| 63 | + | l.sendConfigure() | |
| 64 | 64 | } | |
| 65 | 65 | } | |
| 66 | 66 | } | |
| @@ -58,9 +58,9 @@ func (s *Server) setOutputScale(c ports.SetOutputScale) { | |||||
| 58 | } | 58 | } | ||
| 59 | // Layer surfaces sized from the output follow its new logical size. | 59 | // Layer surfaces sized from the output follow its new logical size. | ||
| 60 | for _, l := range s.layers { | 60 | for _, l := range s.layers { | ||
| 61 | + | // Uses committed state only: pending requests wait for the client's commit. | |||
| 61 | if l.configured && (l.current.width == 0 || l.current.height == 0) { | 62 | if l.configured && (l.current.width == 0 || l.current.height == 0) { | ||
| 62 | - | l.configured = false | 63 | + | l.sendConfigure() |
| 63 | - | l.commit(l.surface.current != nil) | |||
| 64 | } | 64 | } | ||
| 65 | } | 65 | } | ||
| 66 | } | 66 | } | ||
M internal/app/drm.go +1 -1
| @@ -89,7 +89,7 @@ func wantFromConfig(outputs []ports.OutputConfig) drm.Want { | |||
| 89 | 89 | want.Disabled[o.Name] = true | |
| 90 | 90 | continue | |
| 91 | 91 | } | |
| 92 | - | if want.Name != "" { | |
| 92 | + | if want.Name != "" || o.ScaleOnly { | |
| 93 | 93 | continue | |
| 94 | 94 | } | |
| 95 | 95 | want.Name = o.Name | |
| @@ -89,7 +89,7 @@ func wantFromConfig(outputs []ports.OutputConfig) drm.Want { | |||||
| 89 | want.Disabled[o.Name] = true | 89 | want.Disabled[o.Name] = true | ||
| 90 | continue | 90 | continue | ||
| 91 | } | 91 | } | ||
| 92 | - | if want.Name != "" { | 92 | + | if want.Name != "" || o.ScaleOnly { |
| 93 | continue | 93 | continue | ||
| 94 | } | 94 | } | ||
| 95 | want.Name = o.Name | 95 | want.Name = o.Name | ||
A internal/app/drm_scale_test.go +14 -0
| @@ -0,0 +1,14 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/nefertty/internal/ports" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestScaleOnlyDoesNotSelectOutput(t *testing.T) { | |
| 10 | + | want := wantFromConfig([]ports.OutputConfig{{Name: "eDP-1", Scale: 2, ScaleOnly: true}, {Name: "HDMI-A-1", Mode: "1920x1080"}}) | |
| 11 | + | if want.Name != "HDMI-A-1" || want.W != 1920 { | |
| 12 | + | t.Fatalf("%+v", want) | |
| 13 | + | } | |
| 14 | + | } | |
| @@ -0,0 +1,14 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestScaleOnlyDoesNotSelectOutput(t *testing.T) { | |||
| 10 | + | want := wantFromConfig([]ports.OutputConfig{{Name: "eDP-1", Scale: 2, ScaleOnly: true}, {Name: "HDMI-A-1", Mode: "1920x1080"}}) | |||
| 11 | + | if want.Name != "HDMI-A-1" || want.W != 1920 { | |||
| 12 | + | t.Fatalf("%+v", want) | |||
| 13 | + | } | |||
| 14 | + | } | |||
M internal/ports/config.go +7 -4
| @@ -7,11 +7,14 @@ package ports | |||
| 7 | 7 | // connected output that is not off. | |
| 8 | 8 | // Scale is the output scale (0 means 1); layout works in logical pixels, | |
| 9 | 9 | // physical = logical × Scale. | |
| 10 | + | // ScaleOnly marks an entry set by output.<name>.scale alone: it does not | |
| 11 | + | // select the connector. | |
| 10 | 12 | type OutputConfig struct { | |
| 11 | - | Name string | |
| 12 | - | Mode string | |
| 13 | - | Off bool | |
| 14 | - | Scale float64 | |
| 13 | + | Name string | |
| 14 | + | Mode string | |
| 15 | + | Off bool | |
| 16 | + | Scale float64 | |
| 17 | + | ScaleOnly bool | |
| 15 | 18 | } | |
| 16 | 19 | ||
| 17 | 20 | // Config is the parsed compositor configuration (see the config adapter for keys). | |
| @@ -7,11 +7,14 @@ package ports | |||||
| 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, | 8 | // Scale is the output scale (0 means 1); layout works in logical pixels, | ||
| 9 | // physical = logical × Scale. | 9 | // physical = logical × Scale. | ||
| 10 | + | // ScaleOnly marks an entry set by output.<name>.scale alone: it does not | |||
| 11 | + | // select the connector. | |||
| 10 | type OutputConfig struct { | 12 | type OutputConfig struct { | ||
| 11 | - | Name string | 13 | + | Name string |
| 12 | - | Mode string | 14 | + | Mode string |
| 13 | - | Off bool | 15 | + | Off bool |
| 14 | - | Scale float64 | 16 | + | Scale float64 |
| 17 | + | ScaleOnly bool | |||
| 15 | } | 18 | } | ||
| 16 | 19 | ||||
| 17 | // Config is the parsed compositor configuration (see the config adapter for keys). | 20 | // Config is the parsed compositor configuration (see the config adapter for keys). | ||