merge: drm backend
31 files · +1959 -73
M .gitignore +1 -0
| @@ -1,4 +1,5 @@ | |||
| 1 | 1 | /nefertty | |
| 2 | + | /bin/ | |
| 2 | 3 | *.test | |
| 3 | 4 | *.out | |
| 4 | 5 | coverage/ | |
| @@ -1,4 +1,5 @@ | |||||
| 1 | /nefertty | 1 | /nefertty | ||
| 2 | + | /bin/ | |||
| 2 | *.test | 3 | *.test | ||
| 3 | *.out | 4 | *.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 | |
| 2 | 18 | build: | |
| 3 | 19 | CGO_ENABLED=0 go build ./... | |
| 4 | 20 | test: | |
| @@ -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 | |||
| 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 | |||
| 16 | 16 | [terminal] | |
| 17 | 17 | command = ["foot"] | |
| 18 | 18 | [layout] | |
| 19 | - | gaps = 8 | |
| 19 | + | gaps = 0 | |
| 20 | 20 | ``` | |
| 21 | 21 | ||
| 22 | 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. | |
| @@ -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 = 8 | 19 | + | 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 ( | |||
| 10 | 10 | "runtime/debug" | |
| 11 | 11 | "strings" | |
| 12 | 12 | "syscall" | |
| 13 | + | "time" | |
| 13 | 14 | ||
| 14 | 15 | "github.com/bnema/nefertty/internal/adapters/config" | |
| 15 | 16 | "github.com/bnema/nefertty/internal/app" | |
| @@ -79,6 +80,7 @@ func run() error { | |||
| 79 | 80 | flags := flag.NewFlagSet("nefertty", flag.ContinueOnError) | |
| 80 | 81 | backend := flags.String("backend", "drm", "drm or headless") | |
| 81 | 82 | screenshot := flags.String("screenshot", "", "write PNG frames to directory") | |
| 83 | + | size := flags.String("size", "1920x1080", "headless output size WxH") | |
| 82 | 84 | inputPath := flags.String("input", "", "headless input script path (- for stdin)") | |
| 83 | 85 | noTerminal := flags.Bool("no-terminal", false, "skip initial terminal") | |
| 84 | 86 | timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)") | |
| @@ -100,6 +102,12 @@ func run() error { | |||
| 100 | 102 | fmt.Fprintln(os.Stderr, err) | |
| 101 | 103 | 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 | 111 | if *inputPath != "" && *backend != "headless" { | |
| 104 | 112 | err := usageError{fmt.Errorf("--input requires --backend=headless")} | |
| 105 | 113 | fmt.Fprintln(os.Stderr, err) | |
| @@ -161,15 +169,31 @@ func run() error { | |||
| 161 | 169 | } | |
| 162 | 170 | defer closeLog() | |
| 163 | 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 | 181 | configLog := logging.For(ctx, "config") | |
| 165 | 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 { | |
| 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 | |
| 175 | 199 | } | |
| @@ -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 err | 103 | 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 nil | 186 | + | err = nil |
| 170 | - | } | 187 | + | } |
| 171 | - | log.Error().Err(err).Msg("application failed") | 188 | + | reason := "clean" |
| 172 | - | return err | 189 | + | switch { |
| 173 | - | } | 190 | + | case err != nil: |
| 174 | - | return 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 | |||
| 175 | } | 199 | } | ||
M examples/config.toml +19 -1
| @@ -12,11 +12,29 @@ command = ["foot"] | |||
| 12 | 12 | [background] | |
| 13 | 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 | 21 | [layout] | |
| 16 | - | gaps = 8 | |
| 22 | + | gaps = 0 | |
| 17 | 23 | default_column_width = "1/2" | |
| 18 | 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 | 38 | [render] | |
| 21 | 39 | direct_scanout = true | |
| 22 | 40 | ||
| @@ -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 = 8 | 22 | + | 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 = true | 39 | direct_scanout = true | ||
| 22 | 40 | ||||
M internal/adapters/config/config.go +41 -2
| @@ -18,7 +18,7 @@ var defaultBinds = map[string]string{ | |||
| 18 | 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 | 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 | 22 | var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) | |
| 23 | 23 | ||
| 24 | 24 | func DefaultPath() string { | |
| @@ -38,7 +38,10 @@ func Defaults() ports.Config { | |||
| 38 | 38 | c.Keyboard.CmdKey = "super" | |
| 39 | 39 | c.Terminal.Command = []string{"foot"} | |
| 40 | 40 | 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 | |
| 42 | 45 | c.Layout.DefaultColumnWidth = "1/2" | |
| 43 | 46 | c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"} | |
| 44 | 47 | c.Binds = make(map[string]string, len(defaultBinds)) | |
| @@ -99,6 +102,24 @@ func Load(path string) (ports.Config, error) { | |||
| 99 | 102 | } | |
| 100 | 103 | 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 | 123 | func width(s string) bool { | |
| 103 | 124 | if s == "1" { | |
| 104 | 125 | return true | |
| @@ -189,6 +210,24 @@ func Validate(c ports.Config) error { | |||
| 189 | 210 | if !color.MatchString(c.Background.Color) { | |
| 190 | 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 | 231 | if c.Layout.Gaps < 0 || c.Layout.Gaps > 200 { | |
| 193 | 232 | add("layout.gaps", "must be between 0 and 200") | |
| 194 | 233 | } | |
| @@ -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 = 8 | 41 | + | 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, nil | 103 | 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 true | 125 | 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) { | |||
| 86 | 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 | + | } | |
| @@ -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) { | |||
| 161 | 161 | return | |
| 162 | 162 | } | |
| 163 | 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 | 169 | if err != nil { | |
| 166 | 170 | return | |
| 167 | 171 | } | |
| @@ -362,20 +366,31 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||
| 362 | 366 | continue | |
| 363 | 367 | } | |
| 364 | 368 | 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 | 376 | content := contents[w.ID] | |
| 367 | 377 | if content.Pixels == nil { | |
| 368 | 378 | add(body, windowColor(w.ID), nil, image.Point{}) | |
| 369 | 379 | } else { | |
| 370 | 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 | 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 | 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)} { | |
| 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{}) | |
| 379 | 394 | } | |
| 380 | 395 | } | |
| 381 | 396 | } | |
| @@ -471,6 +486,19 @@ func (r *Renderer) Pixels() *image.RGBA { | |||
| 471 | 486 | 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 | 502 | func (r *Renderer) Close() { | |
| 475 | 503 | if r == nil { | |
| 476 | 504 | return | |
| @@ -161,7 +161,11 @@ func New(width, height int) (r *Renderer, err error) { | |||||
| 161 | return | 161 | 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 | return | 170 | return | ||
| 167 | } | 171 | } | ||
| @@ -362,20 +366,31 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||||
| 362 | continue | 366 | continue | ||
| 363 | } | 367 | } | ||
| 364 | x, y := w.Rect.X, w.Rect.Y | 368 | 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 out | 486 | 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 | return | 504 | return | ||
M internal/adapters/vulkan/renderer_test.go +7 -3
| @@ -57,10 +57,13 @@ func TestRendererRender(t *testing.T) { | |||
| 57 | 57 | win := ports.SceneWindow{ID: 1, Rect: ports.Rect{X: 8, Y: 8, W: 16, H: 16}} | |
| 58 | 58 | c := windowColor(1) | |
| 59 | 59 | 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}) | |
| 62 | 62 | 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 | 67 | s.Windows[0] = ports.SceneWindow{ID: 2, Rect: ports.Rect{X: -10, Y: 0, W: 20, H: 10}} | |
| 65 | 68 | c = windowColor(2) | |
| 66 | 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 | 131 | if got, want := r.Pixels().RGBAAt(10, 10), (color.RGBA{c[0], c[1], c[2], 255}); got != want { | |
| 129 | 132 | t.Errorf("overlap = %v, want %v", got, want) | |
| 130 | 133 | } | |
| 134 | + | scene.Border = ports.Border{Width: 4, Active: "#ffffff"} | |
| 131 | 135 | scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 2, Y: 2, W: 16, H: 16}, Focused: true}} | |
| 132 | 136 | contents := map[ports.WindowID]ports.SurfaceContent{1: {Width: 16, Height: 16, Stride: 64, Opaque: true, Pixels: make([]byte, 16*16*4)}} | |
| 133 | 137 | 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 = true | 62 | 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 { | |||
| 13 | 13 | func() error { return registerXDG(d, s) }, | |
| 14 | 14 | func() error { return registerLayer(d, o, s) }, | |
| 15 | 15 | func() error { return registerXDGOutput(d, o) }, | |
| 16 | + | func() error { return registerDecoration(d, s) }, | |
| 16 | 17 | func() error { | |
| 17 | 18 | return wayland.NewCompositorGlobal(d, 6, func(c server.Client, v, id uint32) { wayland.NewCompositor(c, int32(v), id, compositor{s}) }) | |
| 18 | 19 | }, | |
| @@ -36,14 +37,15 @@ func registerGlobals(d *server.Display, o Options, s *Server) error { | |||
| 36 | 37 | if e != nil { | |
| 37 | 38 | return | |
| 38 | 39 | } | |
| 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)) | |
| 41 | 43 | if v >= 2 { | |
| 42 | 44 | r.SendScale(1) | |
| 43 | 45 | } | |
| 44 | 46 | if v >= 4 { | |
| 45 | - | r.SendName("HEADLESS-1") | |
| 46 | - | r.SendDescription("NeferTTY headless output") | |
| 47 | + | r.SendName(info.Name) | |
| 48 | + | r.SendDescription(info.Description) | |
| 47 | 49 | } | |
| 48 | 50 | if v >= 2 { | |
| 49 | 51 | 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 | return | 38 | 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 ( | |||
| 10 | 10 | ||
| 11 | 11 | "github.com/bnema/nefertty/internal/ports" | |
| 12 | 12 | "github.com/bnema/purego-libwayland/protocol/wayland" | |
| 13 | - | "github.com/bnema/purego-libwayland/protocol/xdgshell" | |
| 14 | 13 | "github.com/bnema/purego-libwayland/server" | |
| 15 | 14 | "github.com/bnema/zerowrap" | |
| 16 | 15 | "golang.org/x/sys/unix" | |
| @@ -19,10 +18,33 @@ import ( | |||
| 19 | 18 | type Options struct { | |
| 20 | 19 | RuntimeDir string | |
| 21 | 20 | OutputWidth, OutputHeight int | |
| 21 | + | Output OutputInfo | |
| 22 | 22 | Keymap string | |
| 23 | 23 | 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 | 48 | // Channels carries client notifications and commands. Events may be unbuffered. | |
| 27 | 49 | type Channels struct { | |
| 28 | 50 | Events chan<- ports.ClientEvent | |
| @@ -358,17 +380,9 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||
| 358 | 380 | s.log.Debug().Uint64("id", uint64(c.ID)).Msg("configure missing window") | |
| 359 | 381 | return | |
| 360 | 382 | } | |
| 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() | |
| 372 | 386 | case ports.CloseWindow: | |
| 373 | 387 | w := s.windows[c.ID] | |
| 374 | 388 | 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 string | 19 | RuntimeDir string | ||
| 21 | OutputWidth, OutputHeight int | 20 | OutputWidth, OutputHeight int | ||
| 21 | + | Output OutputInfo | |||
| 22 | Keymap string | 22 | Keymap string | ||
| 23 | RepeatRate, RepeatDelay int | 23 | 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.ClientEvent | 50 | 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 | return | 381 | return | ||
| 360 | } | 382 | } | ||
| 361 | - | var states []byte | 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") |
| 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 { | |||
| 23 | 23 | server *Server | |
| 24 | 24 | current, pending *wayland.Buffer | |
| 25 | 25 | attached bool | |
| 26 | + | lastW, lastH int // last logged buffer size | |
| 26 | 27 | callbacks []*wayland.Callback | |
| 27 | 28 | role func(bool) | |
| 28 | 29 | destroyed bool | |
| @@ -87,6 +88,10 @@ func (s *surface) Commit(*wayland.Surface) { | |||
| 87 | 88 | b := s.current | |
| 88 | 89 | if state, ok := s.server.buffers[b.Resource]; ok { | |
| 89 | 90 | if c, ok := state.content(id); ok { | |
| 91 | + | if c.Width != s.lastW || c.Height != s.lastH { | |
| 92 | + | s.lastW, s.lastH = c.Width, c.Height | |
| 93 | + | s.server.log.Info().Uint64("id", uint64(id)).Int("w", c.Width).Int("h", c.Height).Msg("buffer size") | |
| 94 | + | } | |
| 90 | 95 | s.server.emitContent(c) | |
| 91 | 96 | } else { | |
| 92 | 97 | state.pool.shm.PostError(uint32(wayland.ShmErrorInvalidFd), "SHM backing file truncated") | |
| @@ -23,6 +23,7 @@ type surface struct { | |||||
| 23 | server *Server | 23 | server *Server | ||
| 24 | current, pending *wayland.Buffer | 24 | current, pending *wayland.Buffer | ||
| 25 | attached bool | 25 | attached bool | ||
| 26 | + | lastW, lastH int // last logged buffer size | |||
| 26 | callbacks []*wayland.Callback | 27 | callbacks []*wayland.Callback | ||
| 27 | role func(bool) | 28 | role func(bool) | ||
| 28 | destroyed bool | 29 | destroyed bool | ||
| @@ -87,6 +88,10 @@ func (s *surface) Commit(*wayland.Surface) { | |||||
| 87 | b := s.current | 88 | b := s.current | ||
| 88 | if state, ok := s.server.buffers[b.Resource]; ok { | 89 | if state, ok := s.server.buffers[b.Resource]; ok { | ||
| 89 | if c, ok := state.content(id); ok { | 90 | if c, ok := state.content(id); ok { | ||
| 91 | + | if c.Width != s.lastW || c.Height != s.lastH { | |||
| 92 | + | s.lastW, s.lastH = c.Width, c.Height | |||
| 93 | + | s.server.log.Info().Uint64("id", uint64(id)).Int("w", c.Width).Int("h", c.Height).Msg("buffer size") | |||
| 94 | + | } | |||
| 90 | s.server.emitContent(c) | 95 | s.server.emitContent(c) | ||
| 91 | } else { | 96 | } else { | ||
| 92 | state.pool.shm.PostError(uint32(wayland.ShmErrorInvalidFd), "SHM backing file truncated") | 97 | state.pool.shm.PostError(uint32(wayland.ShmErrorInvalidFd), "SHM backing file truncated") | ||
M internal/adapters/wayland/xdg.go +36 -1
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package wayland | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "encoding/binary" | |
| 4 | 5 | "github.com/bnema/nefertty/internal/ports" | |
| 5 | 6 | "github.com/bnema/purego-libwayland/protocol/wayland" | |
| 6 | 7 | "github.com/bnema/purego-libwayland/protocol/xdgshell" | |
| @@ -77,6 +78,32 @@ type window struct { | |||
| 77 | 78 | xdg *xdgSurface | |
| 78 | 79 | appID, title string | |
| 79 | 80 | 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 | 109 | func (w *window) unmap() { | |
| @@ -152,7 +179,15 @@ func (x *xdgSurface) GetPopup(r *xdgshell.Surface, id uint32, _ *xdgshell.Surfac | |||
| 152 | 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 | 191 | func (x *xdgSurface) AckConfigure(r *xdgshell.Surface, serial uint32) { | |
| 157 | 192 | for i, issued := range x.serials { | |
| 158 | 193 | if issued == serial { | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package wayland | 1 | 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 *xdgSurface | 78 | xdg *xdgSurface | ||
| 78 | appID, title string | 79 | appID, title string | ||
| 79 | mapped bool | 80 | 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 ( | |||
| 6 | 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 | 14 | func registerXDGOutput(d *server.Display, o Options) error { | |
| 12 | 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 | 19 | func (xdgOutputManager) Destroy(*xdgoutput.ZxdgOutputManagerV1) {} | |
| @@ -22,8 +25,8 @@ func (m xdgOutputManager) GetXdgOutput(r *xdgoutput.ZxdgOutputManagerV1, id uint | |||
| 22 | 25 | x.SendLogicalPosition(0, 0) | |
| 23 | 26 | x.SendLogicalSize(m.width, m.height) | |
| 24 | 27 | 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) | |
| 27 | 30 | } | |
| 28 | 31 | if r.Version() < 3 || output == nil || output.Version() < 2 { | |
| 29 | 32 | 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 ( | |||
| 12 | 12 | "time" | |
| 13 | 13 | ||
| 14 | 14 | "github.com/bnema/nefertty/internal/adapters/config" | |
| 15 | + | "github.com/bnema/nefertty/internal/adapters/drm" | |
| 15 | 16 | "github.com/bnema/nefertty/internal/adapters/headless" | |
| 16 | 17 | "github.com/bnema/nefertty/internal/adapters/headlessinput" | |
| 17 | 18 | "github.com/bnema/nefertty/internal/adapters/launcher" | |
| 19 | + | "github.com/bnema/nefertty/internal/adapters/libinput" | |
| 18 | 20 | "github.com/bnema/nefertty/internal/adapters/vulkan" | |
| 19 | 21 | "github.com/bnema/nefertty/internal/adapters/wayland" | |
| 20 | 22 | "github.com/bnema/nefertty/internal/adapters/xkb" | |
| @@ -30,6 +32,8 @@ type Options struct { | |||
| 30 | 32 | Timeout time.Duration | |
| 31 | 33 | NoTerminal bool | |
| 32 | 34 | ScreenshotDir string | |
| 35 | + | // Width and Height size the headless output; 0 means 1920x1080. | |
| 36 | + | Width, Height int | |
| 33 | 37 | // Run closes Script on shutdown; the reader goroutine exits after Close. | |
| 34 | 38 | Script io.ReadCloser | |
| 35 | 39 | testScenes chan<- ports.Scene | |
| @@ -39,17 +43,30 @@ func Run(ctx context.Context, opts Options) error { return run(ctx, opts, nil) } | |||
| 39 | 43 | ||
| 40 | 44 | func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)) error { | |
| 41 | 45 | log := logging.For(ctx, "app") | |
| 42 | - | if opts.Backend != "headless" { | |
| 43 | - | return fmt.Errorf("drm backend not implemented yet") | |
| 44 | - | } | |
| 45 | 46 | log.Info().Str("backend", opts.Backend).Msg("starting nefertty") | |
| 46 | 47 | var cancel context.CancelFunc | |
| 47 | 48 | if opts.Timeout > 0 { | |
| 48 | - | ctx, cancel = context.WithTimeout(ctx, opts.Timeout) | |
| 49 | + | ctx, cancel = context.WithTimeoutCause(ctx, opts.Timeout, errTimeout) | |
| 49 | 50 | } else { | |
| 50 | 51 | ctx, cancel = context.WithCancel(ctx) | |
| 51 | 52 | } | |
| 52 | 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 | 70 | client := make(chan ports.ClientEvent, 32) | |
| 54 | 71 | input := make(chan ports.InputEvent, 32) | |
| 55 | 72 | output := make(chan ports.OutputEvent, 32) | |
| @@ -65,14 +82,14 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 65 | 82 | if err != nil { | |
| 66 | 83 | return err | |
| 67 | 84 | } | |
| 68 | - | output <- ports.OutputMode{Width: 1920, Height: 1080} | |
| 85 | + | output <- ports.OutputMode{Width: width, Height: height} | |
| 69 | 86 | km, err := xkb.New(xkb.RMLVO{Layout: opts.Config.Keyboard.Layout, Variant: opts.Config.Keyboard.Variant, Options: opts.Config.Keyboard.Options}) | |
| 70 | 87 | if err != nil { | |
| 71 | 88 | return err | |
| 72 | 89 | } | |
| 73 | 90 | keymap := km.String() | |
| 74 | 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 | 93 | if err != nil { | |
| 77 | 94 | km.Close() | |
| 78 | 95 | return err | |
| @@ -87,7 +104,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 87 | 104 | } | |
| 88 | 105 | var workers sync.WaitGroup | |
| 89 | 106 | workers.Add(7) | |
| 90 | - | done := make(chan error, 7) | |
| 107 | + | done := make(chan error, 8) | |
| 91 | 108 | path := opts.ConfigPath | |
| 92 | 109 | if path == "" { | |
| 93 | 110 | path = config.DefaultPath() | |
| @@ -99,6 +116,12 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 99 | 116 | script := make(chan string) | |
| 100 | 117 | go func() { | |
| 101 | 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 | 125 | _ = headlessinput.Run(ctx, km, script, input, logging.For(ctx, "input")) | |
| 103 | 126 | }() | |
| 104 | 127 | if opts.Script != nil { | |
| @@ -127,7 +150,19 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 127 | 150 | ||
| 128 | 151 | go func() { | |
| 129 | 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 | 166 | r, err := vulkan.New(w, h) | |
| 132 | 167 | if err != nil { | |
| 133 | 168 | return nil, err | |
| @@ -136,10 +171,18 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 136 | 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 | 178 | var result error | |
| 140 | 179 | select { | |
| 141 | 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 | 184 | case result = <-done: | |
| 185 | + | log.Info().AnErr("cause", result).Msg("stopping") | |
| 143 | 186 | } | |
| 144 | 187 | cancel() | |
| 145 | 188 | workers.Wait() | |
| @@ -149,7 +192,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 149 | 192 | 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 | 196 | return nil | |
| 154 | 197 | } | |
| 155 | 198 | return result | |
| @@ -181,7 +224,12 @@ func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors | |||
| 181 | 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 | 233 | case err := <-configErrors: | |
| 186 | 234 | configLog.Warn().Err(err).Msg("config rejected") | |
| 187 | 235 | } | |
| @@ -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.Duration | 32 | Timeout time.Duration | ||
| 31 | NoTerminal bool | 33 | NoTerminal bool | ||
| 32 | ScreenshotDir string | 34 | 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.ReadCloser | 38 | Script io.ReadCloser | ||
| 35 | testScenes chan<- ports.Scene | 39 | 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.CancelFunc | 47 | 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 err | 83 | 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 err | 88 | 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 err | 95 | return err | ||
| @@ -87,7 +104,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 87 | } | 104 | } | ||
| 88 | var workers sync.WaitGroup | 105 | 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.ConfigPath | 108 | 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, err | 168 | 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 error | 178 | 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 = err | 192 | 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 nil | 196 | return nil | ||
| 154 | } | 197 | } | ||
| 155 | return result | 198 | 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) { | |||
| 140 | 140 | select { | |
| 141 | 141 | case s := <-scenes: | |
| 142 | 142 | 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) | |
| 145 | 146 | } | |
| 146 | 147 | return | |
| 147 | 148 | } | |
| @@ -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 | return | 147 | 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 { | |||
| 145 | 145 | 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 | 160 | func (c *Core) publish(ctx context.Context) error { | |
| 149 | 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 | 163 | alive := map[WindowID]bool{} | |
| 152 | 164 | focus, _ := c.ws.Focused() | |
| 153 | 165 | for _, p := range c.ws.Layout() { | |
| @@ -163,7 +175,8 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 163 | 175 | } | |
| 164 | 176 | 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 | 180 | if old, ok := c.sent[p.ID]; !ok || old != v { | |
| 168 | 181 | if err := c.command(ctx, v); err != nil { | |
| 169 | 182 | return err | |
| @@ -280,7 +293,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 280 | 293 | var x, y float64 | |
| 281 | 294 | // Fullscreen wins; otherwise the last visible placement is topmost. | |
| 282 | 295 | for _, p := range c.ws.Layout() { | |
| 283 | - | r := p.Rect | |
| 296 | + | r := c.clientRect(p) | |
| 284 | 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 | 298 | if id == 0 || p.Fullscreen { | |
| 286 | 299 | 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 nil | 145 | 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 | continue | 176 | 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 err | 182 | return err | ||
| @@ -280,7 +293,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 280 | var x, y float64 | 293 | 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.Rect | 296 | + | 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 { | |||
| 24 | 24 | func TestOwner(t *testing.T) { | |
| 25 | 25 | cfg := config.Defaults() | |
| 26 | 26 | cfg.Keyboard.CmdKey = "alt" | |
| 27 | + | cfg.Border.Width = 0 | |
| 28 | + | cfg.Layout.Gaps = 8 | |
| 27 | 29 | client := make(chan ports.ClientEvent, 128) | |
| 28 | 30 | input := make(chan ports.InputEvent, 32) | |
| 29 | 31 | output := make(chan ports.OutputEvent, 8) | |
| @@ -195,6 +197,8 @@ func TestBoundReleaseSwallowed(t *testing.T) { | |||
| 195 | 197 | ||
| 196 | 198 | func TestPointerFocusAndGrab(t *testing.T) { | |
| 197 | 199 | cfg := config.Defaults() | |
| 200 | + | cfg.Border.Width = 0 | |
| 201 | + | cfg.Layout.Gaps = 8 | |
| 198 | 202 | client := make(chan ports.ClientEvent, 8) | |
| 199 | 203 | input := make(chan ports.InputEvent, 8) | |
| 200 | 204 | output := make(chan ports.OutputEvent, 8) | |
| @@ -246,3 +250,35 @@ func TestPointerFocusAndGrab(t *testing.T) { | |||
| 246 | 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 | + | } | |
| @@ -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 ( | |||
| 6 | 6 | "fmt" | |
| 7 | 7 | "os" | |
| 8 | 8 | "path/filepath" | |
| 9 | + | "sort" | |
| 9 | 10 | "strings" | |
| 11 | + | "time" | |
| 10 | 12 | ||
| 11 | 13 | "github.com/bnema/zerowrap" | |
| 12 | 14 | "github.com/rs/zerolog" | |
| @@ -14,7 +16,7 @@ import ( | |||
| 14 | 16 | ) | |
| 15 | 17 | ||
| 16 | 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 | 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 | 42 | 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 | 50 | // The caller must invoke close after all logging is complete. | |
| 45 | 51 | func Open(ctx context.Context, level, debug string) (context.Context, func() error, error) { | |
| 46 | 52 | selected, err := ParseDebug(debug) | |
| @@ -55,18 +61,22 @@ func Open(ctx context.Context, level, debug string) (context.Context, func() err | |||
| 55 | 61 | } | |
| 56 | 62 | state = filepath.Join(home, ".local", "state") | |
| 57 | 63 | } | |
| 58 | - | dir := filepath.Join(state, "nefertty") | |
| 64 | + | dir := filepath.Join(state, "nefertty", "runs") | |
| 59 | 65 | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 60 | 66 | return nil, nil, err | |
| 61 | 67 | } | |
| 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 { | |
| 64 | 71 | return nil, nil, err | |
| 65 | 72 | } | |
| 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() | |
| 68 | 77 | return nil, nil, err | |
| 69 | 78 | } | |
| 79 | + | pruneRuns(dir) | |
| 70 | 80 | var output = zerowrap.Config{Level: level, Format: "json", Output: file} | |
| 71 | 81 | if term.IsTerminal(int(os.Stderr.Fd())) { | |
| 72 | 82 | output.Output = &consoleAndFile{file: file} | |
| @@ -77,6 +87,15 @@ func Open(ctx context.Context, level, debug string) (context.Context, func() err | |||
| 77 | 87 | 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 | 99 | type consoleAndFile struct{ file *os.File } | |
| 81 | 100 | ||
| 82 | 101 | 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, nil | 42 | 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, err | 66 | 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, err | 71 | 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, err | 77 | 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, nil | 87 | 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 ( | |||
| 7 | 7 | "os" | |
| 8 | 8 | "path/filepath" | |
| 9 | 9 | "testing" | |
| 10 | + | "time" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| 12 | 13 | func TestParseDebug(t *testing.T) { | |
| @@ -32,8 +33,10 @@ func TestParseDebug(t *testing.T) { | |||
| 32 | 33 | ||
| 33 | 34 | func TestComponentLevelsAndRotation(t *testing.T) { | |
| 34 | 35 | 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 | |
| 37 | 40 | ctx, closeLog, err := Open(context.Background(), "info", "core") | |
| 38 | 41 | if err != nil { | |
| 39 | 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 { | |
| 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) | |
| 69 | 73 | } | |
| 70 | 74 | } | |
| 71 | 75 | ||
| @@ -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 @@ | |||
| 1 | 1 | 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 | 14 | // Config is the parsed compositor configuration. | |
| 4 | 15 | type Config struct { | |
| 5 | 16 | Keyboard struct { | |
| @@ -10,13 +21,21 @@ type Config struct { | |||
| 10 | 21 | } `toml:"keyboard"` | |
| 11 | 22 | Terminal struct{ Command []string } `toml:"terminal"` | |
| 12 | 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 | 31 | Gaps int | |
| 15 | 32 | DefaultColumnWidth string `toml:"default_column_width"` | |
| 16 | 33 | Presets []string | |
| 17 | 34 | } `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 { | |
| 20 | 39 | DirectScanout bool `toml:"direct_scanout"` | |
| 21 | 40 | } `toml:"render"` | |
| 22 | 41 | Log struct { | |
| @@ -1,5 +1,16 @@ | |||||
| 1 | package ports | 1 | 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 int | 31 | Gaps int | ||
| 15 | DefaultColumnWidth string `toml:"default_column_width"` | 32 | DefaultColumnWidth string `toml:"default_column_width"` | ||
| 16 | Presets []string | 33 | 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 { | |||
| 187 | 187 | Seq uint64 | |
| 188 | 188 | OutputWidth, OutputHeight int | |
| 189 | 189 | Background string | |
| 190 | + | Border Border | |
| 190 | 191 | Windows []SceneWindow | |
| 191 | 192 | // Layers are drawn in slice order: background and bottom before windows, | |
| 192 | 193 | // top and overlay after. A fullscreen window covers bottom and top. | |
| 193 | 194 | 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 | 203 | // SceneLayer carries core → renderer layer surface placement. | |
| 197 | 204 | type SceneLayer struct { | |
| 198 | 205 | ID WindowID | |
| @@ -187,12 +187,19 @@ type Scene struct { | |||||
| 187 | Seq uint64 | 187 | Seq uint64 | ||
| 188 | OutputWidth, OutputHeight int | 188 | OutputWidth, OutputHeight int | ||
| 189 | Background string | 189 | Background string | ||
| 190 | + | Border Border | |||
| 190 | Windows []SceneWindow | 191 | 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 []SceneLayer | 194 | 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 WindowID | 205 | ID WindowID | ||