detached 3ef2072

fix(layout): server-side decorations, tiled states and border inset so clients fill their column

commit
3ef2072ebe30da7afba1b6b6351207e0dd860c37
author
brice · · 2026-09-24 09:27 UTC
parent
c3dedda
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +145 -11
M cmd/nefertty/main.go +8 -1
@@ -80,6 +80,7 @@ func run() error {
8080 flags := flag.NewFlagSet("nefertty", flag.ContinueOnError)
8181 backend := flags.String("backend", "drm", "drm or headless")
8282 screenshot := flags.String("screenshot", "", "write PNG frames to directory")
83+ size := flags.String("size", "1920x1080", "headless output size WxH")
8384 inputPath := flags.String("input", "", "headless input script path (- for stdin)")
8485 noTerminal := flags.Bool("no-terminal", false, "skip initial terminal")
8586 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")
@@ -101,6 +102,12 @@ func run() error {
101102 fmt.Fprintln(os.Stderr, err)
102103 return err
103104 }
105+ outW, outH, _, sizeErr := config.ParseMode(*size)
106+ if sizeErr != nil {
107+ err := usageError{fmt.Errorf("--size: %w", sizeErr)}
108+ fmt.Fprintln(os.Stderr, err)
109+ return err
110+ }
104111 if *inputPath != "" && *backend != "headless" {
105112 err := usageError{fmt.Errorf("--input requires --backend=headless")}
106113 fmt.Fprintln(os.Stderr, err)
@@ -173,7 +180,7 @@ func run() error {
173180 log.Info().Strs("args", os.Args[1:]).Str("backend", *backend).Str("tty", os.Getenv("XDG_VTNR")).Str("session_type", os.Getenv("XDG_SESSION_TYPE")).Msg("run")
174181 configLog := logging.For(ctx, "config")
175182 configLog.Info().Str("path", path).Msg("loaded config")
176- err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot, Script: script})
183+ err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot, Width: outW, Height: outH, Script: script})
177184 // SIGINT and SIGTERM cancel the context and are clean exits.
178185 if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) {
179186 err = nil
@@ -80,6 +80,7 @@ func run() error {
80 flags := flag.NewFlagSet("nefertty", flag.ContinueOnError)80 flags := flag.NewFlagSet("nefertty", flag.ContinueOnError)
81 backend := flags.String("backend", "drm", "drm or headless")81 backend := flags.String("backend", "drm", "drm or headless")
82 screenshot := flags.String("screenshot", "", "write PNG frames to directory")82 screenshot := flags.String("screenshot", "", "write PNG frames to directory")
83+ size := flags.String("size", "1920x1080", "headless output size WxH")
83 inputPath := flags.String("input", "", "headless input script path (- for stdin)")84 inputPath := flags.String("input", "", "headless input script path (- for stdin)")
84 noTerminal := flags.Bool("no-terminal", false, "skip initial terminal")85 noTerminal := flags.Bool("no-terminal", false, "skip initial terminal")
85 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")86 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")
@@ -101,6 +102,12 @@ func run() error {
101 fmt.Fprintln(os.Stderr, err)102 fmt.Fprintln(os.Stderr, err)
102 return err103 return err
103 }104 }
105+ outW, outH, _, sizeErr := config.ParseMode(*size)
106+ if sizeErr != nil {
107+ err := usageError{fmt.Errorf("--size: %w", sizeErr)}
108+ fmt.Fprintln(os.Stderr, err)
109+ return err
110+ }
104 if *inputPath != "" && *backend != "headless" {111 if *inputPath != "" && *backend != "headless" {
105 err := usageError{fmt.Errorf("--input requires --backend=headless")}112 err := usageError{fmt.Errorf("--input requires --backend=headless")}
106 fmt.Fprintln(os.Stderr, err)113 fmt.Fprintln(os.Stderr, err)
@@ -173,7 +180,7 @@ func run() error {
173 log.Info().Strs("args", os.Args[1:]).Str("backend", *backend).Str("tty", os.Getenv("XDG_VTNR")).Str("session_type", os.Getenv("XDG_SESSION_TYPE")).Msg("run")180 log.Info().Strs("args", os.Args[1:]).Str("backend", *backend).Str("tty", os.Getenv("XDG_VTNR")).Str("session_type", os.Getenv("XDG_SESSION_TYPE")).Msg("run")
174 configLog := logging.For(ctx, "config")181 configLog := logging.For(ctx, "config")
175 configLog.Info().Str("path", path).Msg("loaded config")182 configLog.Info().Str("path", path).Msg("loaded config")
176- err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot, Script: script})183+ err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot, Width: outW, Height: outH, Script: script})
177 // SIGINT and SIGTERM cancel the context and are clean exits.184 // SIGINT and SIGTERM cancel the context and are clean exits.
178 if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) {185 if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) {
179 err = nil186 err = nil
M internal/adapters/vulkan/renderer.go +10 -4
@@ -366,22 +366,28 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
366366 continue
367367 }
368368 x, y := w.Rect.X, w.Rect.Y
369- body := image.Rect(x, y, x+w.Rect.W, y+w.Rect.H)
369+ // Content sits inside the border; core sized the client to match.
370+ b := 0
371+ if !w.Fullscreen {
372+ b = min(max(s.Border.Width, 0), w.Rect.W/2, w.Rect.H/2)
373+ }
374+ 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)
370376 content := contents[w.ID]
371377 if content.Pixels == nil {
372378 add(body, windowColor(w.ID), nil, image.Point{})
373379 } else {
374380 add(body, parseColor(s.Background), nil, image.Point{})
375- width, height := min(content.Width, w.Rect.W), min(content.Height, w.Rect.H)
381+ width, height := min(content.Width, cw), min(content.Height, ch)
376382 if width > 0 && height > 0 && content.Stride >= width*4 && len(content.Pixels) >= (height-1)*content.Stride+width*4 {
377- add(image.Rect(x, y, x+width, y+height), [3]uint8{}, &content, image.Pt(x, y))
383+ add(image.Rect(cx, cy, cx+width, cy+height), [3]uint8{}, &content, image.Pt(cx, cy))
378384 }
379385 }
380386 borderColor := s.Border.Inactive
381387 if w.Focused {
382388 borderColor = s.Border.Active
383389 }
384- if b := s.Border.Width; b > 0 && borderColor != "" && !w.Fullscreen && w.Rect.W > 2*b && w.Rect.H > 2*b {
390+ if b > 0 && borderColor != "" {
385391 rgb := parseColor(borderColor)
386392 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)} {
387393 add(strip, rgb, nil, image.Point{})
@@ -366,22 +366,28 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
366 continue366 continue
367 }367 }
368 x, y := w.Rect.X, w.Rect.Y368 x, y := w.Rect.X, w.Rect.Y
369- body := image.Rect(x, y, x+w.Rect.W, y+w.Rect.H)369+ // Content sits inside the border; core sized the client to match.
370+ b := 0
371+ if !w.Fullscreen {
372+ b = min(max(s.Border.Width, 0), w.Rect.W/2, w.Rect.H/2)
373+ }
374+ 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)
370 content := contents[w.ID]376 content := contents[w.ID]
371 if content.Pixels == nil {377 if content.Pixels == nil {
372 add(body, windowColor(w.ID), nil, image.Point{})378 add(body, windowColor(w.ID), nil, image.Point{})
373 } else {379 } else {
374 add(body, parseColor(s.Background), nil, image.Point{})380 add(body, parseColor(s.Background), nil, image.Point{})
375- width, height := min(content.Width, w.Rect.W), min(content.Height, w.Rect.H)381+ width, height := min(content.Width, cw), min(content.Height, ch)
376 if width > 0 && height > 0 && content.Stride >= width*4 && len(content.Pixels) >= (height-1)*content.Stride+width*4 {382 if width > 0 && height > 0 && content.Stride >= width*4 && len(content.Pixels) >= (height-1)*content.Stride+width*4 {
377- add(image.Rect(x, y, x+width, y+height), [3]uint8{}, &content, image.Pt(x, y))383+ add(image.Rect(cx, cy, cx+width, cy+height), [3]uint8{}, &content, image.Pt(cx, cy))
378 }384 }
379 }385 }
380 borderColor := s.Border.Inactive386 borderColor := s.Border.Inactive
381 if w.Focused {387 if w.Focused {
382 borderColor = s.Border.Active388 borderColor = s.Border.Active
383 }389 }
384- if b := s.Border.Width; b > 0 && borderColor != "" && !w.Fullscreen && w.Rect.W > 2*b && w.Rect.H > 2*b {390+ if b > 0 && borderColor != "" {
385 rgb := parseColor(borderColor)391 rgb := parseColor(borderColor)
386 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)} {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)} {
387 add(strip, rgb, nil, image.Point{})393 add(strip, rgb, nil, image.Point{})
A internal/adapters/wayland/decoration.go +41 -0
@@ -0,0 +1,41 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/purego-libwayland/protocol/xdgdecoration"
5+ "github.com/bnema/purego-libwayland/protocol/xdgshell"
6+ "github.com/bnema/purego-libwayland/server"
7+)
8+
9+// Server-side decorations: nefertty draws the border, so clients must not draw
10+// their own title bar or shadow (which would shrink the visible content).
11+type decorationManager struct{}
12+
13+func registerDecoration(d *server.Display) error {
14+ return xdgdecoration.NewZxdgDecorationManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
15+ _, _ = xdgdecoration.NewZxdgDecorationManagerV1(c, int32(v), id, decorationManager{})
16+ })
17+}
18+
19+func (decorationManager) Destroy(*xdgdecoration.ZxdgDecorationManagerV1) {}
20+
21+func (decorationManager) GetToplevelDecoration(r *xdgdecoration.ZxdgDecorationManagerV1, id uint32, _ *xdgshell.Toplevel) {
22+ d, err := xdgdecoration.NewZxdgToplevelDecorationV1(r.Client(), r.Version(), id, decoration{})
23+ if err != nil {
24+ return
25+ }
26+ // Sent before the toplevel's configure, which the client waits for.
27+ d.SendConfigure(uint32(xdgdecoration.ZxdgToplevelDecorationV1ModeServerSide))
28+}
29+
30+type decoration struct{}
31+
32+func (decoration) Destroy(*xdgdecoration.ZxdgToplevelDecorationV1) {}
33+
34+// Any requested mode is answered with server-side.
35+func (decoration) SetMode(r *xdgdecoration.ZxdgToplevelDecorationV1, _ uint32) {
36+ r.SendConfigure(uint32(xdgdecoration.ZxdgToplevelDecorationV1ModeServerSide))
37+}
38+
39+func (decoration) UnsetMode(r *xdgdecoration.ZxdgToplevelDecorationV1) {
40+ r.SendConfigure(uint32(xdgdecoration.ZxdgToplevelDecorationV1ModeServerSide))
41+}
@@ -0,0 +1,41 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/purego-libwayland/protocol/xdgdecoration"
5+ "github.com/bnema/purego-libwayland/protocol/xdgshell"
6+ "github.com/bnema/purego-libwayland/server"
7+)
8+
9+// Server-side decorations: nefertty draws the border, so clients must not draw
10+// their own title bar or shadow (which would shrink the visible content).
11+type decorationManager struct{}
12+
13+func registerDecoration(d *server.Display) error {
14+ return xdgdecoration.NewZxdgDecorationManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
15+ _, _ = xdgdecoration.NewZxdgDecorationManagerV1(c, int32(v), id, decorationManager{})
16+ })
17+}
18+
19+func (decorationManager) Destroy(*xdgdecoration.ZxdgDecorationManagerV1) {}
20+
21+func (decorationManager) GetToplevelDecoration(r *xdgdecoration.ZxdgDecorationManagerV1, id uint32, _ *xdgshell.Toplevel) {
22+ d, err := xdgdecoration.NewZxdgToplevelDecorationV1(r.Client(), r.Version(), id, decoration{})
23+ if err != nil {
24+ return
25+ }
26+ // Sent before the toplevel's configure, which the client waits for.
27+ d.SendConfigure(uint32(xdgdecoration.ZxdgToplevelDecorationV1ModeServerSide))
28+}
29+
30+type decoration struct{}
31+
32+func (decoration) Destroy(*xdgdecoration.ZxdgToplevelDecorationV1) {}
33+
34+// Any requested mode is answered with server-side.
35+func (decoration) SetMode(r *xdgdecoration.ZxdgToplevelDecorationV1, _ uint32) {
36+ r.SendConfigure(uint32(xdgdecoration.ZxdgToplevelDecorationV1ModeServerSide))
37+}
38+
39+func (decoration) UnsetMode(r *xdgdecoration.ZxdgToplevelDecorationV1) {
40+ r.SendConfigure(uint32(xdgdecoration.ZxdgToplevelDecorationV1ModeServerSide))
41+}
M internal/adapters/wayland/globals.go +1 -0
@@ -13,6 +13,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
1313 func() error { return registerXDG(d, s) },
1414 func() error { return registerLayer(d, o, s) },
1515 func() error { return registerXDGOutput(d, o) },
16+ func() error { return registerDecoration(d) },
1617 func() error {
1718 return wayland.NewCompositorGlobal(d, 6, func(c server.Client, v, id uint32) { wayland.NewCompositor(c, int32(v), id, compositor{s}) })
1819 },
@@ -13,6 +13,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) 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) },
16+ func() error { return registerDecoration(d) },
16 func() error {17 func() error {
17 return wayland.NewCompositorGlobal(d, 6, func(c server.Client, v, id uint32) { wayland.NewCompositor(c, int32(v), id, compositor{s}) })18 return wayland.NewCompositorGlobal(d, 6, func(c server.Client, v, id uint32) { wayland.NewCompositor(c, int32(v), id, compositor{s}) })
18 },19 },
M internal/adapters/wayland/server.go +9 -0
@@ -388,6 +388,15 @@ func (s *Server) apply(cmd ports.ClientCommand) {
388388 if c.Activated {
389389 states = binary.LittleEndian.AppendUint32(states, uint32(xdgshell.ToplevelStateActivated))
390390 }
391+ if !c.Fullscreen {
392+ // Tiled: the client must use exactly this size (no CSD shadows or rounding).
393+ for _, st := range []xdgshell.ToplevelState{xdgshell.ToplevelStateTiledLeft, xdgshell.ToplevelStateTiledRight, xdgshell.ToplevelStateTiledTop, xdgshell.ToplevelStateTiledBottom} {
394+ if w.toplevel.Version() >= 2 {
395+ states = binary.LittleEndian.AppendUint32(states, uint32(st))
396+ }
397+ }
398+ }
399+ 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")
391400 w.toplevel.SendConfigure(int32(c.Width), int32(c.Height), states)
392401 s.serial++
393402 w.xdg.resource.SendConfigure(s.serial)
@@ -388,6 +388,15 @@ func (s *Server) apply(cmd ports.ClientCommand) {
388 if c.Activated {388 if c.Activated {
389 states = binary.LittleEndian.AppendUint32(states, uint32(xdgshell.ToplevelStateActivated))389 states = binary.LittleEndian.AppendUint32(states, uint32(xdgshell.ToplevelStateActivated))
390 }390 }
391+ if !c.Fullscreen {
392+ // Tiled: the client must use exactly this size (no CSD shadows or rounding).
393+ for _, st := range []xdgshell.ToplevelState{xdgshell.ToplevelStateTiledLeft, xdgshell.ToplevelStateTiledRight, xdgshell.ToplevelStateTiledTop, xdgshell.ToplevelStateTiledBottom} {
394+ if w.toplevel.Version() >= 2 {
395+ states = binary.LittleEndian.AppendUint32(states, uint32(st))
396+ }
397+ }
398+ }
399+ 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")
391 w.toplevel.SendConfigure(int32(c.Width), int32(c.Height), states)400 w.toplevel.SendConfigure(int32(c.Width), int32(c.Height), states)
392 s.serial++401 s.serial++
393 w.xdg.resource.SendConfigure(s.serial)402 w.xdg.resource.SendConfigure(s.serial)
M internal/adapters/wayland/surface.go +5 -0
@@ -23,6 +23,7 @@ type surface struct {
2323 server *Server
2424 current, pending *wayland.Buffer
2525 attached bool
26+ lastW, lastH int // last logged buffer size
2627 callbacks []*wayland.Callback
2728 role func(bool)
2829 destroyed bool
@@ -87,6 +88,10 @@ func (s *surface) Commit(*wayland.Surface) {
8788 b := s.current
8889 if state, ok := s.server.buffers[b.Resource]; ok {
8990 if c, ok := state.content(id); ok {
91+ if c.Width != s.lastW || c.Height != s.lastH {
92+ 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")
94+ }
9095 s.server.emitContent(c)
9196 } else {
9297 state.pool.shm.PostError(uint32(wayland.ShmErrorInvalidFd), "SHM backing file truncated")
@@ -23,6 +23,7 @@ type surface struct {
23 server *Server23 server *Server
24 current, pending *wayland.Buffer24 current, pending *wayland.Buffer
25 attached bool25 attached bool
26+ lastW, lastH int // last logged buffer size
26 callbacks []*wayland.Callback27 callbacks []*wayland.Callback
27 role func(bool)28 role func(bool)
28 destroyed bool29 destroyed bool
@@ -87,6 +88,10 @@ func (s *surface) Commit(*wayland.Surface) {
87 b := s.current88 b := s.current
88 if state, ok := s.server.buffers[b.Resource]; ok {89 if state, ok := s.server.buffers[b.Resource]; ok {
89 if c, ok := state.content(id); ok {90 if c, ok := state.content(id); ok {
91+ if c.Width != s.lastW || c.Height != s.lastH {
92+ 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")
94+ }
90 s.server.emitContent(c)95 s.server.emitContent(c)
91 } else {96 } else {
92 state.pool.shm.PostError(uint32(wayland.ShmErrorInvalidFd), "SHM backing file truncated")97 state.pool.shm.PostError(uint32(wayland.ShmErrorInvalidFd), "SHM backing file truncated")
M internal/adapters/wayland/xdg.go +9 -1
@@ -152,7 +152,15 @@ func (x *xdgSurface) GetPopup(r *xdgshell.Surface, id uint32, _ *xdgshell.Surfac
152152 p.SendPopupDone()
153153 }
154154 }
155-func (x *xdgSurface) SetWindowGeometry(*xdgshell.Surface, int32, int32, int32, int32) {}
155+
156+// SetWindowGeometry is logged only: placement uses the full buffer for now.
157+func (x *xdgSurface) SetWindowGeometry(_ *xdgshell.Surface, gx, gy, gw, gh int32) {
158+ id := uint64(0)
159+ if x.window != nil {
160+ id = uint64(x.window.id)
161+ }
162+ x.server.log.Info().Uint64("id", id).Int32("x", gx).Int32("y", gy).Int32("w", gw).Int32("h", gh).Msg("window geometry")
163+}
156164 func (x *xdgSurface) AckConfigure(r *xdgshell.Surface, serial uint32) {
157165 for i, issued := range x.serials {
158166 if issued == serial {
@@ -152,7 +152,15 @@ func (x *xdgSurface) GetPopup(r *xdgshell.Surface, id uint32, _ *xdgshell.Surfac
152 p.SendPopupDone()152 p.SendPopupDone()
153 }153 }
154 }154 }
155-func (x *xdgSurface) SetWindowGeometry(*xdgshell.Surface, int32, int32, int32, int32) {}155+
156+// SetWindowGeometry is logged only: placement uses the full buffer for now.
157+func (x *xdgSurface) SetWindowGeometry(_ *xdgshell.Surface, gx, gy, gw, gh int32) {
158+ id := uint64(0)
159+ if x.window != nil {
160+ id = uint64(x.window.id)
161+ }
162+ x.server.log.Info().Uint64("id", id).Int32("x", gx).Int32("y", gy).Int32("w", gw).Int32("h", gh).Msg("window geometry")
163+}
156 func (x *xdgSurface) AckConfigure(r *xdgshell.Surface, serial uint32) {164 func (x *xdgSurface) AckConfigure(r *xdgshell.Surface, serial uint32) {
157 for i, issued := range x.serials {165 for i, issued := range x.serials {
158 if issued == serial {166 if issued == serial {
M internal/app/app.go +14 -3
@@ -5,6 +5,7 @@ import (
55 "bufio"
66 "context"
77 "errors"
8+ "fmt"
89 "io"
910 "os"
1011 "sync"
@@ -31,6 +32,8 @@ type Options struct {
3132 Timeout time.Duration
3233 NoTerminal bool
3334 ScreenshotDir string
35+ // Width and Height size the headless output; 0 means 1920x1080.
36+ Width, Height int
3437 // Run closes Script on shutdown; the reader goroutine exits after Close.
3538 Script io.ReadCloser
3639 testScenes chan<- ports.Scene
@@ -48,7 +51,10 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
4851 ctx, cancel = context.WithCancel(ctx)
4952 }
5053 defer cancel()
51- width, height := 1920, 1080
54+ width, height := opts.Width, opts.Height
55+ if width <= 0 || height <= 0 {
56+ width, height = 1920, 1080
57+ }
5258 var outInfo wayland.OutputInfo
5359 var hw *drmBackend
5460 if opts.Backend == "drm" {
@@ -156,7 +162,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
156162 })
157163 return
158164 }
159- done <- headless.Run(ctx, headless.Options{Width: 1920, Height: 1080, ScreenshotDir: opts.ScreenshotDir, Log: logging.For(ctx, "render"), NewRenderer: func(w, h int) (headless.Renderer, error) {
165+ done <- headless.Run(ctx, headless.Options{Width: width, Height: height, ScreenshotDir: opts.ScreenshotDir, Log: logging.For(ctx, "render"), NewRenderer: func(w, h int) (headless.Renderer, error) {
160166 r, err := vulkan.New(w, h)
161167 if err != nil {
162168 return nil, err
@@ -218,7 +224,12 @@ func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors
218224 default:
219225 }
220226 }
221- log.Debug().Uint64("seq", s.Seq).Int("windows", len(s.Windows)).Msg("scene")
227+ ev := log.Debug().Uint64("seq", s.Seq).Int("out_w", s.OutputWidth).Int("out_h", s.OutputHeight)
228+ rects := make([]string, 0, len(s.Windows))
229+ for _, w := range s.Windows {
230+ rects = append(rects, fmt.Sprintf("%d:%d,%d %dx%d", w.ID, w.Rect.X, w.Rect.Y, w.Rect.W, w.Rect.H))
231+ }
232+ ev.Strs("windows", rects).Msg("scene")
222233 case err := <-configErrors:
223234 configLog.Warn().Err(err).Msg("config rejected")
224235 }
@@ -5,6 +5,7 @@ import (
5 "bufio"5 "bufio"
6 "context"6 "context"
7 "errors"7 "errors"
8+ "fmt"
8 "io"9 "io"
9 "os"10 "os"
10 "sync"11 "sync"
@@ -31,6 +32,8 @@ type Options struct {
31 Timeout time.Duration32 Timeout time.Duration
32 NoTerminal bool33 NoTerminal bool
33 ScreenshotDir string34 ScreenshotDir string
35+ // Width and Height size the headless output; 0 means 1920x1080.
36+ Width, Height int
34 // Run closes Script on shutdown; the reader goroutine exits after Close.37 // Run closes Script on shutdown; the reader goroutine exits after Close.
35 Script io.ReadCloser38 Script io.ReadCloser
36 testScenes chan<- ports.Scene39 testScenes chan<- ports.Scene
@@ -48,7 +51,10 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
48 ctx, cancel = context.WithCancel(ctx)51 ctx, cancel = context.WithCancel(ctx)
49 }52 }
50 defer cancel()53 defer cancel()
51- width, height := 1920, 108054+ width, height := opts.Width, opts.Height
55+ if width <= 0 || height <= 0 {
56+ width, height = 1920, 1080
57+ }
52 var outInfo wayland.OutputInfo58 var outInfo wayland.OutputInfo
53 var hw *drmBackend59 var hw *drmBackend
54 if opts.Backend == "drm" {60 if opts.Backend == "drm" {
@@ -156,7 +162,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
156 })162 })
157 return163 return
158 }164 }
159- done <- headless.Run(ctx, headless.Options{Width: 1920, Height: 1080, ScreenshotDir: opts.ScreenshotDir, Log: logging.For(ctx, "render"), NewRenderer: func(w, h int) (headless.Renderer, error) {165+ done <- headless.Run(ctx, headless.Options{Width: width, Height: height, ScreenshotDir: opts.ScreenshotDir, Log: logging.For(ctx, "render"), NewRenderer: func(w, h int) (headless.Renderer, error) {
160 r, err := vulkan.New(w, h)166 r, err := vulkan.New(w, h)
161 if err != nil {167 if err != nil {
162 return nil, err168 return nil, err
@@ -218,7 +224,12 @@ func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors
218 default:224 default:
219 }225 }
220 }226 }
221- log.Debug().Uint64("seq", s.Seq).Int("windows", len(s.Windows)).Msg("scene")227+ ev := log.Debug().Uint64("seq", s.Seq).Int("out_w", s.OutputWidth).Int("out_h", s.OutputHeight)
228+ rects := make([]string, 0, len(s.Windows))
229+ for _, w := range s.Windows {
230+ rects = append(rects, fmt.Sprintf("%d:%d,%d %dx%d", w.ID, w.Rect.X, w.Rect.Y, w.Rect.W, w.Rect.H))
231+ }
232+ ev.Strs("windows", rects).Msg("scene")
222 case err := <-configErrors:233 case err := <-configErrors:
223 configLog.Warn().Err(err).Msg("config rejected")234 configLog.Warn().Err(err).Msg("config rejected")
224 }235 }
M internal/core/core.go +14 -2
@@ -145,6 +145,17 @@ func (c *Core) command(ctx context.Context, v ports.ClientCommand) error {
145145 return nil
146146 }
147147 }
148+// clientRect is the placement minus the border drawn around the client.
149+// The renderer applies the same inset (ports.Border).
150+func (c *Core) clientRect(p Placement) Rect {
151+ r := p.Rect
152+ if p.Fullscreen {
153+ return r
154+ }
155+ b := min(max(c.cfg.Border.Width, 0), r.W/2, r.H/2)
156+ return Rect{X: r.X + b, Y: r.Y + b, W: r.W - 2*b, H: r.H - 2*b}
157+}
158+
148159 func (c *Core) publish(ctx context.Context) error {
149160 c.seq++
150161 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...)}
@@ -163,7 +174,8 @@ func (c *Core) publish(ctx context.Context) error {
163174 }
164175 continue
165176 }
166- v := ports.ConfigureWindow{ID: p.ID, Width: p.Rect.W, Height: p.Rect.H, Fullscreen: p.Fullscreen, Activated: p.Focused}
177+ r := c.clientRect(p)
178+ v := ports.ConfigureWindow{ID: p.ID, Width: r.W, Height: r.H, Fullscreen: p.Fullscreen, Activated: p.Focused}
167179 if old, ok := c.sent[p.ID]; !ok || old != v {
168180 if err := c.command(ctx, v); err != nil {
169181 return err
@@ -280,7 +292,7 @@ func (c *Core) Run(ctx context.Context) error {
280292 var x, y float64
281293 // Fullscreen wins; otherwise the last visible placement is topmost.
282294 for _, p := range c.ws.Layout() {
283- r := p.Rect
295+ r := c.clientRect(p)
284296 if !p.Hidden && r.W > 0 && r.H > 0 && c.cursorX >= float64(r.X) && c.cursorX < float64(r.X+r.W) && c.cursorY >= float64(r.Y) && c.cursorY < float64(r.Y+r.H) {
285297 if id == 0 || p.Fullscreen {
286298 id, x, y = p.ID, c.cursorX-float64(r.X), c.cursorY-float64(r.Y)
@@ -145,6 +145,17 @@ func (c *Core) command(ctx context.Context, v ports.ClientCommand) error {
145 return nil145 return nil
146 }146 }
147 }147 }
148+// clientRect is the placement minus the border drawn around the client.
149+// The renderer applies the same inset (ports.Border).
150+func (c *Core) clientRect(p Placement) Rect {
151+ r := p.Rect
152+ if p.Fullscreen {
153+ return r
154+ }
155+ b := min(max(c.cfg.Border.Width, 0), r.W/2, r.H/2)
156+ return Rect{X: r.X + b, Y: r.Y + b, W: r.W - 2*b, H: r.H - 2*b}
157+}
158+
148 func (c *Core) publish(ctx context.Context) error {159 func (c *Core) publish(ctx context.Context) error {
149 c.seq++160 c.seq++
150 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...)}161 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...)}
@@ -163,7 +174,8 @@ func (c *Core) publish(ctx context.Context) error {
163 }174 }
164 continue175 continue
165 }176 }
166- v := ports.ConfigureWindow{ID: p.ID, Width: p.Rect.W, Height: p.Rect.H, Fullscreen: p.Fullscreen, Activated: p.Focused}177+ r := c.clientRect(p)
178+ v := ports.ConfigureWindow{ID: p.ID, Width: r.W, Height: r.H, Fullscreen: p.Fullscreen, Activated: p.Focused}
167 if old, ok := c.sent[p.ID]; !ok || old != v {179 if old, ok := c.sent[p.ID]; !ok || old != v {
168 if err := c.command(ctx, v); err != nil {180 if err := c.command(ctx, v); err != nil {
169 return err181 return err
@@ -280,7 +292,7 @@ func (c *Core) Run(ctx context.Context) error {
280 var x, y float64292 var x, y float64
281 // Fullscreen wins; otherwise the last visible placement is topmost.293 // Fullscreen wins; otherwise the last visible placement is topmost.
282 for _, p := range c.ws.Layout() {294 for _, p := range c.ws.Layout() {
283- r := p.Rect295+ r := c.clientRect(p)
284 if !p.Hidden && r.W > 0 && r.H > 0 && c.cursorX >= float64(r.X) && c.cursorX < float64(r.X+r.W) && c.cursorY >= float64(r.Y) && c.cursorY < float64(r.Y+r.H) {296 if !p.Hidden && r.W > 0 && r.H > 0 && c.cursorX >= float64(r.X) && c.cursorX < float64(r.X+r.W) && c.cursorY >= float64(r.Y) && c.cursorY < float64(r.Y+r.H) {
285 if id == 0 || p.Fullscreen {297 if id == 0 || p.Fullscreen {
286 id, x, y = p.ID, c.cursorX-float64(r.X), c.cursorY-float64(r.Y)298 id, x, y = p.ID, c.cursorX-float64(r.X), c.cursorY-float64(r.Y)
M internal/core/core_test.go +34 -0
@@ -24,6 +24,7 @@ func receive[T any](t *testing.T, ch <-chan T) T {
2424 func TestOwner(t *testing.T) {
2525 cfg := config.Defaults()
2626 cfg.Keyboard.CmdKey = "alt"
27+ cfg.Border.Width = 0
2728 client := make(chan ports.ClientEvent, 128)
2829 input := make(chan ports.InputEvent, 32)
2930 output := make(chan ports.OutputEvent, 8)
@@ -195,6 +196,7 @@ func TestBoundReleaseSwallowed(t *testing.T) {
195196
196197 func TestPointerFocusAndGrab(t *testing.T) {
197198 cfg := config.Defaults()
199+ cfg.Border.Width = 0
198200 client := make(chan ports.ClientEvent, 8)
199201 input := make(chan ports.InputEvent, 8)
200202 output := make(chan ports.OutputEvent, 8)
@@ -246,3 +248,35 @@ func TestPointerFocusAndGrab(t *testing.T) {
246248 t.Fatal(v)
247249 }
248250 }
251+
252+func TestBorderInset(t *testing.T) {
253+ cfg := config.Defaults()
254+ cfg.Border.Width = 2
255+ client := make(chan ports.ClientEvent, 8)
256+ input := make(chan ports.InputEvent, 8)
257+ output := make(chan ports.OutputEvent, 8)
258+ commands := make(chan ports.ClientCommand, 64)
259+ scenes := make(chan ports.Scene, 1)
260+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
261+ if err != nil {
262+ t.Fatal(err)
263+ }
264+ ctx, cancel := context.WithCancel(context.Background())
265+ defer cancel()
266+ go c.Run(ctx)
267+ output <- ports.OutputMode{Width: 100, Height: 80}
268+ receive(t, scenes)
269+ client <- ports.WindowMapped{ID: 1}
270+ r := receive(t, scenes).Windows[0].Rect
271+ // The scene keeps the outer rect; the client is configured 2px smaller on each side.
272+ if v := receive(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W - 4, Height: r.H - 4, Activated: true}) {
273+ t.Fatalf("%v for outer %v", v, r)
274+ }
275+ for len(commands) > 0 {
276+ <-commands
277+ }
278+ input <- ports.PointerMotion{X: float64(r.X + 2), Y: float64(r.Y + 5), TimeMsec: 1}
279+ if v := receive(t, commands); v != (ports.PointerFocus{ID: 1, X: 0, Y: 3}) {
280+ t.Fatal(v)
281+ }
282+}
@@ -24,6 +24,7 @@ func receive[T any](t *testing.T, ch <-chan T) T {
24 func TestOwner(t *testing.T) {24 func TestOwner(t *testing.T) {
25 cfg := config.Defaults()25 cfg := config.Defaults()
26 cfg.Keyboard.CmdKey = "alt"26 cfg.Keyboard.CmdKey = "alt"
27+ cfg.Border.Width = 0
27 client := make(chan ports.ClientEvent, 128)28 client := make(chan ports.ClientEvent, 128)
28 input := make(chan ports.InputEvent, 32)29 input := make(chan ports.InputEvent, 32)
29 output := make(chan ports.OutputEvent, 8)30 output := make(chan ports.OutputEvent, 8)
@@ -195,6 +196,7 @@ func TestBoundReleaseSwallowed(t *testing.T) {
195 196
196 func TestPointerFocusAndGrab(t *testing.T) {197 func TestPointerFocusAndGrab(t *testing.T) {
197 cfg := config.Defaults()198 cfg := config.Defaults()
199+ cfg.Border.Width = 0
198 client := make(chan ports.ClientEvent, 8)200 client := make(chan ports.ClientEvent, 8)
199 input := make(chan ports.InputEvent, 8)201 input := make(chan ports.InputEvent, 8)
200 output := make(chan ports.OutputEvent, 8)202 output := make(chan ports.OutputEvent, 8)
@@ -246,3 +248,35 @@ func TestPointerFocusAndGrab(t *testing.T) {
246 t.Fatal(v)248 t.Fatal(v)
247 }249 }
248 }250 }
251+
252+func TestBorderInset(t *testing.T) {
253+ cfg := config.Defaults()
254+ cfg.Border.Width = 2
255+ client := make(chan ports.ClientEvent, 8)
256+ input := make(chan ports.InputEvent, 8)
257+ output := make(chan ports.OutputEvent, 8)
258+ commands := make(chan ports.ClientCommand, 64)
259+ scenes := make(chan ports.Scene, 1)
260+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
261+ if err != nil {
262+ t.Fatal(err)
263+ }
264+ ctx, cancel := context.WithCancel(context.Background())
265+ defer cancel()
266+ go c.Run(ctx)
267+ output <- ports.OutputMode{Width: 100, Height: 80}
268+ receive(t, scenes)
269+ client <- ports.WindowMapped{ID: 1}
270+ r := receive(t, scenes).Windows[0].Rect
271+ // The scene keeps the outer rect; the client is configured 2px smaller on each side.
272+ if v := receive(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W - 4, Height: r.H - 4, Activated: true}) {
273+ t.Fatalf("%v for outer %v", v, r)
274+ }
275+ for len(commands) > 0 {
276+ <-commands
277+ }
278+ input <- ports.PointerMotion{X: float64(r.X + 2), Y: float64(r.Y + 5), TimeMsec: 1}
279+ if v := receive(t, commands); v != (ports.PointerFocus{ID: 1, X: 0, Y: 3}) {
280+ t.Fatal(v)
281+ }
282+}

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 26

Tags 10

↑↓ moveenter openesc close