detached 4dd986f

merge: drm backend

commit
4dd986f774c571d7028427b64d6d24c9e3bf1079
author
brice · · 2026-09-24 09:38 UTC
merge of
b5740ee + e3eccfd · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
31 files · +1959 -73
M .gitignore +1 -0
@@ -1,4 +1,5 @@
11 /nefertty
2+/bin/
23 *.test
34 *.out
45 coverage/
@@ -1,4 +1,5 @@
1 /nefertty1 /nefertty
2+/bin/
2 *.test3 *.test
3 *.out4 *.out
4 coverage/5 coverage/
M Makefile +17 -1
@@ -1,4 +1,20 @@
1-.PHONY: build test vet race mocks mocks-check arch check
1+.PHONY: build test vet race mocks mocks-check arch check bin tty logs
2+
3+TTY_TIMEOUT ?= 60s
4+RUNS := $(or $(XDG_STATE_HOME),$(HOME)/.local/state)/nefertty/runs
5+
6+# Build a fresh binary with the git revision embedded.
7+bin:
8+ CGO_ENABLED=0 go build -o bin/nefertty ./cmd/nefertty
9+
10+# Run on the current TTY. Quit: Ctrl+Alt+Backspace, or wait TTY_TIMEOUT.
11+tty: bin
12+ ./bin/nefertty --backend=drm --debug=all --timeout=$(TTY_TIMEOUT); \
13+ echo "exit $$? - log: $(RUNS)/latest.log"
14+
15+# Summarise the last run: lifecycle, warnings and errors.
16+logs:
17+ @jq -r 'select(.level != "debug") | "\(.time) \(.level) [\(.component)] \(.message) \(del(.time,.level,.component,.message) | tostring)"' $(RUNS)/latest.log
218 build:
319 CGO_ENABLED=0 go build ./...
420 test:
@@ -1,4 +1,20 @@
1-.PHONY: build test vet race mocks mocks-check arch check1+.PHONY: build test vet race mocks mocks-check arch check bin tty logs
2+
3+TTY_TIMEOUT ?= 60s
4+RUNS := $(or $(XDG_STATE_HOME),$(HOME)/.local/state)/nefertty/runs
5+
6+# Build a fresh binary with the git revision embedded.
7+bin:
8+ CGO_ENABLED=0 go build -o bin/nefertty ./cmd/nefertty
9+
10+# Run on the current TTY. Quit: Ctrl+Alt+Backspace, or wait TTY_TIMEOUT.
11+tty: bin
12+ ./bin/nefertty --backend=drm --debug=all --timeout=$(TTY_TIMEOUT); \
13+ echo "exit $$? - log: $(RUNS)/latest.log"
14+
15+# Summarise the last run: lifecycle, warnings and errors.
16+logs:
17+ @jq -r 'select(.level != "debug") | "\(.time) \(.level) [\(.component)] \(.message) \(del(.time,.level,.component,.message) | tostring)"' $(RUNS)/latest.log
2 build:18 build:
3 CGO_ENABLED=0 go build ./...19 CGO_ENABLED=0 go build ./...
4 test:20 test:
M README.md +1 -1
@@ -16,7 +16,7 @@ Configuration lives at `$XDG_CONFIG_HOME/nefertty/config.toml` (or `~/.config/ne
1616 [terminal]
1717 command = ["foot"]
1818 [layout]
19-gaps = 8
19+gaps = 0
2020 ```
2121
2222 See `examples/config.toml` for all defaults. Configuration reloads automatically when the file changes (including atomic replacements). Layout gaps, widths and presets, binds, background, and terminal command apply live; `keyboard.*` requires a restart for now. Invalid or removed files leave the current configuration in place.
@@ -16,7 +16,7 @@ Configuration lives at `$XDG_CONFIG_HOME/nefertty/config.toml` (or `~/.config/ne
16 [terminal]16 [terminal]
17 command = ["foot"]17 command = ["foot"]
18 [layout]18 [layout]
19-gaps = 819+gaps = 0
20 ```20 ```
21 21
22 See `examples/config.toml` for all defaults. Configuration reloads automatically when the file changes (including atomic replacements). Layout gaps, widths and presets, binds, background, and terminal command apply live; `keyboard.*` requires a restart for now. Invalid or removed files leave the current configuration in place.22 See `examples/config.toml` for all defaults. Configuration reloads automatically when the file changes (including atomic replacements). Layout gaps, widths and presets, binds, background, and terminal command apply live; `keyboard.*` requires a restart for now. Invalid or removed files leave the current configuration in place.
M cmd/nefertty/main.go +33 -9
@@ -10,6 +10,7 @@ import (
1010 "runtime/debug"
1111 "strings"
1212 "syscall"
13+ "time"
1314
1415 "github.com/bnema/nefertty/internal/adapters/config"
1516 "github.com/bnema/nefertty/internal/app"
@@ -79,6 +80,7 @@ func run() error {
7980 flags := flag.NewFlagSet("nefertty", flag.ContinueOnError)
8081 backend := flags.String("backend", "drm", "drm or headless")
8182 screenshot := flags.String("screenshot", "", "write PNG frames to directory")
83+ size := flags.String("size", "1920x1080", "headless output size WxH")
8284 inputPath := flags.String("input", "", "headless input script path (- for stdin)")
8385 noTerminal := flags.Bool("no-terminal", false, "skip initial terminal")
8486 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")
@@ -100,6 +102,12 @@ func run() error {
100102 fmt.Fprintln(os.Stderr, err)
101103 return err
102104 }
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+ }
103111 if *inputPath != "" && *backend != "headless" {
104112 err := usageError{fmt.Errorf("--input requires --backend=headless")}
105113 fmt.Fprintln(os.Stderr, err)
@@ -161,15 +169,31 @@ func run() error {
161169 }
162170 defer closeLog()
163171 log := logging.For(ctx, "app")
172+ defer func() {
173+ if p := recover(); p != nil {
174+ log.Error().Interface("panic", p).Str("stack", string(debug.Stack())).Msg("exit")
175+ _ = closeLog()
176+ panic(p)
177+ }
178+ }()
179+ start := time.Now()
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")
164181 configLog := logging.For(ctx, "config")
165182 configLog.Info().Str("path", path).Msg("loaded config")
166- if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot, Script: script}); err != nil {
167- // SIGINT and SIGTERM cancel the context and are clean exits.
168- if ctx.Err() != nil && errors.Is(err, context.Canceled) {
169- return nil
170- }
171- log.Error().Err(err).Msg("application failed")
172- return err
173- }
174- return nil
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})
184+ // SIGINT and SIGTERM cancel the context and are clean exits.
185+ if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) {
186+ err = nil
187+ }
188+ reason := "clean"
189+ switch {
190+ case err != nil:
191+ reason = "error"
192+ case ctx.Err() != nil:
193+ reason = "signal"
194+ case *timeout > 0 && time.Since(start) >= *timeout:
195+ reason = "timeout"
196+ }
197+ log.Info().Str("reason", reason).AnErr("error", err).Dur("uptime", time.Since(start)).Msg("exit")
198+ return err
175199 }
@@ -10,6 +10,7 @@ import (
10 "runtime/debug"10 "runtime/debug"
11 "strings"11 "strings"
12 "syscall"12 "syscall"
13+ "time"
13 14
14 "github.com/bnema/nefertty/internal/adapters/config"15 "github.com/bnema/nefertty/internal/adapters/config"
15 "github.com/bnema/nefertty/internal/app"16 "github.com/bnema/nefertty/internal/app"
@@ -79,6 +80,7 @@ func run() error {
79 flags := flag.NewFlagSet("nefertty", flag.ContinueOnError)80 flags := flag.NewFlagSet("nefertty", flag.ContinueOnError)
80 backend := flags.String("backend", "drm", "drm or headless")81 backend := flags.String("backend", "drm", "drm or headless")
81 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")
82 inputPath := flags.String("input", "", "headless input script path (- for stdin)")84 inputPath := flags.String("input", "", "headless input script path (- for stdin)")
83 noTerminal := flags.Bool("no-terminal", false, "skip initial terminal")85 noTerminal := flags.Bool("no-terminal", false, "skip initial terminal")
84 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")86 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")
@@ -100,6 +102,12 @@ func run() error {
100 fmt.Fprintln(os.Stderr, err)102 fmt.Fprintln(os.Stderr, err)
101 return err103 return err
102 }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+ }
103 if *inputPath != "" && *backend != "headless" {111 if *inputPath != "" && *backend != "headless" {
104 err := usageError{fmt.Errorf("--input requires --backend=headless")}112 err := usageError{fmt.Errorf("--input requires --backend=headless")}
105 fmt.Fprintln(os.Stderr, err)113 fmt.Fprintln(os.Stderr, err)
@@ -161,15 +169,31 @@ func run() error {
161 }169 }
162 defer closeLog()170 defer closeLog()
163 log := logging.For(ctx, "app")171 log := logging.For(ctx, "app")
172+ defer func() {
173+ if p := recover(); p != nil {
174+ log.Error().Interface("panic", p).Str("stack", string(debug.Stack())).Msg("exit")
175+ _ = closeLog()
176+ panic(p)
177+ }
178+ }()
179+ start := time.Now()
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")
164 configLog := logging.For(ctx, "config")181 configLog := logging.For(ctx, "config")
165 configLog.Info().Str("path", path).Msg("loaded config")182 configLog.Info().Str("path", path).Msg("loaded config")
166- if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot, Script: script}); err != nil {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})
167- // SIGINT and SIGTERM cancel the context and are clean exits.184+ // SIGINT and SIGTERM cancel the context and are clean exits.
168- if ctx.Err() != nil && errors.Is(err, context.Canceled) {185+ if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) {
169- return nil186+ err = nil
170- }187+ }
171- log.Error().Err(err).Msg("application failed")188+ reason := "clean"
172- return err189+ switch {
173- }190+ case err != nil:
174- return nil191+ reason = "error"
192+ case ctx.Err() != nil:
193+ reason = "signal"
194+ case *timeout > 0 && time.Since(start) >= *timeout:
195+ reason = "timeout"
196+ }
197+ log.Info().Str("reason", reason).AnErr("error", err).Dur("uptime", time.Since(start)).Msg("exit")
198+ return err
175 }199 }
M examples/config.toml +19 -1
@@ -12,11 +12,29 @@ command = ["foot"]
1212 [background]
1313 color = "#1e1e2e"
1414
15+[border]
16+# Drawn inside the window edge; width = 0 disables it. inactive = "" draws nothing.
17+width = 2
18+active = "#b4befe"
19+inactive = ""
20+
1521 [layout]
16-gaps = 8
22+gaps = 0
1723 default_column_width = "1/2"
1824 presets = ["1/3", "1/2", "2/3", "1"]
1925
26+# Physical displays (drm backend). The first connected, enabled entry is used;
27+# without one, the first connected display that is not off. List names with
28+# `make tty` logs ("connector" lines) or wlr-randr.
29+# mode: "WxH" picks the highest refresh, "WxH@Hz" the closest; omit for preferred.
30+# [[output]]
31+# name = "DP-2"
32+# mode = "5120x2160@165.058"
33+#
34+# [[output]]
35+# name = "HDMI-A-1"
36+# off = true
37+
2038 [render]
2139 direct_scanout = true
2240
@@ -12,11 +12,29 @@ command = ["foot"]
12 [background]12 [background]
13 color = "#1e1e2e"13 color = "#1e1e2e"
14 14
15+[border]
16+# Drawn inside the window edge; width = 0 disables it. inactive = "" draws nothing.
17+width = 2
18+active = "#b4befe"
19+inactive = ""
20+
15 [layout]21 [layout]
16-gaps = 822+gaps = 0
17 default_column_width = "1/2"23 default_column_width = "1/2"
18 presets = ["1/3", "1/2", "2/3", "1"]24 presets = ["1/3", "1/2", "2/3", "1"]
19 25
26+# Physical displays (drm backend). The first connected, enabled entry is used;
27+# without one, the first connected display that is not off. List names with
28+# `make tty` logs ("connector" lines) or wlr-randr.
29+# mode: "WxH" picks the highest refresh, "WxH@Hz" the closest; omit for preferred.
30+# [[output]]
31+# name = "DP-2"
32+# mode = "5120x2160@165.058"
33+#
34+# [[output]]
35+# name = "HDMI-A-1"
36+# off = true
37+
20 [render]38 [render]
21 direct_scanout = true39 direct_scanout = true
22 40
M internal/adapters/config/config.go +41 -2
@@ -18,7 +18,7 @@ var defaultBinds = map[string]string{
1818 "Cmd+Return": "spawn-terminal", "Cmd+Left": "focus-column-left", "Cmd+Right": "focus-column-right", "Cmd+Up": "focus-window-up", "Cmd+Down": "focus-window-down", "Cmd+Shift+Left": "move-column-left", "Cmd+Shift+Right": "move-column-right", "Cmd+R": "cycle-column-width", "Cmd+Shift+F": "toggle-fullscreen", "Cmd+Q": "close-window", "Ctrl+Alt+BackSpace": "quit",
1919 }
2020 var actions = map[string]bool{"none": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "toggle-fullscreen": true, "close-window": true, "quit": true}
21-var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "render": true, "sync": true, "config": true, "app": true}
21+var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}
2222 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
2323
2424 func DefaultPath() string {
@@ -38,7 +38,10 @@ func Defaults() ports.Config {
3838 c.Keyboard.CmdKey = "super"
3939 c.Terminal.Command = []string{"foot"}
4040 c.Background.Color = "#1e1e2e"
41- c.Layout.Gaps = 8
41+ c.Border.Width = 2
42+ c.Border.Active = "#b4befe"
43+ c.Border.Inactive = ""
44+ c.Layout.Gaps = 0
4245 c.Layout.DefaultColumnWidth = "1/2"
4346 c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"}
4447 c.Binds = make(map[string]string, len(defaultBinds))
@@ -99,6 +102,24 @@ func Load(path string) (ports.Config, error) {
99102 }
100103 return c, nil
101104 }
105+
106+// ParseMode parses "WxH" or "WxH@Hz"; hz is 0 when omitted.
107+func ParseMode(s string) (w, h int, hz float64, err error) {
108+ size, rate, hasRate := strings.Cut(s, "@")
109+ ws, hs, ok := strings.Cut(size, "x")
110+ w, e1 := strconv.Atoi(ws)
111+ h, e2 := strconv.Atoi(hs)
112+ if !ok || e1 != nil || e2 != nil || w <= 0 || h <= 0 {
113+ return 0, 0, 0, fmt.Errorf("must be WxH or WxH@Hz, got %q", s)
114+ }
115+ if hasRate {
116+ if hz, err = strconv.ParseFloat(rate, 64); err != nil || hz <= 0 {
117+ return 0, 0, 0, fmt.Errorf("invalid refresh rate in %q", s)
118+ }
119+ }
120+ return w, h, hz, nil
121+}
122+
102123 func width(s string) bool {
103124 if s == "1" {
104125 return true
@@ -189,6 +210,24 @@ func Validate(c ports.Config) error {
189210 if !color.MatchString(c.Background.Color) {
190211 add("background.color", "must be #rrggbb")
191212 }
213+ if c.Border.Width < 0 || c.Border.Width > 32 {
214+ add("border.width", "must be between 0 and 32")
215+ }
216+ if !color.MatchString(c.Border.Active) {
217+ add("border.active", "must be #rrggbb")
218+ }
219+ if c.Border.Inactive != "" && !color.MatchString(c.Border.Inactive) {
220+ add("border.inactive", `must be #rrggbb or "" (none)`)
221+ }
222+ for i, o := range c.Outputs {
223+ path := fmt.Sprintf("output[%d]", i)
224+ if o.Name == "" {
225+ add(path+".name", "must not be empty")
226+ }
227+ if _, _, _, err := ParseMode(o.Mode); o.Mode != "" && err != nil {
228+ add(path+".mode", err.Error())
229+ }
230+ }
192231 if c.Layout.Gaps < 0 || c.Layout.Gaps > 200 {
193232 add("layout.gaps", "must be between 0 and 200")
194233 }
@@ -18,7 +18,7 @@ var defaultBinds = map[string]string{
18 "Cmd+Return": "spawn-terminal", "Cmd+Left": "focus-column-left", "Cmd+Right": "focus-column-right", "Cmd+Up": "focus-window-up", "Cmd+Down": "focus-window-down", "Cmd+Shift+Left": "move-column-left", "Cmd+Shift+Right": "move-column-right", "Cmd+R": "cycle-column-width", "Cmd+Shift+F": "toggle-fullscreen", "Cmd+Q": "close-window", "Ctrl+Alt+BackSpace": "quit",18 "Cmd+Return": "spawn-terminal", "Cmd+Left": "focus-column-left", "Cmd+Right": "focus-column-right", "Cmd+Up": "focus-window-up", "Cmd+Down": "focus-window-down", "Cmd+Shift+Left": "move-column-left", "Cmd+Shift+Right": "move-column-right", "Cmd+R": "cycle-column-width", "Cmd+Shift+F": "toggle-fullscreen", "Cmd+Q": "close-window", "Ctrl+Alt+BackSpace": "quit",
19 }19 }
20 var actions = map[string]bool{"none": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "toggle-fullscreen": true, "close-window": true, "quit": true}20 var actions = map[string]bool{"none": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "toggle-fullscreen": true, "close-window": true, "quit": true}
21-var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "render": true, "sync": true, "config": true, "app": true}21+var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}
22 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)22 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
23 23
24 func DefaultPath() string {24 func DefaultPath() string {
@@ -38,7 +38,10 @@ func Defaults() ports.Config {
38 c.Keyboard.CmdKey = "super"38 c.Keyboard.CmdKey = "super"
39 c.Terminal.Command = []string{"foot"}39 c.Terminal.Command = []string{"foot"}
40 c.Background.Color = "#1e1e2e"40 c.Background.Color = "#1e1e2e"
41- c.Layout.Gaps = 841+ c.Border.Width = 2
42+ c.Border.Active = "#b4befe"
43+ c.Border.Inactive = ""
44+ c.Layout.Gaps = 0
42 c.Layout.DefaultColumnWidth = "1/2"45 c.Layout.DefaultColumnWidth = "1/2"
43 c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"}46 c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"}
44 c.Binds = make(map[string]string, len(defaultBinds))47 c.Binds = make(map[string]string, len(defaultBinds))
@@ -99,6 +102,24 @@ func Load(path string) (ports.Config, error) {
99 }102 }
100 return c, nil103 return c, nil
101 }104 }
105+
106+// ParseMode parses "WxH" or "WxH@Hz"; hz is 0 when omitted.
107+func ParseMode(s string) (w, h int, hz float64, err error) {
108+ size, rate, hasRate := strings.Cut(s, "@")
109+ ws, hs, ok := strings.Cut(size, "x")
110+ w, e1 := strconv.Atoi(ws)
111+ h, e2 := strconv.Atoi(hs)
112+ if !ok || e1 != nil || e2 != nil || w <= 0 || h <= 0 {
113+ return 0, 0, 0, fmt.Errorf("must be WxH or WxH@Hz, got %q", s)
114+ }
115+ if hasRate {
116+ if hz, err = strconv.ParseFloat(rate, 64); err != nil || hz <= 0 {
117+ return 0, 0, 0, fmt.Errorf("invalid refresh rate in %q", s)
118+ }
119+ }
120+ return w, h, hz, nil
121+}
122+
102 func width(s string) bool {123 func width(s string) bool {
103 if s == "1" {124 if s == "1" {
104 return true125 return true
@@ -189,6 +210,24 @@ func Validate(c ports.Config) error {
189 if !color.MatchString(c.Background.Color) {210 if !color.MatchString(c.Background.Color) {
190 add("background.color", "must be #rrggbb")211 add("background.color", "must be #rrggbb")
191 }212 }
213+ if c.Border.Width < 0 || c.Border.Width > 32 {
214+ add("border.width", "must be between 0 and 32")
215+ }
216+ if !color.MatchString(c.Border.Active) {
217+ add("border.active", "must be #rrggbb")
218+ }
219+ if c.Border.Inactive != "" && !color.MatchString(c.Border.Inactive) {
220+ add("border.inactive", `must be #rrggbb or "" (none)`)
221+ }
222+ for i, o := range c.Outputs {
223+ path := fmt.Sprintf("output[%d]", i)
224+ if o.Name == "" {
225+ add(path+".name", "must not be empty")
226+ }
227+ if _, _, _, err := ParseMode(o.Mode); o.Mode != "" && err != nil {
228+ add(path+".mode", err.Error())
229+ }
230+ }
192 if c.Layout.Gaps < 0 || c.Layout.Gaps > 200 {231 if c.Layout.Gaps < 0 || c.Layout.Gaps > 200 {
193 add("layout.gaps", "must be between 0 and 200")232 add("layout.gaps", "must be between 0 and 200")
194 }233 }
M internal/adapters/config/config_test.go +27 -0
@@ -86,3 +86,30 @@ func TestBindMerge(t *testing.T) {
8686 t.Fatal(c.Binds)
8787 }
8888 }
89+
90+func TestOutputs(t *testing.T) {
91+ path := filepath.Join(t.TempDir(), "c.toml")
92+ good := "[[output]]\nname = \"DP-2\"\nmode = \"5120x2160@165.058\"\n\n[[output]]\nname = \"HDMI-A-1\"\noff = true\n"
93+ if err := os.WriteFile(path, []byte(good), 0o600); err != nil {
94+ t.Fatal(err)
95+ }
96+ c, err := Load(path)
97+ if err != nil {
98+ t.Fatal(err)
99+ }
100+ if len(c.Outputs) != 2 || c.Outputs[0].Name != "DP-2" || !c.Outputs[1].Off {
101+ t.Fatalf("%+v", c.Outputs)
102+ }
103+ for _, bad := range []string{"[[output]]\nmode = \"1x1\"\n", "[[output]]\nname = \"DP-2\"\nmode = \"big\"\n", "[[output]]\nname = \"DP-2\"\nmode = \"1920x1080@0\"\n", "[[output]]\nname = \"DP-2\"\nscale = 2\n"} {
104+ if err := os.WriteFile(path, []byte(bad), 0o600); err != nil {
105+ t.Fatal(err)
106+ }
107+ if _, err := Load(path); err == nil {
108+ t.Errorf("accepted %q", bad)
109+ }
110+ }
111+ w, h, hz, err := ParseMode("3440x1440")
112+ if err != nil || w != 3440 || h != 1440 || hz != 0 {
113+ t.Fatalf("ParseMode: %d %d %v %v", w, h, hz, err)
114+ }
115+}
@@ -86,3 +86,30 @@ func TestBindMerge(t *testing.T) {
86 t.Fatal(c.Binds)86 t.Fatal(c.Binds)
87 }87 }
88 }88 }
89+
90+func TestOutputs(t *testing.T) {
91+ path := filepath.Join(t.TempDir(), "c.toml")
92+ good := "[[output]]\nname = \"DP-2\"\nmode = \"5120x2160@165.058\"\n\n[[output]]\nname = \"HDMI-A-1\"\noff = true\n"
93+ if err := os.WriteFile(path, []byte(good), 0o600); err != nil {
94+ t.Fatal(err)
95+ }
96+ c, err := Load(path)
97+ if err != nil {
98+ t.Fatal(err)
99+ }
100+ if len(c.Outputs) != 2 || c.Outputs[0].Name != "DP-2" || !c.Outputs[1].Off {
101+ t.Fatalf("%+v", c.Outputs)
102+ }
103+ for _, bad := range []string{"[[output]]\nmode = \"1x1\"\n", "[[output]]\nname = \"DP-2\"\nmode = \"big\"\n", "[[output]]\nname = \"DP-2\"\nmode = \"1920x1080@0\"\n", "[[output]]\nname = \"DP-2\"\nscale = 2\n"} {
104+ if err := os.WriteFile(path, []byte(bad), 0o600); err != nil {
105+ t.Fatal(err)
106+ }
107+ if _, err := Load(path); err == nil {
108+ t.Errorf("accepted %q", bad)
109+ }
110+ }
111+ w, h, hz, err := ParseMode("3440x1440")
112+ if err != nil || w != 3440 || h != 1440 || hz != 0 {
113+ t.Fatalf("ParseMode: %d %d %v %v", w, h, hz, err)
114+ }
115+}
A internal/adapters/drm/edid.go +63 -0
@@ -0,0 +1,63 @@
1+package drm
2+
3+import (
4+ "bufio"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+)
9+
10+// Monitor identifies the display attached to a connector.
11+type Monitor struct {
12+ Make, Model, Serial string
13+}
14+
15+// readMonitor parses the connector's EDID from sysfs. Missing data yields "Unknown".
16+func readMonitor(card, connector string) Monitor {
17+ data, err := os.ReadFile(filepath.Join("/sys/class/drm", filepath.Base(card)+"-"+connector, "edid"))
18+ if err != nil {
19+ return Monitor{Make: "Unknown", Model: "Unknown"}
20+ }
21+ return parseEDID(data)
22+}
23+
24+func parseEDID(d []byte) Monitor {
25+ m := Monitor{Make: "Unknown", Model: "Unknown"}
26+ if len(d) < 128 || string(d[:8]) != "\x00\xff\xff\xff\xff\xff\xff\x00" {
27+ return m
28+ }
29+ id := uint16(d[8])<<8 | uint16(d[9])
30+ pnp := string([]byte{byte(id>>10&0x1f) + '@', byte(id>>5&0x1f) + '@', byte(id&0x1f) + '@'})
31+ m.Make = pnpName(pnp)
32+ // Display descriptors: 0xFC name, 0xFF serial.
33+ for off := 54; off+18 <= 126; off += 18 {
34+ b := d[off : off+18]
35+ if b[0] != 0 || b[1] != 0 {
36+ continue
37+ }
38+ text := strings.TrimRight(strings.SplitN(string(b[5:18]), "\n", 2)[0], " ")
39+ switch b[3] {
40+ case 0xfc:
41+ m.Model = text
42+ case 0xff:
43+ m.Serial = text
44+ }
45+ }
46+ return m
47+}
48+
49+// pnpName maps a PNP id to a vendor name via hwdata, falling back to the id.
50+func pnpName(id string) string {
51+ f, err := os.Open("/usr/share/hwdata/pnp.ids")
52+ if err != nil {
53+ return id
54+ }
55+ defer f.Close()
56+ s := bufio.NewScanner(f)
57+ for s.Scan() {
58+ if name, ok := strings.CutPrefix(s.Text(), id+"\t"); ok {
59+ return name
60+ }
61+ }
62+ return id
63+}
@@ -0,0 +1,63 @@
1+package drm
2+
3+import (
4+ "bufio"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+)
9+
10+// Monitor identifies the display attached to a connector.
11+type Monitor struct {
12+ Make, Model, Serial string
13+}
14+
15+// readMonitor parses the connector's EDID from sysfs. Missing data yields "Unknown".
16+func readMonitor(card, connector string) Monitor {
17+ data, err := os.ReadFile(filepath.Join("/sys/class/drm", filepath.Base(card)+"-"+connector, "edid"))
18+ if err != nil {
19+ return Monitor{Make: "Unknown", Model: "Unknown"}
20+ }
21+ return parseEDID(data)
22+}
23+
24+func parseEDID(d []byte) Monitor {
25+ m := Monitor{Make: "Unknown", Model: "Unknown"}
26+ if len(d) < 128 || string(d[:8]) != "\x00\xff\xff\xff\xff\xff\xff\x00" {
27+ return m
28+ }
29+ id := uint16(d[8])<<8 | uint16(d[9])
30+ pnp := string([]byte{byte(id>>10&0x1f) + '@', byte(id>>5&0x1f) + '@', byte(id&0x1f) + '@'})
31+ m.Make = pnpName(pnp)
32+ // Display descriptors: 0xFC name, 0xFF serial.
33+ for off := 54; off+18 <= 126; off += 18 {
34+ b := d[off : off+18]
35+ if b[0] != 0 || b[1] != 0 {
36+ continue
37+ }
38+ text := strings.TrimRight(strings.SplitN(string(b[5:18]), "\n", 2)[0], " ")
39+ switch b[3] {
40+ case 0xfc:
41+ m.Model = text
42+ case 0xff:
43+ m.Serial = text
44+ }
45+ }
46+ return m
47+}
48+
49+// pnpName maps a PNP id to a vendor name via hwdata, falling back to the id.
50+func pnpName(id string) string {
51+ f, err := os.Open("/usr/share/hwdata/pnp.ids")
52+ if err != nil {
53+ return id
54+ }
55+ defer f.Close()
56+ s := bufio.NewScanner(f)
57+ for s.Scan() {
58+ if name, ok := strings.CutPrefix(s.Text(), id+"\t"); ok {
59+ return name
60+ }
61+ }
62+ return id
63+}
A internal/adapters/drm/kms.go +351 -0
@@ -0,0 +1,351 @@
1+// Package drm drives one KMS output with raw ioctls and dumb buffers.
2+package drm
3+
4+import (
5+ "errors"
6+ "fmt"
7+ "math"
8+ "unsafe"
9+
10+ "golang.org/x/sys/unix"
11+)
12+
13+// ioctl numbers for amd64/arm64 (DRM_IOWR('d', nr, size)).
14+const (
15+ ioctlGetResources = 0xC04064A0
16+ ioctlGetCrtc = 0xC06864A1
17+ ioctlSetCrtc = 0xC06864A2
18+ ioctlGetEncoder = 0xC01464A6
19+ ioctlGetConnector = 0xC05064A7
20+ ioctlAddFB = 0xC01C64AE
21+ ioctlRmFB = 0xC00464AF
22+ ioctlPageFlip = 0xC01864B0
23+ ioctlCreateDumb = 0xC02064B2
24+ ioctlMapDumb = 0xC01064B3
25+ ioctlDestroyDumb = 0xC00464B4
26+
27+ pageFlipEvent = 1
28+ eventFlipDone = 2
29+ modeTypePrefered = 1 << 3
30+ connected = 1
31+)
32+
33+type cardRes struct {
34+ fbs, crtcs, connectors, encoders uint64
35+ countFbs, countCrtcs, countConns, countEncoders uint32
36+ minW, maxW, minH, maxH uint32
37+}
38+
39+type modeInfo struct {
40+ Clock uint32
41+ HDisplay, HSyncStart, HSyncEnd, HTotal uint16
42+ HSkew uint16
43+ VDisplay, VSyncStart, VSyncEnd, VTotal uint16
44+ VScan uint16
45+ VRefresh, Flags, Type uint32
46+ Name [32]byte
47+}
48+
49+func (m modeInfo) String() string {
50+ return fmt.Sprintf("%dx%d@%.3f", m.HDisplay, m.VDisplay, float64(m.refreshMilli())/1000)
51+}
52+
53+type getConnector struct {
54+ encoders, modes, props, propValues uint64
55+ countModes, countProps, countEncoders uint32
56+ encoderID, connectorID, connType, typeID uint32
57+ connection, mmW, mmH, subpixel uint32
58+ _ uint32
59+}
60+
61+type getEncoder struct{ encoderID, encoderType, crtcID, possibleCrtcs, possibleClones uint32 }
62+
63+type modeCrtc struct {
64+ // unsafe.Pointer (not uint64) so the GC keeps it valid if the stack moves.
65+ setConnectors unsafe.Pointer
66+ countConnectors uint32
67+ crtcID, fbID uint32
68+ x, y uint32
69+ gammaSize uint32
70+ modeValid uint32
71+ mode modeInfo
72+}
73+
74+type pageFlip struct {
75+ crtcID, fbID, flags, reserved uint32
76+ userData uint64
77+}
78+
79+type createDumb struct {
80+ height, width, bpp, flags uint32
81+ handle, pitch uint32
82+ size uint64
83+}
84+
85+type mapDumb struct {
86+ handle, pad uint32
87+ offset uint64
88+}
89+
90+type fbCmd struct{ fbID, width, height, pitch, bpp, depth, handle uint32 }
91+
92+func ioctl(fd int, req uintptr, arg unsafe.Pointer) error {
93+ for {
94+ _, _, e := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), req, uintptr(arg))
95+ if e == unix.EINTR || e == unix.EAGAIN {
96+ continue
97+ }
98+ if e != 0 {
99+ return e
100+ }
101+ return nil
102+ }
103+}
104+
105+// connector is the subset of KMS connector state used to pick an output.
106+type connector struct {
107+ id, encoderID uint32
108+ name string
109+ connected bool
110+ mmW, mmH int
111+ modes []modeInfo
112+ encoders []uint32
113+}
114+
115+var connectorTypes = map[uint32]string{1: "VGA", 2: "DVI-I", 3: "DVI-D", 10: "DP", 11: "HDMI-A", 12: "HDMI-B", 7: "LVDS", 14: "eDP", 15: "Virtual", 16: "DSI", 17: "DPI", 20: "USB"}
116+
117+// Want selects a connector and mode; see ports.OutputConfig.
118+type Want struct {
119+ Name string
120+ W, H int
121+ Hz float64 // 0: highest refresh at W x H
122+ Disabled map[string]bool
123+ // Strict accepts only the connector named Name (used to search every card first).
124+ Strict bool
125+}
126+
127+// refreshMilli is the exact refresh in mHz, computed from the timings like wlroots.
128+func (m modeInfo) refreshMilli() int {
129+ if m.HTotal == 0 || m.VTotal == 0 {
130+ return int(m.VRefresh) * 1000
131+ }
132+ r := (int64(m.Clock)*1_000_000/int64(m.HTotal) + int64(m.VTotal)/2) / int64(m.VTotal)
133+ if m.Flags&(1<<4) != 0 { // interlace
134+ r *= 2
135+ }
136+ if m.Flags&(1<<5) != 0 { // doublescan
137+ r /= 2
138+ }
139+ if m.VScan > 1 {
140+ r /= int64(m.VScan)
141+ }
142+ return int(r)
143+}
144+
145+// pickConnector applies want: the named connector if connected, else the first
146+// connected one that is not disabled. The mode is the requested one, else preferred.
147+func pickConnector(conns []connector, want Want) (connector, modeInfo, error) {
148+ var chosen *connector
149+ for i := range conns {
150+ c := &conns[i]
151+ if !c.connected || len(c.modes) == 0 || want.Disabled[c.name] {
152+ continue
153+ }
154+ if c.name == want.Name {
155+ chosen = c
156+ break
157+ }
158+ if chosen == nil && !want.Strict {
159+ chosen = c
160+ }
161+ }
162+ if chosen == nil {
163+ return connector{}, modeInfo{}, errors.New("no connected display")
164+ }
165+ if chosen.name == want.Name && want.W > 0 {
166+ if m, ok := pickMode(chosen.modes, want.W, want.H, want.Hz); ok {
167+ return *chosen, m, nil
168+ }
169+ }
170+ for _, m := range chosen.modes {
171+ if m.Type&modeTypePrefered != 0 {
172+ return *chosen, m, nil
173+ }
174+ }
175+ return *chosen, chosen.modes[0], nil
176+}
177+
178+// pickMode finds W x H with the refresh closest to hz, or the highest when hz is 0.
179+func pickMode(modes []modeInfo, w, h int, hz float64) (modeInfo, bool) {
180+ best, found := modeInfo{}, false
181+ score := func(m modeInfo) float64 {
182+ r := float64(m.refreshMilli()) / 1000
183+ if hz == 0 {
184+ return -r
185+ }
186+ return math.Abs(r - hz)
187+ }
188+ for _, m := range modes {
189+ if int(m.HDisplay) != w || int(m.VDisplay) != h || m.Flags&(1<<4) != 0 {
190+ continue
191+ }
192+ if !found || score(m) < score(best) {
193+ best, found = m, true
194+ }
195+ }
196+ return best, found
197+}
198+
199+func resources(fd int) (crtcs, conns []uint32, err error) {
200+ var r cardRes
201+ if err := ioctl(fd, ioctlGetResources, unsafe.Pointer(&r)); err != nil {
202+ return nil, nil, fmt.Errorf("get resources: %w", err)
203+ }
204+ crtcs = make([]uint32, r.countCrtcs)
205+ conns = make([]uint32, r.countConns)
206+ r = cardRes{countCrtcs: r.countCrtcs, countConns: r.countConns}
207+ if len(crtcs) > 0 {
208+ r.crtcs = uint64(uintptr(unsafe.Pointer(&crtcs[0])))
209+ }
210+ if len(conns) > 0 {
211+ r.connectors = uint64(uintptr(unsafe.Pointer(&conns[0])))
212+ }
213+ if err := ioctl(fd, ioctlGetResources, unsafe.Pointer(&r)); err != nil {
214+ return nil, nil, fmt.Errorf("get resources: %w", err)
215+ }
216+ return crtcs[:min(int(r.countCrtcs), len(crtcs))], conns[:min(int(r.countConns), len(conns))], nil
217+}
218+
219+func readConnector(fd int, id uint32) (connector, error) {
220+ g := getConnector{connectorID: id}
221+ if err := ioctl(fd, ioctlGetConnector, unsafe.Pointer(&g)); err != nil {
222+ return connector{}, err
223+ }
224+ modes := make([]modeInfo, g.countModes)
225+ encs := make([]uint32, g.countEncoders)
226+ g2 := getConnector{connectorID: id, countModes: g.countModes, countEncoders: g.countEncoders}
227+ if len(modes) > 0 {
228+ g2.modes = uint64(uintptr(unsafe.Pointer(&modes[0])))
229+ }
230+ if len(encs) > 0 {
231+ g2.encoders = uint64(uintptr(unsafe.Pointer(&encs[0])))
232+ }
233+ if err := ioctl(fd, ioctlGetConnector, unsafe.Pointer(&g2)); err != nil {
234+ return connector{}, err
235+ }
236+ name := connectorTypes[g2.connType]
237+ if name == "" {
238+ name = "Unknown"
239+ }
240+ return connector{
241+ id: id, encoderID: g2.encoderID, name: fmt.Sprintf("%s-%d", name, g2.typeID),
242+ connected: g2.connection == connected, mmW: int(g2.mmW), mmH: int(g2.mmH),
243+ modes: modes[:min(int(g2.countModes), len(modes))],
244+ encoders: encs[:min(int(g2.countEncoders), len(encs))],
245+ }, nil
246+}
247+
248+// pickCrtc prefers the connector's current encoder CRTC, else any CRTC an encoder allows.
249+func pickCrtc(fd int, c connector, crtcs []uint32) (uint32, error) {
250+ encs := c.encoders
251+ if c.encoderID != 0 {
252+ encs = append([]uint32{c.encoderID}, encs...)
253+ }
254+ for _, id := range encs {
255+ e := getEncoder{encoderID: id}
256+ if ioctl(fd, ioctlGetEncoder, unsafe.Pointer(&e)) != nil {
257+ continue
258+ }
259+ if id == c.encoderID && e.crtcID != 0 {
260+ return e.crtcID, nil
261+ }
262+ for i, crtc := range crtcs {
263+ if e.possibleCrtcs&(1<<i) != 0 {
264+ return crtc, nil
265+ }
266+ }
267+ }
268+ return 0, fmt.Errorf("no CRTC for %s", c.name)
269+}
270+
271+type dumbBuffer struct {
272+ handle, fbID, pitch uint32
273+ mem []byte
274+}
275+
276+func newDumb(fd int, w, h int) (*dumbBuffer, error) {
277+ c := createDumb{width: uint32(w), height: uint32(h), bpp: 32}
278+ if err := ioctl(fd, ioctlCreateDumb, unsafe.Pointer(&c)); err != nil {
279+ return nil, fmt.Errorf("create dumb: %w", err)
280+ }
281+ b := &dumbBuffer{handle: c.handle, pitch: c.pitch}
282+ f := fbCmd{width: uint32(w), height: uint32(h), pitch: c.pitch, bpp: 32, depth: 24, handle: c.handle}
283+ if err := ioctl(fd, ioctlAddFB, unsafe.Pointer(&f)); err != nil {
284+ b.destroy(fd)
285+ return nil, fmt.Errorf("add fb: %w", err)
286+ }
287+ b.fbID = f.fbID
288+ m := mapDumb{handle: c.handle}
289+ if err := ioctl(fd, ioctlMapDumb, unsafe.Pointer(&m)); err != nil {
290+ b.destroy(fd)
291+ return nil, fmt.Errorf("map dumb: %w", err)
292+ }
293+ mem, err := unix.Mmap(fd, int64(m.offset), int(c.size), unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
294+ if err != nil {
295+ b.destroy(fd)
296+ return nil, fmt.Errorf("mmap dumb: %w", err)
297+ }
298+ b.mem = mem
299+ return b, nil
300+}
301+
302+func (b *dumbBuffer) destroy(fd int) {
303+ if b.mem != nil {
304+ _ = unix.Munmap(b.mem)
305+ b.mem = nil
306+ }
307+ if b.fbID != 0 {
308+ id := b.fbID
309+ _ = ioctl(fd, ioctlRmFB, unsafe.Pointer(&id))
310+ b.fbID = 0
311+ }
312+ h := b.handle
313+ _ = ioctl(fd, ioctlDestroyDumb, unsafe.Pointer(&h))
314+}
315+
316+func setCrtc(fd int, crtc, conn, fb uint32, mode *modeInfo) error {
317+ ids := []uint32{conn}
318+ c := modeCrtc{crtcID: crtc, fbID: fb, countConnectors: 1, setConnectors: unsafe.Pointer(&ids[0])}
319+ if mode != nil {
320+ c.mode, c.modeValid = *mode, 1
321+ }
322+ return ioctl(fd, ioctlSetCrtc, unsafe.Pointer(&c))
323+}
324+
325+func getCrtc(fd int, crtc uint32) (modeCrtc, error) {
326+ c := modeCrtc{crtcID: crtc}
327+ err := ioctl(fd, ioctlGetCrtc, unsafe.Pointer(&c))
328+ return c, err
329+}
330+
331+func flip(fd int, crtc, fb uint32) error {
332+ p := pageFlip{crtcID: crtc, fbID: fb, flags: pageFlipEvent}
333+ return ioctl(fd, ioctlPageFlip, unsafe.Pointer(&p))
334+}
335+
336+// countFlipEvents parses DRM events read from the card fd.
337+func countFlipEvents(buf []byte) int {
338+ n := 0
339+ for len(buf) >= 8 {
340+ typ := uint32(buf[0]) | uint32(buf[1])<<8 | uint32(buf[2])<<16 | uint32(buf[3])<<24
341+ length := int(uint32(buf[4]) | uint32(buf[5])<<8 | uint32(buf[6])<<16 | uint32(buf[7])<<24)
342+ if length < 8 || length > len(buf) {
343+ break
344+ }
345+ if typ == eventFlipDone {
346+ n++
347+ }
348+ buf = buf[length:]
349+ }
350+ return n
351+}
@@ -0,0 +1,351 @@
1+// Package drm drives one KMS output with raw ioctls and dumb buffers.
2+package drm
3+
4+import (
5+ "errors"
6+ "fmt"
7+ "math"
8+ "unsafe"
9+
10+ "golang.org/x/sys/unix"
11+)
12+
13+// ioctl numbers for amd64/arm64 (DRM_IOWR('d', nr, size)).
14+const (
15+ ioctlGetResources = 0xC04064A0
16+ ioctlGetCrtc = 0xC06864A1
17+ ioctlSetCrtc = 0xC06864A2
18+ ioctlGetEncoder = 0xC01464A6
19+ ioctlGetConnector = 0xC05064A7
20+ ioctlAddFB = 0xC01C64AE
21+ ioctlRmFB = 0xC00464AF
22+ ioctlPageFlip = 0xC01864B0
23+ ioctlCreateDumb = 0xC02064B2
24+ ioctlMapDumb = 0xC01064B3
25+ ioctlDestroyDumb = 0xC00464B4
26+
27+ pageFlipEvent = 1
28+ eventFlipDone = 2
29+ modeTypePrefered = 1 << 3
30+ connected = 1
31+)
32+
33+type cardRes struct {
34+ fbs, crtcs, connectors, encoders uint64
35+ countFbs, countCrtcs, countConns, countEncoders uint32
36+ minW, maxW, minH, maxH uint32
37+}
38+
39+type modeInfo struct {
40+ Clock uint32
41+ HDisplay, HSyncStart, HSyncEnd, HTotal uint16
42+ HSkew uint16
43+ VDisplay, VSyncStart, VSyncEnd, VTotal uint16
44+ VScan uint16
45+ VRefresh, Flags, Type uint32
46+ Name [32]byte
47+}
48+
49+func (m modeInfo) String() string {
50+ return fmt.Sprintf("%dx%d@%.3f", m.HDisplay, m.VDisplay, float64(m.refreshMilli())/1000)
51+}
52+
53+type getConnector struct {
54+ encoders, modes, props, propValues uint64
55+ countModes, countProps, countEncoders uint32
56+ encoderID, connectorID, connType, typeID uint32
57+ connection, mmW, mmH, subpixel uint32
58+ _ uint32
59+}
60+
61+type getEncoder struct{ encoderID, encoderType, crtcID, possibleCrtcs, possibleClones uint32 }
62+
63+type modeCrtc struct {
64+ // unsafe.Pointer (not uint64) so the GC keeps it valid if the stack moves.
65+ setConnectors unsafe.Pointer
66+ countConnectors uint32
67+ crtcID, fbID uint32
68+ x, y uint32
69+ gammaSize uint32
70+ modeValid uint32
71+ mode modeInfo
72+}
73+
74+type pageFlip struct {
75+ crtcID, fbID, flags, reserved uint32
76+ userData uint64
77+}
78+
79+type createDumb struct {
80+ height, width, bpp, flags uint32
81+ handle, pitch uint32
82+ size uint64
83+}
84+
85+type mapDumb struct {
86+ handle, pad uint32
87+ offset uint64
88+}
89+
90+type fbCmd struct{ fbID, width, height, pitch, bpp, depth, handle uint32 }
91+
92+func ioctl(fd int, req uintptr, arg unsafe.Pointer) error {
93+ for {
94+ _, _, e := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), req, uintptr(arg))
95+ if e == unix.EINTR || e == unix.EAGAIN {
96+ continue
97+ }
98+ if e != 0 {
99+ return e
100+ }
101+ return nil
102+ }
103+}
104+
105+// connector is the subset of KMS connector state used to pick an output.
106+type connector struct {
107+ id, encoderID uint32
108+ name string
109+ connected bool
110+ mmW, mmH int
111+ modes []modeInfo
112+ encoders []uint32
113+}
114+
115+var connectorTypes = map[uint32]string{1: "VGA", 2: "DVI-I", 3: "DVI-D", 10: "DP", 11: "HDMI-A", 12: "HDMI-B", 7: "LVDS", 14: "eDP", 15: "Virtual", 16: "DSI", 17: "DPI", 20: "USB"}
116+
117+// Want selects a connector and mode; see ports.OutputConfig.
118+type Want struct {
119+ Name string
120+ W, H int
121+ Hz float64 // 0: highest refresh at W x H
122+ Disabled map[string]bool
123+ // Strict accepts only the connector named Name (used to search every card first).
124+ Strict bool
125+}
126+
127+// refreshMilli is the exact refresh in mHz, computed from the timings like wlroots.
128+func (m modeInfo) refreshMilli() int {
129+ if m.HTotal == 0 || m.VTotal == 0 {
130+ return int(m.VRefresh) * 1000
131+ }
132+ r := (int64(m.Clock)*1_000_000/int64(m.HTotal) + int64(m.VTotal)/2) / int64(m.VTotal)
133+ if m.Flags&(1<<4) != 0 { // interlace
134+ r *= 2
135+ }
136+ if m.Flags&(1<<5) != 0 { // doublescan
137+ r /= 2
138+ }
139+ if m.VScan > 1 {
140+ r /= int64(m.VScan)
141+ }
142+ return int(r)
143+}
144+
145+// pickConnector applies want: the named connector if connected, else the first
146+// connected one that is not disabled. The mode is the requested one, else preferred.
147+func pickConnector(conns []connector, want Want) (connector, modeInfo, error) {
148+ var chosen *connector
149+ for i := range conns {
150+ c := &conns[i]
151+ if !c.connected || len(c.modes) == 0 || want.Disabled[c.name] {
152+ continue
153+ }
154+ if c.name == want.Name {
155+ chosen = c
156+ break
157+ }
158+ if chosen == nil && !want.Strict {
159+ chosen = c
160+ }
161+ }
162+ if chosen == nil {
163+ return connector{}, modeInfo{}, errors.New("no connected display")
164+ }
165+ if chosen.name == want.Name && want.W > 0 {
166+ if m, ok := pickMode(chosen.modes, want.W, want.H, want.Hz); ok {
167+ return *chosen, m, nil
168+ }
169+ }
170+ for _, m := range chosen.modes {
171+ if m.Type&modeTypePrefered != 0 {
172+ return *chosen, m, nil
173+ }
174+ }
175+ return *chosen, chosen.modes[0], nil
176+}
177+
178+// pickMode finds W x H with the refresh closest to hz, or the highest when hz is 0.
179+func pickMode(modes []modeInfo, w, h int, hz float64) (modeInfo, bool) {
180+ best, found := modeInfo{}, false
181+ score := func(m modeInfo) float64 {
182+ r := float64(m.refreshMilli()) / 1000
183+ if hz == 0 {
184+ return -r
185+ }
186+ return math.Abs(r - hz)
187+ }
188+ for _, m := range modes {
189+ if int(m.HDisplay) != w || int(m.VDisplay) != h || m.Flags&(1<<4) != 0 {
190+ continue
191+ }
192+ if !found || score(m) < score(best) {
193+ best, found = m, true
194+ }
195+ }
196+ return best, found
197+}
198+
199+func resources(fd int) (crtcs, conns []uint32, err error) {
200+ var r cardRes
201+ if err := ioctl(fd, ioctlGetResources, unsafe.Pointer(&r)); err != nil {
202+ return nil, nil, fmt.Errorf("get resources: %w", err)
203+ }
204+ crtcs = make([]uint32, r.countCrtcs)
205+ conns = make([]uint32, r.countConns)
206+ r = cardRes{countCrtcs: r.countCrtcs, countConns: r.countConns}
207+ if len(crtcs) > 0 {
208+ r.crtcs = uint64(uintptr(unsafe.Pointer(&crtcs[0])))
209+ }
210+ if len(conns) > 0 {
211+ r.connectors = uint64(uintptr(unsafe.Pointer(&conns[0])))
212+ }
213+ if err := ioctl(fd, ioctlGetResources, unsafe.Pointer(&r)); err != nil {
214+ return nil, nil, fmt.Errorf("get resources: %w", err)
215+ }
216+ return crtcs[:min(int(r.countCrtcs), len(crtcs))], conns[:min(int(r.countConns), len(conns))], nil
217+}
218+
219+func readConnector(fd int, id uint32) (connector, error) {
220+ g := getConnector{connectorID: id}
221+ if err := ioctl(fd, ioctlGetConnector, unsafe.Pointer(&g)); err != nil {
222+ return connector{}, err
223+ }
224+ modes := make([]modeInfo, g.countModes)
225+ encs := make([]uint32, g.countEncoders)
226+ g2 := getConnector{connectorID: id, countModes: g.countModes, countEncoders: g.countEncoders}
227+ if len(modes) > 0 {
228+ g2.modes = uint64(uintptr(unsafe.Pointer(&modes[0])))
229+ }
230+ if len(encs) > 0 {
231+ g2.encoders = uint64(uintptr(unsafe.Pointer(&encs[0])))
232+ }
233+ if err := ioctl(fd, ioctlGetConnector, unsafe.Pointer(&g2)); err != nil {
234+ return connector{}, err
235+ }
236+ name := connectorTypes[g2.connType]
237+ if name == "" {
238+ name = "Unknown"
239+ }
240+ return connector{
241+ id: id, encoderID: g2.encoderID, name: fmt.Sprintf("%s-%d", name, g2.typeID),
242+ connected: g2.connection == connected, mmW: int(g2.mmW), mmH: int(g2.mmH),
243+ modes: modes[:min(int(g2.countModes), len(modes))],
244+ encoders: encs[:min(int(g2.countEncoders), len(encs))],
245+ }, nil
246+}
247+
248+// pickCrtc prefers the connector's current encoder CRTC, else any CRTC an encoder allows.
249+func pickCrtc(fd int, c connector, crtcs []uint32) (uint32, error) {
250+ encs := c.encoders
251+ if c.encoderID != 0 {
252+ encs = append([]uint32{c.encoderID}, encs...)
253+ }
254+ for _, id := range encs {
255+ e := getEncoder{encoderID: id}
256+ if ioctl(fd, ioctlGetEncoder, unsafe.Pointer(&e)) != nil {
257+ continue
258+ }
259+ if id == c.encoderID && e.crtcID != 0 {
260+ return e.crtcID, nil
261+ }
262+ for i, crtc := range crtcs {
263+ if e.possibleCrtcs&(1<<i) != 0 {
264+ return crtc, nil
265+ }
266+ }
267+ }
268+ return 0, fmt.Errorf("no CRTC for %s", c.name)
269+}
270+
271+type dumbBuffer struct {
272+ handle, fbID, pitch uint32
273+ mem []byte
274+}
275+
276+func newDumb(fd int, w, h int) (*dumbBuffer, error) {
277+ c := createDumb{width: uint32(w), height: uint32(h), bpp: 32}
278+ if err := ioctl(fd, ioctlCreateDumb, unsafe.Pointer(&c)); err != nil {
279+ return nil, fmt.Errorf("create dumb: %w", err)
280+ }
281+ b := &dumbBuffer{handle: c.handle, pitch: c.pitch}
282+ f := fbCmd{width: uint32(w), height: uint32(h), pitch: c.pitch, bpp: 32, depth: 24, handle: c.handle}
283+ if err := ioctl(fd, ioctlAddFB, unsafe.Pointer(&f)); err != nil {
284+ b.destroy(fd)
285+ return nil, fmt.Errorf("add fb: %w", err)
286+ }
287+ b.fbID = f.fbID
288+ m := mapDumb{handle: c.handle}
289+ if err := ioctl(fd, ioctlMapDumb, unsafe.Pointer(&m)); err != nil {
290+ b.destroy(fd)
291+ return nil, fmt.Errorf("map dumb: %w", err)
292+ }
293+ mem, err := unix.Mmap(fd, int64(m.offset), int(c.size), unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
294+ if err != nil {
295+ b.destroy(fd)
296+ return nil, fmt.Errorf("mmap dumb: %w", err)
297+ }
298+ b.mem = mem
299+ return b, nil
300+}
301+
302+func (b *dumbBuffer) destroy(fd int) {
303+ if b.mem != nil {
304+ _ = unix.Munmap(b.mem)
305+ b.mem = nil
306+ }
307+ if b.fbID != 0 {
308+ id := b.fbID
309+ _ = ioctl(fd, ioctlRmFB, unsafe.Pointer(&id))
310+ b.fbID = 0
311+ }
312+ h := b.handle
313+ _ = ioctl(fd, ioctlDestroyDumb, unsafe.Pointer(&h))
314+}
315+
316+func setCrtc(fd int, crtc, conn, fb uint32, mode *modeInfo) error {
317+ ids := []uint32{conn}
318+ c := modeCrtc{crtcID: crtc, fbID: fb, countConnectors: 1, setConnectors: unsafe.Pointer(&ids[0])}
319+ if mode != nil {
320+ c.mode, c.modeValid = *mode, 1
321+ }
322+ return ioctl(fd, ioctlSetCrtc, unsafe.Pointer(&c))
323+}
324+
325+func getCrtc(fd int, crtc uint32) (modeCrtc, error) {
326+ c := modeCrtc{crtcID: crtc}
327+ err := ioctl(fd, ioctlGetCrtc, unsafe.Pointer(&c))
328+ return c, err
329+}
330+
331+func flip(fd int, crtc, fb uint32) error {
332+ p := pageFlip{crtcID: crtc, fbID: fb, flags: pageFlipEvent}
333+ return ioctl(fd, ioctlPageFlip, unsafe.Pointer(&p))
334+}
335+
336+// countFlipEvents parses DRM events read from the card fd.
337+func countFlipEvents(buf []byte) int {
338+ n := 0
339+ for len(buf) >= 8 {
340+ typ := uint32(buf[0]) | uint32(buf[1])<<8 | uint32(buf[2])<<16 | uint32(buf[3])<<24
341+ length := int(uint32(buf[4]) | uint32(buf[5])<<8 | uint32(buf[6])<<16 | uint32(buf[7])<<24)
342+ if length < 8 || length > len(buf) {
343+ break
344+ }
345+ if typ == eventFlipDone {
346+ n++
347+ }
348+ buf = buf[length:]
349+ }
350+ return n
351+}
A internal/adapters/drm/kms_test.go +108 -0
@@ -0,0 +1,108 @@
1+package drm
2+
3+import (
4+ "encoding/binary"
5+ "testing"
6+ "unsafe"
7+)
8+
9+func TestStructSizesMatchIoctls(t *testing.T) {
10+ for _, tc := range []struct {
11+ name string
12+ got uintptr
13+ req uintptr
14+ }{
15+ {"card_res", unsafe.Sizeof(cardRes{}), ioctlGetResources},
16+ {"mode_crtc", unsafe.Sizeof(modeCrtc{}), ioctlSetCrtc},
17+ {"get_encoder", unsafe.Sizeof(getEncoder{}), ioctlGetEncoder},
18+ {"get_connector", unsafe.Sizeof(getConnector{}), ioctlGetConnector},
19+ {"fb_cmd", unsafe.Sizeof(fbCmd{}), ioctlAddFB},
20+ {"page_flip", unsafe.Sizeof(pageFlip{}), ioctlPageFlip},
21+ {"create_dumb", unsafe.Sizeof(createDumb{}), ioctlCreateDumb},
22+ {"map_dumb", unsafe.Sizeof(mapDumb{}), ioctlMapDumb},
23+ } {
24+ if want := (tc.req >> 16) & 0x3fff; tc.got != want {
25+ t.Errorf("%s: size %d, ioctl encodes %d", tc.name, tc.got, want)
26+ }
27+ }
28+ if unsafe.Sizeof(modeInfo{}) != 68 {
29+ t.Errorf("modeInfo size %d, want 68", unsafe.Sizeof(modeInfo{}))
30+ }
31+}
32+
33+func TestPickConnector(t *testing.T) {
34+ mode := func(w, h uint16, hz uint32, pref bool) modeInfo {
35+ // htotal*vtotal = w*h keeps refreshMilli = clock*1000/(w*h).
36+ m := modeInfo{HDisplay: w, VDisplay: h, HTotal: w, VTotal: h, Clock: uint32(uint64(w) * uint64(h) * uint64(hz) / 1000), VRefresh: hz}
37+ if pref {
38+ m.Type = modeTypePrefered
39+ }
40+ return m
41+ }
42+ lg := connector{name: "DP-2", connected: true, modes: []modeInfo{mode(5120, 2160, 165, true), mode(3440, 1440, 100, true), mode(5120, 2160, 100, false), mode(5120, 2160, 60, false)}}
43+ tv := connector{name: "HDMI-A-1", connected: true, modes: []modeInfo{mode(3840, 2160, 60, true), mode(3840, 2160, 120, false)}}
44+ off := connector{name: "DP-1", modes: []modeInfo{mode(1920, 1080, 60, true)}}
45+ for _, tc := range []struct {
46+ name string
47+ conns []connector
48+ want Want
49+ conn string
50+ mode string
51+ err bool
52+ }{
53+ {"none", nil, Want{}, "", "", true},
54+ {"first connected, first preferred", []connector{off, tv, lg}, Want{}, "HDMI-A-1", "3840x2160@60.000", false},
55+ {"named", []connector{tv, lg}, Want{Name: "DP-2"}, "DP-2", "5120x2160@165.000", false},
56+ {"named mode closest refresh", []connector{tv, lg}, Want{Name: "DP-2", W: 5120, H: 2160, Hz: 99}, "DP-2", "5120x2160@100.000", false},
57+ {"named mode highest refresh", []connector{lg}, Want{Name: "DP-2", W: 5120, H: 2160}, "DP-2", "5120x2160@165.000", false},
58+ {"unknown mode falls back to preferred", []connector{lg}, Want{Name: "DP-2", W: 800, H: 600}, "DP-2", "5120x2160@165.000", false},
59+ {"missing name falls back", []connector{tv}, Want{Name: "DP-2"}, "HDMI-A-1", "3840x2160@60.000", false},
60+ {"disabled skipped", []connector{tv, lg}, Want{Disabled: map[string]bool{"HDMI-A-1": true}}, "DP-2", "5120x2160@165.000", false},
61+ {"strict finds named", []connector{tv, lg}, Want{Name: "DP-2", Strict: true}, "DP-2", "5120x2160@165.000", false},
62+ {"strict without named", []connector{tv}, Want{Name: "DP-2", Strict: true}, "", "", true},
63+ {"all disabled", []connector{tv}, Want{Disabled: map[string]bool{"HDMI-A-1": true}}, "", "", true},
64+ } {
65+ c, m, err := pickConnector(tc.conns, tc.want)
66+ if (err != nil) != tc.err || c.name != tc.conn || (err == nil && m.String() != tc.mode) {
67+ t.Errorf("%s: got %q %s %v", tc.name, c.name, m, err)
68+ }
69+ }
70+}
71+
72+func TestRefreshMilliLG(t *testing.T) {
73+ // Real 5120x2160@165.058 CVT-RB timings.
74+ m := modeInfo{Clock: 1_989_530, HDisplay: 5120, HTotal: 5200, VDisplay: 2160, VTotal: 2318}
75+ if got := m.refreshMilli(); got < 165_000 || got > 165_100 {
76+ t.Fatalf("refresh %d mHz", got)
77+ }
78+}
79+
80+func TestParseEDID(t *testing.T) {
81+ d := make([]byte, 128)
82+ copy(d, "\x00\xff\xff\xff\xff\xff\xff\x00")
83+ d[8], d[9] = 0x1e, 0x6d // GSM
84+ copy(d[54:], []byte{0, 0, 0, 0xfc, 0})
85+ copy(d[59:], "LG ULTRAGEAR+\n")
86+ copy(d[72:], []byte{0, 0, 0, 0xff, 0})
87+ copy(d[77:], "SN123\n ")
88+ m := parseEDID(d)
89+ if m.Model != "LG ULTRAGEAR+" || m.Serial != "SN123" || (m.Make != "GSM" && m.Make != "LG Electronics") {
90+ t.Fatalf("%+v", m)
91+ }
92+}
93+
94+func TestCountFlipEvents(t *testing.T) {
95+ ev := func(typ uint32, n int) []byte {
96+ b := make([]byte, n)
97+ binary.LittleEndian.PutUint32(b, typ)
98+ binary.LittleEndian.PutUint32(b[4:], uint32(n))
99+ return b
100+ }
101+ buf := append(append(ev(eventFlipDone, 32), ev(1, 32)...), ev(eventFlipDone, 32)...)
102+ if n := countFlipEvents(buf); n != 2 {
103+ t.Fatalf("got %d flips", n)
104+ }
105+ if n := countFlipEvents(buf[:20]); n != 0 {
106+ t.Fatalf("truncated: got %d", n)
107+ }
108+}
@@ -0,0 +1,108 @@
1+package drm
2+
3+import (
4+ "encoding/binary"
5+ "testing"
6+ "unsafe"
7+)
8+
9+func TestStructSizesMatchIoctls(t *testing.T) {
10+ for _, tc := range []struct {
11+ name string
12+ got uintptr
13+ req uintptr
14+ }{
15+ {"card_res", unsafe.Sizeof(cardRes{}), ioctlGetResources},
16+ {"mode_crtc", unsafe.Sizeof(modeCrtc{}), ioctlSetCrtc},
17+ {"get_encoder", unsafe.Sizeof(getEncoder{}), ioctlGetEncoder},
18+ {"get_connector", unsafe.Sizeof(getConnector{}), ioctlGetConnector},
19+ {"fb_cmd", unsafe.Sizeof(fbCmd{}), ioctlAddFB},
20+ {"page_flip", unsafe.Sizeof(pageFlip{}), ioctlPageFlip},
21+ {"create_dumb", unsafe.Sizeof(createDumb{}), ioctlCreateDumb},
22+ {"map_dumb", unsafe.Sizeof(mapDumb{}), ioctlMapDumb},
23+ } {
24+ if want := (tc.req >> 16) & 0x3fff; tc.got != want {
25+ t.Errorf("%s: size %d, ioctl encodes %d", tc.name, tc.got, want)
26+ }
27+ }
28+ if unsafe.Sizeof(modeInfo{}) != 68 {
29+ t.Errorf("modeInfo size %d, want 68", unsafe.Sizeof(modeInfo{}))
30+ }
31+}
32+
33+func TestPickConnector(t *testing.T) {
34+ mode := func(w, h uint16, hz uint32, pref bool) modeInfo {
35+ // htotal*vtotal = w*h keeps refreshMilli = clock*1000/(w*h).
36+ m := modeInfo{HDisplay: w, VDisplay: h, HTotal: w, VTotal: h, Clock: uint32(uint64(w) * uint64(h) * uint64(hz) / 1000), VRefresh: hz}
37+ if pref {
38+ m.Type = modeTypePrefered
39+ }
40+ return m
41+ }
42+ lg := connector{name: "DP-2", connected: true, modes: []modeInfo{mode(5120, 2160, 165, true), mode(3440, 1440, 100, true), mode(5120, 2160, 100, false), mode(5120, 2160, 60, false)}}
43+ tv := connector{name: "HDMI-A-1", connected: true, modes: []modeInfo{mode(3840, 2160, 60, true), mode(3840, 2160, 120, false)}}
44+ off := connector{name: "DP-1", modes: []modeInfo{mode(1920, 1080, 60, true)}}
45+ for _, tc := range []struct {
46+ name string
47+ conns []connector
48+ want Want
49+ conn string
50+ mode string
51+ err bool
52+ }{
53+ {"none", nil, Want{}, "", "", true},
54+ {"first connected, first preferred", []connector{off, tv, lg}, Want{}, "HDMI-A-1", "3840x2160@60.000", false},
55+ {"named", []connector{tv, lg}, Want{Name: "DP-2"}, "DP-2", "5120x2160@165.000", false},
56+ {"named mode closest refresh", []connector{tv, lg}, Want{Name: "DP-2", W: 5120, H: 2160, Hz: 99}, "DP-2", "5120x2160@100.000", false},
57+ {"named mode highest refresh", []connector{lg}, Want{Name: "DP-2", W: 5120, H: 2160}, "DP-2", "5120x2160@165.000", false},
58+ {"unknown mode falls back to preferred", []connector{lg}, Want{Name: "DP-2", W: 800, H: 600}, "DP-2", "5120x2160@165.000", false},
59+ {"missing name falls back", []connector{tv}, Want{Name: "DP-2"}, "HDMI-A-1", "3840x2160@60.000", false},
60+ {"disabled skipped", []connector{tv, lg}, Want{Disabled: map[string]bool{"HDMI-A-1": true}}, "DP-2", "5120x2160@165.000", false},
61+ {"strict finds named", []connector{tv, lg}, Want{Name: "DP-2", Strict: true}, "DP-2", "5120x2160@165.000", false},
62+ {"strict without named", []connector{tv}, Want{Name: "DP-2", Strict: true}, "", "", true},
63+ {"all disabled", []connector{tv}, Want{Disabled: map[string]bool{"HDMI-A-1": true}}, "", "", true},
64+ } {
65+ c, m, err := pickConnector(tc.conns, tc.want)
66+ if (err != nil) != tc.err || c.name != tc.conn || (err == nil && m.String() != tc.mode) {
67+ t.Errorf("%s: got %q %s %v", tc.name, c.name, m, err)
68+ }
69+ }
70+}
71+
72+func TestRefreshMilliLG(t *testing.T) {
73+ // Real 5120x2160@165.058 CVT-RB timings.
74+ m := modeInfo{Clock: 1_989_530, HDisplay: 5120, HTotal: 5200, VDisplay: 2160, VTotal: 2318}
75+ if got := m.refreshMilli(); got < 165_000 || got > 165_100 {
76+ t.Fatalf("refresh %d mHz", got)
77+ }
78+}
79+
80+func TestParseEDID(t *testing.T) {
81+ d := make([]byte, 128)
82+ copy(d, "\x00\xff\xff\xff\xff\xff\xff\x00")
83+ d[8], d[9] = 0x1e, 0x6d // GSM
84+ copy(d[54:], []byte{0, 0, 0, 0xfc, 0})
85+ copy(d[59:], "LG ULTRAGEAR+\n")
86+ copy(d[72:], []byte{0, 0, 0, 0xff, 0})
87+ copy(d[77:], "SN123\n ")
88+ m := parseEDID(d)
89+ if m.Model != "LG ULTRAGEAR+" || m.Serial != "SN123" || (m.Make != "GSM" && m.Make != "LG Electronics") {
90+ t.Fatalf("%+v", m)
91+ }
92+}
93+
94+func TestCountFlipEvents(t *testing.T) {
95+ ev := func(typ uint32, n int) []byte {
96+ b := make([]byte, n)
97+ binary.LittleEndian.PutUint32(b, typ)
98+ binary.LittleEndian.PutUint32(b[4:], uint32(n))
99+ return b
100+ }
101+ buf := append(append(ev(eventFlipDone, 32), ev(1, 32)...), ev(eventFlipDone, 32)...)
102+ if n := countFlipEvents(buf); n != 2 {
103+ t.Fatalf("got %d flips", n)
104+ }
105+ if n := countFlipEvents(buf[:20]); n != 0 {
106+ t.Fatalf("truncated: got %d", n)
107+ }
108+}
A internal/adapters/drm/output.go +263 -0
@@ -0,0 +1,263 @@
1+package drm
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "sync"
8+ "time"
9+
10+ "github.com/bnema/nefertty/internal/ports"
11+ "github.com/bnema/zerowrap"
12+ "golang.org/x/sys/unix"
13+)
14+
15+// Renderer draws a scene into an RGBA image of the output size.
16+type Renderer interface {
17+ Render(ports.Scene, map[ports.WindowID]ports.SurfaceContent) error
18+ // CopyBGRX writes the last frame as XRGB8888 rows of the given pitch.
19+ CopyBGRX(dst []byte, pitch int)
20+ Close()
21+}
22+
23+// Output is one KMS connector driven with two dumb buffers.
24+type Output struct {
25+ fd int
26+ crtc uint32
27+ conn connector
28+ mode modeInfo
29+ saved modeCrtc
30+ bufs [2]*dumbBuffer
31+ back int
32+ pending bool
33+ flipStart time.Time
34+ flips int
35+ monitor Monitor
36+ log zerowrap.Logger
37+}
38+
39+// Info describes the chosen output for clients (wl_output, xdg-output).
40+type Info struct {
41+ Name string
42+ Monitor Monitor
43+ Width, Height int
44+ RefreshMilli int
45+ PhysicalW, PhysicalH int // millimetres
46+}
47+
48+// Open picks a connector on the card fd according to want and allocates buffers.
49+func Open(fd int, card string, want Want, log zerowrap.Logger) (*Output, error) {
50+ crtcs, ids, err := resources(fd)
51+ if err != nil {
52+ return nil, err
53+ }
54+ var conns []connector
55+ for _, id := range ids {
56+ c, err := readConnector(fd, id)
57+ if err != nil {
58+ log.Warn().Err(err).Uint32("connector", id).Msg("read connector")
59+ continue
60+ }
61+ ev := log.Info().Str("connector", c.name).Bool("connected", c.connected)
62+ if c.connected {
63+ mon := readMonitor(card, c.name)
64+ modes := make([]string, 0, len(c.modes))
65+ for _, m := range c.modes {
66+ modes = append(modes, m.String())
67+ }
68+ ev = ev.Str("make", mon.Make).Str("model", mon.Model).Int("mm_w", c.mmW).Int("mm_h", c.mmH).Strs("modes", modes)
69+ }
70+ ev.Msg("connector")
71+ conns = append(conns, c)
72+ }
73+ c, mode, err := pickConnector(conns, want)
74+ if err != nil {
75+ return nil, fmt.Errorf("%s: %w", card, err)
76+ }
77+ crtc, err := pickCrtc(fd, c, crtcs)
78+ if err != nil {
79+ return nil, err
80+ }
81+ o := &Output{fd: fd, crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card, c.name)}
82+ if want.Name != "" && want.Name != c.name {
83+ log.Warn().Str("wanted", want.Name).Str("using", c.name).Msg("configured output not connected")
84+ }
85+ if o.saved, err = getCrtc(fd, crtc); err != nil {
86+ log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")
87+ }
88+ for i := range o.bufs {
89+ if o.bufs[i], err = newDumb(fd, o.Width(), o.Height()); err != nil {
90+ o.Close()
91+ return nil, err
92+ }
93+ }
94+ log.Info().Str("card", card).Str("connector", c.name).Str("mode", mode.String()).Str("make", o.monitor.Make).Str("model", o.monitor.Model).Uint32("crtc", crtc).Msg("output")
95+ return o, nil
96+}
97+
98+// Info returns the chosen connector, monitor and mode.
99+func (o *Output) Info() Info {
100+ return Info{Name: o.conn.name, Monitor: o.monitor, Width: o.Width(), Height: o.Height(), RefreshMilli: o.mode.refreshMilli(), PhysicalW: o.conn.mmW, PhysicalH: o.conn.mmH}
101+}
102+
103+func (o *Output) Width() int { return int(o.mode.HDisplay) }
104+func (o *Output) Height() int { return int(o.mode.VDisplay) }
105+
106+// modeset shows the front buffer; needed at start and after every VT resume.
107+func (o *Output) modeset() error {
108+ o.pending = false
109+ front := o.bufs[1-o.back]
110+ if err := setCrtc(o.fd, o.crtc, o.conn.id, front.fbID, &o.mode); err != nil {
111+ return fmt.Errorf("set crtc: %w", err)
112+ }
113+ o.log.Info().Str("connector", o.conn.name).Msg("modeset")
114+ return nil
115+}
116+
117+// present copies img into the back buffer and queues a page flip.
118+func (o *Output) present(r Renderer) error {
119+ b := o.bufs[o.back]
120+ r.CopyBGRX(b.mem, int(b.pitch))
121+ if err := flip(o.fd, o.crtc, b.fbID); err != nil {
122+ return err
123+ }
124+ o.pending, o.flipStart = true, time.Now()
125+ o.back = 1 - o.back
126+ return nil
127+}
128+
129+// Close restores the CRTC state found at startup and frees buffers.
130+func (o *Output) Close() {
131+ if o.saved.crtcID != 0 {
132+ ids := []uint32{o.conn.id}
133+ s := o.saved
134+ if s.modeValid != 0 {
135+ _ = setCrtc(o.fd, s.crtcID, ids[0], s.fbID, &s.mode)
136+ }
137+ }
138+ for _, b := range o.bufs {
139+ if b != nil {
140+ b.destroy(o.fd)
141+ }
142+ }
143+}
144+
145+// Run renders scenes and flips until ctx ends. active reports seat enable/disable.
146+func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (Renderer, error), active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent) error {
147+ r, err := newRenderer(o.Width(), o.Height())
148+ if err != nil {
149+ return fmt.Errorf("create renderer: %w", err)
150+ }
151+ defer r.Close()
152+ if err := o.modeset(); err != nil {
153+ return err
154+ }
155+ events := make(chan int, 4)
156+ evErr := make(chan error, 1)
157+ evCtx, stop := context.WithCancel(ctx)
158+ var reader sync.WaitGroup
159+ reader.Go(func() { o.readEvents(evCtx, events, evErr) })
160+ // Join the reader before the card fd can be closed by the caller.
161+ defer func() { stop(); reader.Wait() }()
162+ surfaces := make(map[ports.WindowID]ports.SurfaceContent)
163+ var scene ports.Scene
164+ haveScene, dirty, enabled := false, false, true
165+ frame := 0
166+ stats := time.NewTicker(10 * time.Second)
167+ defer stats.Stop()
168+ for {
169+ select {
170+ case <-ctx.Done():
171+ return nil
172+ case err := <-evErr:
173+ return err
174+ case on := <-active:
175+ // Modeset on every enable: a fast disable+enable can coalesce to one true.
176+ if on {
177+ if err := o.modeset(); err != nil {
178+ o.log.Error().Err(err).Msg("resume")
179+ return err
180+ }
181+ dirty = haveScene
182+ }
183+ enabled = on
184+ o.log.Info().Bool("enabled", on).Msg("output")
185+ case n := <-events:
186+ if !o.pending {
187+ continue // stale event from before a modeset
188+ }
189+ o.pending = false
190+ o.flips += n
191+ if d := time.Since(o.flipStart); d > 20*time.Millisecond {
192+ o.log.Info().Dur("flip_ms", d).Msg("slow flip")
193+ }
194+ case s := <-scenes:
195+ scene, haveScene, dirty = s, true, true
196+ case c := <-contents:
197+ if c.Pixels == nil {
198+ delete(surfaces, c.ID)
199+ } else {
200+ surfaces[c.ID] = c
201+ }
202+ dirty = true
203+ case <-stats.C:
204+ o.log.Info().Int("frames", frame).Int("flips", o.flips).Msg("stats")
205+ }
206+ if !dirty || !haveScene || !enabled || o.pending {
207+ continue
208+ }
209+ start := time.Now()
210+ if err := r.Render(scene, surfaces); err != nil {
211+ return fmt.Errorf("render frame: %w", err)
212+ }
213+ copyStart := time.Now()
214+ if err := o.present(r); err != nil {
215+ if errors.Is(err, unix.EBUSY) {
216+ // A flip is still in flight; retry after its event.
217+ o.pending, o.flipStart = true, time.Now()
218+ continue // dirty stays true
219+ }
220+ if errors.Is(err, unix.EACCES) || errors.Is(err, unix.EPERM) {
221+ // Lost DRM master mid-switch; the seat disable follows.
222+ o.log.Warn().Err(err).Msg("flip refused")
223+ enabled = false
224+ continue
225+ }
226+ o.log.Error().Err(err).Msg("page flip")
227+ return fmt.Errorf("page flip: %w", err)
228+ }
229+ dirty = false
230+ frame++
231+ o.log.Debug().Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Dur("render", time.Since(start)).Dur("copy", time.Since(copyStart)).Msg("frame")
232+ }
233+}
234+
235+func (o *Output) readEvents(ctx context.Context, events chan<- int, errs chan<- error) {
236+ buf := make([]byte, 1024)
237+ for ctx.Err() == nil {
238+ fds := []unix.PollFd{{Fd: int32(o.fd), Events: unix.POLLIN}}
239+ n, err := unix.Poll(fds, 100)
240+ if err != nil && !errors.Is(err, unix.EINTR) {
241+ errs <- fmt.Errorf("drm poll: %w", err)
242+ return
243+ }
244+ if n <= 0 {
245+ continue
246+ }
247+ m, err := unix.Read(o.fd, buf)
248+ if err != nil {
249+ if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) {
250+ continue
251+ }
252+ errs <- fmt.Errorf("drm read: %w", err)
253+ return
254+ }
255+ if c := countFlipEvents(buf[:m]); c > 0 {
256+ select {
257+ case events <- c:
258+ case <-ctx.Done():
259+ return
260+ }
261+ }
262+ }
263+}
@@ -0,0 +1,263 @@
1+package drm
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "sync"
8+ "time"
9+
10+ "github.com/bnema/nefertty/internal/ports"
11+ "github.com/bnema/zerowrap"
12+ "golang.org/x/sys/unix"
13+)
14+
15+// Renderer draws a scene into an RGBA image of the output size.
16+type Renderer interface {
17+ Render(ports.Scene, map[ports.WindowID]ports.SurfaceContent) error
18+ // CopyBGRX writes the last frame as XRGB8888 rows of the given pitch.
19+ CopyBGRX(dst []byte, pitch int)
20+ Close()
21+}
22+
23+// Output is one KMS connector driven with two dumb buffers.
24+type Output struct {
25+ fd int
26+ crtc uint32
27+ conn connector
28+ mode modeInfo
29+ saved modeCrtc
30+ bufs [2]*dumbBuffer
31+ back int
32+ pending bool
33+ flipStart time.Time
34+ flips int
35+ monitor Monitor
36+ log zerowrap.Logger
37+}
38+
39+// Info describes the chosen output for clients (wl_output, xdg-output).
40+type Info struct {
41+ Name string
42+ Monitor Monitor
43+ Width, Height int
44+ RefreshMilli int
45+ PhysicalW, PhysicalH int // millimetres
46+}
47+
48+// Open picks a connector on the card fd according to want and allocates buffers.
49+func Open(fd int, card string, want Want, log zerowrap.Logger) (*Output, error) {
50+ crtcs, ids, err := resources(fd)
51+ if err != nil {
52+ return nil, err
53+ }
54+ var conns []connector
55+ for _, id := range ids {
56+ c, err := readConnector(fd, id)
57+ if err != nil {
58+ log.Warn().Err(err).Uint32("connector", id).Msg("read connector")
59+ continue
60+ }
61+ ev := log.Info().Str("connector", c.name).Bool("connected", c.connected)
62+ if c.connected {
63+ mon := readMonitor(card, c.name)
64+ modes := make([]string, 0, len(c.modes))
65+ for _, m := range c.modes {
66+ modes = append(modes, m.String())
67+ }
68+ ev = ev.Str("make", mon.Make).Str("model", mon.Model).Int("mm_w", c.mmW).Int("mm_h", c.mmH).Strs("modes", modes)
69+ }
70+ ev.Msg("connector")
71+ conns = append(conns, c)
72+ }
73+ c, mode, err := pickConnector(conns, want)
74+ if err != nil {
75+ return nil, fmt.Errorf("%s: %w", card, err)
76+ }
77+ crtc, err := pickCrtc(fd, c, crtcs)
78+ if err != nil {
79+ return nil, err
80+ }
81+ o := &Output{fd: fd, crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card, c.name)}
82+ if want.Name != "" && want.Name != c.name {
83+ log.Warn().Str("wanted", want.Name).Str("using", c.name).Msg("configured output not connected")
84+ }
85+ if o.saved, err = getCrtc(fd, crtc); err != nil {
86+ log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")
87+ }
88+ for i := range o.bufs {
89+ if o.bufs[i], err = newDumb(fd, o.Width(), o.Height()); err != nil {
90+ o.Close()
91+ return nil, err
92+ }
93+ }
94+ log.Info().Str("card", card).Str("connector", c.name).Str("mode", mode.String()).Str("make", o.monitor.Make).Str("model", o.monitor.Model).Uint32("crtc", crtc).Msg("output")
95+ return o, nil
96+}
97+
98+// Info returns the chosen connector, monitor and mode.
99+func (o *Output) Info() Info {
100+ return Info{Name: o.conn.name, Monitor: o.monitor, Width: o.Width(), Height: o.Height(), RefreshMilli: o.mode.refreshMilli(), PhysicalW: o.conn.mmW, PhysicalH: o.conn.mmH}
101+}
102+
103+func (o *Output) Width() int { return int(o.mode.HDisplay) }
104+func (o *Output) Height() int { return int(o.mode.VDisplay) }
105+
106+// modeset shows the front buffer; needed at start and after every VT resume.
107+func (o *Output) modeset() error {
108+ o.pending = false
109+ front := o.bufs[1-o.back]
110+ if err := setCrtc(o.fd, o.crtc, o.conn.id, front.fbID, &o.mode); err != nil {
111+ return fmt.Errorf("set crtc: %w", err)
112+ }
113+ o.log.Info().Str("connector", o.conn.name).Msg("modeset")
114+ return nil
115+}
116+
117+// present copies img into the back buffer and queues a page flip.
118+func (o *Output) present(r Renderer) error {
119+ b := o.bufs[o.back]
120+ r.CopyBGRX(b.mem, int(b.pitch))
121+ if err := flip(o.fd, o.crtc, b.fbID); err != nil {
122+ return err
123+ }
124+ o.pending, o.flipStart = true, time.Now()
125+ o.back = 1 - o.back
126+ return nil
127+}
128+
129+// Close restores the CRTC state found at startup and frees buffers.
130+func (o *Output) Close() {
131+ if o.saved.crtcID != 0 {
132+ ids := []uint32{o.conn.id}
133+ s := o.saved
134+ if s.modeValid != 0 {
135+ _ = setCrtc(o.fd, s.crtcID, ids[0], s.fbID, &s.mode)
136+ }
137+ }
138+ for _, b := range o.bufs {
139+ if b != nil {
140+ b.destroy(o.fd)
141+ }
142+ }
143+}
144+
145+// Run renders scenes and flips until ctx ends. active reports seat enable/disable.
146+func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (Renderer, error), active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent) error {
147+ r, err := newRenderer(o.Width(), o.Height())
148+ if err != nil {
149+ return fmt.Errorf("create renderer: %w", err)
150+ }
151+ defer r.Close()
152+ if err := o.modeset(); err != nil {
153+ return err
154+ }
155+ events := make(chan int, 4)
156+ evErr := make(chan error, 1)
157+ evCtx, stop := context.WithCancel(ctx)
158+ var reader sync.WaitGroup
159+ reader.Go(func() { o.readEvents(evCtx, events, evErr) })
160+ // Join the reader before the card fd can be closed by the caller.
161+ defer func() { stop(); reader.Wait() }()
162+ surfaces := make(map[ports.WindowID]ports.SurfaceContent)
163+ var scene ports.Scene
164+ haveScene, dirty, enabled := false, false, true
165+ frame := 0
166+ stats := time.NewTicker(10 * time.Second)
167+ defer stats.Stop()
168+ for {
169+ select {
170+ case <-ctx.Done():
171+ return nil
172+ case err := <-evErr:
173+ return err
174+ case on := <-active:
175+ // Modeset on every enable: a fast disable+enable can coalesce to one true.
176+ if on {
177+ if err := o.modeset(); err != nil {
178+ o.log.Error().Err(err).Msg("resume")
179+ return err
180+ }
181+ dirty = haveScene
182+ }
183+ enabled = on
184+ o.log.Info().Bool("enabled", on).Msg("output")
185+ case n := <-events:
186+ if !o.pending {
187+ continue // stale event from before a modeset
188+ }
189+ o.pending = false
190+ o.flips += n
191+ if d := time.Since(o.flipStart); d > 20*time.Millisecond {
192+ o.log.Info().Dur("flip_ms", d).Msg("slow flip")
193+ }
194+ case s := <-scenes:
195+ scene, haveScene, dirty = s, true, true
196+ case c := <-contents:
197+ if c.Pixels == nil {
198+ delete(surfaces, c.ID)
199+ } else {
200+ surfaces[c.ID] = c
201+ }
202+ dirty = true
203+ case <-stats.C:
204+ o.log.Info().Int("frames", frame).Int("flips", o.flips).Msg("stats")
205+ }
206+ if !dirty || !haveScene || !enabled || o.pending {
207+ continue
208+ }
209+ start := time.Now()
210+ if err := r.Render(scene, surfaces); err != nil {
211+ return fmt.Errorf("render frame: %w", err)
212+ }
213+ copyStart := time.Now()
214+ if err := o.present(r); err != nil {
215+ if errors.Is(err, unix.EBUSY) {
216+ // A flip is still in flight; retry after its event.
217+ o.pending, o.flipStart = true, time.Now()
218+ continue // dirty stays true
219+ }
220+ if errors.Is(err, unix.EACCES) || errors.Is(err, unix.EPERM) {
221+ // Lost DRM master mid-switch; the seat disable follows.
222+ o.log.Warn().Err(err).Msg("flip refused")
223+ enabled = false
224+ continue
225+ }
226+ o.log.Error().Err(err).Msg("page flip")
227+ return fmt.Errorf("page flip: %w", err)
228+ }
229+ dirty = false
230+ frame++
231+ o.log.Debug().Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Dur("render", time.Since(start)).Dur("copy", time.Since(copyStart)).Msg("frame")
232+ }
233+}
234+
235+func (o *Output) readEvents(ctx context.Context, events chan<- int, errs chan<- error) {
236+ buf := make([]byte, 1024)
237+ for ctx.Err() == nil {
238+ fds := []unix.PollFd{{Fd: int32(o.fd), Events: unix.POLLIN}}
239+ n, err := unix.Poll(fds, 100)
240+ if err != nil && !errors.Is(err, unix.EINTR) {
241+ errs <- fmt.Errorf("drm poll: %w", err)
242+ return
243+ }
244+ if n <= 0 {
245+ continue
246+ }
247+ m, err := unix.Read(o.fd, buf)
248+ if err != nil {
249+ if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) {
250+ continue
251+ }
252+ errs <- fmt.Errorf("drm read: %w", err)
253+ return
254+ }
255+ if c := countFlipEvents(buf[:m]); c > 0 {
256+ select {
257+ case events <- c:
258+ case <-ctx.Done():
259+ return
260+ }
261+ }
262+ }
263+}
A internal/adapters/libinput/libinput.go +295 -0
@@ -0,0 +1,295 @@
1+// Package libinput reads keyboards and pointers through libinput's udev backend.
2+package libinput
3+
4+import (
5+ "context"
6+ "errors"
7+ "fmt"
8+ "strings"
9+ "sync"
10+ "time"
11+ "unsafe"
12+
13+ "github.com/bnema/nefertty/internal/adapters/xkb"
14+ "github.com/bnema/nefertty/internal/ports"
15+ "github.com/bnema/purego"
16+ "github.com/bnema/zerowrap"
17+ "golang.org/x/sys/unix"
18+)
19+
20+// ErrEmergencyQuit is returned when Ctrl+Alt+Backspace is pressed.
21+var ErrEmergencyQuit = errors.New("emergency quit key")
22+
23+// Seat is the device broker libinput opens devices through.
24+type Seat interface {
25+ OpenDevice(path string) (int, error)
26+ CloseDevice(fd int)
27+ SwitchVT(vt int)
28+}
29+
30+const (
31+ evDeviceAdded = 1
32+ evDeviceRemoved = 2
33+ evKeyboardKey = 300
34+ evPointerMotion = 400
35+ evPointerAbs = 401
36+ evPointerButton = 402
37+)
38+
39+var (
40+ loadOnce sync.Once
41+ loadErr error
42+
43+ udevNew func() uintptr
44+ udevUnref func(uintptr) uintptr
45+ createContext func(iface unsafe.Pointer, data uintptr, udev uintptr) uintptr
46+ assignSeat func(li uintptr, seat string) int32
47+ unref func(li uintptr) uintptr
48+ getFD func(li uintptr) int32
49+ dispatch func(li uintptr) int32
50+ getEvent func(li uintptr) uintptr
51+ eventType func(ev uintptr) int32
52+ eventDestroy func(ev uintptr)
53+ eventDevice func(ev uintptr) uintptr
54+ deviceName func(dev uintptr) string
55+ suspend func(li uintptr)
56+ resume func(li uintptr) int32
57+ keyboardEvent func(ev uintptr) uintptr
58+ keyboardKey func(kev uintptr) uint32
59+ keyboardState func(kev uintptr) int32
60+ pointerEvent func(ev uintptr) uintptr
61+ pointerDX func(pev uintptr) float64
62+ pointerDY func(pev uintptr) float64
63+ pointerAbsX func(pev uintptr, width uint32) float64
64+ pointerAbsY func(pev uintptr, height uint32) float64
65+ pointerButton func(pev uintptr) uint32
66+ pointerBtnState func(pev uintptr) int32
67+ iface [2]uintptr
68+ active Seat
69+)
70+
71+func load() error {
72+ loadOnce.Do(func() {
73+ udev, err := purego.Dlopen("libudev.so.1", purego.RTLD_NOW|purego.RTLD_LOCAL)
74+ if err != nil {
75+ loadErr = fmt.Errorf("open libudev.so.1: %w", err)
76+ return
77+ }
78+ lib, err := purego.Dlopen("libinput.so.10", purego.RTLD_NOW|purego.RTLD_LOCAL)
79+ if err != nil {
80+ loadErr = fmt.Errorf("open libinput.so.10: %w", err)
81+ return
82+ }
83+ defer func() {
84+ if r := recover(); r != nil {
85+ loadErr = fmt.Errorf("libinput: %v", r)
86+ }
87+ }()
88+ purego.RegisterLibFunc(&udevNew, udev, "udev_new")
89+ purego.RegisterLibFunc(&udevUnref, udev, "udev_unref")
90+ reg := func(fn any, name string) { purego.RegisterLibFunc(fn, lib, "libinput_"+name) }
91+ reg(&createContext, "udev_create_context")
92+ reg(&assignSeat, "udev_assign_seat")
93+ reg(&unref, "unref")
94+ reg(&getFD, "get_fd")
95+ reg(&dispatch, "dispatch")
96+ reg(&getEvent, "get_event")
97+ reg(&eventType, "event_get_type")
98+ reg(&eventDestroy, "event_destroy")
99+ reg(&eventDevice, "event_get_device")
100+ reg(&deviceName, "device_get_name")
101+ reg(&suspend, "suspend")
102+ reg(&resume, "resume")
103+ reg(&keyboardEvent, "event_get_keyboard_event")
104+ reg(&keyboardKey, "event_keyboard_get_key")
105+ reg(&keyboardState, "event_keyboard_get_key_state")
106+ reg(&pointerEvent, "event_get_pointer_event")
107+ reg(&pointerDX, "event_pointer_get_dx")
108+ reg(&pointerDY, "event_pointer_get_dy")
109+ reg(&pointerAbsX, "event_pointer_get_absolute_x_transformed")
110+ reg(&pointerAbsY, "event_pointer_get_absolute_y_transformed")
111+ reg(&pointerButton, "event_pointer_get_button")
112+ reg(&pointerBtnState, "event_pointer_get_button_state")
113+ iface[0] = purego.NewCallback(func(path *byte, _, _ uintptr) uintptr {
114+ fd, err := active.OpenDevice(goString(path))
115+ if err != nil {
116+ return uintptr(uint32(^uint32(unix.EACCES) + 1)) // -EACCES as C int
117+ }
118+ return uintptr(fd)
119+ })
120+ iface[1] = purego.NewCallback(func(fd, _ uintptr) { active.CloseDevice(int(int32(fd))) })
121+ })
122+ return loadErr
123+}
124+
125+func goString(p *byte) string {
126+ if p == nil {
127+ return ""
128+ }
129+ n := 0
130+ for *(*byte)(unsafe.Add(unsafe.Pointer(p), n)) != 0 {
131+ n++
132+ }
133+ return string(unsafe.Slice(p, n))
134+}
135+
136+// Options configures the input adapter.
137+type Options struct {
138+ Seat Seat
139+ SeatName string
140+ Keymap *xkb.Keymap
141+ Width, Height int
142+ Active <-chan bool
143+ Log zerowrap.Logger
144+}
145+
146+// Run owns libinput and the keymap, converting device events into input events.
147+func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error {
148+ defer opts.Keymap.Close()
149+ if err := load(); err != nil {
150+ return err
151+ }
152+ active = opts.Seat
153+ udev := udevNew()
154+ if udev == 0 {
155+ return errors.New("udev_new failed")
156+ }
157+ defer udevUnref(udev)
158+ li := createContext(unsafe.Pointer(&iface), 0, udev)
159+ if li == 0 {
160+ return errors.New("libinput_udev_create_context failed")
161+ }
162+ defer unref(li)
163+ name := opts.SeatName
164+ if name == "" {
165+ name = "seat0"
166+ }
167+ if assignSeat(li, name) != 0 {
168+ return fmt.Errorf("libinput_udev_assign_seat %s failed", name)
169+ }
170+ p := pointer{w: float64(opts.Width), h: float64(opts.Height)}
171+ p.x, p.y = p.w/2, p.h/2
172+ fd := getFD(li)
173+ for ctx.Err() == nil {
174+ select {
175+ case on := <-opts.Active:
176+ if on {
177+ resume(li)
178+ } else {
179+ suspend(li)
180+ }
181+ opts.Log.Info().Bool("active", on).Msg("input")
182+ default:
183+ }
184+ fds := []unix.PollFd{{Fd: fd, Events: unix.POLLIN}}
185+ n, err := unix.Poll(fds, 100)
186+ if err != nil && !errors.Is(err, unix.EINTR) {
187+ return fmt.Errorf("libinput poll: %w", err)
188+ }
189+ if n <= 0 {
190+ continue
191+ }
192+ if dispatch(li) != 0 {
193+ opts.Log.Warn().Msg("libinput_dispatch failed")
194+ }
195+ for ev := getEvent(li); ev != 0; ev = getEvent(li) {
196+ out, err := translate(ev, opts, &p)
197+ eventDestroy(ev)
198+ if err != nil {
199+ return err
200+ }
201+ if out == nil {
202+ continue
203+ }
204+ select {
205+ case input <- out:
206+ case <-ctx.Done():
207+ return nil
208+ }
209+ }
210+ }
211+ return nil
212+}
213+
214+func translate(ev uintptr, opts Options, p *pointer) (ports.InputEvent, error) {
215+ now := uint32(time.Now().UnixMilli())
216+ log := opts.Log
217+ switch eventType(ev) {
218+ case evDeviceAdded:
219+ log.Info().Str("device", deviceName(eventDevice(ev))).Msg("input device added")
220+ case evDeviceRemoved:
221+ log.Info().Str("device", deviceName(eventDevice(ev))).Msg("input device removed")
222+ case evKeyboardKey:
223+ k := keyboardEvent(ev)
224+ code, pressed := keyboardKey(k), keyboardState(k) == 1
225+ ke := opts.Keymap.Key(code, pressed, now)
226+ log.Debug().Uint32("code", code).Str("keysym", ke.Keysym).Bool("pressed", pressed).Uint8("mods", uint8(ke.Mods)).Msg("key")
227+ switch action, vt := hotkey(ke); action {
228+ case hotkeyQuit:
229+ log.Warn().Str("reason", "emergency-key").Msg("quit")
230+ return nil, ErrEmergencyQuit
231+ case hotkeyVT:
232+ opts.Seat.SwitchVT(vt)
233+ return nil, nil
234+ }
235+ return ke, nil
236+ case evPointerMotion:
237+ pe := pointerEvent(ev)
238+ x, y := p.move(pointerDX(pe), pointerDY(pe))
239+ log.Debug().Float64("x", x).Float64("y", y).Msg("pointer")
240+ return ports.PointerMotion{X: x, Y: y, TimeMsec: now}, nil
241+ case evPointerAbs:
242+ pe := pointerEvent(ev)
243+ x, y := p.set(pointerAbsX(pe, uint32(p.w)), pointerAbsY(pe, uint32(p.h)))
244+ return ports.PointerMotion{X: x, Y: y, TimeMsec: now}, nil
245+ case evPointerButton:
246+ pe := pointerEvent(ev)
247+ b, pressed := pointerButton(pe), pointerBtnState(pe) == 1
248+ log.Debug().Uint32("button", b).Bool("pressed", pressed).Msg("button")
249+ return ports.PointerButton{Button: b, Pressed: pressed, TimeMsec: now}, nil
250+ }
251+ return nil, nil
252+}
253+
254+type hotkeyAction int
255+
256+const (
257+ hotkeyNone hotkeyAction = iota
258+ hotkeyQuit
259+ hotkeyVT
260+)
261+
262+// hotkey detects compositor-reserved keys on press: Ctrl+Alt+Backspace and Ctrl+Alt+F1..F12.
263+func hotkey(ke ports.KeyEvent) (hotkeyAction, int) {
264+ if !ke.Pressed {
265+ return hotkeyNone, 0
266+ }
267+ if vt, ok := strings.CutPrefix(ke.Keysym, "XF86Switch_VT_"); ok {
268+ var n int
269+ if _, err := fmt.Sscan(vt, &n); err == nil && n >= 1 && n <= 12 {
270+ return hotkeyVT, n
271+ }
272+ }
273+ if ke.Mods&(ports.ModCtrl|ports.ModAlt) != ports.ModCtrl|ports.ModAlt {
274+ return hotkeyNone, 0
275+ }
276+ switch {
277+ case ke.Keysym == "BackSpace" || ke.Keycode == 14:
278+ return hotkeyQuit, 0
279+ case ke.Keycode >= 59 && ke.Keycode <= 68: // F1..F10
280+ return hotkeyVT, int(ke.Keycode) - 58
281+ case ke.Keycode == 87 || ke.Keycode == 88: // F11, F12
282+ return hotkeyVT, int(ke.Keycode) - 76
283+ }
284+ return hotkeyNone, 0
285+}
286+
287+type pointer struct{ x, y, w, h float64 }
288+
289+func (p *pointer) move(dx, dy float64) (float64, float64) { return p.set(p.x+dx, p.y+dy) }
290+
291+func (p *pointer) set(x, y float64) (float64, float64) {
292+ p.x = min(max(x, 0), p.w-1)
293+ p.y = min(max(y, 0), p.h-1)
294+ return p.x, p.y
295+}
@@ -0,0 +1,295 @@
1+// Package libinput reads keyboards and pointers through libinput's udev backend.
2+package libinput
3+
4+import (
5+ "context"
6+ "errors"
7+ "fmt"
8+ "strings"
9+ "sync"
10+ "time"
11+ "unsafe"
12+
13+ "github.com/bnema/nefertty/internal/adapters/xkb"
14+ "github.com/bnema/nefertty/internal/ports"
15+ "github.com/bnema/purego"
16+ "github.com/bnema/zerowrap"
17+ "golang.org/x/sys/unix"
18+)
19+
20+// ErrEmergencyQuit is returned when Ctrl+Alt+Backspace is pressed.
21+var ErrEmergencyQuit = errors.New("emergency quit key")
22+
23+// Seat is the device broker libinput opens devices through.
24+type Seat interface {
25+ OpenDevice(path string) (int, error)
26+ CloseDevice(fd int)
27+ SwitchVT(vt int)
28+}
29+
30+const (
31+ evDeviceAdded = 1
32+ evDeviceRemoved = 2
33+ evKeyboardKey = 300
34+ evPointerMotion = 400
35+ evPointerAbs = 401
36+ evPointerButton = 402
37+)
38+
39+var (
40+ loadOnce sync.Once
41+ loadErr error
42+
43+ udevNew func() uintptr
44+ udevUnref func(uintptr) uintptr
45+ createContext func(iface unsafe.Pointer, data uintptr, udev uintptr) uintptr
46+ assignSeat func(li uintptr, seat string) int32
47+ unref func(li uintptr) uintptr
48+ getFD func(li uintptr) int32
49+ dispatch func(li uintptr) int32
50+ getEvent func(li uintptr) uintptr
51+ eventType func(ev uintptr) int32
52+ eventDestroy func(ev uintptr)
53+ eventDevice func(ev uintptr) uintptr
54+ deviceName func(dev uintptr) string
55+ suspend func(li uintptr)
56+ resume func(li uintptr) int32
57+ keyboardEvent func(ev uintptr) uintptr
58+ keyboardKey func(kev uintptr) uint32
59+ keyboardState func(kev uintptr) int32
60+ pointerEvent func(ev uintptr) uintptr
61+ pointerDX func(pev uintptr) float64
62+ pointerDY func(pev uintptr) float64
63+ pointerAbsX func(pev uintptr, width uint32) float64
64+ pointerAbsY func(pev uintptr, height uint32) float64
65+ pointerButton func(pev uintptr) uint32
66+ pointerBtnState func(pev uintptr) int32
67+ iface [2]uintptr
68+ active Seat
69+)
70+
71+func load() error {
72+ loadOnce.Do(func() {
73+ udev, err := purego.Dlopen("libudev.so.1", purego.RTLD_NOW|purego.RTLD_LOCAL)
74+ if err != nil {
75+ loadErr = fmt.Errorf("open libudev.so.1: %w", err)
76+ return
77+ }
78+ lib, err := purego.Dlopen("libinput.so.10", purego.RTLD_NOW|purego.RTLD_LOCAL)
79+ if err != nil {
80+ loadErr = fmt.Errorf("open libinput.so.10: %w", err)
81+ return
82+ }
83+ defer func() {
84+ if r := recover(); r != nil {
85+ loadErr = fmt.Errorf("libinput: %v", r)
86+ }
87+ }()
88+ purego.RegisterLibFunc(&udevNew, udev, "udev_new")
89+ purego.RegisterLibFunc(&udevUnref, udev, "udev_unref")
90+ reg := func(fn any, name string) { purego.RegisterLibFunc(fn, lib, "libinput_"+name) }
91+ reg(&createContext, "udev_create_context")
92+ reg(&assignSeat, "udev_assign_seat")
93+ reg(&unref, "unref")
94+ reg(&getFD, "get_fd")
95+ reg(&dispatch, "dispatch")
96+ reg(&getEvent, "get_event")
97+ reg(&eventType, "event_get_type")
98+ reg(&eventDestroy, "event_destroy")
99+ reg(&eventDevice, "event_get_device")
100+ reg(&deviceName, "device_get_name")
101+ reg(&suspend, "suspend")
102+ reg(&resume, "resume")
103+ reg(&keyboardEvent, "event_get_keyboard_event")
104+ reg(&keyboardKey, "event_keyboard_get_key")
105+ reg(&keyboardState, "event_keyboard_get_key_state")
106+ reg(&pointerEvent, "event_get_pointer_event")
107+ reg(&pointerDX, "event_pointer_get_dx")
108+ reg(&pointerDY, "event_pointer_get_dy")
109+ reg(&pointerAbsX, "event_pointer_get_absolute_x_transformed")
110+ reg(&pointerAbsY, "event_pointer_get_absolute_y_transformed")
111+ reg(&pointerButton, "event_pointer_get_button")
112+ reg(&pointerBtnState, "event_pointer_get_button_state")
113+ iface[0] = purego.NewCallback(func(path *byte, _, _ uintptr) uintptr {
114+ fd, err := active.OpenDevice(goString(path))
115+ if err != nil {
116+ return uintptr(uint32(^uint32(unix.EACCES) + 1)) // -EACCES as C int
117+ }
118+ return uintptr(fd)
119+ })
120+ iface[1] = purego.NewCallback(func(fd, _ uintptr) { active.CloseDevice(int(int32(fd))) })
121+ })
122+ return loadErr
123+}
124+
125+func goString(p *byte) string {
126+ if p == nil {
127+ return ""
128+ }
129+ n := 0
130+ for *(*byte)(unsafe.Add(unsafe.Pointer(p), n)) != 0 {
131+ n++
132+ }
133+ return string(unsafe.Slice(p, n))
134+}
135+
136+// Options configures the input adapter.
137+type Options struct {
138+ Seat Seat
139+ SeatName string
140+ Keymap *xkb.Keymap
141+ Width, Height int
142+ Active <-chan bool
143+ Log zerowrap.Logger
144+}
145+
146+// Run owns libinput and the keymap, converting device events into input events.
147+func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error {
148+ defer opts.Keymap.Close()
149+ if err := load(); err != nil {
150+ return err
151+ }
152+ active = opts.Seat
153+ udev := udevNew()
154+ if udev == 0 {
155+ return errors.New("udev_new failed")
156+ }
157+ defer udevUnref(udev)
158+ li := createContext(unsafe.Pointer(&iface), 0, udev)
159+ if li == 0 {
160+ return errors.New("libinput_udev_create_context failed")
161+ }
162+ defer unref(li)
163+ name := opts.SeatName
164+ if name == "" {
165+ name = "seat0"
166+ }
167+ if assignSeat(li, name) != 0 {
168+ return fmt.Errorf("libinput_udev_assign_seat %s failed", name)
169+ }
170+ p := pointer{w: float64(opts.Width), h: float64(opts.Height)}
171+ p.x, p.y = p.w/2, p.h/2
172+ fd := getFD(li)
173+ for ctx.Err() == nil {
174+ select {
175+ case on := <-opts.Active:
176+ if on {
177+ resume(li)
178+ } else {
179+ suspend(li)
180+ }
181+ opts.Log.Info().Bool("active", on).Msg("input")
182+ default:
183+ }
184+ fds := []unix.PollFd{{Fd: fd, Events: unix.POLLIN}}
185+ n, err := unix.Poll(fds, 100)
186+ if err != nil && !errors.Is(err, unix.EINTR) {
187+ return fmt.Errorf("libinput poll: %w", err)
188+ }
189+ if n <= 0 {
190+ continue
191+ }
192+ if dispatch(li) != 0 {
193+ opts.Log.Warn().Msg("libinput_dispatch failed")
194+ }
195+ for ev := getEvent(li); ev != 0; ev = getEvent(li) {
196+ out, err := translate(ev, opts, &p)
197+ eventDestroy(ev)
198+ if err != nil {
199+ return err
200+ }
201+ if out == nil {
202+ continue
203+ }
204+ select {
205+ case input <- out:
206+ case <-ctx.Done():
207+ return nil
208+ }
209+ }
210+ }
211+ return nil
212+}
213+
214+func translate(ev uintptr, opts Options, p *pointer) (ports.InputEvent, error) {
215+ now := uint32(time.Now().UnixMilli())
216+ log := opts.Log
217+ switch eventType(ev) {
218+ case evDeviceAdded:
219+ log.Info().Str("device", deviceName(eventDevice(ev))).Msg("input device added")
220+ case evDeviceRemoved:
221+ log.Info().Str("device", deviceName(eventDevice(ev))).Msg("input device removed")
222+ case evKeyboardKey:
223+ k := keyboardEvent(ev)
224+ code, pressed := keyboardKey(k), keyboardState(k) == 1
225+ ke := opts.Keymap.Key(code, pressed, now)
226+ log.Debug().Uint32("code", code).Str("keysym", ke.Keysym).Bool("pressed", pressed).Uint8("mods", uint8(ke.Mods)).Msg("key")
227+ switch action, vt := hotkey(ke); action {
228+ case hotkeyQuit:
229+ log.Warn().Str("reason", "emergency-key").Msg("quit")
230+ return nil, ErrEmergencyQuit
231+ case hotkeyVT:
232+ opts.Seat.SwitchVT(vt)
233+ return nil, nil
234+ }
235+ return ke, nil
236+ case evPointerMotion:
237+ pe := pointerEvent(ev)
238+ x, y := p.move(pointerDX(pe), pointerDY(pe))
239+ log.Debug().Float64("x", x).Float64("y", y).Msg("pointer")
240+ return ports.PointerMotion{X: x, Y: y, TimeMsec: now}, nil
241+ case evPointerAbs:
242+ pe := pointerEvent(ev)
243+ x, y := p.set(pointerAbsX(pe, uint32(p.w)), pointerAbsY(pe, uint32(p.h)))
244+ return ports.PointerMotion{X: x, Y: y, TimeMsec: now}, nil
245+ case evPointerButton:
246+ pe := pointerEvent(ev)
247+ b, pressed := pointerButton(pe), pointerBtnState(pe) == 1
248+ log.Debug().Uint32("button", b).Bool("pressed", pressed).Msg("button")
249+ return ports.PointerButton{Button: b, Pressed: pressed, TimeMsec: now}, nil
250+ }
251+ return nil, nil
252+}
253+
254+type hotkeyAction int
255+
256+const (
257+ hotkeyNone hotkeyAction = iota
258+ hotkeyQuit
259+ hotkeyVT
260+)
261+
262+// hotkey detects compositor-reserved keys on press: Ctrl+Alt+Backspace and Ctrl+Alt+F1..F12.
263+func hotkey(ke ports.KeyEvent) (hotkeyAction, int) {
264+ if !ke.Pressed {
265+ return hotkeyNone, 0
266+ }
267+ if vt, ok := strings.CutPrefix(ke.Keysym, "XF86Switch_VT_"); ok {
268+ var n int
269+ if _, err := fmt.Sscan(vt, &n); err == nil && n >= 1 && n <= 12 {
270+ return hotkeyVT, n
271+ }
272+ }
273+ if ke.Mods&(ports.ModCtrl|ports.ModAlt) != ports.ModCtrl|ports.ModAlt {
274+ return hotkeyNone, 0
275+ }
276+ switch {
277+ case ke.Keysym == "BackSpace" || ke.Keycode == 14:
278+ return hotkeyQuit, 0
279+ case ke.Keycode >= 59 && ke.Keycode <= 68: // F1..F10
280+ return hotkeyVT, int(ke.Keycode) - 58
281+ case ke.Keycode == 87 || ke.Keycode == 88: // F11, F12
282+ return hotkeyVT, int(ke.Keycode) - 76
283+ }
284+ return hotkeyNone, 0
285+}
286+
287+type pointer struct{ x, y, w, h float64 }
288+
289+func (p *pointer) move(dx, dy float64) (float64, float64) { return p.set(p.x+dx, p.y+dy) }
290+
291+func (p *pointer) set(x, y float64) (float64, float64) {
292+ p.x = min(max(x, 0), p.w-1)
293+ p.y = min(max(y, 0), p.h-1)
294+ return p.x, p.y
295+}
A internal/adapters/libinput/libinput_test.go +40 -0
@@ -0,0 +1,40 @@
1+package libinput
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/nefertty/internal/ports"
7+)
8+
9+func TestHotkey(t *testing.T) {
10+ ca := ports.ModCtrl | ports.ModAlt
11+ for _, tc := range []struct {
12+ name string
13+ ev ports.KeyEvent
14+ action hotkeyAction
15+ vt int
16+ }{
17+ {"ctrl alt backspace", ports.KeyEvent{Keysym: "BackSpace", Keycode: 14, Mods: ca, Pressed: true}, hotkeyQuit, 0},
18+ {"release ignored", ports.KeyEvent{Keysym: "BackSpace", Keycode: 14, Mods: ca}, hotkeyNone, 0},
19+ {"ctrl only", ports.KeyEvent{Keysym: "BackSpace", Keycode: 14, Mods: ports.ModCtrl, Pressed: true}, hotkeyNone, 0},
20+ {"xkb vt sym", ports.KeyEvent{Keysym: "XF86Switch_VT_3", Keycode: 61, Mods: ca, Pressed: true}, hotkeyVT, 3},
21+ {"ctrl alt f2 raw", ports.KeyEvent{Keysym: "F2", Keycode: 60, Mods: ca, Pressed: true}, hotkeyVT, 2},
22+ {"ctrl alt f12 raw", ports.KeyEvent{Keysym: "F12", Keycode: 88, Mods: ca, Pressed: true}, hotkeyVT, 12},
23+ {"plain f2", ports.KeyEvent{Keysym: "F2", Keycode: 60, Pressed: true}, hotkeyNone, 0},
24+ } {
25+ a, vt := hotkey(tc.ev)
26+ if a != tc.action || vt != tc.vt {
27+ t.Errorf("%s: got %v %d", tc.name, a, vt)
28+ }
29+ }
30+}
31+
32+func TestPointerClamp(t *testing.T) {
33+ p := pointer{x: 10, y: 10, w: 100, h: 50}
34+ if x, y := p.move(-20, 100); x != 0 || y != 49 {
35+ t.Fatalf("got %v %v", x, y)
36+ }
37+ if x, y := p.move(30.5, -5); x != 30.5 || y != 44 {
38+ t.Fatalf("got %v %v", x, y)
39+ }
40+}
@@ -0,0 +1,40 @@
1+package libinput
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/nefertty/internal/ports"
7+)
8+
9+func TestHotkey(t *testing.T) {
10+ ca := ports.ModCtrl | ports.ModAlt
11+ for _, tc := range []struct {
12+ name string
13+ ev ports.KeyEvent
14+ action hotkeyAction
15+ vt int
16+ }{
17+ {"ctrl alt backspace", ports.KeyEvent{Keysym: "BackSpace", Keycode: 14, Mods: ca, Pressed: true}, hotkeyQuit, 0},
18+ {"release ignored", ports.KeyEvent{Keysym: "BackSpace", Keycode: 14, Mods: ca}, hotkeyNone, 0},
19+ {"ctrl only", ports.KeyEvent{Keysym: "BackSpace", Keycode: 14, Mods: ports.ModCtrl, Pressed: true}, hotkeyNone, 0},
20+ {"xkb vt sym", ports.KeyEvent{Keysym: "XF86Switch_VT_3", Keycode: 61, Mods: ca, Pressed: true}, hotkeyVT, 3},
21+ {"ctrl alt f2 raw", ports.KeyEvent{Keysym: "F2", Keycode: 60, Mods: ca, Pressed: true}, hotkeyVT, 2},
22+ {"ctrl alt f12 raw", ports.KeyEvent{Keysym: "F12", Keycode: 88, Mods: ca, Pressed: true}, hotkeyVT, 12},
23+ {"plain f2", ports.KeyEvent{Keysym: "F2", Keycode: 60, Pressed: true}, hotkeyNone, 0},
24+ } {
25+ a, vt := hotkey(tc.ev)
26+ if a != tc.action || vt != tc.vt {
27+ t.Errorf("%s: got %v %d", tc.name, a, vt)
28+ }
29+ }
30+}
31+
32+func TestPointerClamp(t *testing.T) {
33+ p := pointer{x: 10, y: 10, w: 100, h: 50}
34+ if x, y := p.move(-20, 100); x != 0 || y != 49 {
35+ t.Fatalf("got %v %v", x, y)
36+ }
37+ if x, y := p.move(30.5, -5); x != 30.5 || y != 44 {
38+ t.Fatalf("got %v %v", x, y)
39+ }
40+}
A internal/adapters/seat/seat.go +233 -0
@@ -0,0 +1,233 @@
1+// Package seat opens a libseat session (logind or seatd) and hands out device fds.
2+package seat
3+
4+import (
5+ "context"
6+ "errors"
7+ "fmt"
8+ "runtime"
9+ "sync"
10+ "unsafe"
11+
12+ "github.com/bnema/purego"
13+ "github.com/bnema/zerowrap"
14+ "golang.org/x/sys/unix"
15+)
16+
17+var (
18+ loadOnce sync.Once
19+ loadErr error
20+
21+ openSeat func(listener unsafe.Pointer, data uintptr) uintptr
22+ closeSeat func(seat uintptr) int32
23+ disableSeat func(seat uintptr) int32
24+ openDevice func(seat uintptr, path string, fd *int32) int32
25+ closeDevice func(seat uintptr, id int32) int32
26+ seatName func(seat uintptr) string
27+ switchSession func(seat uintptr, session int32) int32
28+ getFD func(seat uintptr) int32
29+ dispatch func(seat uintptr, timeout int32) int32
30+
31+ listener [2]uintptr
32+ // libseat allows one seat per process; callbacks find it here.
33+ current *Seat
34+)
35+
36+func load() error {
37+ loadOnce.Do(func() {
38+ lib, err := purego.Dlopen("libseat.so.1", purego.RTLD_NOW|purego.RTLD_LOCAL)
39+ if err != nil {
40+ loadErr = fmt.Errorf("open libseat.so.1: %w", err)
41+ return
42+ }
43+ defer func() {
44+ if r := recover(); r != nil {
45+ loadErr = fmt.Errorf("libseat: %v", r)
46+ }
47+ }()
48+ reg := func(fn any, name string) { purego.RegisterLibFunc(fn, lib, "libseat_"+name) }
49+ reg(&openSeat, "open_seat")
50+ reg(&closeSeat, "close_seat")
51+ reg(&disableSeat, "disable_seat")
52+ reg(&openDevice, "open_device")
53+ reg(&closeDevice, "close_device")
54+ reg(&seatName, "seat_name")
55+ reg(&switchSession, "switch_session")
56+ reg(&getFD, "get_fd")
57+ reg(&dispatch, "dispatch")
58+ listener[0] = purego.NewCallback(func(_, _ uintptr) { current.changed(true) })
59+ listener[1] = purego.NewCallback(func(_, _ uintptr) { current.changed(false) })
60+ })
61+ return loadErr
62+}
63+
64+// Seat owns the libseat handle. libseat is not thread-safe, so every call goes
65+// through mu; callbacks only run inside dispatch, which already holds it.
66+type Seat struct {
67+ mu sync.Mutex
68+ handle uintptr
69+ active bool
70+ devices map[int32]int32 // fd → libseat device id
71+ subs []chan bool
72+ log zerowrap.Logger
73+}
74+
75+// Open connects to logind or seatd and waits for the first enable.
76+func Open(ctx context.Context, log zerowrap.Logger) (*Seat, error) {
77+ if err := load(); err != nil {
78+ return nil, err
79+ }
80+ if current != nil {
81+ return nil, errors.New("seat already open")
82+ }
83+ s := &Seat{devices: make(map[int32]int32), log: log}
84+ current = s
85+ s.mu.Lock()
86+ defer s.mu.Unlock()
87+ s.handle = openSeat(unsafe.Pointer(&listener), 0)
88+ runtime.KeepAlive(&listener)
89+ if s.handle == 0 {
90+ current = nil
91+ return nil, errors.New("libseat_open_seat failed: no logind/seatd session for this TTY (run from a text console you are logged into)")
92+ }
93+ log.Info().Str("seat", seatName(s.handle)).Msg("seat opened")
94+ for !s.active {
95+ if err := s.dispatchLocked(ctx, 100); err != nil {
96+ s.closeLocked()
97+ return nil, fmt.Errorf("waiting for seat enable: %w", err)
98+ }
99+ }
100+ return s, nil
101+}
102+
103+func (s *Seat) changed(active bool) {
104+ s.active = active
105+ s.log.Info().Bool("active", active).Msg("seat")
106+ if !active {
107+ // Devices are revoked by the seat manager; ack so the VT can switch.
108+ disableSeat(s.handle)
109+ }
110+ for _, c := range s.subs {
111+ select {
112+ case c <- active:
113+ default:
114+ select {
115+ case <-c:
116+ default:
117+ }
118+ c <- active
119+ }
120+ }
121+}
122+
123+// Name returns the seat name, e.g. seat0.
124+func (s *Seat) Name() string {
125+ s.mu.Lock()
126+ defer s.mu.Unlock()
127+ return seatName(s.handle)
128+}
129+
130+// Subscribe returns a channel that receives the seat state after every enable/disable.
131+// The current state is sent first.
132+func (s *Seat) Subscribe() <-chan bool {
133+ c := make(chan bool, 1)
134+ s.mu.Lock()
135+ c <- s.active
136+ s.subs = append(s.subs, c)
137+ s.mu.Unlock()
138+ return c
139+}
140+
141+func (s *Seat) dispatchLocked(ctx context.Context, timeoutMs int) error {
142+ if ctx.Err() != nil {
143+ return ctx.Err()
144+ }
145+ fds := []unix.PollFd{{Fd: getFD(s.handle), Events: unix.POLLIN}}
146+ if _, err := unix.Poll(fds, timeoutMs); err != nil && !errors.Is(err, unix.EINTR) {
147+ return err
148+ }
149+ if dispatch(s.handle, 0) < 0 {
150+ return errors.New("libseat_dispatch failed")
151+ }
152+ return nil
153+}
154+
155+// Run dispatches seat events until ctx ends. The owner calls Close afterwards,
156+// once outputs have restored their CRTC state.
157+func (s *Seat) Run(ctx context.Context) error {
158+ for ctx.Err() == nil {
159+ fds := []unix.PollFd{{Fd: getFD(s.handle), Events: unix.POLLIN}}
160+ if _, err := unix.Poll(fds, 100); err != nil && !errors.Is(err, unix.EINTR) {
161+ return fmt.Errorf("seat poll: %w", err)
162+ }
163+ // Dispatch every tick: libseat may queue events without the fd becoming readable.
164+ s.mu.Lock()
165+ r := dispatch(s.handle, 0)
166+ s.mu.Unlock()
167+ if r < 0 {
168+ return errors.New("libseat_dispatch failed")
169+ }
170+ }
171+ return nil
172+}
173+
174+// OpenDevice opens path through the seat manager and returns its fd.
175+func (s *Seat) OpenDevice(path string) (int, error) {
176+ s.mu.Lock()
177+ defer s.mu.Unlock()
178+ var fd int32 = -1
179+ id := openDevice(s.handle, path, &fd)
180+ if id < 0 {
181+ s.log.Warn().Str("path", path).Msg("open device failed")
182+ return -1, fmt.Errorf("libseat_open_device %s failed", path)
183+ }
184+ s.devices[fd] = id
185+ s.log.Info().Str("path", path).Int32("fd", fd).Msg("device opened")
186+ return int(fd), nil
187+}
188+
189+// CloseDevice releases a fd returned by OpenDevice.
190+func (s *Seat) CloseDevice(fd int) {
191+ s.mu.Lock()
192+ defer s.mu.Unlock()
193+ id, ok := s.devices[int32(fd)]
194+ if !ok {
195+ return
196+ }
197+ delete(s.devices, int32(fd))
198+ closeDevice(s.handle, id)
199+ _ = unix.Close(fd)
200+ s.log.Info().Int("fd", fd).Msg("device closed")
201+}
202+
203+// SwitchVT asks the seat manager to switch to virtual terminal vt.
204+func (s *Seat) SwitchVT(vt int) {
205+ s.mu.Lock()
206+ defer s.mu.Unlock()
207+ s.log.Info().Int("vt", vt).Msg("switch vt")
208+ if switchSession(s.handle, int32(vt)) < 0 {
209+ s.log.Warn().Int("vt", vt).Msg("switch vt failed")
210+ }
211+}
212+
213+// Close releases all devices and the seat; the VT returns to text mode. Safe to call twice.
214+func (s *Seat) Close() {
215+ s.mu.Lock()
216+ defer s.mu.Unlock()
217+ s.closeLocked()
218+}
219+
220+func (s *Seat) closeLocked() {
221+ if s.handle == 0 {
222+ return
223+ }
224+ for fd, id := range s.devices {
225+ closeDevice(s.handle, id)
226+ _ = unix.Close(int(fd))
227+ }
228+ s.devices = map[int32]int32{}
229+ closeSeat(s.handle)
230+ s.handle = 0
231+ current = nil
232+ s.log.Info().Msg("seat closed")
233+}
@@ -0,0 +1,233 @@
1+// Package seat opens a libseat session (logind or seatd) and hands out device fds.
2+package seat
3+
4+import (
5+ "context"
6+ "errors"
7+ "fmt"
8+ "runtime"
9+ "sync"
10+ "unsafe"
11+
12+ "github.com/bnema/purego"
13+ "github.com/bnema/zerowrap"
14+ "golang.org/x/sys/unix"
15+)
16+
17+var (
18+ loadOnce sync.Once
19+ loadErr error
20+
21+ openSeat func(listener unsafe.Pointer, data uintptr) uintptr
22+ closeSeat func(seat uintptr) int32
23+ disableSeat func(seat uintptr) int32
24+ openDevice func(seat uintptr, path string, fd *int32) int32
25+ closeDevice func(seat uintptr, id int32) int32
26+ seatName func(seat uintptr) string
27+ switchSession func(seat uintptr, session int32) int32
28+ getFD func(seat uintptr) int32
29+ dispatch func(seat uintptr, timeout int32) int32
30+
31+ listener [2]uintptr
32+ // libseat allows one seat per process; callbacks find it here.
33+ current *Seat
34+)
35+
36+func load() error {
37+ loadOnce.Do(func() {
38+ lib, err := purego.Dlopen("libseat.so.1", purego.RTLD_NOW|purego.RTLD_LOCAL)
39+ if err != nil {
40+ loadErr = fmt.Errorf("open libseat.so.1: %w", err)
41+ return
42+ }
43+ defer func() {
44+ if r := recover(); r != nil {
45+ loadErr = fmt.Errorf("libseat: %v", r)
46+ }
47+ }()
48+ reg := func(fn any, name string) { purego.RegisterLibFunc(fn, lib, "libseat_"+name) }
49+ reg(&openSeat, "open_seat")
50+ reg(&closeSeat, "close_seat")
51+ reg(&disableSeat, "disable_seat")
52+ reg(&openDevice, "open_device")
53+ reg(&closeDevice, "close_device")
54+ reg(&seatName, "seat_name")
55+ reg(&switchSession, "switch_session")
56+ reg(&getFD, "get_fd")
57+ reg(&dispatch, "dispatch")
58+ listener[0] = purego.NewCallback(func(_, _ uintptr) { current.changed(true) })
59+ listener[1] = purego.NewCallback(func(_, _ uintptr) { current.changed(false) })
60+ })
61+ return loadErr
62+}
63+
64+// Seat owns the libseat handle. libseat is not thread-safe, so every call goes
65+// through mu; callbacks only run inside dispatch, which already holds it.
66+type Seat struct {
67+ mu sync.Mutex
68+ handle uintptr
69+ active bool
70+ devices map[int32]int32 // fd → libseat device id
71+ subs []chan bool
72+ log zerowrap.Logger
73+}
74+
75+// Open connects to logind or seatd and waits for the first enable.
76+func Open(ctx context.Context, log zerowrap.Logger) (*Seat, error) {
77+ if err := load(); err != nil {
78+ return nil, err
79+ }
80+ if current != nil {
81+ return nil, errors.New("seat already open")
82+ }
83+ s := &Seat{devices: make(map[int32]int32), log: log}
84+ current = s
85+ s.mu.Lock()
86+ defer s.mu.Unlock()
87+ s.handle = openSeat(unsafe.Pointer(&listener), 0)
88+ runtime.KeepAlive(&listener)
89+ if s.handle == 0 {
90+ current = nil
91+ return nil, errors.New("libseat_open_seat failed: no logind/seatd session for this TTY (run from a text console you are logged into)")
92+ }
93+ log.Info().Str("seat", seatName(s.handle)).Msg("seat opened")
94+ for !s.active {
95+ if err := s.dispatchLocked(ctx, 100); err != nil {
96+ s.closeLocked()
97+ return nil, fmt.Errorf("waiting for seat enable: %w", err)
98+ }
99+ }
100+ return s, nil
101+}
102+
103+func (s *Seat) changed(active bool) {
104+ s.active = active
105+ s.log.Info().Bool("active", active).Msg("seat")
106+ if !active {
107+ // Devices are revoked by the seat manager; ack so the VT can switch.
108+ disableSeat(s.handle)
109+ }
110+ for _, c := range s.subs {
111+ select {
112+ case c <- active:
113+ default:
114+ select {
115+ case <-c:
116+ default:
117+ }
118+ c <- active
119+ }
120+ }
121+}
122+
123+// Name returns the seat name, e.g. seat0.
124+func (s *Seat) Name() string {
125+ s.mu.Lock()
126+ defer s.mu.Unlock()
127+ return seatName(s.handle)
128+}
129+
130+// Subscribe returns a channel that receives the seat state after every enable/disable.
131+// The current state is sent first.
132+func (s *Seat) Subscribe() <-chan bool {
133+ c := make(chan bool, 1)
134+ s.mu.Lock()
135+ c <- s.active
136+ s.subs = append(s.subs, c)
137+ s.mu.Unlock()
138+ return c
139+}
140+
141+func (s *Seat) dispatchLocked(ctx context.Context, timeoutMs int) error {
142+ if ctx.Err() != nil {
143+ return ctx.Err()
144+ }
145+ fds := []unix.PollFd{{Fd: getFD(s.handle), Events: unix.POLLIN}}
146+ if _, err := unix.Poll(fds, timeoutMs); err != nil && !errors.Is(err, unix.EINTR) {
147+ return err
148+ }
149+ if dispatch(s.handle, 0) < 0 {
150+ return errors.New("libseat_dispatch failed")
151+ }
152+ return nil
153+}
154+
155+// Run dispatches seat events until ctx ends. The owner calls Close afterwards,
156+// once outputs have restored their CRTC state.
157+func (s *Seat) Run(ctx context.Context) error {
158+ for ctx.Err() == nil {
159+ fds := []unix.PollFd{{Fd: getFD(s.handle), Events: unix.POLLIN}}
160+ if _, err := unix.Poll(fds, 100); err != nil && !errors.Is(err, unix.EINTR) {
161+ return fmt.Errorf("seat poll: %w", err)
162+ }
163+ // Dispatch every tick: libseat may queue events without the fd becoming readable.
164+ s.mu.Lock()
165+ r := dispatch(s.handle, 0)
166+ s.mu.Unlock()
167+ if r < 0 {
168+ return errors.New("libseat_dispatch failed")
169+ }
170+ }
171+ return nil
172+}
173+
174+// OpenDevice opens path through the seat manager and returns its fd.
175+func (s *Seat) OpenDevice(path string) (int, error) {
176+ s.mu.Lock()
177+ defer s.mu.Unlock()
178+ var fd int32 = -1
179+ id := openDevice(s.handle, path, &fd)
180+ if id < 0 {
181+ s.log.Warn().Str("path", path).Msg("open device failed")
182+ return -1, fmt.Errorf("libseat_open_device %s failed", path)
183+ }
184+ s.devices[fd] = id
185+ s.log.Info().Str("path", path).Int32("fd", fd).Msg("device opened")
186+ return int(fd), nil
187+}
188+
189+// CloseDevice releases a fd returned by OpenDevice.
190+func (s *Seat) CloseDevice(fd int) {
191+ s.mu.Lock()
192+ defer s.mu.Unlock()
193+ id, ok := s.devices[int32(fd)]
194+ if !ok {
195+ return
196+ }
197+ delete(s.devices, int32(fd))
198+ closeDevice(s.handle, id)
199+ _ = unix.Close(fd)
200+ s.log.Info().Int("fd", fd).Msg("device closed")
201+}
202+
203+// SwitchVT asks the seat manager to switch to virtual terminal vt.
204+func (s *Seat) SwitchVT(vt int) {
205+ s.mu.Lock()
206+ defer s.mu.Unlock()
207+ s.log.Info().Int("vt", vt).Msg("switch vt")
208+ if switchSession(s.handle, int32(vt)) < 0 {
209+ s.log.Warn().Int("vt", vt).Msg("switch vt failed")
210+ }
211+}
212+
213+// Close releases all devices and the seat; the VT returns to text mode. Safe to call twice.
214+func (s *Seat) Close() {
215+ s.mu.Lock()
216+ defer s.mu.Unlock()
217+ s.closeLocked()
218+}
219+
220+func (s *Seat) closeLocked() {
221+ if s.handle == 0 {
222+ return
223+ }
224+ for fd, id := range s.devices {
225+ closeDevice(s.handle, id)
226+ _ = unix.Close(int(fd))
227+ }
228+ s.devices = map[int32]int32{}
229+ closeSeat(s.handle)
230+ s.handle = 0
231+ current = nil
232+ s.log.Info().Msg("seat closed")
233+}
M internal/adapters/vulkan/renderer.go +34 -6
@@ -161,7 +161,11 @@ func New(width, height int) (r *Renderer, err error) {
161161 return
162162 }
163163 r.dd.GetBufferMemoryRequirements(r.device, r.buffer, &req)
164- kind, err = r.findMemoryType(req.MemoryTypeBits, vk.MemoryPropertyHostVisibleBit|vk.MemoryPropertyHostCoherentBit)
164+ // The CPU reads this buffer every frame: uncached memory makes that ~100x slower.
165+ kind, err = r.findMemoryType(req.MemoryTypeBits, vk.MemoryPropertyHostVisibleBit|vk.MemoryPropertyHostCoherentBit|vk.MemoryPropertyHostCachedBit)
166+ if err != nil {
167+ kind, err = r.findMemoryType(req.MemoryTypeBits, vk.MemoryPropertyHostVisibleBit|vk.MemoryPropertyHostCoherentBit)
168+ }
165169 if err != nil {
166170 return
167171 }
@@ -362,20 +366,31 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
362366 continue
363367 }
364368 x, y := w.Rect.X, w.Rect.Y
365- 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)
366376 content := contents[w.ID]
367377 if content.Pixels == nil {
368378 add(body, windowColor(w.ID), nil, image.Point{})
369379 } else {
370380 add(body, parseColor(s.Background), nil, image.Point{})
371- 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)
372382 if width > 0 && height > 0 && content.Stride >= width*4 && len(content.Pixels) >= (height-1)*content.Stride+width*4 {
373- 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))
374384 }
375385 }
386+ borderColor := s.Border.Inactive
376387 if w.Focused {
377- for _, strip := range []image.Rectangle{image.Rect(x, y, x+w.Rect.W, y+4), image.Rect(x, y+w.Rect.H-4, x+w.Rect.W, y+w.Rect.H), image.Rect(x, y+4, x+4, y+w.Rect.H-4), image.Rect(x+w.Rect.W-4, y+4, x+w.Rect.W, y+w.Rect.H-4)} {
378- add(strip, [3]uint8{255, 255, 255}, nil, image.Point{})
388+ borderColor = s.Border.Active
389+ }
390+ if b > 0 && borderColor != "" {
391+ rgb := parseColor(borderColor)
392+ for _, strip := range []image.Rectangle{image.Rect(x, y, x+w.Rect.W, y+b), image.Rect(x, y+w.Rect.H-b, x+w.Rect.W, y+w.Rect.H), image.Rect(x, y+b, x+b, y+w.Rect.H-b), image.Rect(x+w.Rect.W-b, y+b, x+w.Rect.W, y+w.Rect.H-b)} {
393+ add(strip, rgb, nil, image.Point{})
379394 }
380395 }
381396 }
@@ -471,6 +486,19 @@ func (r *Renderer) Pixels() *image.RGBA {
471486 return out
472487 }
473488
489+// CopyBGRX writes the last frame into an XRGB8888 buffer with the given pitch.
490+// The image is B8G8R8A8, which is already XRGB8888 in memory.
491+func (r *Renderer) CopyBGRX(dst []byte, pitch int) {
492+ if r.mapped == nil {
493+ return
494+ }
495+ row := r.width * 4
496+ src := unsafe.Slice((*byte)(r.mapped), row*r.height)
497+ for y := 0; y < r.height; y++ {
498+ copy(dst[y*pitch:y*pitch+row], src[y*row:(y+1)*row])
499+ }
500+}
501+
474502 func (r *Renderer) Close() {
475503 if r == nil {
476504 return
@@ -161,7 +161,11 @@ func New(width, height int) (r *Renderer, err error) {
161 return161 return
162 }162 }
163 r.dd.GetBufferMemoryRequirements(r.device, r.buffer, &req)163 r.dd.GetBufferMemoryRequirements(r.device, r.buffer, &req)
164- kind, err = r.findMemoryType(req.MemoryTypeBits, vk.MemoryPropertyHostVisibleBit|vk.MemoryPropertyHostCoherentBit)164+ // The CPU reads this buffer every frame: uncached memory makes that ~100x slower.
165+ kind, err = r.findMemoryType(req.MemoryTypeBits, vk.MemoryPropertyHostVisibleBit|vk.MemoryPropertyHostCoherentBit|vk.MemoryPropertyHostCachedBit)
166+ if err != nil {
167+ kind, err = r.findMemoryType(req.MemoryTypeBits, vk.MemoryPropertyHostVisibleBit|vk.MemoryPropertyHostCoherentBit)
168+ }
165 if err != nil {169 if err != nil {
166 return170 return
167 }171 }
@@ -362,20 +366,31 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
362 continue366 continue
363 }367 }
364 x, y := w.Rect.X, w.Rect.Y368 x, y := w.Rect.X, w.Rect.Y
365- 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)
366 content := contents[w.ID]376 content := contents[w.ID]
367 if content.Pixels == nil {377 if content.Pixels == nil {
368 add(body, windowColor(w.ID), nil, image.Point{})378 add(body, windowColor(w.ID), nil, image.Point{})
369 } else {379 } else {
370 add(body, parseColor(s.Background), nil, image.Point{})380 add(body, parseColor(s.Background), nil, image.Point{})
371- 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)
372 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 {
373- 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))
374 }384 }
375 }385 }
386+ borderColor := s.Border.Inactive
376 if w.Focused {387 if w.Focused {
377- for _, strip := range []image.Rectangle{image.Rect(x, y, x+w.Rect.W, y+4), image.Rect(x, y+w.Rect.H-4, x+w.Rect.W, y+w.Rect.H), image.Rect(x, y+4, x+4, y+w.Rect.H-4), image.Rect(x+w.Rect.W-4, y+4, x+w.Rect.W, y+w.Rect.H-4)} {388+ borderColor = s.Border.Active
378- add(strip, [3]uint8{255, 255, 255}, nil, image.Point{})389+ }
390+ if b > 0 && borderColor != "" {
391+ rgb := parseColor(borderColor)
392+ for _, strip := range []image.Rectangle{image.Rect(x, y, x+w.Rect.W, y+b), image.Rect(x, y+w.Rect.H-b, x+w.Rect.W, y+w.Rect.H), image.Rect(x, y+b, x+b, y+w.Rect.H-b), image.Rect(x+w.Rect.W-b, y+b, x+w.Rect.W, y+w.Rect.H-b)} {
393+ add(strip, rgb, nil, image.Point{})
379 }394 }
380 }395 }
381 }396 }
@@ -471,6 +486,19 @@ func (r *Renderer) Pixels() *image.RGBA {
471 return out486 return out
472 }487 }
473 488
489+// CopyBGRX writes the last frame into an XRGB8888 buffer with the given pitch.
490+// The image is B8G8R8A8, which is already XRGB8888 in memory.
491+func (r *Renderer) CopyBGRX(dst []byte, pitch int) {
492+ if r.mapped == nil {
493+ return
494+ }
495+ row := r.width * 4
496+ src := unsafe.Slice((*byte)(r.mapped), row*r.height)
497+ for y := 0; y < r.height; y++ {
498+ copy(dst[y*pitch:y*pitch+row], src[y*row:(y+1)*row])
499+ }
500+}
501+
474 func (r *Renderer) Close() {502 func (r *Renderer) Close() {
475 if r == nil {503 if r == nil {
476 return504 return
M internal/adapters/vulkan/renderer_test.go +7 -3
@@ -57,10 +57,13 @@ func TestRendererRender(t *testing.T) {
5757 win := ports.SceneWindow{ID: 1, Rect: ports.Rect{X: 8, Y: 8, W: 16, H: 16}}
5858 c := windowColor(1)
5959 wc := color.RGBA{c[0], c[1], c[2], 255}
60- s := ports.Scene{Background: "#102030", Windows: []ports.SceneWindow{win}}
61- check(s, map[image.Point]color.RGBA{{0, 0}: bg, {16, 16}: wc})
60+ s := ports.Scene{Background: "#102030", Border: ports.Border{Width: 2, Active: "#b4befe", Inactive: "#313244"}, Windows: []ports.SceneWindow{win}}
61+ check(s, map[image.Point]color.RGBA{{0, 0}: bg, {9, 9}: {0x31, 0x32, 0x44, 255}, {10, 10}: wc, {16, 16}: wc})
6262 s.Windows[0].Focused = true
63- check(s, map[image.Point]color.RGBA{{9, 9}: {255, 255, 255, 255}, {16, 16}: wc})
63+ check(s, map[image.Point]color.RGBA{{9, 9}: {0xb4, 0xbe, 0xfe, 255}, {10, 10}: wc, {16, 16}: wc})
64+ s.Windows[0].Fullscreen = true
65+ check(s, map[image.Point]color.RGBA{{9, 9}: wc})
66+ s.Windows[0].Fullscreen = false
6467 s.Windows[0] = ports.SceneWindow{ID: 2, Rect: ports.Rect{X: -10, Y: 0, W: 20, H: 10}}
6568 c = windowColor(2)
6669 check(s, map[image.Point]color.RGBA{{5, 5}: {c[0], c[1], c[2], 255}, {15, 5}: bg})
@@ -128,6 +131,7 @@ func TestRendererUploadOrderAndOpaque(t *testing.T) {
128131 if got, want := r.Pixels().RGBAAt(10, 10), (color.RGBA{c[0], c[1], c[2], 255}); got != want {
129132 t.Errorf("overlap = %v, want %v", got, want)
130133 }
134+ scene.Border = ports.Border{Width: 4, Active: "#ffffff"}
131135 scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 2, Y: 2, W: 16, H: 16}, Focused: true}}
132136 contents := map[ports.WindowID]ports.SurfaceContent{1: {Width: 16, Height: 16, Stride: 64, Opaque: true, Pixels: make([]byte, 16*16*4)}}
133137 for i := 0; i < len(contents[1].Pixels); i += 4 {
@@ -57,10 +57,13 @@ func TestRendererRender(t *testing.T) {
57 win := ports.SceneWindow{ID: 1, Rect: ports.Rect{X: 8, Y: 8, W: 16, H: 16}}57 win := ports.SceneWindow{ID: 1, Rect: ports.Rect{X: 8, Y: 8, W: 16, H: 16}}
58 c := windowColor(1)58 c := windowColor(1)
59 wc := color.RGBA{c[0], c[1], c[2], 255}59 wc := color.RGBA{c[0], c[1], c[2], 255}
60- s := ports.Scene{Background: "#102030", Windows: []ports.SceneWindow{win}}60+ s := ports.Scene{Background: "#102030", Border: ports.Border{Width: 2, Active: "#b4befe", Inactive: "#313244"}, Windows: []ports.SceneWindow{win}}
61- check(s, map[image.Point]color.RGBA{{0, 0}: bg, {16, 16}: wc})61+ check(s, map[image.Point]color.RGBA{{0, 0}: bg, {9, 9}: {0x31, 0x32, 0x44, 255}, {10, 10}: wc, {16, 16}: wc})
62 s.Windows[0].Focused = true62 s.Windows[0].Focused = true
63- check(s, map[image.Point]color.RGBA{{9, 9}: {255, 255, 255, 255}, {16, 16}: wc})63+ check(s, map[image.Point]color.RGBA{{9, 9}: {0xb4, 0xbe, 0xfe, 255}, {10, 10}: wc, {16, 16}: wc})
64+ s.Windows[0].Fullscreen = true
65+ check(s, map[image.Point]color.RGBA{{9, 9}: wc})
66+ s.Windows[0].Fullscreen = false
64 s.Windows[0] = ports.SceneWindow{ID: 2, Rect: ports.Rect{X: -10, Y: 0, W: 20, H: 10}}67 s.Windows[0] = ports.SceneWindow{ID: 2, Rect: ports.Rect{X: -10, Y: 0, W: 20, H: 10}}
65 c = windowColor(2)68 c = windowColor(2)
66 check(s, map[image.Point]color.RGBA{{5, 5}: {c[0], c[1], c[2], 255}, {15, 5}: bg})69 check(s, map[image.Point]color.RGBA{{5, 5}: {c[0], c[1], c[2], 255}, {15, 5}: bg})
@@ -128,6 +131,7 @@ func TestRendererUploadOrderAndOpaque(t *testing.T) {
128 if got, want := r.Pixels().RGBAAt(10, 10), (color.RGBA{c[0], c[1], c[2], 255}); got != want {131 if got, want := r.Pixels().RGBAAt(10, 10), (color.RGBA{c[0], c[1], c[2], 255}); got != want {
129 t.Errorf("overlap = %v, want %v", got, want)132 t.Errorf("overlap = %v, want %v", got, want)
130 }133 }
134+ scene.Border = ports.Border{Width: 4, Active: "#ffffff"}
131 scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 2, Y: 2, W: 16, H: 16}, Focused: true}}135 scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 2, Y: 2, W: 16, H: 16}, Focused: true}}
132 contents := map[ports.WindowID]ports.SurfaceContent{1: {Width: 16, Height: 16, Stride: 64, Opaque: true, Pixels: make([]byte, 16*16*4)}}136 contents := map[ports.WindowID]ports.SurfaceContent{1: {Width: 16, Height: 16, Stride: 64, Opaque: true, Pixels: make([]byte, 16*16*4)}}
133 for i := 0; i < len(contents[1].Pixels); i += 4 {137 for i := 0; i < len(contents[1].Pixels); i += 4 {
A internal/adapters/wayland/decoration.go +51 -0
@@ -0,0 +1,51 @@
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{ s *Server }
12+
13+func registerDecoration(d *server.Display, s *Server) error {
14+ return xdgdecoration.NewZxdgDecorationManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
15+ _, _ = xdgdecoration.NewZxdgDecorationManagerV1(c, int32(v), id, decorationManager{s})
16+ })
17+}
18+
19+func (decorationManager) Destroy(*xdgdecoration.ZxdgDecorationManagerV1) {}
20+
21+func (m decorationManager) GetToplevelDecoration(r *xdgdecoration.ZxdgDecorationManagerV1, id uint32, t *xdgshell.Toplevel) {
22+ d := &decoration{s: m.s, toplevel: t}
23+ res, err := xdgdecoration.NewZxdgToplevelDecorationV1(r.Client(), r.Version(), id, d)
24+ if err != nil {
25+ return
26+ }
27+ d.send(res)
28+}
29+
30+type decoration struct {
31+ s *Server
32+ toplevel *xdgshell.Toplevel
33+}
34+
35+func (*decoration) Destroy(*xdgdecoration.ZxdgToplevelDecorationV1) {}
36+
37+// Any requested mode is answered with server-side.
38+func (d *decoration) SetMode(r *xdgdecoration.ZxdgToplevelDecorationV1, _ uint32) { d.send(r) }
39+func (d *decoration) UnsetMode(r *xdgdecoration.ZxdgToplevelDecorationV1) { d.send(r) }
40+
41+// send answers server-side. The mode applies on the next xdg_surface.configure,
42+// so a window already configured is configured again with its last size.
43+func (d *decoration) send(r *xdgdecoration.ZxdgToplevelDecorationV1) {
44+ r.SendConfigure(uint32(xdgdecoration.ZxdgToplevelDecorationV1ModeServerSide))
45+ for _, w := range d.s.windows {
46+ if w.toplevel == d.toplevel && w.hasLast && w.toplevel.Resource.Alive() && w.xdg.resource.Resource.Alive() {
47+ w.sendConfigure()
48+ return
49+ }
50+ }
51+}
@@ -0,0 +1,51 @@
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{ s *Server }
12+
13+func registerDecoration(d *server.Display, s *Server) error {
14+ return xdgdecoration.NewZxdgDecorationManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
15+ _, _ = xdgdecoration.NewZxdgDecorationManagerV1(c, int32(v), id, decorationManager{s})
16+ })
17+}
18+
19+func (decorationManager) Destroy(*xdgdecoration.ZxdgDecorationManagerV1) {}
20+
21+func (m decorationManager) GetToplevelDecoration(r *xdgdecoration.ZxdgDecorationManagerV1, id uint32, t *xdgshell.Toplevel) {
22+ d := &decoration{s: m.s, toplevel: t}
23+ res, err := xdgdecoration.NewZxdgToplevelDecorationV1(r.Client(), r.Version(), id, d)
24+ if err != nil {
25+ return
26+ }
27+ d.send(res)
28+}
29+
30+type decoration struct {
31+ s *Server
32+ toplevel *xdgshell.Toplevel
33+}
34+
35+func (*decoration) Destroy(*xdgdecoration.ZxdgToplevelDecorationV1) {}
36+
37+// Any requested mode is answered with server-side.
38+func (d *decoration) SetMode(r *xdgdecoration.ZxdgToplevelDecorationV1, _ uint32) { d.send(r) }
39+func (d *decoration) UnsetMode(r *xdgdecoration.ZxdgToplevelDecorationV1) { d.send(r) }
40+
41+// send answers server-side. The mode applies on the next xdg_surface.configure,
42+// so a window already configured is configured again with its last size.
43+func (d *decoration) send(r *xdgdecoration.ZxdgToplevelDecorationV1) {
44+ r.SendConfigure(uint32(xdgdecoration.ZxdgToplevelDecorationV1ModeServerSide))
45+ for _, w := range d.s.windows {
46+ if w.toplevel == d.toplevel && w.hasLast && w.toplevel.Resource.Alive() && w.xdg.resource.Resource.Alive() {
47+ w.sendConfigure()
48+ return
49+ }
50+ }
51+}
M internal/adapters/wayland/globals.go +6 -4
@@ -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, s) },
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 },
@@ -36,14 +37,15 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
3637 if e != nil {
3738 return
3839 }
39- r.SendGeometry(0, 0, 0, 0, 0, "nefertty", "headless", 0)
40- r.SendMode(3, int32(o.OutputWidth), int32(o.OutputHeight), 60000)
40+ info := o.output()
41+ r.SendGeometry(0, 0, int32(info.PhysicalW), int32(info.PhysicalH), 0, info.Make, info.Model, 0)
42+ r.SendMode(3, int32(o.OutputWidth), int32(o.OutputHeight), int32(info.RefreshMilli))
4143 if v >= 2 {
4244 r.SendScale(1)
4345 }
4446 if v >= 4 {
45- r.SendName("HEADLESS-1")
46- r.SendDescription("NeferTTY headless output")
47+ r.SendName(info.Name)
48+ r.SendDescription(info.Description)
4749 }
4850 if v >= 2 {
4951 r.SendDone()
@@ -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, s) },
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 },
@@ -36,14 +37,15 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
36 if e != nil {37 if e != nil {
37 return38 return
38 }39 }
39- r.SendGeometry(0, 0, 0, 0, 0, "nefertty", "headless", 0)40+ info := o.output()
40- r.SendMode(3, int32(o.OutputWidth), int32(o.OutputHeight), 60000)41+ r.SendGeometry(0, 0, int32(info.PhysicalW), int32(info.PhysicalH), 0, info.Make, info.Model, 0)
42+ r.SendMode(3, int32(o.OutputWidth), int32(o.OutputHeight), int32(info.RefreshMilli))
41 if v >= 2 {43 if v >= 2 {
42 r.SendScale(1)44 r.SendScale(1)
43 }45 }
44 if v >= 4 {46 if v >= 4 {
45- r.SendName("HEADLESS-1")47+ r.SendName(info.Name)
46- r.SendDescription("NeferTTY headless output")48+ r.SendDescription(info.Description)
47 }49 }
48 if v >= 2 {50 if v >= 2 {
49 r.SendDone()51 r.SendDone()
M internal/adapters/wayland/server.go +26 -12
@@ -10,7 +10,6 @@ import (
1010
1111 "github.com/bnema/nefertty/internal/ports"
1212 "github.com/bnema/purego-libwayland/protocol/wayland"
13- "github.com/bnema/purego-libwayland/protocol/xdgshell"
1413 "github.com/bnema/purego-libwayland/server"
1514 "github.com/bnema/zerowrap"
1615 "golang.org/x/sys/unix"
@@ -19,10 +18,33 @@ import (
1918 type Options struct {
2019 RuntimeDir string
2120 OutputWidth, OutputHeight int
21+ Output OutputInfo
2222 Keymap string
2323 RepeatRate, RepeatDelay int
2424 }
2525
26+// OutputInfo describes the advertised wl_output. Zero values fall back to the headless output.
27+type OutputInfo struct {
28+ Name, Description string
29+ Make, Model string
30+ RefreshMilli int
31+ PhysicalW, PhysicalH int
32+}
33+
34+func (o Options) output() OutputInfo {
35+ i := o.Output
36+ if i.Name == "" {
37+ i.Name, i.Description = "HEADLESS-1", "NeferTTY headless output"
38+ }
39+ if i.Make == "" {
40+ i.Make, i.Model = "nefertty", "headless"
41+ }
42+ if i.RefreshMilli == 0 {
43+ i.RefreshMilli = 60000
44+ }
45+ return i
46+}
47+
2648 // Channels carries client notifications and commands. Events may be unbuffered.
2749 type Channels struct {
2850 Events chan<- ports.ClientEvent
@@ -358,17 +380,9 @@ func (s *Server) apply(cmd ports.ClientCommand) {
358380 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("configure missing window")
359381 return
360382 }
361- var states []byte
362- if c.Fullscreen {
363- states = binary.LittleEndian.AppendUint32(states, uint32(xdgshell.ToplevelStateFullscreen))
364- }
365- if c.Activated {
366- states = binary.LittleEndian.AppendUint32(states, uint32(xdgshell.ToplevelStateActivated))
367- }
368- w.toplevel.SendConfigure(int32(c.Width), int32(c.Height), states)
369- s.serial++
370- w.xdg.resource.SendConfigure(s.serial)
371- w.xdg.serials = append(w.xdg.serials, s.serial)
383+ s.log.Info().Uint64("id", uint64(c.ID)).Int("w", c.Width).Int("h", c.Height).Bool("fullscreen", c.Fullscreen).Bool("activated", c.Activated).Msg("configure")
384+ w.last, w.hasLast = c, true
385+ w.sendConfigure()
372386 case ports.CloseWindow:
373387 w := s.windows[c.ID]
374388 if w == nil || !w.toplevel.Resource.Alive() {
@@ -10,7 +10,6 @@ import (
10 10
11 "github.com/bnema/nefertty/internal/ports"11 "github.com/bnema/nefertty/internal/ports"
12 "github.com/bnema/purego-libwayland/protocol/wayland"12 "github.com/bnema/purego-libwayland/protocol/wayland"
13- "github.com/bnema/purego-libwayland/protocol/xdgshell"
14 "github.com/bnema/purego-libwayland/server"13 "github.com/bnema/purego-libwayland/server"
15 "github.com/bnema/zerowrap"14 "github.com/bnema/zerowrap"
16 "golang.org/x/sys/unix"15 "golang.org/x/sys/unix"
@@ -19,10 +18,33 @@ import (
19 type Options struct {18 type Options struct {
20 RuntimeDir string19 RuntimeDir string
21 OutputWidth, OutputHeight int20 OutputWidth, OutputHeight int
21+ Output OutputInfo
22 Keymap string22 Keymap string
23 RepeatRate, RepeatDelay int23 RepeatRate, RepeatDelay int
24 }24 }
25 25
26+// OutputInfo describes the advertised wl_output. Zero values fall back to the headless output.
27+type OutputInfo struct {
28+ Name, Description string
29+ Make, Model string
30+ RefreshMilli int
31+ PhysicalW, PhysicalH int
32+}
33+
34+func (o Options) output() OutputInfo {
35+ i := o.Output
36+ if i.Name == "" {
37+ i.Name, i.Description = "HEADLESS-1", "NeferTTY headless output"
38+ }
39+ if i.Make == "" {
40+ i.Make, i.Model = "nefertty", "headless"
41+ }
42+ if i.RefreshMilli == 0 {
43+ i.RefreshMilli = 60000
44+ }
45+ return i
46+}
47+
26 // Channels carries client notifications and commands. Events may be unbuffered.48 // Channels carries client notifications and commands. Events may be unbuffered.
27 type Channels struct {49 type Channels struct {
28 Events chan<- ports.ClientEvent50 Events chan<- ports.ClientEvent
@@ -358,17 +380,9 @@ func (s *Server) apply(cmd ports.ClientCommand) {
358 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("configure missing window")380 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("configure missing window")
359 return381 return
360 }382 }
361- var states []byte383+ 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")
362- if c.Fullscreen {384+ w.last, w.hasLast = c, true
363- states = binary.LittleEndian.AppendUint32(states, uint32(xdgshell.ToplevelStateFullscreen))385+ w.sendConfigure()
364- }
365- if c.Activated {
366- states = binary.LittleEndian.AppendUint32(states, uint32(xdgshell.ToplevelStateActivated))
367- }
368- w.toplevel.SendConfigure(int32(c.Width), int32(c.Height), states)
369- s.serial++
370- w.xdg.resource.SendConfigure(s.serial)
371- w.xdg.serials = append(w.xdg.serials, s.serial)
372 case ports.CloseWindow:386 case ports.CloseWindow:
373 w := s.windows[c.ID]387 w := s.windows[c.ID]
374 if w == nil || !w.toplevel.Resource.Alive() {388 if w == nil || !w.toplevel.Resource.Alive() {
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 +36 -1
@@ -1,6 +1,7 @@
11 package wayland
22
33 import (
4+ "encoding/binary"
45 "github.com/bnema/nefertty/internal/ports"
56 "github.com/bnema/purego-libwayland/protocol/wayland"
67 "github.com/bnema/purego-libwayland/protocol/xdgshell"
@@ -77,6 +78,32 @@ type window struct {
7778 xdg *xdgSurface
7879 appID, title string
7980 mapped bool
81+ // last is the most recent core configure, resent when decoration mode changes.
82+ last ports.ConfigureWindow
83+ hasLast bool
84+}
85+
86+// sendConfigure sends w.last as xdg_toplevel + xdg_surface configure.
87+func (w *window) sendConfigure() {
88+ c := w.last
89+ var states []byte
90+ if c.Fullscreen {
91+ states = binary.LittleEndian.AppendUint32(states, uint32(xdgshell.ToplevelStateFullscreen))
92+ }
93+ if c.Activated {
94+ states = binary.LittleEndian.AppendUint32(states, uint32(xdgshell.ToplevelStateActivated))
95+ }
96+ if !c.Fullscreen && w.toplevel.Version() >= 2 {
97+ // Tiled: the client must use exactly this size (no CSD shadows or rounding).
98+ for _, st := range []xdgshell.ToplevelState{xdgshell.ToplevelStateTiledLeft, xdgshell.ToplevelStateTiledRight, xdgshell.ToplevelStateTiledTop, xdgshell.ToplevelStateTiledBottom} {
99+ states = binary.LittleEndian.AppendUint32(states, uint32(st))
100+ }
101+ }
102+ w.toplevel.SendConfigure(int32(c.Width), int32(c.Height), states)
103+ s := w.xdg.server
104+ s.serial++
105+ w.xdg.resource.SendConfigure(s.serial)
106+ w.xdg.serials = append(w.xdg.serials, s.serial)
80107 }
81108
82109 func (w *window) unmap() {
@@ -152,7 +179,15 @@ func (x *xdgSurface) GetPopup(r *xdgshell.Surface, id uint32, _ *xdgshell.Surfac
152179 p.SendPopupDone()
153180 }
154181 }
155-func (x *xdgSurface) SetWindowGeometry(*xdgshell.Surface, int32, int32, int32, int32) {}
182+
183+// SetWindowGeometry is logged only: placement uses the full buffer for now.
184+func (x *xdgSurface) SetWindowGeometry(_ *xdgshell.Surface, gx, gy, gw, gh int32) {
185+ id := uint64(0)
186+ if x.window != nil {
187+ id = uint64(x.window.id)
188+ }
189+ x.server.log.Info().Uint64("id", id).Int32("x", gx).Int32("y", gy).Int32("w", gw).Int32("h", gh).Msg("window geometry")
190+}
156191 func (x *xdgSurface) AckConfigure(r *xdgshell.Surface, serial uint32) {
157192 for i, issued := range x.serials {
158193 if issued == serial {
@@ -1,6 +1,7 @@
1 package wayland1 package wayland
2 2
3 import (3 import (
4+ "encoding/binary"
4 "github.com/bnema/nefertty/internal/ports"5 "github.com/bnema/nefertty/internal/ports"
5 "github.com/bnema/purego-libwayland/protocol/wayland"6 "github.com/bnema/purego-libwayland/protocol/wayland"
6 "github.com/bnema/purego-libwayland/protocol/xdgshell"7 "github.com/bnema/purego-libwayland/protocol/xdgshell"
@@ -77,6 +78,32 @@ type window struct {
77 xdg *xdgSurface78 xdg *xdgSurface
78 appID, title string79 appID, title string
79 mapped bool80 mapped bool
81+ // last is the most recent core configure, resent when decoration mode changes.
82+ last ports.ConfigureWindow
83+ hasLast bool
84+}
85+
86+// sendConfigure sends w.last as xdg_toplevel + xdg_surface configure.
87+func (w *window) sendConfigure() {
88+ c := w.last
89+ var states []byte
90+ if c.Fullscreen {
91+ states = binary.LittleEndian.AppendUint32(states, uint32(xdgshell.ToplevelStateFullscreen))
92+ }
93+ if c.Activated {
94+ states = binary.LittleEndian.AppendUint32(states, uint32(xdgshell.ToplevelStateActivated))
95+ }
96+ if !c.Fullscreen && w.toplevel.Version() >= 2 {
97+ // Tiled: the client must use exactly this size (no CSD shadows or rounding).
98+ for _, st := range []xdgshell.ToplevelState{xdgshell.ToplevelStateTiledLeft, xdgshell.ToplevelStateTiledRight, xdgshell.ToplevelStateTiledTop, xdgshell.ToplevelStateTiledBottom} {
99+ states = binary.LittleEndian.AppendUint32(states, uint32(st))
100+ }
101+ }
102+ w.toplevel.SendConfigure(int32(c.Width), int32(c.Height), states)
103+ s := w.xdg.server
104+ s.serial++
105+ w.xdg.resource.SendConfigure(s.serial)
106+ w.xdg.serials = append(w.xdg.serials, s.serial)
80 }107 }
81 108
82 func (w *window) unmap() {109 func (w *window) unmap() {
@@ -152,7 +179,15 @@ func (x *xdgSurface) GetPopup(r *xdgshell.Surface, id uint32, _ *xdgshell.Surfac
152 p.SendPopupDone()179 p.SendPopupDone()
153 }180 }
154 }181 }
155-func (x *xdgSurface) SetWindowGeometry(*xdgshell.Surface, int32, int32, int32, int32) {}182+
183+// SetWindowGeometry is logged only: placement uses the full buffer for now.
184+func (x *xdgSurface) SetWindowGeometry(_ *xdgshell.Surface, gx, gy, gw, gh int32) {
185+ id := uint64(0)
186+ if x.window != nil {
187+ id = uint64(x.window.id)
188+ }
189+ x.server.log.Info().Uint64("id", id).Int32("x", gx).Int32("y", gy).Int32("w", gw).Int32("h", gh).Msg("window geometry")
190+}
156 func (x *xdgSurface) AckConfigure(r *xdgshell.Surface, serial uint32) {191 func (x *xdgSurface) AckConfigure(r *xdgshell.Surface, serial uint32) {
157 for i, issued := range x.serials {192 for i, issued := range x.serials {
158 if issued == serial {193 if issued == serial {
M internal/adapters/wayland/xdgoutput.go +7 -4
@@ -6,11 +6,14 @@ import (
66 "github.com/bnema/purego-libwayland/server"
77 )
88
9-type xdgOutputManager struct{ width, height int32 }
9+type xdgOutputManager struct {
10+ width, height int32
11+ info OutputInfo
12+}
1013
1114 func registerXDGOutput(d *server.Display, o Options) error {
1215 return xdgoutput.NewZxdgOutputManagerV1Global(d, 3, func(c server.Client, v, id uint32) {
13- _, _ = xdgoutput.NewZxdgOutputManagerV1(c, int32(v), id, xdgOutputManager{int32(o.OutputWidth), int32(o.OutputHeight)})
16+ _, _ = xdgoutput.NewZxdgOutputManagerV1(c, int32(v), id, xdgOutputManager{int32(o.OutputWidth), int32(o.OutputHeight), o.output()})
1417 })
1518 }
1619 func (xdgOutputManager) Destroy(*xdgoutput.ZxdgOutputManagerV1) {}
@@ -22,8 +25,8 @@ func (m xdgOutputManager) GetXdgOutput(r *xdgoutput.ZxdgOutputManagerV1, id uint
2225 x.SendLogicalPosition(0, 0)
2326 x.SendLogicalSize(m.width, m.height)
2427 if r.Version() >= 2 {
25- x.SendName("HEADLESS-1")
26- x.SendDescription("NeferTTY headless output")
28+ x.SendName(m.info.Name)
29+ x.SendDescription(m.info.Description)
2730 }
2831 if r.Version() < 3 || output == nil || output.Version() < 2 {
2932 x.SendDone()
@@ -6,11 +6,14 @@ import (
6 "github.com/bnema/purego-libwayland/server"6 "github.com/bnema/purego-libwayland/server"
7 )7 )
8 8
9-type xdgOutputManager struct{ width, height int32 }9+type xdgOutputManager struct {
10+ width, height int32
11+ info OutputInfo
12+}
10 13
11 func registerXDGOutput(d *server.Display, o Options) error {14 func registerXDGOutput(d *server.Display, o Options) error {
12 return xdgoutput.NewZxdgOutputManagerV1Global(d, 3, func(c server.Client, v, id uint32) {15 return xdgoutput.NewZxdgOutputManagerV1Global(d, 3, func(c server.Client, v, id uint32) {
13- _, _ = xdgoutput.NewZxdgOutputManagerV1(c, int32(v), id, xdgOutputManager{int32(o.OutputWidth), int32(o.OutputHeight)})16+ _, _ = xdgoutput.NewZxdgOutputManagerV1(c, int32(v), id, xdgOutputManager{int32(o.OutputWidth), int32(o.OutputHeight), o.output()})
14 })17 })
15 }18 }
16 func (xdgOutputManager) Destroy(*xdgoutput.ZxdgOutputManagerV1) {}19 func (xdgOutputManager) Destroy(*xdgoutput.ZxdgOutputManagerV1) {}
@@ -22,8 +25,8 @@ func (m xdgOutputManager) GetXdgOutput(r *xdgoutput.ZxdgOutputManagerV1, id uint
22 x.SendLogicalPosition(0, 0)25 x.SendLogicalPosition(0, 0)
23 x.SendLogicalSize(m.width, m.height)26 x.SendLogicalSize(m.width, m.height)
24 if r.Version() >= 2 {27 if r.Version() >= 2 {
25- x.SendName("HEADLESS-1")28+ x.SendName(m.info.Name)
26- x.SendDescription("NeferTTY headless output")29+ x.SendDescription(m.info.Description)
27 }30 }
28 if r.Version() < 3 || output == nil || output.Version() < 2 {31 if r.Version() < 3 || output == nil || output.Version() < 2 {
29 x.SendDone()32 x.SendDone()
M internal/app/app.go +58 -10
@@ -12,9 +12,11 @@ import (
1212 "time"
1313
1414 "github.com/bnema/nefertty/internal/adapters/config"
15+ "github.com/bnema/nefertty/internal/adapters/drm"
1516 "github.com/bnema/nefertty/internal/adapters/headless"
1617 "github.com/bnema/nefertty/internal/adapters/headlessinput"
1718 "github.com/bnema/nefertty/internal/adapters/launcher"
19+ "github.com/bnema/nefertty/internal/adapters/libinput"
1820 "github.com/bnema/nefertty/internal/adapters/vulkan"
1921 "github.com/bnema/nefertty/internal/adapters/wayland"
2022 "github.com/bnema/nefertty/internal/adapters/xkb"
@@ -30,6 +32,8 @@ type Options struct {
3032 Timeout time.Duration
3133 NoTerminal bool
3234 ScreenshotDir string
35+ // Width and Height size the headless output; 0 means 1920x1080.
36+ Width, Height int
3337 // Run closes Script on shutdown; the reader goroutine exits after Close.
3438 Script io.ReadCloser
3539 testScenes chan<- ports.Scene
@@ -39,17 +43,30 @@ func Run(ctx context.Context, opts Options) error { return run(ctx, opts, nil) }
3943
4044 func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)) error {
4145 log := logging.For(ctx, "app")
42- if opts.Backend != "headless" {
43- return fmt.Errorf("drm backend not implemented yet")
44- }
4546 log.Info().Str("backend", opts.Backend).Msg("starting nefertty")
4647 var cancel context.CancelFunc
4748 if opts.Timeout > 0 {
48- ctx, cancel = context.WithTimeout(ctx, opts.Timeout)
49+ ctx, cancel = context.WithTimeoutCause(ctx, opts.Timeout, errTimeout)
4950 } else {
5051 ctx, cancel = context.WithCancel(ctx)
5152 }
5253 defer cancel()
54+ width, height := opts.Width, opts.Height
55+ if width <= 0 || height <= 0 {
56+ width, height = 1920, 1080
57+ }
58+ var outInfo wayland.OutputInfo
59+ var hw *drmBackend
60+ if opts.Backend == "drm" {
61+ var err error
62+ if hw, err = openDRM(ctx, opts.Config.Outputs); err != nil {
63+ return err
64+ }
65+ defer hw.close()
66+ width, height = hw.out.Width(), hw.out.Height()
67+ outInfo = hw.outputInfo()
68+ hw.inputActive, hw.outputActive = hw.seat.Subscribe(), hw.seat.Subscribe()
69+ }
5370 client := make(chan ports.ClientEvent, 32)
5471 input := make(chan ports.InputEvent, 32)
5572 output := make(chan ports.OutputEvent, 32)
@@ -65,14 +82,14 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
6582 if err != nil {
6683 return err
6784 }
68- output <- ports.OutputMode{Width: 1920, Height: 1080}
85+ output <- ports.OutputMode{Width: width, Height: height}
6986 km, err := xkb.New(xkb.RMLVO{Layout: opts.Config.Keyboard.Layout, Variant: opts.Config.Keyboard.Variant, Options: opts.Config.Keyboard.Options})
7087 if err != nil {
7188 return err
7289 }
7390 keymap := km.String()
7491 runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
75- server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, OutputWidth: 1920, OutputHeight: 1080, Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents}, logging.For(ctx, "wayland"))
92+ server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, OutputWidth: width, OutputHeight: height, Output: outInfo, Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents}, logging.For(ctx, "wayland"))
7693 if err != nil {
7794 km.Close()
7895 return err
@@ -87,7 +104,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
87104 }
88105 var workers sync.WaitGroup
89106 workers.Add(7)
90- done := make(chan error, 7)
107+ done := make(chan error, 8)
91108 path := opts.ConfigPath
92109 if path == "" {
93110 path = config.DefaultPath()
@@ -99,6 +116,12 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
99116 script := make(chan string)
100117 go func() {
101118 defer workers.Done()
119+ if hw != nil {
120+ done <- safe("input", func() error {
121+ return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Width: width, Height: height, Active: hw.inputActive, Log: logging.For(ctx, "input")}, input)
122+ })
123+ return
124+ }
102125 _ = headlessinput.Run(ctx, km, script, input, logging.For(ctx, "input"))
103126 }()
104127 if opts.Script != nil {
@@ -127,7 +150,19 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
127150
128151 go func() {
129152 defer workers.Done()
130- 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) {
153+ if hw != nil {
154+ done <- safe("output", func() error {
155+ return hw.out.Run(ctx, func(w, h int) (drm.Renderer, error) {
156+ r, err := vulkan.New(w, h)
157+ if err != nil {
158+ return nil, err
159+ }
160+ return r, nil
161+ }, hw.outputActive, renderScenes, contents)
162+ })
163+ return
164+ }
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) {
131166 r, err := vulkan.New(w, h)
132167 if err != nil {
133168 return nil, err
@@ -136,10 +171,18 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
136171 }}, renderScenes, contents)
137172 }()
138173
174+ if hw != nil {
175+ workers.Add(1)
176+ go func() { defer workers.Done(); done <- safe("seat", func() error { return hw.seat.Run(ctx) }) }()
177+ }
139178 var result error
140179 select {
141180 case <-ctx.Done():
181+ if errors.Is(context.Cause(ctx), errTimeout) {
182+ log.Info().Str("reason", "timeout").Dur("after", opts.Timeout).Msg("stopping")
183+ }
142184 case result = <-done:
185+ log.Info().AnErr("cause", result).Msg("stopping")
143186 }
144187 cancel()
145188 workers.Wait()
@@ -149,7 +192,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
149192 result = err
150193 }
151194 }
152- if errors.Is(result, core.ErrQuit) {
195+ if errors.Is(result, core.ErrQuit) || errors.Is(result, libinput.ErrEmergencyQuit) {
153196 return nil
154197 }
155198 return result
@@ -181,7 +224,12 @@ func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors
181224 default:
182225 }
183226 }
184- 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")
185233 case err := <-configErrors:
186234 configLog.Warn().Err(err).Msg("config rejected")
187235 }
@@ -12,9 +12,11 @@ import (
12 "time"12 "time"
13 13
14 "github.com/bnema/nefertty/internal/adapters/config"14 "github.com/bnema/nefertty/internal/adapters/config"
15+ "github.com/bnema/nefertty/internal/adapters/drm"
15 "github.com/bnema/nefertty/internal/adapters/headless"16 "github.com/bnema/nefertty/internal/adapters/headless"
16 "github.com/bnema/nefertty/internal/adapters/headlessinput"17 "github.com/bnema/nefertty/internal/adapters/headlessinput"
17 "github.com/bnema/nefertty/internal/adapters/launcher"18 "github.com/bnema/nefertty/internal/adapters/launcher"
19+ "github.com/bnema/nefertty/internal/adapters/libinput"
18 "github.com/bnema/nefertty/internal/adapters/vulkan"20 "github.com/bnema/nefertty/internal/adapters/vulkan"
19 "github.com/bnema/nefertty/internal/adapters/wayland"21 "github.com/bnema/nefertty/internal/adapters/wayland"
20 "github.com/bnema/nefertty/internal/adapters/xkb"22 "github.com/bnema/nefertty/internal/adapters/xkb"
@@ -30,6 +32,8 @@ type Options struct {
30 Timeout time.Duration32 Timeout time.Duration
31 NoTerminal bool33 NoTerminal bool
32 ScreenshotDir string34 ScreenshotDir string
35+ // Width and Height size the headless output; 0 means 1920x1080.
36+ Width, Height int
33 // Run closes Script on shutdown; the reader goroutine exits after Close.37 // Run closes Script on shutdown; the reader goroutine exits after Close.
34 Script io.ReadCloser38 Script io.ReadCloser
35 testScenes chan<- ports.Scene39 testScenes chan<- ports.Scene
@@ -39,17 +43,30 @@ func Run(ctx context.Context, opts Options) error { return run(ctx, opts, nil) }
39 43
40 func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)) error {44 func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)) error {
41 log := logging.For(ctx, "app")45 log := logging.For(ctx, "app")
42- if opts.Backend != "headless" {
43- return fmt.Errorf("drm backend not implemented yet")
44- }
45 log.Info().Str("backend", opts.Backend).Msg("starting nefertty")46 log.Info().Str("backend", opts.Backend).Msg("starting nefertty")
46 var cancel context.CancelFunc47 var cancel context.CancelFunc
47 if opts.Timeout > 0 {48 if opts.Timeout > 0 {
48- ctx, cancel = context.WithTimeout(ctx, opts.Timeout)49+ ctx, cancel = context.WithTimeoutCause(ctx, opts.Timeout, errTimeout)
49 } else {50 } else {
50 ctx, cancel = context.WithCancel(ctx)51 ctx, cancel = context.WithCancel(ctx)
51 }52 }
52 defer cancel()53 defer cancel()
54+ width, height := opts.Width, opts.Height
55+ if width <= 0 || height <= 0 {
56+ width, height = 1920, 1080
57+ }
58+ var outInfo wayland.OutputInfo
59+ var hw *drmBackend
60+ if opts.Backend == "drm" {
61+ var err error
62+ if hw, err = openDRM(ctx, opts.Config.Outputs); err != nil {
63+ return err
64+ }
65+ defer hw.close()
66+ width, height = hw.out.Width(), hw.out.Height()
67+ outInfo = hw.outputInfo()
68+ hw.inputActive, hw.outputActive = hw.seat.Subscribe(), hw.seat.Subscribe()
69+ }
53 client := make(chan ports.ClientEvent, 32)70 client := make(chan ports.ClientEvent, 32)
54 input := make(chan ports.InputEvent, 32)71 input := make(chan ports.InputEvent, 32)
55 output := make(chan ports.OutputEvent, 32)72 output := make(chan ports.OutputEvent, 32)
@@ -65,14 +82,14 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
65 if err != nil {82 if err != nil {
66 return err83 return err
67 }84 }
68- output <- ports.OutputMode{Width: 1920, Height: 1080}85+ output <- ports.OutputMode{Width: width, Height: height}
69 km, err := xkb.New(xkb.RMLVO{Layout: opts.Config.Keyboard.Layout, Variant: opts.Config.Keyboard.Variant, Options: opts.Config.Keyboard.Options})86 km, err := xkb.New(xkb.RMLVO{Layout: opts.Config.Keyboard.Layout, Variant: opts.Config.Keyboard.Variant, Options: opts.Config.Keyboard.Options})
70 if err != nil {87 if err != nil {
71 return err88 return err
72 }89 }
73 keymap := km.String()90 keymap := km.String()
74 runtimeDir := os.Getenv("XDG_RUNTIME_DIR")91 runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
75- server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, OutputWidth: 1920, OutputHeight: 1080, Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents}, logging.For(ctx, "wayland"))92+ server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, OutputWidth: width, OutputHeight: height, Output: outInfo, Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents}, logging.For(ctx, "wayland"))
76 if err != nil {93 if err != nil {
77 km.Close()94 km.Close()
78 return err95 return err
@@ -87,7 +104,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
87 }104 }
88 var workers sync.WaitGroup105 var workers sync.WaitGroup
89 workers.Add(7)106 workers.Add(7)
90- done := make(chan error, 7)107+ done := make(chan error, 8)
91 path := opts.ConfigPath108 path := opts.ConfigPath
92 if path == "" {109 if path == "" {
93 path = config.DefaultPath()110 path = config.DefaultPath()
@@ -99,6 +116,12 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
99 script := make(chan string)116 script := make(chan string)
100 go func() {117 go func() {
101 defer workers.Done()118 defer workers.Done()
119+ if hw != nil {
120+ done <- safe("input", func() error {
121+ return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Width: width, Height: height, Active: hw.inputActive, Log: logging.For(ctx, "input")}, input)
122+ })
123+ return
124+ }
102 _ = headlessinput.Run(ctx, km, script, input, logging.For(ctx, "input"))125 _ = headlessinput.Run(ctx, km, script, input, logging.For(ctx, "input"))
103 }()126 }()
104 if opts.Script != nil {127 if opts.Script != nil {
@@ -127,7 +150,19 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
127 150
128 go func() {151 go func() {
129 defer workers.Done()152 defer workers.Done()
130- 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) {153+ if hw != nil {
154+ done <- safe("output", func() error {
155+ return hw.out.Run(ctx, func(w, h int) (drm.Renderer, error) {
156+ r, err := vulkan.New(w, h)
157+ if err != nil {
158+ return nil, err
159+ }
160+ return r, nil
161+ }, hw.outputActive, renderScenes, contents)
162+ })
163+ return
164+ }
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) {
131 r, err := vulkan.New(w, h)166 r, err := vulkan.New(w, h)
132 if err != nil {167 if err != nil {
133 return nil, err168 return nil, err
@@ -136,10 +171,18 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
136 }}, renderScenes, contents)171 }}, renderScenes, contents)
137 }()172 }()
138 173
174+ if hw != nil {
175+ workers.Add(1)
176+ go func() { defer workers.Done(); done <- safe("seat", func() error { return hw.seat.Run(ctx) }) }()
177+ }
139 var result error178 var result error
140 select {179 select {
141 case <-ctx.Done():180 case <-ctx.Done():
181+ if errors.Is(context.Cause(ctx), errTimeout) {
182+ log.Info().Str("reason", "timeout").Dur("after", opts.Timeout).Msg("stopping")
183+ }
142 case result = <-done:184 case result = <-done:
185+ log.Info().AnErr("cause", result).Msg("stopping")
143 }186 }
144 cancel()187 cancel()
145 workers.Wait()188 workers.Wait()
@@ -149,7 +192,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
149 result = err192 result = err
150 }193 }
151 }194 }
152- if errors.Is(result, core.ErrQuit) {195+ if errors.Is(result, core.ErrQuit) || errors.Is(result, libinput.ErrEmergencyQuit) {
153 return nil196 return nil
154 }197 }
155 return result198 return result
@@ -181,7 +224,12 @@ func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors
181 default:224 default:
182 }225 }
183 }226 }
184- 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")
185 case err := <-configErrors:233 case err := <-configErrors:
186 configLog.Warn().Err(err).Msg("config rejected")234 configLog.Warn().Err(err).Msg("config rejected")
187 }235 }
M internal/app/app_test.go +3 -2
@@ -140,8 +140,9 @@ func TestHeadlessSpawnClose(t *testing.T) {
140140 select {
141141 case s := <-scenes:
142142 if len(s.Windows) == count {
143- if count == 1 && s.Windows[0].Rect.W != 948 {
144- t.Errorf("width = %d, want 948", s.Windows[0].Rect.W)
143+ // Half of 1920 with the default zero gaps.
144+ if count == 1 && s.Windows[0].Rect.W != 960 {
145+ t.Errorf("width = %d, want 960", s.Windows[0].Rect.W)
145146 }
146147 return
147148 }
@@ -140,8 +140,9 @@ func TestHeadlessSpawnClose(t *testing.T) {
140 select {140 select {
141 case s := <-scenes:141 case s := <-scenes:
142 if len(s.Windows) == count {142 if len(s.Windows) == count {
143- if count == 1 && s.Windows[0].Rect.W != 948 {143+ // Half of 1920 with the default zero gaps.
144- t.Errorf("width = %d, want 948", s.Windows[0].Rect.W)144+ if count == 1 && s.Windows[0].Rect.W != 960 {
145+ t.Errorf("width = %d, want 960", s.Windows[0].Rect.W)
145 }146 }
146 return147 return
147 }148 }
A internal/app/drm.go +119 -0
@@ -0,0 +1,119 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "path/filepath"
8+ "runtime/debug"
9+ "sort"
10+ "strings"
11+
12+ "github.com/bnema/nefertty/internal/adapters/config"
13+ "github.com/bnema/nefertty/internal/adapters/drm"
14+ "github.com/bnema/nefertty/internal/adapters/seat"
15+ "github.com/bnema/nefertty/internal/adapters/wayland"
16+ "github.com/bnema/nefertty/internal/logging"
17+ "github.com/bnema/nefertty/internal/ports"
18+)
19+
20+var errTimeout = errors.New("timeout")
21+
22+type drmBackend struct {
23+ seat *seat.Seat
24+ out *drm.Output
25+ fd int
26+
27+ inputActive, outputActive <-chan bool
28+}
29+
30+// safe turns a panic in a hardware goroutine into an error, so the TTY is still restored.
31+func safe(name string, fn func() error) (err error) {
32+ defer func() {
33+ if p := recover(); p != nil {
34+ err = fmt.Errorf("%s panic: %v\n%s", name, p, debug.Stack())
35+ }
36+ }()
37+ return fn()
38+}
39+
40+// openDRM opens the seat and the first card with a connected display.
41+func openDRM(ctx context.Context, outputs []ports.OutputConfig) (*drmBackend, error) {
42+ log := logging.For(ctx, "drm")
43+ s, err := seat.Open(ctx, logging.For(ctx, "seat"))
44+ if err != nil {
45+ log.Error().Err(err).Msg("open seat")
46+ return nil, fmt.Errorf("open seat: %w", err)
47+ }
48+ want := wantFromConfig(outputs)
49+ cards, _ := filepath.Glob("/dev/dri/card[0-9]*")
50+ sort.Strings(cards)
51+ var errs []error
52+ // First pass: the configured output on any card; second: any usable output.
53+ passes := []drm.Want{want}
54+ if want.Name != "" {
55+ strict := want
56+ strict.Strict = true
57+ passes = []drm.Want{strict, want}
58+ }
59+ for _, w := range passes {
60+ for _, card := range cards {
61+ fd, err := s.OpenDevice(card)
62+ if err != nil {
63+ errs = append(errs, err)
64+ continue
65+ }
66+ out, err := drm.Open(fd, card, w, log)
67+ if err != nil {
68+ if !w.Strict {
69+ log.Warn().Err(err).Str("card", card).Msg("card unusable")
70+ errs = append(errs, err)
71+ }
72+ s.CloseDevice(fd)
73+ continue
74+ }
75+ return &drmBackend{seat: s, out: out, fd: fd}, nil
76+ }
77+ }
78+ s.Close()
79+ err = fmt.Errorf("no usable DRM card in %v: %w", cards, errors.Join(errs...))
80+ log.Error().Err(err).Msg("open drm")
81+ return nil, err
82+}
83+
84+// wantFromConfig turns [[output]] entries into a connector choice: the first enabled entry wins.
85+func wantFromConfig(outputs []ports.OutputConfig) drm.Want {
86+ want := drm.Want{Disabled: map[string]bool{}}
87+ for _, o := range outputs {
88+ if o.Off {
89+ want.Disabled[o.Name] = true
90+ continue
91+ }
92+ if want.Name != "" {
93+ continue
94+ }
95+ want.Name = o.Name
96+ if o.Mode != "" {
97+ // Validated at config load.
98+ want.W, want.H, want.Hz, _ = config.ParseMode(o.Mode)
99+ }
100+ }
101+ return want
102+}
103+
104+// outputInfo describes the chosen output for wl_output and xdg-output.
105+func (b *drmBackend) outputInfo() wayland.OutputInfo {
106+ i := b.out.Info()
107+ return wayland.OutputInfo{
108+ Name: i.Name, Make: i.Monitor.Make, Model: i.Monitor.Model,
109+ Description: strings.TrimSpace(i.Monitor.Make + " " + i.Monitor.Model + " " + i.Monitor.Serial + " (" + i.Name + ")"),
110+ RefreshMilli: i.RefreshMilli, PhysicalW: i.PhysicalW, PhysicalH: i.PhysicalH,
111+ }
112+}
113+
114+// close restores the CRTC, then releases the card and the seat so the TTY comes back.
115+func (b *drmBackend) close() {
116+ b.out.Close()
117+ b.seat.CloseDevice(b.fd)
118+ b.seat.Close()
119+}
@@ -0,0 +1,119 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "path/filepath"
8+ "runtime/debug"
9+ "sort"
10+ "strings"
11+
12+ "github.com/bnema/nefertty/internal/adapters/config"
13+ "github.com/bnema/nefertty/internal/adapters/drm"
14+ "github.com/bnema/nefertty/internal/adapters/seat"
15+ "github.com/bnema/nefertty/internal/adapters/wayland"
16+ "github.com/bnema/nefertty/internal/logging"
17+ "github.com/bnema/nefertty/internal/ports"
18+)
19+
20+var errTimeout = errors.New("timeout")
21+
22+type drmBackend struct {
23+ seat *seat.Seat
24+ out *drm.Output
25+ fd int
26+
27+ inputActive, outputActive <-chan bool
28+}
29+
30+// safe turns a panic in a hardware goroutine into an error, so the TTY is still restored.
31+func safe(name string, fn func() error) (err error) {
32+ defer func() {
33+ if p := recover(); p != nil {
34+ err = fmt.Errorf("%s panic: %v\n%s", name, p, debug.Stack())
35+ }
36+ }()
37+ return fn()
38+}
39+
40+// openDRM opens the seat and the first card with a connected display.
41+func openDRM(ctx context.Context, outputs []ports.OutputConfig) (*drmBackend, error) {
42+ log := logging.For(ctx, "drm")
43+ s, err := seat.Open(ctx, logging.For(ctx, "seat"))
44+ if err != nil {
45+ log.Error().Err(err).Msg("open seat")
46+ return nil, fmt.Errorf("open seat: %w", err)
47+ }
48+ want := wantFromConfig(outputs)
49+ cards, _ := filepath.Glob("/dev/dri/card[0-9]*")
50+ sort.Strings(cards)
51+ var errs []error
52+ // First pass: the configured output on any card; second: any usable output.
53+ passes := []drm.Want{want}
54+ if want.Name != "" {
55+ strict := want
56+ strict.Strict = true
57+ passes = []drm.Want{strict, want}
58+ }
59+ for _, w := range passes {
60+ for _, card := range cards {
61+ fd, err := s.OpenDevice(card)
62+ if err != nil {
63+ errs = append(errs, err)
64+ continue
65+ }
66+ out, err := drm.Open(fd, card, w, log)
67+ if err != nil {
68+ if !w.Strict {
69+ log.Warn().Err(err).Str("card", card).Msg("card unusable")
70+ errs = append(errs, err)
71+ }
72+ s.CloseDevice(fd)
73+ continue
74+ }
75+ return &drmBackend{seat: s, out: out, fd: fd}, nil
76+ }
77+ }
78+ s.Close()
79+ err = fmt.Errorf("no usable DRM card in %v: %w", cards, errors.Join(errs...))
80+ log.Error().Err(err).Msg("open drm")
81+ return nil, err
82+}
83+
84+// wantFromConfig turns [[output]] entries into a connector choice: the first enabled entry wins.
85+func wantFromConfig(outputs []ports.OutputConfig) drm.Want {
86+ want := drm.Want{Disabled: map[string]bool{}}
87+ for _, o := range outputs {
88+ if o.Off {
89+ want.Disabled[o.Name] = true
90+ continue
91+ }
92+ if want.Name != "" {
93+ continue
94+ }
95+ want.Name = o.Name
96+ if o.Mode != "" {
97+ // Validated at config load.
98+ want.W, want.H, want.Hz, _ = config.ParseMode(o.Mode)
99+ }
100+ }
101+ return want
102+}
103+
104+// outputInfo describes the chosen output for wl_output and xdg-output.
105+func (b *drmBackend) outputInfo() wayland.OutputInfo {
106+ i := b.out.Info()
107+ return wayland.OutputInfo{
108+ Name: i.Name, Make: i.Monitor.Make, Model: i.Monitor.Model,
109+ Description: strings.TrimSpace(i.Monitor.Make + " " + i.Monitor.Model + " " + i.Monitor.Serial + " (" + i.Name + ")"),
110+ RefreshMilli: i.RefreshMilli, PhysicalW: i.PhysicalW, PhysicalH: i.PhysicalH,
111+ }
112+}
113+
114+// close restores the CRTC, then releases the card and the seat so the TTY comes back.
115+func (b *drmBackend) close() {
116+ b.out.Close()
117+ b.seat.CloseDevice(b.fd)
118+ b.seat.Close()
119+}
M internal/core/core.go +16 -3
@@ -145,9 +145,21 @@ func (c *Core) command(ctx context.Context, v ports.ClientCommand) error {
145145 return nil
146146 }
147147 }
148+
149+// clientRect is the placement minus the border drawn around the client.
150+// The renderer applies the same inset (ports.Border).
151+func (c *Core) clientRect(p Placement) Rect {
152+ r := p.Rect
153+ if p.Fullscreen {
154+ return r
155+ }
156+ b := min(max(c.cfg.Border.Width, 0), r.W/2, r.H/2)
157+ return Rect{X: r.X + b, Y: r.Y + b, W: r.W - 2*b, H: r.H - 2*b}
158+}
159+
148160 func (c *Core) publish(ctx context.Context) error {
149161 c.seq++
150- scene := ports.Scene{Seq: c.seq, OutputWidth: c.ws.Output.W, OutputHeight: c.ws.Output.H, Background: c.cfg.Background.Color, Windows: make([]ports.SceneWindow, 0), Layers: append([]ports.SceneLayer(nil), c.placed...)}
162+ 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...)}
151163 alive := map[WindowID]bool{}
152164 focus, _ := c.ws.Focused()
153165 for _, p := range c.ws.Layout() {
@@ -163,7 +175,8 @@ func (c *Core) publish(ctx context.Context) error {
163175 }
164176 continue
165177 }
166- v := ports.ConfigureWindow{ID: p.ID, Width: p.Rect.W, Height: p.Rect.H, Fullscreen: p.Fullscreen, Activated: p.Focused}
178+ r := c.clientRect(p)
179+ v := ports.ConfigureWindow{ID: p.ID, Width: r.W, Height: r.H, Fullscreen: p.Fullscreen, Activated: p.Focused}
167180 if old, ok := c.sent[p.ID]; !ok || old != v {
168181 if err := c.command(ctx, v); err != nil {
169182 return err
@@ -280,7 +293,7 @@ func (c *Core) Run(ctx context.Context) error {
280293 var x, y float64
281294 // Fullscreen wins; otherwise the last visible placement is topmost.
282295 for _, p := range c.ws.Layout() {
283- r := p.Rect
296+ r := c.clientRect(p)
284297 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) {
285298 if id == 0 || p.Fullscreen {
286299 id, x, y = p.ID, c.cursorX-float64(r.X), c.cursorY-float64(r.Y)
@@ -145,9 +145,21 @@ func (c *Core) command(ctx context.Context, v ports.ClientCommand) error {
145 return nil145 return nil
146 }146 }
147 }147 }
148+
149+// clientRect is the placement minus the border drawn around the client.
150+// The renderer applies the same inset (ports.Border).
151+func (c *Core) clientRect(p Placement) Rect {
152+ r := p.Rect
153+ if p.Fullscreen {
154+ return r
155+ }
156+ b := min(max(c.cfg.Border.Width, 0), r.W/2, r.H/2)
157+ return Rect{X: r.X + b, Y: r.Y + b, W: r.W - 2*b, H: r.H - 2*b}
158+}
159+
148 func (c *Core) publish(ctx context.Context) error {160 func (c *Core) publish(ctx context.Context) error {
149 c.seq++161 c.seq++
150- scene := ports.Scene{Seq: c.seq, OutputWidth: c.ws.Output.W, OutputHeight: c.ws.Output.H, Background: c.cfg.Background.Color, Windows: make([]ports.SceneWindow, 0), Layers: append([]ports.SceneLayer(nil), c.placed...)}162+ 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...)}
151 alive := map[WindowID]bool{}163 alive := map[WindowID]bool{}
152 focus, _ := c.ws.Focused()164 focus, _ := c.ws.Focused()
153 for _, p := range c.ws.Layout() {165 for _, p := range c.ws.Layout() {
@@ -163,7 +175,8 @@ func (c *Core) publish(ctx context.Context) error {
163 }175 }
164 continue176 continue
165 }177 }
166- v := ports.ConfigureWindow{ID: p.ID, Width: p.Rect.W, Height: p.Rect.H, Fullscreen: p.Fullscreen, Activated: p.Focused}178+ r := c.clientRect(p)
179+ 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 {180 if old, ok := c.sent[p.ID]; !ok || old != v {
168 if err := c.command(ctx, v); err != nil {181 if err := c.command(ctx, v); err != nil {
169 return err182 return err
@@ -280,7 +293,7 @@ func (c *Core) Run(ctx context.Context) error {
280 var x, y float64293 var x, y float64
281 // Fullscreen wins; otherwise the last visible placement is topmost.294 // Fullscreen wins; otherwise the last visible placement is topmost.
282 for _, p := range c.ws.Layout() {295 for _, p := range c.ws.Layout() {
283- r := p.Rect296+ 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) {297 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 {298 if id == 0 || p.Fullscreen {
286 id, x, y = p.ID, c.cursorX-float64(r.X), c.cursorY-float64(r.Y)299 id, x, y = p.ID, c.cursorX-float64(r.X), c.cursorY-float64(r.Y)
M internal/core/core_test.go +36 -0
@@ -24,6 +24,8 @@ 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
28+ cfg.Layout.Gaps = 8
2729 client := make(chan ports.ClientEvent, 128)
2830 input := make(chan ports.InputEvent, 32)
2931 output := make(chan ports.OutputEvent, 8)
@@ -195,6 +197,8 @@ func TestBoundReleaseSwallowed(t *testing.T) {
195197
196198 func TestPointerFocusAndGrab(t *testing.T) {
197199 cfg := config.Defaults()
200+ cfg.Border.Width = 0
201+ cfg.Layout.Gaps = 8
198202 client := make(chan ports.ClientEvent, 8)
199203 input := make(chan ports.InputEvent, 8)
200204 output := make(chan ports.OutputEvent, 8)
@@ -246,3 +250,35 @@ func TestPointerFocusAndGrab(t *testing.T) {
246250 t.Fatal(v)
247251 }
248252 }
253+
254+func TestBorderInset(t *testing.T) {
255+ cfg := config.Defaults()
256+ cfg.Border.Width = 2
257+ client := make(chan ports.ClientEvent, 8)
258+ input := make(chan ports.InputEvent, 8)
259+ output := make(chan ports.OutputEvent, 8)
260+ commands := make(chan ports.ClientCommand, 64)
261+ scenes := make(chan ports.Scene, 1)
262+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
263+ if err != nil {
264+ t.Fatal(err)
265+ }
266+ ctx, cancel := context.WithCancel(context.Background())
267+ defer cancel()
268+ go c.Run(ctx)
269+ output <- ports.OutputMode{Width: 100, Height: 80}
270+ receive(t, scenes)
271+ client <- ports.WindowMapped{ID: 1}
272+ r := receive(t, scenes).Windows[0].Rect
273+ // The scene keeps the outer rect; the client is configured 2px smaller on each side.
274+ if v := receive(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W - 4, Height: r.H - 4, Activated: true}) {
275+ t.Fatalf("%v for outer %v", v, r)
276+ }
277+ for len(commands) > 0 {
278+ <-commands
279+ }
280+ input <- ports.PointerMotion{X: float64(r.X + 2), Y: float64(r.Y + 5), TimeMsec: 1}
281+ if v := receive(t, commands); v != (ports.PointerFocus{ID: 1, X: 0, Y: 3}) {
282+ t.Fatal(v)
283+ }
284+}
@@ -24,6 +24,8 @@ 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
28+ cfg.Layout.Gaps = 8
27 client := make(chan ports.ClientEvent, 128)29 client := make(chan ports.ClientEvent, 128)
28 input := make(chan ports.InputEvent, 32)30 input := make(chan ports.InputEvent, 32)
29 output := make(chan ports.OutputEvent, 8)31 output := make(chan ports.OutputEvent, 8)
@@ -195,6 +197,8 @@ func TestBoundReleaseSwallowed(t *testing.T) {
195 197
196 func TestPointerFocusAndGrab(t *testing.T) {198 func TestPointerFocusAndGrab(t *testing.T) {
197 cfg := config.Defaults()199 cfg := config.Defaults()
200+ cfg.Border.Width = 0
201+ cfg.Layout.Gaps = 8
198 client := make(chan ports.ClientEvent, 8)202 client := make(chan ports.ClientEvent, 8)
199 input := make(chan ports.InputEvent, 8)203 input := make(chan ports.InputEvent, 8)
200 output := make(chan ports.OutputEvent, 8)204 output := make(chan ports.OutputEvent, 8)
@@ -246,3 +250,35 @@ func TestPointerFocusAndGrab(t *testing.T) {
246 t.Fatal(v)250 t.Fatal(v)
247 }251 }
248 }252 }
253+
254+func TestBorderInset(t *testing.T) {
255+ cfg := config.Defaults()
256+ cfg.Border.Width = 2
257+ client := make(chan ports.ClientEvent, 8)
258+ input := make(chan ports.InputEvent, 8)
259+ output := make(chan ports.OutputEvent, 8)
260+ commands := make(chan ports.ClientCommand, 64)
261+ scenes := make(chan ports.Scene, 1)
262+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
263+ if err != nil {
264+ t.Fatal(err)
265+ }
266+ ctx, cancel := context.WithCancel(context.Background())
267+ defer cancel()
268+ go c.Run(ctx)
269+ output <- ports.OutputMode{Width: 100, Height: 80}
270+ receive(t, scenes)
271+ client <- ports.WindowMapped{ID: 1}
272+ r := receive(t, scenes).Windows[0].Rect
273+ // The scene keeps the outer rect; the client is configured 2px smaller on each side.
274+ if v := receive(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W - 4, Height: r.H - 4, Activated: true}) {
275+ t.Fatalf("%v for outer %v", v, r)
276+ }
277+ for len(commands) > 0 {
278+ <-commands
279+ }
280+ input <- ports.PointerMotion{X: float64(r.X + 2), Y: float64(r.Y + 5), TimeMsec: 1}
281+ if v := receive(t, commands); v != (ports.PointerFocus{ID: 1, X: 0, Y: 3}) {
282+ t.Fatal(v)
283+ }
284+}
M internal/logging/logging.go +26 -7
@@ -6,7 +6,9 @@ import (
66 "fmt"
77 "os"
88 "path/filepath"
9+ "sort"
910 "strings"
11+ "time"
1012
1113 "github.com/bnema/zerowrap"
1214 "github.com/rs/zerolog"
@@ -14,7 +16,7 @@ import (
1416 )
1517
1618 var components = map[string]bool{
17- "core": true, "wayland": true, "input": true, "drm": true,
19+ "core": true, "wayland": true, "input": true, "drm": true, "seat": true,
1820 "render": true, "sync": true, "config": true, "app": true,
1921 }
2022
@@ -40,7 +42,11 @@ func ParseDebug(value string) (map[string]bool, error) {
4042 return selected, nil
4143 }
4244
43-// Open rotates the previous run's log, opens a fresh JSON log and attaches the logger to ctx.
45+// keepRuns is how many per-run log files Open retains.
46+const keepRuns = 20
47+
48+// Open creates runs/<timestamp>.log, points runs/latest.log at it, prunes old runs
49+// and attaches the logger to ctx.
4450 // The caller must invoke close after all logging is complete.
4551 func Open(ctx context.Context, level, debug string) (context.Context, func() error, error) {
4652 selected, err := ParseDebug(debug)
@@ -55,18 +61,22 @@ func Open(ctx context.Context, level, debug string) (context.Context, func() err
5561 }
5662 state = filepath.Join(home, ".local", "state")
5763 }
58- dir := filepath.Join(state, "nefertty")
64+ dir := filepath.Join(state, "nefertty", "runs")
5965 if err := os.MkdirAll(dir, 0o700); err != nil {
6066 return nil, nil, err
6167 }
62- path := filepath.Join(dir, "nefertty.log")
63- if err := os.Rename(path, path+".1"); err != nil && !os.IsNotExist(err) {
68+ name := time.Now().Format("20060102-150405.000") + ".log"
69+ file, err := os.OpenFile(filepath.Join(dir, name), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
70+ if err != nil {
6471 return nil, nil, err
6572 }
66- file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
67- if err != nil {
73+ latest := filepath.Join(dir, "latest.log")
74+ _ = os.Remove(latest)
75+ if err := os.Symlink(name, latest); err != nil {
76+ _ = file.Close()
6877 return nil, nil, err
6978 }
79+ pruneRuns(dir)
7080 var output = zerowrap.Config{Level: level, Format: "json", Output: file}
7181 if term.IsTerminal(int(os.Stderr.Fd())) {
7282 output.Output = &consoleAndFile{file: file}
@@ -77,6 +87,15 @@ func Open(ctx context.Context, level, debug string) (context.Context, func() err
7787 return ctx, file.Close, nil
7888 }
7989
90+func pruneRuns(dir string) {
91+ runs, _ := filepath.Glob(filepath.Join(dir, "2*.log"))
92+ sort.Strings(runs)
93+ for len(runs) > keepRuns {
94+ _ = os.Remove(runs[0])
95+ runs = runs[1:]
96+ }
97+}
98+
8099 type consoleAndFile struct{ file *os.File }
81100
82101 func (w *consoleAndFile) Write(p []byte) (int, error) {
@@ -6,7 +6,9 @@ import (
6 "fmt"6 "fmt"
7 "os"7 "os"
8 "path/filepath"8 "path/filepath"
9+ "sort"
9 "strings"10 "strings"
11+ "time"
10 12
11 "github.com/bnema/zerowrap"13 "github.com/bnema/zerowrap"
12 "github.com/rs/zerolog"14 "github.com/rs/zerolog"
@@ -14,7 +16,7 @@ import (
14 )16 )
15 17
16 var components = map[string]bool{18 var components = map[string]bool{
17- "core": true, "wayland": true, "input": true, "drm": true,19+ "core": true, "wayland": true, "input": true, "drm": true, "seat": true,
18 "render": true, "sync": true, "config": true, "app": true,20 "render": true, "sync": true, "config": true, "app": true,
19 }21 }
20 22
@@ -40,7 +42,11 @@ func ParseDebug(value string) (map[string]bool, error) {
40 return selected, nil42 return selected, nil
41 }43 }
42 44
43-// Open rotates the previous run's log, opens a fresh JSON log and attaches the logger to ctx.45+// keepRuns is how many per-run log files Open retains.
46+const keepRuns = 20
47+
48+// Open creates runs/<timestamp>.log, points runs/latest.log at it, prunes old runs
49+// and attaches the logger to ctx.
44 // The caller must invoke close after all logging is complete.50 // The caller must invoke close after all logging is complete.
45 func Open(ctx context.Context, level, debug string) (context.Context, func() error, error) {51 func Open(ctx context.Context, level, debug string) (context.Context, func() error, error) {
46 selected, err := ParseDebug(debug)52 selected, err := ParseDebug(debug)
@@ -55,18 +61,22 @@ func Open(ctx context.Context, level, debug string) (context.Context, func() err
55 }61 }
56 state = filepath.Join(home, ".local", "state")62 state = filepath.Join(home, ".local", "state")
57 }63 }
58- dir := filepath.Join(state, "nefertty")64+ dir := filepath.Join(state, "nefertty", "runs")
59 if err := os.MkdirAll(dir, 0o700); err != nil {65 if err := os.MkdirAll(dir, 0o700); err != nil {
60 return nil, nil, err66 return nil, nil, err
61 }67 }
62- path := filepath.Join(dir, "nefertty.log")68+ name := time.Now().Format("20060102-150405.000") + ".log"
63- if err := os.Rename(path, path+".1"); err != nil && !os.IsNotExist(err) {69+ file, err := os.OpenFile(filepath.Join(dir, name), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
70+ if err != nil {
64 return nil, nil, err71 return nil, nil, err
65 }72 }
66- file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)73+ latest := filepath.Join(dir, "latest.log")
67- if err != nil {74+ _ = os.Remove(latest)
75+ if err := os.Symlink(name, latest); err != nil {
76+ _ = file.Close()
68 return nil, nil, err77 return nil, nil, err
69 }78 }
79+ pruneRuns(dir)
70 var output = zerowrap.Config{Level: level, Format: "json", Output: file}80 var output = zerowrap.Config{Level: level, Format: "json", Output: file}
71 if term.IsTerminal(int(os.Stderr.Fd())) {81 if term.IsTerminal(int(os.Stderr.Fd())) {
72 output.Output = &consoleAndFile{file: file}82 output.Output = &consoleAndFile{file: file}
@@ -77,6 +87,15 @@ func Open(ctx context.Context, level, debug string) (context.Context, func() err
77 return ctx, file.Close, nil87 return ctx, file.Close, nil
78 }88 }
79 89
90+func pruneRuns(dir string) {
91+ runs, _ := filepath.Glob(filepath.Join(dir, "2*.log"))
92+ sort.Strings(runs)
93+ for len(runs) > keepRuns {
94+ _ = os.Remove(runs[0])
95+ runs = runs[1:]
96+ }
97+}
98+
80 type consoleAndFile struct{ file *os.File }99 type consoleAndFile struct{ file *os.File }
81 100
82 func (w *consoleAndFile) Write(p []byte) (int, error) {101 func (w *consoleAndFile) Write(p []byte) (int, error) {
M internal/logging/logging_test.go +8 -4
@@ -7,6 +7,7 @@ import (
77 "os"
88 "path/filepath"
99 "testing"
10+ "time"
1011 )
1112
1213 func TestParseDebug(t *testing.T) {
@@ -32,8 +33,10 @@ func TestParseDebug(t *testing.T) {
3233
3334 func TestComponentLevelsAndRotation(t *testing.T) {
3435 t.Setenv("XDG_STATE_HOME", t.TempDir())
35- path := filepath.Join(os.Getenv("XDG_STATE_HOME"), "nefertty", "nefertty.log")
36- for run := 0; run < 2; run++ {
36+ dir := filepath.Join(os.Getenv("XDG_STATE_HOME"), "nefertty", "runs")
37+ path := filepath.Join(dir, "latest.log")
38+ for run := 0; run < keepRuns+2; run++ {
39+ time.Sleep(2 * time.Millisecond) // distinct millisecond timestamps
3740 ctx, closeLog, err := Open(context.Background(), "info", "core")
3841 if err != nil {
3942 t.Fatal(err)
@@ -64,8 +67,9 @@ func TestComponentLevelsAndRotation(t *testing.T) {
6467 }
6568 }
6669 }
67- if _, err := os.Stat(path + ".1"); err != nil {
68- t.Fatal(err)
70+ runs, _ := filepath.Glob(filepath.Join(dir, "2*.log"))
71+ if len(runs) != keepRuns {
72+ t.Fatalf("kept %d runs, want %d", len(runs), keepRuns)
6973 }
7074 }
7175
@@ -7,6 +7,7 @@ import (
7 "os"7 "os"
8 "path/filepath"8 "path/filepath"
9 "testing"9 "testing"
10+ "time"
10 )11 )
11 12
12 func TestParseDebug(t *testing.T) {13 func TestParseDebug(t *testing.T) {
@@ -32,8 +33,10 @@ func TestParseDebug(t *testing.T) {
32 33
33 func TestComponentLevelsAndRotation(t *testing.T) {34 func TestComponentLevelsAndRotation(t *testing.T) {
34 t.Setenv("XDG_STATE_HOME", t.TempDir())35 t.Setenv("XDG_STATE_HOME", t.TempDir())
35- path := filepath.Join(os.Getenv("XDG_STATE_HOME"), "nefertty", "nefertty.log")36+ dir := filepath.Join(os.Getenv("XDG_STATE_HOME"), "nefertty", "runs")
36- for run := 0; run < 2; run++ {37+ path := filepath.Join(dir, "latest.log")
38+ for run := 0; run < keepRuns+2; run++ {
39+ time.Sleep(2 * time.Millisecond) // distinct millisecond timestamps
37 ctx, closeLog, err := Open(context.Background(), "info", "core")40 ctx, closeLog, err := Open(context.Background(), "info", "core")
38 if err != nil {41 if err != nil {
39 t.Fatal(err)42 t.Fatal(err)
@@ -64,8 +67,9 @@ func TestComponentLevelsAndRotation(t *testing.T) {
64 }67 }
65 }68 }
66 }69 }
67- if _, err := os.Stat(path + ".1"); err != nil {70+ runs, _ := filepath.Glob(filepath.Join(dir, "2*.log"))
68- t.Fatal(err)71+ if len(runs) != keepRuns {
72+ t.Fatalf("kept %d runs, want %d", len(runs), keepRuns)
69 }73 }
70 }74 }
71 75
M internal/ports/config.go +22 -3
@@ -1,5 +1,16 @@
11 package ports
22
3+// OutputConfig configures one connector. Name is the connector (e.g. "DP-2").
4+// Mode is "WxH" (highest refresh) or "WxH@Hz" (closest refresh); empty picks the
5+// monitor's preferred mode. Off disables the connector.
6+// The first listed, connected, enabled output is used; without a match, the first
7+// connected output that is not off.
8+type OutputConfig struct {
9+ Name string
10+ Mode string
11+ Off bool
12+}
13+
314 // Config is the parsed compositor configuration.
415 type Config struct {
516 Keyboard struct {
@@ -10,13 +21,21 @@ type Config struct {
1021 } `toml:"keyboard"`
1122 Terminal struct{ Command []string } `toml:"terminal"`
1223 Background struct{ Color string } `toml:"background"`
13- Layout struct {
24+ // Border is drawn inside the window edge; Width 0 disables it.
25+ Border struct {
26+ Width int
27+ Active string
28+ Inactive string
29+ } `toml:"border"`
30+ Layout struct {
1431 Gaps int
1532 DefaultColumnWidth string `toml:"default_column_width"`
1633 Presets []string
1734 } `toml:"layout"`
18- Binds map[string]string `toml:"binds"`
19- Render struct {
35+ // Outputs selects and configures physical displays (drm backend).
36+ Outputs []OutputConfig `toml:"output"`
37+ Binds map[string]string `toml:"binds"`
38+ Render struct {
2039 DirectScanout bool `toml:"direct_scanout"`
2140 } `toml:"render"`
2241 Log struct {
@@ -1,5 +1,16 @@
1 package ports1 package ports
2 2
3+// OutputConfig configures one connector. Name is the connector (e.g. "DP-2").
4+// Mode is "WxH" (highest refresh) or "WxH@Hz" (closest refresh); empty picks the
5+// monitor's preferred mode. Off disables the connector.
6+// The first listed, connected, enabled output is used; without a match, the first
7+// connected output that is not off.
8+type OutputConfig struct {
9+ Name string
10+ Mode string
11+ Off bool
12+}
13+
3 // Config is the parsed compositor configuration.14 // Config is the parsed compositor configuration.
4 type Config struct {15 type Config struct {
5 Keyboard struct {16 Keyboard struct {
@@ -10,13 +21,21 @@ type Config struct {
10 } `toml:"keyboard"`21 } `toml:"keyboard"`
11 Terminal struct{ Command []string } `toml:"terminal"`22 Terminal struct{ Command []string } `toml:"terminal"`
12 Background struct{ Color string } `toml:"background"`23 Background struct{ Color string } `toml:"background"`
13- Layout struct {24+ // Border is drawn inside the window edge; Width 0 disables it.
25+ Border struct {
26+ Width int
27+ Active string
28+ Inactive string
29+ } `toml:"border"`
30+ Layout struct {
14 Gaps int31 Gaps int
15 DefaultColumnWidth string `toml:"default_column_width"`32 DefaultColumnWidth string `toml:"default_column_width"`
16 Presets []string33 Presets []string
17 } `toml:"layout"`34 } `toml:"layout"`
18- Binds map[string]string `toml:"binds"`35+ // Outputs selects and configures physical displays (drm backend).
19- Render struct {36+ Outputs []OutputConfig `toml:"output"`
37+ Binds map[string]string `toml:"binds"`
38+ Render struct {
20 DirectScanout bool `toml:"direct_scanout"`39 DirectScanout bool `toml:"direct_scanout"`
21 } `toml:"render"`40 } `toml:"render"`
22 Log struct {41 Log struct {
M internal/ports/messages.go +7 -0
@@ -187,12 +187,19 @@ type Scene struct {
187187 Seq uint64
188188 OutputWidth, OutputHeight int
189189 Background string
190+ Border Border
190191 Windows []SceneWindow
191192 // Layers are drawn in slice order: background and bottom before windows,
192193 // top and overlay after. A fullscreen window covers bottom and top.
193194 Layers []SceneLayer
194195 }
195196
197+// Border carries the window border style; colors are #rrggbb, "" skips drawing.
198+type Border struct {
199+ Width int
200+ Active, Inactive string
201+}
202+
196203 // SceneLayer carries core → renderer layer surface placement.
197204 type SceneLayer struct {
198205 ID WindowID
@@ -187,12 +187,19 @@ type Scene struct {
187 Seq uint64187 Seq uint64
188 OutputWidth, OutputHeight int188 OutputWidth, OutputHeight int
189 Background string189 Background string
190+ Border Border
190 Windows []SceneWindow191 Windows []SceneWindow
191 // Layers are drawn in slice order: background and bottom before windows,192 // Layers are drawn in slice order: background and bottom before windows,
192 // top and overlay after. A fullscreen window covers bottom and top.193 // top and overlay after. A fullscreen window covers bottom and top.
193 Layers []SceneLayer194 Layers []SceneLayer
194 }195 }
195 196
197+// Border carries the window border style; colors are #rrggbb, "" skips drawing.
198+type Border struct {
199+ Width int
200+ Active, Inactive string
201+}
202+
196 // SceneLayer carries core → renderer layer surface placement.203 // SceneLayer carries core → renderer layer surface placement.
197 type SceneLayer struct {204 type SceneLayer struct {
198 ID WindowID205 ID WindowID

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 23

Tags 10

↑↓ moveenter openesc close