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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 80 | 80 | flags := flag.NewFlagSet("nefertty", flag.ContinueOnError) | |
| 81 | 81 | backend := flags.String("backend", "drm", "drm or headless") | |
| 82 | 82 | screenshot := flags.String("screenshot", "", "write PNG frames to directory") | |
| 83 | + | size := flags.String("size", "1920x1080", "headless output size WxH") | |
| 83 | 84 | inputPath := flags.String("input", "", "headless input script path (- for stdin)") | |
| 84 | 85 | noTerminal := flags.Bool("no-terminal", false, "skip initial terminal") | |
| 85 | 86 | timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)") | |
| @@ -101,6 +102,12 @@ func run() error { | |||
| 101 | 102 | fmt.Fprintln(os.Stderr, err) | |
| 102 | 103 | 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 | 111 | if *inputPath != "" && *backend != "headless" { | |
| 105 | 112 | err := usageError{fmt.Errorf("--input requires --backend=headless")} | |
| 106 | 113 | fmt.Fprintln(os.Stderr, err) | |
| @@ -173,7 +180,7 @@ func run() error { | |||
| 173 | 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 | 181 | configLog := logging.For(ctx, "config") | |
| 175 | 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 | 184 | // SIGINT and SIGTERM cancel the context and are clean exits. | |
| 178 | 185 | if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) { | |
| 179 | 186 | 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 err | 103 | 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 = nil | 186 | 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 | |||
| 366 | 366 | continue | |
| 367 | 367 | } | |
| 368 | 368 | 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 | 376 | content := contents[w.ID] | |
| 371 | 377 | if content.Pixels == nil { | |
| 372 | 378 | add(body, windowColor(w.ID), nil, image.Point{}) | |
| 373 | 379 | } else { | |
| 374 | 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 | 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 | 386 | borderColor := s.Border.Inactive | |
| 381 | 387 | if w.Focused { | |
| 382 | 388 | 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 | 391 | rgb := parseColor(borderColor) | |
| 386 | 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 | 393 | add(strip, rgb, nil, image.Point{}) | |
| @@ -366,22 +366,28 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||||
| 366 | continue | 366 | continue | ||
| 367 | } | 367 | } | ||
| 368 | x, y := w.Rect.X, w.Rect.Y | 368 | 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.Inactive | 386 | borderColor := s.Border.Inactive | ||
| 381 | if w.Focused { | 387 | if w.Focused { | ||
| 382 | borderColor = s.Border.Active | 388 | 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 { | |||
| 13 | 13 | func() error { return registerXDG(d, s) }, | |
| 14 | 14 | func() error { return registerLayer(d, o, s) }, | |
| 15 | 15 | func() error { return registerXDGOutput(d, o) }, | |
| 16 | + | func() error { return registerDecoration(d) }, | |
| 16 | 17 | func() error { | |
| 17 | 18 | return wayland.NewCompositorGlobal(d, 6, func(c server.Client, v, id uint32) { wayland.NewCompositor(c, int32(v), id, compositor{s}) }) | |
| 18 | 19 | }, | |
| @@ -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) { | |||
| 388 | 388 | if c.Activated { | |
| 389 | 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 | 400 | w.toplevel.SendConfigure(int32(c.Width), int32(c.Height), states) | |
| 392 | 401 | s.serial++ | |
| 393 | 402 | 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 { | |||
| 23 | 23 | server *Server | |
| 24 | 24 | current, pending *wayland.Buffer | |
| 25 | 25 | attached bool | |
| 26 | + | lastW, lastH int // last logged buffer size | |
| 26 | 27 | callbacks []*wayland.Callback | |
| 27 | 28 | role func(bool) | |
| 28 | 29 | destroyed bool | |
| @@ -87,6 +88,10 @@ func (s *surface) Commit(*wayland.Surface) { | |||
| 87 | 88 | b := s.current | |
| 88 | 89 | if state, ok := s.server.buffers[b.Resource]; ok { | |
| 89 | 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 | 95 | s.server.emitContent(c) | |
| 91 | 96 | } else { | |
| 92 | 97 | state.pool.shm.PostError(uint32(wayland.ShmErrorInvalidFd), "SHM backing file truncated") | |
| @@ -23,6 +23,7 @@ type surface struct { | |||||
| 23 | server *Server | 23 | server *Server | ||
| 24 | current, pending *wayland.Buffer | 24 | current, pending *wayland.Buffer | ||
| 25 | attached bool | 25 | attached bool | ||
| 26 | + | lastW, lastH int // last logged buffer size | |||
| 26 | callbacks []*wayland.Callback | 27 | callbacks []*wayland.Callback | ||
| 27 | role func(bool) | 28 | role func(bool) | ||
| 28 | destroyed bool | 29 | destroyed bool | ||
| @@ -87,6 +88,10 @@ func (s *surface) Commit(*wayland.Surface) { | |||||
| 87 | b := s.current | 88 | 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 | |||
| 152 | 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 | 164 | func (x *xdgSurface) AckConfigure(r *xdgshell.Surface, serial uint32) { | |
| 157 | 165 | for i, issued := range x.serials { | |
| 158 | 166 | 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 ( | |||
| 5 | 5 | "bufio" | |
| 6 | 6 | "context" | |
| 7 | 7 | "errors" | |
| 8 | + | "fmt" | |
| 8 | 9 | "io" | |
| 9 | 10 | "os" | |
| 10 | 11 | "sync" | |
| @@ -31,6 +32,8 @@ type Options struct { | |||
| 31 | 32 | Timeout time.Duration | |
| 32 | 33 | NoTerminal bool | |
| 33 | 34 | ScreenshotDir string | |
| 35 | + | // Width and Height size the headless output; 0 means 1920x1080. | |
| 36 | + | Width, Height int | |
| 34 | 37 | // Run closes Script on shutdown; the reader goroutine exits after Close. | |
| 35 | 38 | Script io.ReadCloser | |
| 36 | 39 | testScenes chan<- ports.Scene | |
| @@ -48,7 +51,10 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 48 | 51 | ctx, cancel = context.WithCancel(ctx) | |
| 49 | 52 | } | |
| 50 | 53 | 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 | + | } | |
| 52 | 58 | var outInfo wayland.OutputInfo | |
| 53 | 59 | var hw *drmBackend | |
| 54 | 60 | if opts.Backend == "drm" { | |
| @@ -156,7 +162,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 156 | 162 | }) | |
| 157 | 163 | 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 | 166 | r, err := vulkan.New(w, h) | |
| 161 | 167 | if err != nil { | |
| 162 | 168 | return nil, err | |
| @@ -218,7 +224,12 @@ func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors | |||
| 218 | 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 | 233 | case err := <-configErrors: | |
| 223 | 234 | configLog.Warn().Err(err).Msg("config rejected") | |
| 224 | 235 | } | |
| @@ -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.Duration | 32 | Timeout time.Duration | ||
| 32 | NoTerminal bool | 33 | NoTerminal bool | ||
| 33 | ScreenshotDir string | 34 | 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.ReadCloser | 38 | Script io.ReadCloser | ||
| 36 | testScenes chan<- ports.Scene | 39 | 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, 1080 | 54 | + | width, height := opts.Width, opts.Height |
| 55 | + | if width <= 0 || height <= 0 { | |||
| 56 | + | width, height = 1920, 1080 | |||
| 57 | + | } | |||
| 52 | var outInfo wayland.OutputInfo | 58 | var outInfo wayland.OutputInfo | ||
| 53 | var hw *drmBackend | 59 | 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 | return | 163 | 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, err | 168 | 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 { | |||
| 145 | 145 | 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 | 159 | func (c *Core) publish(ctx context.Context) error { | |
| 149 | 160 | c.seq++ | |
| 150 | 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 | 175 | 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 | 179 | if old, ok := c.sent[p.ID]; !ok || old != v { | |
| 168 | 180 | if err := c.command(ctx, v); err != nil { | |
| 169 | 181 | return err | |
| @@ -280,7 +292,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 280 | 292 | var x, y float64 | |
| 281 | 293 | // Fullscreen wins; otherwise the last visible placement is topmost. | |
| 282 | 294 | for _, p := range c.ws.Layout() { | |
| 283 | - | r := p.Rect | |
| 295 | + | r := c.clientRect(p) | |
| 284 | 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 | 297 | if id == 0 || p.Fullscreen { | |
| 286 | 298 | 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 nil | 145 | 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 | continue | 175 | 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 err | 181 | return err | ||
| @@ -280,7 +292,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 280 | var x, y float64 | 292 | 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.Rect | 295 | + | 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 { | |||
| 24 | 24 | func TestOwner(t *testing.T) { | |
| 25 | 25 | cfg := config.Defaults() | |
| 26 | 26 | cfg.Keyboard.CmdKey = "alt" | |
| 27 | + | cfg.Border.Width = 0 | |
| 27 | 28 | client := make(chan ports.ClientEvent, 128) | |
| 28 | 29 | input := make(chan ports.InputEvent, 32) | |
| 29 | 30 | output := make(chan ports.OutputEvent, 8) | |
| @@ -195,6 +196,7 @@ func TestBoundReleaseSwallowed(t *testing.T) { | |||
| 195 | 196 | ||
| 196 | 197 | func TestPointerFocusAndGrab(t *testing.T) { | |
| 197 | 198 | cfg := config.Defaults() | |
| 199 | + | cfg.Border.Width = 0 | |
| 198 | 200 | client := make(chan ports.ClientEvent, 8) | |
| 199 | 201 | input := make(chan ports.InputEvent, 8) | |
| 200 | 202 | output := make(chan ports.OutputEvent, 8) | |
| @@ -246,3 +248,35 @@ func TestPointerFocusAndGrab(t *testing.T) { | |||
| 246 | 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 | + | } | |
| @@ -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 | + | } | |||