feat: split kiosk into fallback canvas and OSD overlay layers (#8)
Build Ground Station / build (push) Successful in 37s
Build Ground Station / build (push) Successful in 37s
Drive two independent visual layers per display target from one shared video presence signal: the fullscreen fallback/menu canvas below video and a transparent, input-transparent OSD overlay above video, reserved as the future flight OSD rendering surface. WFB and video decode stay out of the UI process; pilot video keeps rendering priority.
This commit was merged in pull request #12.
This commit is contained in:
@@ -16,9 +16,10 @@ Current capabilities:
|
||||
- logs channel changes instead of every frame;
|
||||
- starts/stops `fpv-video` from the configured CH6 power/lifecycle control;
|
||||
- starts an independent kiosk display before video, tolerates temporary X11 downtime without restarting the supervisor, and stops deterministically on shutdown;
|
||||
- keeps the fallback canvas below `fpv-video`, with `Brak połączenia WFB` while video is absent.
|
||||
- keeps the fullscreen fallback/menu canvas below `fpv-video`, showing `Brak połączenia WFB` only while video is absent;
|
||||
- maps a transparent, input-transparent OSD overlay above `fpv-video` as the reserved flight OSD rendering surface: it never presents pixels over the video or intercepts its input.
|
||||
|
||||
Future MIPI/touch targets use their own template rather than sharing one global layout. Flight OSD will use a separate transparent layer above video, preserving the pilot stream's priority.
|
||||
Future MIPI/touch targets use their own templates rather than sharing one global layout. The flight OSD will render on the existing transparent layer above video, preserving the pilot stream's priority.
|
||||
|
||||
## Installation on Orange Pi
|
||||
|
||||
@@ -57,5 +58,5 @@ serial:
|
||||
## Runtime dependencies
|
||||
|
||||
- X11 session on `:0` owned by `orangepi`; the kiosk waits for X11 and reconnects after display-manager restarts;
|
||||
- `wmctrl` for fullscreen/below kiosk state; without it the canvas maps as a normal window and the condition is logged;
|
||||
- `wmctrl` for the fullscreen/stacking kiosk window states; without it the kiosk windows map as normal windows and the condition is logged;
|
||||
- `runuser` from util-linux for the unprivileged kiosk launch.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Command groundstation-display renders the Ground Station kiosk canvas. It
|
||||
// Command groundstation-display renders the Ground Station kiosk layers. It
|
||||
// owns its display lifecycle independently of WFB, the transmitter and the
|
||||
// video launcher: it waits for its target to appear, redraws as needed, and
|
||||
// stops only when it receives SIGINT or SIGTERM.
|
||||
@@ -16,19 +16,31 @@ import (
|
||||
"git.piksar.eu/piotr/groundstation/internal/display"
|
||||
)
|
||||
|
||||
// buildTarget selects the target-specific opener and template. Each target
|
||||
// owns its own backend and layouts; there is no shared global template.
|
||||
// buildTarget wires one shared video presence signal into both the opener
|
||||
// (which observes the video output lifecycle) and the fallback template
|
||||
// (which shows or hides itself from it).
|
||||
func buildTarget(name string) (display.Opener, display.Template, error) {
|
||||
// buildTarget selects the target-specific layers. Each target owns its own
|
||||
// backend and layouts; there is no shared global template. The Viture target
|
||||
// builds two independent layers — the fullscreen fallback/menu canvas below
|
||||
// video and the transparent OSD overlay above it — wired to one shared video
|
||||
// presence signal: the sessions observe the video output lifecycle on the
|
||||
// display server and the templates draw or hide from it. WFB and video
|
||||
// decode never move into this process.
|
||||
func buildTarget(name string) ([]display.Layer, error) {
|
||||
switch name {
|
||||
case "x11":
|
||||
video := display.NewVideoPresence()
|
||||
return display.X11Opener{Video: video},
|
||||
display.NewFallbackTemplate(video, "Brak połączenia WFB"), nil
|
||||
return []display.Layer{
|
||||
{
|
||||
Name: "fallback-canvas",
|
||||
Opener: display.X11Opener{Video: video},
|
||||
Template: display.NewFallbackTemplate(video, "Brak połączenia WFB"),
|
||||
},
|
||||
{
|
||||
Name: "osd-overlay",
|
||||
Opener: display.X11Opener{Video: video, Role: display.RoleAbove},
|
||||
Template: display.NewOSDTemplate(video),
|
||||
},
|
||||
}, nil
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("unknown display target %q (supported: x11)", name)
|
||||
return nil, fmt.Errorf("unknown display target %q (supported: x11)", name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +48,7 @@ func main() {
|
||||
target := flag.String("target", "x11", "display target: x11 (Viture) today; mipi planned")
|
||||
flag.Parse()
|
||||
|
||||
opener, template, err := buildTarget(*target)
|
||||
layers, err := buildTarget(*target)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -44,9 +56,7 @@ func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// Temporary X11 unavailability never exits this process; display.Run
|
||||
// Temporary X11 unavailability never exits this process; every layer
|
||||
// keeps retrying internally until the context is cancelled.
|
||||
if err := display.Run(ctx, opener, template, display.Options{}); err != nil {
|
||||
log.Fatalf("display: %v", err)
|
||||
}
|
||||
display.RunLayers(ctx, layers)
|
||||
}
|
||||
|
||||
@@ -19,45 +19,67 @@ func (c *recordingCanvas) Text(_, _ int, message string) {
|
||||
}
|
||||
func (c *recordingCanvas) Flush() { c.flushes++ }
|
||||
|
||||
func TestBuildTargetWiresSharedVideoPresenceToFallback(t *testing.T) {
|
||||
opener, template, err := buildTarget("x11")
|
||||
func TestBuildTargetBuildsIndependentVitureLayers(t *testing.T) {
|
||||
layers, err := buildTarget("x11")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(layers) != 2 {
|
||||
t.Fatalf("layers=%d want the fallback canvas and the OSD overlay", len(layers))
|
||||
}
|
||||
canvas, osd := layers[0], layers[1]
|
||||
if canvas.Name != "fallback-canvas" || osd.Name != "osd-overlay" {
|
||||
t.Fatalf("layer names %q/%q want fallback-canvas/osd-overlay", canvas.Name, osd.Name)
|
||||
}
|
||||
|
||||
x11, ok := opener.(display.X11Opener)
|
||||
canvasOpener, ok := canvas.Opener.(display.X11Opener)
|
||||
if !ok {
|
||||
t.Fatalf("opener %T must be the X11 target", opener)
|
||||
t.Fatalf("canvas opener %T must be the X11 target", canvas.Opener)
|
||||
}
|
||||
if x11.Video == nil {
|
||||
t.Fatal("x11 opener must observe the video presence signal")
|
||||
if canvasOpener.Role != display.RoleBelow {
|
||||
t.Fatalf("canvas layer role %v must stay below video", canvasOpener.Role)
|
||||
}
|
||||
osdOpener, ok := osd.Opener.(display.X11Opener)
|
||||
if !ok {
|
||||
t.Fatalf("OSD opener %T must be the X11 target", osd.Opener)
|
||||
}
|
||||
if osdOpener.Role != display.RoleAbove {
|
||||
t.Fatalf("OSD layer role %v must stay above video", osdOpener.Role)
|
||||
}
|
||||
|
||||
canvas := &recordingCanvas{width: 800, height: 600}
|
||||
template.Draw(canvas)
|
||||
if len(canvas.lines) != 1 || canvas.lines[0] != "Brak połączenia WFB" {
|
||||
t.Fatalf("lines=%q want the exact fallback text", canvas.lines)
|
||||
// Both layers must share one presence signal so the video lifecycle
|
||||
// observed by either session drives both templates.
|
||||
if canvasOpener.Video == nil || osdOpener.Video == nil || canvasOpener.Video != osdOpener.Video {
|
||||
t.Fatal("layers must share one video presence signal")
|
||||
}
|
||||
|
||||
// The opener and the template must share one presence signal so the
|
||||
// video lifecycle observed by the session drives the fallback.
|
||||
x11.Video.SetActive(true)
|
||||
canvas.lines = nil
|
||||
template.Draw(canvas)
|
||||
if len(canvas.lines) != 0 {
|
||||
t.Fatalf("lines=%q must disappear while video is present", canvas.lines)
|
||||
// The canvas layer keeps the target-specific fallback template with the
|
||||
// exact fallback text.
|
||||
canvasSurface := &recordingCanvas{width: 800, height: 600}
|
||||
canvas.Template.Draw(canvasSurface)
|
||||
if len(canvasSurface.lines) != 1 || canvasSurface.lines[0] != "Brak połączenia WFB" {
|
||||
t.Fatalf("canvas lines=%q want the exact fallback text", canvasSurface.lines)
|
||||
}
|
||||
|
||||
x11.Video.SetActive(false)
|
||||
canvas.lines = nil
|
||||
template.Draw(canvas)
|
||||
if len(canvas.lines) != 1 || canvas.lines[0] != "Brak połączenia WFB" {
|
||||
t.Fatalf("lines=%q want the fallback back after video end", canvas.lines)
|
||||
// The OSD layer is the OSD surface, never the fallback.
|
||||
osdSurface := &recordingCanvas{width: 800, height: 600}
|
||||
osd.Template.Draw(osdSurface)
|
||||
if len(osdSurface.lines) != 0 {
|
||||
t.Fatalf("OSD lines=%q want none: the overlay must not duplicate the fallback", osdSurface.lines)
|
||||
}
|
||||
|
||||
// Video presence hides the fallback on the canvas layer only.
|
||||
canvasOpener.Video.SetActive(true)
|
||||
canvasSurface.lines = nil
|
||||
canvas.Template.Draw(canvasSurface)
|
||||
if len(canvasSurface.lines) != 0 {
|
||||
t.Fatalf("canvas lines=%q must disappear while video is present", canvasSurface.lines)
|
||||
}
|
||||
canvasOpener.Video.SetActive(false)
|
||||
}
|
||||
|
||||
func TestBuildTargetRejectsUnknownTarget(t *testing.T) {
|
||||
if _, _, err := buildTarget("hdmi-mirror"); err == nil {
|
||||
if _, err := buildTarget("hdmi-mirror"); err == nil {
|
||||
t.Fatal("unknown target must fail")
|
||||
}
|
||||
}
|
||||
|
||||
+47
-10
@@ -1,17 +1,21 @@
|
||||
// Package display owns the Ground Station kiosk canvas lifecycle. The kiosk
|
||||
// runs independently of WFB, the transmitter and the RC video knob: it waits
|
||||
// for its rendering target to appear, keeps a fullscreen black canvas alive
|
||||
// and stops only when told to. While decoded pilot video is absent the
|
||||
// canvas shows the fallback; when video output is presented the video window
|
||||
// takes rendering priority and the fallback disappears, returning when video
|
||||
// ends. Presence is observed from the video output itself on the display
|
||||
// server, never from WFB or launcher processes. Each target (X11/Viture
|
||||
// today, MIPI/touch later) provides its own opener and templates instead of
|
||||
// one global layout.
|
||||
// Package display owns the Ground Station kiosk layers. The kiosk runs
|
||||
// independently of WFB, the transmitter and the RC video knob: it waits for
|
||||
// its rendering target to appear, keeps its windows alive and stops only
|
||||
// when told to. Each target drives two independent visual layers: a
|
||||
// fullscreen fallback/menu canvas below video, visible while decoded pilot
|
||||
// video is absent, and a transparent, input-transparent OSD overlay above
|
||||
// video, reserved as the future flight OSD rendering surface. When video
|
||||
// output is presented the video window takes rendering priority and the
|
||||
// fallback disappears, returning when video ends. Presence is observed from
|
||||
// the video output itself on the display server, never from WFB or launcher
|
||||
// processes, and the video pipeline is never moved into, coupled to or
|
||||
// touched by this package. Each target (X11/Viture today, MIPI/touch later)
|
||||
// provides its own openers and templates instead of one global layout.
|
||||
package display
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -108,6 +112,39 @@ func Run(ctx context.Context, opener Opener, template Template, options Options)
|
||||
}
|
||||
}
|
||||
|
||||
// Layer is one independent visual layer of a display target: its own
|
||||
// connection, window role and template. A lost or degraded layer never
|
||||
// affects the others, so a missing overlay cannot disturb the fallback
|
||||
// canvas and neither can disturb pilot video.
|
||||
type Layer struct {
|
||||
// Name identifies the layer in diagnostics.
|
||||
Name string
|
||||
// Opener connects to the layer's own window on the target.
|
||||
Opener Opener
|
||||
// Template renders the layer's target-specific layout.
|
||||
Template Template
|
||||
// Options tune the layer's lifecycle loop. Zero values select the
|
||||
// defaults.
|
||||
Options Options
|
||||
}
|
||||
|
||||
// RunLayers drives every layer of a display target until ctx is cancelled,
|
||||
// each on its own connection with its own reconnect backoff. A layer whose
|
||||
// target keeps failing keeps retrying and never stops the other layers, so
|
||||
// the Ground Station supervisor stays up. RunLayers returns only after every
|
||||
// layer has stopped.
|
||||
func RunLayers(ctx context.Context, layers []Layer) {
|
||||
var wg sync.WaitGroup
|
||||
for _, layer := range layers {
|
||||
wg.Add(1)
|
||||
go func(layer Layer) {
|
||||
defer wg.Done()
|
||||
_ = Run(ctx, layer.Opener, layer.Template, layer.Options)
|
||||
}(layer)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func nextDelay(delay, max time.Duration) time.Duration {
|
||||
delay *= 2
|
||||
if delay > max {
|
||||
|
||||
@@ -242,6 +242,87 @@ func TestRunReconnectsAfterSessionFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLayersDrawsEveryLayerOnItsOwnCanvas(t *testing.T) {
|
||||
canvasSession := newFakeSession()
|
||||
osdSession := newFakeSession()
|
||||
layers := []Layer{
|
||||
{
|
||||
Name: "fallback-canvas",
|
||||
Opener: fakeOpener{open: func(int) (Session, error) { return canvasSession, nil }},
|
||||
Template: NewStatusTemplate("canvas"),
|
||||
},
|
||||
{
|
||||
Name: "osd-overlay",
|
||||
Opener: fakeOpener{open: func(int) (Session, error) { return osdSession, nil }},
|
||||
Template: NewOSDTemplate(NewVideoPresence()),
|
||||
},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
RunLayers(ctx, layers)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
waitFor(t, func() bool { return canvasSession.canvas.lineCount() == 1 }, "canvas layer draw")
|
||||
waitFor(t, func() bool {
|
||||
clears, _ := osdSession.canvas.drawCounts()
|
||||
return clears == 1
|
||||
}, "osd layer transparent clear")
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("run layers did not stop after cancel")
|
||||
}
|
||||
if got := canvasSession.closeCount(); got != 1 {
|
||||
t.Fatalf("canvas session closed %d times, want 1", got)
|
||||
}
|
||||
if got := osdSession.closeCount(); got != 1 {
|
||||
t.Fatalf("osd session closed %d times, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLayersKeepsOtherLayerAliveWhenOneTargetFails(t *testing.T) {
|
||||
alive := newFakeSession()
|
||||
layers := []Layer{
|
||||
{
|
||||
Name: "dead",
|
||||
Opener: fakeOpener{open: func(int) (Session, error) { return nil, errors.New("target down") }},
|
||||
Template: NewStatusTemplate("never drawn"),
|
||||
},
|
||||
{
|
||||
Name: "alive",
|
||||
Opener: fakeOpener{open: func(int) (Session, error) { return alive, nil }},
|
||||
Template: NewOSDTemplate(NewVideoPresence()),
|
||||
},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
RunLayers(ctx, layers)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
waitFor(t, func() bool {
|
||||
clears, _ := alive.canvas.drawCounts()
|
||||
return clears == 1
|
||||
}, "alive layer keeps drawing while the other target fails")
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("run layers did not stop after cancel")
|
||||
}
|
||||
if got := alive.closeCount(); got != 1 {
|
||||
t.Fatalf("alive session closed %d times, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStopsWithoutConnectingWhenContextAlreadyCancelled(t *testing.T) {
|
||||
var opens int32
|
||||
opener := fakeOpener{open: func(int) (Session, error) {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package display
|
||||
|
||||
// OSDTemplate is the flight OSD layout of a display target: a transparent,
|
||||
// input-transparent surface above pilot video, reserved as the future OSD
|
||||
// rendering surface. It presents nothing yet and never draws the fallback:
|
||||
// the OSD is a telemetry surface, not a video substitute, and WFB or video
|
||||
// decode never move into the UI process to feed it. Flight telemetry
|
||||
// rendering arrives in later Ground Station work; the shared presence keeps
|
||||
// the OSD on the same video lifecycle as the fallback canvas, so the session
|
||||
// redraws it on every presence transition.
|
||||
type OSDTemplate struct {
|
||||
video *VideoPresence
|
||||
}
|
||||
|
||||
// NewOSDTemplate builds the OSD surface for a target.
|
||||
func NewOSDTemplate(video *VideoPresence) OSDTemplate {
|
||||
return OSDTemplate{video: video}
|
||||
}
|
||||
|
||||
func (t OSDTemplate) Draw(canvas Canvas) {
|
||||
canvas.Clear()
|
||||
canvas.Flush()
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package display
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestOSDTemplateStaysTransparentWhileVideoAbsent(t *testing.T) {
|
||||
canvas := &fakeCanvas{width: 800, height: 600}
|
||||
|
||||
NewOSDTemplate(NewVideoPresence()).Draw(canvas)
|
||||
|
||||
clears, flushes := canvas.drawCounts()
|
||||
if clears != 1 || flushes != 1 {
|
||||
t.Fatalf("clears=%d flushes=%d want 1 and 1", clears, flushes)
|
||||
}
|
||||
if lines := canvas.lines(); len(lines) != 0 {
|
||||
t.Fatalf("OSD lines=%q want none: the OSD is a telemetry surface, not a fallback", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOSDTemplateStaysTransparentWhileVideoPresent(t *testing.T) {
|
||||
canvas := &fakeCanvas{width: 800, height: 600}
|
||||
presence := NewVideoPresence()
|
||||
presence.SetActive(true)
|
||||
|
||||
NewOSDTemplate(presence).Draw(canvas)
|
||||
|
||||
if lines := canvas.lines(); len(lines) != 0 {
|
||||
t.Fatalf("OSD lines=%q want none while video is present", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOSDTemplateNeverDrawsTheFallbackText(t *testing.T) {
|
||||
canvas := &fakeCanvas{width: 800, height: 600}
|
||||
presence := NewVideoPresence()
|
||||
template := NewOSDTemplate(presence)
|
||||
|
||||
for _, active := range []bool{false, true, false} {
|
||||
presence.SetActive(active)
|
||||
template.Draw(canvas)
|
||||
if lines := canvas.lines(); len(lines) != 0 {
|
||||
t.Fatalf("lines=%q after presence=%v want none", lines, active)
|
||||
}
|
||||
}
|
||||
}
|
||||
+289
-53
@@ -11,6 +11,7 @@ import (
|
||||
"unicode/utf16"
|
||||
|
||||
"github.com/BurntSushi/xgb"
|
||||
"github.com/BurntSushi/xgb/shape"
|
||||
"github.com/BurntSushi/xgb/xproto"
|
||||
)
|
||||
|
||||
@@ -26,8 +27,93 @@ const (
|
||||
maxImageText16Units = 127
|
||||
// windowTypePropertyName is the EWMH property naming a window's role.
|
||||
windowTypePropertyName = "_NET_WM_WINDOW_TYPE"
|
||||
// layerMarkerPropertyName tags Ground Station layer windows so sibling
|
||||
// layers — and their window-manager frames — never count as video
|
||||
// output for each other.
|
||||
layerMarkerPropertyName = "_GROUNDSTATION_LAYER"
|
||||
)
|
||||
|
||||
// osdEventMask selects only repaint bookkeeping for the OSD overlay: no
|
||||
// pointer, key or motion events, so the server never routes device input to
|
||||
// the overlay even before its empty input shape is applied.
|
||||
const osdEventMask = xproto.EventMaskExposure
|
||||
|
||||
// deviceInputEventMask are the pointer and key selection bits a layer above
|
||||
// video must never select: device input belongs to the windows below.
|
||||
const deviceInputEventMask = xproto.EventMaskKeyPress | xproto.EventMaskKeyRelease |
|
||||
xproto.EventMaskButtonPress | xproto.EventMaskButtonRelease |
|
||||
xproto.EventMaskPointerMotion
|
||||
|
||||
// LayerRole orders a Ground Station window against presented video output.
|
||||
// The fallback/menu canvas stays below video; the OSD overlay stays above.
|
||||
type LayerRole int
|
||||
|
||||
const (
|
||||
// RoleBelow marks the fullscreen fallback/menu canvas under video.
|
||||
RoleBelow LayerRole = iota
|
||||
// RoleAbove marks the transparent, input-transparent OSD overlay above
|
||||
// video.
|
||||
RoleAbove
|
||||
)
|
||||
|
||||
// layerMarkerValue is the layer marker payload identifying a role's window
|
||||
// as our own.
|
||||
func layerMarkerValue(role LayerRole) string {
|
||||
if role == RoleAbove {
|
||||
return "osd"
|
||||
}
|
||||
return "kiosk"
|
||||
}
|
||||
|
||||
// markerIsGroundstation reports whether a layer marker payload marks one of
|
||||
// our own windows: those are Ground Station layers, never video output.
|
||||
func markerIsGroundstation(marker string) bool {
|
||||
return marker == layerMarkerValue(RoleBelow) || marker == layerMarkerValue(RoleAbove)
|
||||
}
|
||||
|
||||
// ewmhWindowTypeName is the EWMH window role announced for a layer. The OSD
|
||||
// overlay announces DOCK, the always-above helper role, so window managers
|
||||
// never decorate or bury it; the fallback canvas keeps announcing nothing,
|
||||
// as before.
|
||||
func ewmhWindowTypeName(role LayerRole) string {
|
||||
if role == RoleAbove {
|
||||
return "_NET_WM_WINDOW_TYPE_DOCK"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// fullscreenState is the wmctrl stacking state for a layer role.
|
||||
func fullscreenState(role LayerRole) string {
|
||||
if role == RoleAbove {
|
||||
return "above"
|
||||
}
|
||||
return "below"
|
||||
}
|
||||
|
||||
// stackModeForVideoAppear is where a layer goes relative to the first
|
||||
// presented video window: the canvas parks below it, the OSD rises above it.
|
||||
func stackModeForVideoAppear(role LayerRole) uint32 {
|
||||
if role == RoleAbove {
|
||||
return xproto.StackModeAbove
|
||||
}
|
||||
return xproto.StackModeBelow
|
||||
}
|
||||
|
||||
// raisesOnVideoEnd reports whether a layer raises itself when video ends:
|
||||
// only the below canvas needs that to make the fallback visible again; the
|
||||
// OSD overlay is already above everything.
|
||||
func raisesOnVideoEnd(role LayerRole) bool {
|
||||
return role == RoleBelow
|
||||
}
|
||||
|
||||
// transparentShapeKinds are the shape regions cleared to empty so the OSD
|
||||
// overlay is transparent and passes input through: an empty input shape
|
||||
// never receives pointer or key events, and an empty bounding shape presents
|
||||
// nothing over the video below — with or without a compositor.
|
||||
func transparentShapeKinds() []shape.Kind {
|
||||
return []shape.Kind{shape.SkInput, shape.SkBounding}
|
||||
}
|
||||
|
||||
// ewmhNonVideoTypes are EWMH auxiliary window roles that never count as
|
||||
// video output: desktop shells, panels, menus, dialogs and notifications
|
||||
// must not suppress the fallback. Windows without the property — typical
|
||||
@@ -48,22 +134,27 @@ var ewmhNonVideoTypes = map[string]bool{
|
||||
}
|
||||
|
||||
// X11Opener connects to an X server (selected by DISPLAY in the environment)
|
||||
// and creates the fullscreen kiosk canvas. It is the display target for the
|
||||
// Viture screen; future targets such as MIPI/touch provide their own Opener
|
||||
// and templates.
|
||||
// and maps one fullscreen kiosk window for the layer role it is built with:
|
||||
// the fallback/menu canvas below video (RoleBelow) or the transparent,
|
||||
// input-transparent OSD overlay above video (RoleAbove). It is the display
|
||||
// target for the Viture screen; future targets such as MIPI/touch provide
|
||||
// their own Opener and templates.
|
||||
type X11Opener struct {
|
||||
// PollInterval is how often X events are drained while waiting, so that
|
||||
// cancellation stays responsive. Zero selects 100 ms.
|
||||
PollInterval time.Duration
|
||||
// Fullscreen applies the fullscreen/below window state. It defaults to
|
||||
// Fullscreen applies the fullscreen window state. It defaults to
|
||||
// shelling out to wmctrl; tests may replace it. A failure is logged and
|
||||
// the canvas degrades to a plain window.
|
||||
// the window degrades to a plain window.
|
||||
Fullscreen func(args []string) error
|
||||
// Video is the shared decoded-video presence signal. The session feeds
|
||||
// it from the video output window's own lifecycle on the root screen;
|
||||
// templates consume it to show or hide the fallback. When nil the
|
||||
// opener tracks presence in a private signal.
|
||||
// templates consume it to show or hide themselves. When nil the opener
|
||||
// tracks presence in a private signal.
|
||||
Video *VideoPresence
|
||||
// Role orders this layer's window against presented video: RoleBelow
|
||||
// for the fallback/menu canvas, RoleAbove for the OSD overlay.
|
||||
Role LayerRole
|
||||
}
|
||||
|
||||
func (o X11Opener) presence() *VideoPresence {
|
||||
@@ -102,11 +193,46 @@ func (o X11Opener) Open(ctx context.Context) (Session, error) {
|
||||
return nil, fmt.Errorf("allocate window: %w", err)
|
||||
}
|
||||
|
||||
// The OSD overlay selects no device input even before its empty input
|
||||
// shape exists; the canvas keeps its legacy exposure-only mask.
|
||||
eventMask := uint32(xproto.EventMaskExposure)
|
||||
if o.Role == RoleAbove {
|
||||
eventMask = osdEventMask
|
||||
}
|
||||
values := uint32(xproto.CwBackPixel | xproto.CwEventMask)
|
||||
xproto.CreateWindow(conn, screen.RootDepth, window, screen.Root,
|
||||
0, 0, screen.WidthInPixels, screen.HeightInPixels, 0,
|
||||
xproto.WindowClassInputOutput, screen.RootVisual, values,
|
||||
[]uint32{screen.BlackPixel, xproto.EventMaskExposure})
|
||||
[]uint32{screen.BlackPixel, eventMask})
|
||||
// Tag the window as a Ground Station layer and announce its EWMH role
|
||||
// before mapping, so no observer or window manager ever sees an
|
||||
// untagged frame of our own layers.
|
||||
markerAtom := internAtom(conn, layerMarkerPropertyName)
|
||||
if markerAtom != 0 {
|
||||
marker := layerMarkerValue(o.Role)
|
||||
xproto.ChangeProperty(conn, xproto.PropModeReplace, window, markerAtom,
|
||||
xproto.AtomString, 8, uint32(len(marker)), []byte(marker))
|
||||
}
|
||||
if name := ewmhWindowTypeName(o.Role); name != "" {
|
||||
typeProp := internAtom(conn, windowTypePropertyName)
|
||||
roleAtom := internAtom(conn, name)
|
||||
if typeProp != 0 && roleAtom != 0 {
|
||||
value := make([]byte, 4)
|
||||
binary.LittleEndian.PutUint32(value, uint32(roleAtom))
|
||||
xproto.ChangeProperty(conn, xproto.PropModeReplace, window, typeProp,
|
||||
xproto.AtomAtom, 32, 1, value)
|
||||
}
|
||||
}
|
||||
// The OSD overlay must be transparent and input-transparent from its
|
||||
// first mapped frame: the shapes are emptied before mapping, and a
|
||||
// server without the shape extension never gets the overlay at all, so
|
||||
// it can never present an unshaped frame over pilot video.
|
||||
if o.Role == RoleAbove {
|
||||
if err := applyTransparentShapes(conn, window); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
xproto.MapWindow(conn, window)
|
||||
// Watch the root screen so video output announces itself: a video
|
||||
// window mapping is the first presented video frame, its unmap or
|
||||
@@ -119,26 +245,27 @@ func (o X11Opener) Open(ctx context.Context) (Session, error) {
|
||||
return nil, fmt.Errorf("map kiosk window: %w", err)
|
||||
}
|
||||
|
||||
// A kiosk restart must never cover running video: if video windows are
|
||||
// already presented, record presence and hand rendering priority to the
|
||||
// topmost one before the fallback is drawn anywhere.
|
||||
// A layer reconnect must never cover running video: if video windows
|
||||
// are already presented, record presence and take our side relative to
|
||||
// the topmost one before anything is drawn.
|
||||
presence := o.presence()
|
||||
typeAtom := internWindowTypeAtom(conn)
|
||||
videoWindows := scanVideoWindows(conn, screen.Root, window, typeAtom)
|
||||
videoWindows := scanVideoWindows(conn, screen.Root, window, typeAtom, markerAtom)
|
||||
if len(videoWindows) > 0 {
|
||||
presence.SetActive(true)
|
||||
stackWindowBelow(conn, window, videoWindows[len(videoWindows)-1])
|
||||
stackWindowRelative(conn, window, videoWindows[len(videoWindows)-1],
|
||||
stackModeForVideoAppear(o.Role))
|
||||
} else {
|
||||
presence.SetActive(false)
|
||||
}
|
||||
|
||||
// Fullscreen on the root screen, and kept below the video window: pilot
|
||||
// video owns rendering priority whenever it is available. The kiosk
|
||||
// raises itself only after video ends, to bring the fallback back; a
|
||||
// missing wmctrl only degrades to a plain window and must not kill the
|
||||
// kiosk.
|
||||
if err := o.applyFullscreen(x11FullscreenArgs(uint32(window))); err != nil {
|
||||
log.Printf("kiosk fullscreen state: %v (canvas stays a plain window)", err)
|
||||
// Fullscreen on the root screen, on our side of the video window: pilot
|
||||
// video owns rendering priority whenever it is available. The canvas
|
||||
// raises itself only after video ends, to bring the fallback back; the
|
||||
// OSD overlay is already above video. A missing wmctrl only degrades to
|
||||
// a plain window and must not kill the layer.
|
||||
if err := o.applyFullscreen(x11FullscreenArgs(uint32(window), o.Role)); err != nil {
|
||||
log.Printf("kiosk fullscreen state: %v (window stays a plain window)", err)
|
||||
}
|
||||
|
||||
gc, err := xproto.NewGcontextId(conn)
|
||||
@@ -171,6 +298,8 @@ func (o X11Opener) Open(ctx context.Context) (Session, error) {
|
||||
video: presence,
|
||||
videoWindows: videoWindowSet(videoWindows),
|
||||
typeAtom: typeAtom,
|
||||
markerAtom: markerAtom,
|
||||
role: o.Role,
|
||||
unicodeFont: unicodeFont,
|
||||
}, nil
|
||||
}
|
||||
@@ -183,53 +312,152 @@ func videoWindowSet(windows []xproto.Window) map[xproto.Window]struct{} {
|
||||
return set
|
||||
}
|
||||
|
||||
func x11FullscreenArgs(window uint32) []string {
|
||||
return []string{"-i", "-r", fmt.Sprintf("0x%08x", window), "-b", "add,fullscreen,below"}
|
||||
func x11FullscreenArgs(window uint32, role LayerRole) []string {
|
||||
return []string{"-i", "-r", fmt.Sprintf("0x%08x", window),
|
||||
"-b", "add,fullscreen," + fullscreenState(role)}
|
||||
}
|
||||
|
||||
// applyTransparentShapes empties the overlay's input and bounding shapes
|
||||
// before it is mapped: it then never intercepts pointer or key input and
|
||||
// never presents pixels over pilot video, with or without a compositor.
|
||||
// Future OSD content will punch its output shape per drawn region.
|
||||
func applyTransparentShapes(conn *xgb.Conn, window xproto.Window) error {
|
||||
if err := shape.Init(conn); err != nil {
|
||||
return fmt.Errorf("shape extension: %w", err)
|
||||
}
|
||||
for _, kind := range transparentShapeKinds() {
|
||||
if err := shape.RectanglesChecked(conn, shape.SoSet, kind, 0,
|
||||
window, 0, 0, nil).Check(); err != nil {
|
||||
return fmt.Errorf("empty shape region %d: %w", kind, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanVideoWindows lists already-presented video windows on the root screen
|
||||
// in bottom-to-top stacking order, so a kiosk reconnect can yield priority
|
||||
// to the topmost one and never cover running video.
|
||||
func scanVideoWindows(conn *xgb.Conn, root, own xproto.Window, typeAtom xproto.Atom) []xproto.Window {
|
||||
// in bottom-to-top stacking order, so a layer reconnect can take its side
|
||||
// relative to the topmost one and never cover running video.
|
||||
func scanVideoWindows(conn *xgb.Conn, root, own xproto.Window, typeAtom, markerAtom xproto.Atom) []xproto.Window {
|
||||
tree, err := xproto.QueryTree(conn, root).Reply()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var windows []xproto.Window
|
||||
for _, w := range tree.Children {
|
||||
if w != own && presentedVideoWindow(conn, w, typeAtom) {
|
||||
if w != own && presentedVideoWindow(conn, w, typeAtom, markerAtom) {
|
||||
windows = append(windows, w)
|
||||
}
|
||||
}
|
||||
return windows
|
||||
}
|
||||
|
||||
// presentedVideoWindow reports whether w is a visible output window. Only a
|
||||
// real, presented window counts as video: input-only and override-redirect
|
||||
// helpers such as menus or notifications never do, and neither do windows
|
||||
// carrying an EWMH auxiliary window type.
|
||||
func presentedVideoWindow(conn *xgb.Conn, w xproto.Window, typeAtom xproto.Atom) bool {
|
||||
// windowFacts is the presentation evidence gathered for one candidate
|
||||
// window, so the video decision stays a pure function.
|
||||
type windowFacts struct {
|
||||
inputOutput bool
|
||||
overrideRedirect bool
|
||||
viewable bool
|
||||
auxiliary bool
|
||||
groundstation bool
|
||||
}
|
||||
|
||||
// isPresentedVideoWindow decides whether a window is video output. Only a
|
||||
// real, presented output window counts as video: input-only and
|
||||
// override-redirect helpers such as menus or notifications never do, and
|
||||
// neither do windows carrying an EWMH auxiliary window type or a Ground
|
||||
// Station layer marker.
|
||||
func isPresentedVideoWindow(f windowFacts) bool {
|
||||
return f.inputOutput && !f.overrideRedirect && f.viewable &&
|
||||
!f.auxiliary && !f.groundstation
|
||||
}
|
||||
|
||||
// presentedVideoWindow gathers the presentation facts of w.
|
||||
func presentedVideoWindow(conn *xgb.Conn, w xproto.Window, typeAtom, markerAtom xproto.Atom) bool {
|
||||
attrs, err := xproto.GetWindowAttributes(conn, w).Reply()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return attrs.Class == xproto.WindowClassInputOutput &&
|
||||
!attrs.OverrideRedirect &&
|
||||
attrs.MapState == xproto.MapStateViewable &&
|
||||
!auxiliaryWindowType(conn, w, typeAtom)
|
||||
return isPresentedVideoWindow(windowFacts{
|
||||
inputOutput: attrs.Class == xproto.WindowClassInputOutput,
|
||||
overrideRedirect: attrs.OverrideRedirect,
|
||||
viewable: attrs.MapState == xproto.MapStateViewable,
|
||||
auxiliary: auxiliaryWindowType(conn, w, typeAtom),
|
||||
groundstation: groundstationLayerWindow(conn, w, markerAtom),
|
||||
})
|
||||
}
|
||||
|
||||
// internWindowTypeAtom resolves the EWMH window type property atom, or 0
|
||||
// when the server refuses, disabling the auxiliary type filter.
|
||||
func internWindowTypeAtom(conn *xgb.Conn) xproto.Atom {
|
||||
// markerWalkBounds bound the descendant search for layer markers: window
|
||||
// managers reparent client windows into frame windows, so a Ground Station
|
||||
// layer must be recognized through its frame, and the search must stay
|
||||
// cheap even on a busy desktop screen. depth must cover our window below
|
||||
// one window-manager frame; nodes bounds the total windows inspected.
|
||||
func markerWalkBounds() (depth, nodes int) {
|
||||
return 4, 64
|
||||
}
|
||||
|
||||
// groundstationLayerWindow reports whether w itself or any window below it
|
||||
// carries our layer marker. Window managers frame our windows, so the
|
||||
// marker must be found through frame windows too: otherwise a framed
|
||||
// Ground Station layer could count as video output and permanently
|
||||
// suppress the fallback. When the search hits its bounds or the server
|
||||
// refuses, it reports not-found, degrading to the plain window checks.
|
||||
func groundstationLayerWindow(conn *xgb.Conn, w xproto.Window, markerAtom xproto.Atom) bool {
|
||||
if markerAtom == 0 {
|
||||
return false
|
||||
}
|
||||
depth, nodes := markerWalkBounds()
|
||||
visited := 0
|
||||
var walk func(xproto.Window, int) bool
|
||||
walk = func(w xproto.Window, depth int) bool {
|
||||
if visited >= nodes {
|
||||
return false
|
||||
}
|
||||
visited++
|
||||
if carriesLayerMarker(conn, w, markerAtom) {
|
||||
return true
|
||||
}
|
||||
if depth <= 0 {
|
||||
return false
|
||||
}
|
||||
tree, err := xproto.QueryTree(conn, w).Reply()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, child := range tree.Children {
|
||||
if walk(child, depth-1) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return walk(w, depth)
|
||||
}
|
||||
|
||||
// carriesLayerMarker reports whether w itself carries our layer marker.
|
||||
func carriesLayerMarker(conn *xgb.Conn, w xproto.Window, markerAtom xproto.Atom) bool {
|
||||
reply, err := xproto.GetProperty(conn, false, w, markerAtom,
|
||||
xproto.GetPropertyTypeAny, 0, 64).Reply()
|
||||
if err != nil || reply.ValueLen == 0 {
|
||||
return false
|
||||
}
|
||||
return markerIsGroundstation(string(reply.Value))
|
||||
}
|
||||
|
||||
// internAtom resolves an atom by name, or 0 when the server refuses,
|
||||
// disabling the checks that would use it.
|
||||
func internAtom(conn *xgb.Conn, name string) xproto.Atom {
|
||||
reply, err := xproto.InternAtom(conn, false,
|
||||
uint16(len(windowTypePropertyName)), windowTypePropertyName).Reply()
|
||||
uint16(len(name)), name).Reply()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return reply.Atom
|
||||
}
|
||||
|
||||
func internWindowTypeAtom(conn *xgb.Conn) xproto.Atom {
|
||||
return internAtom(conn, windowTypePropertyName)
|
||||
}
|
||||
|
||||
// auxiliaryWindowType reports whether the window carries an EWMH window
|
||||
// type naming a non-video helper role. A missing property means the window
|
||||
// counts as video.
|
||||
@@ -255,10 +483,11 @@ func auxiliaryWindowType(conn *xgb.Conn, w xproto.Window, typeAtom xproto.Atom)
|
||||
return false
|
||||
}
|
||||
|
||||
func stackWindowBelow(conn *xgb.Conn, window, sibling xproto.Window) {
|
||||
// stackWindowRelative moves window to the given side of sibling.
|
||||
func stackWindowRelative(conn *xgb.Conn, window, sibling xproto.Window, mode uint32) {
|
||||
xproto.ConfigureWindow(conn, window,
|
||||
xproto.ConfigWindowSibling|xproto.ConfigWindowStackMode,
|
||||
[]uint32{uint32(sibling), xproto.StackModeBelow})
|
||||
[]uint32{uint32(sibling), mode})
|
||||
}
|
||||
|
||||
// videoChange is the video-lifecycle meaning of one root structure event.
|
||||
@@ -328,6 +557,8 @@ type x11Session struct {
|
||||
video *VideoPresence
|
||||
videoWindows map[xproto.Window]struct{}
|
||||
typeAtom xproto.Atom
|
||||
markerAtom xproto.Atom
|
||||
role LayerRole
|
||||
unicodeFont bool
|
||||
}
|
||||
|
||||
@@ -395,34 +626,39 @@ func (s *x11Session) WaitEvent(ctx context.Context) (bool, error) {
|
||||
}
|
||||
|
||||
// applyVideoChange folds one root structure event into the shared video
|
||||
// presence and restacks the kiosk so video keeps rendering priority. It
|
||||
// reports whether the template must be redrawn: video appearing makes the
|
||||
// fallback disappear and video ending brings it back. Presence follows the
|
||||
// set of presented video windows, so an unrelated window appearing or
|
||||
// ending can never hide or resurrect the fallback on its own.
|
||||
// presence and restacks this layer so video keeps rendering priority: the
|
||||
// canvas parks below video and the OSD overlay rises above it. It reports
|
||||
// whether the template must be redrawn: video appearing makes the fallback
|
||||
// disappear and video ending brings it back. Presence follows the set of
|
||||
// presented video windows, so an unrelated window appearing or ending can
|
||||
// never hide or resurrect a template on its own.
|
||||
func (s *x11Session) applyVideoChange(event xgb.Event) bool {
|
||||
change, w := classifyVideoChange(event, s.window)
|
||||
switch change {
|
||||
case videoAppeared:
|
||||
// Menus, notifications and other auxiliary windows never count as
|
||||
// video; only a presented output window does.
|
||||
if !presentedVideoWindow(s.conn, w, s.typeAtom) {
|
||||
// Menus, notifications, other auxiliary windows and our own sibling
|
||||
// layers never count as video; only a presented output window does.
|
||||
if !presentedVideoWindow(s.conn, w, s.typeAtom, s.markerAtom) {
|
||||
return false
|
||||
}
|
||||
if !addVideoWindow(s.videoWindows, w) {
|
||||
return false
|
||||
}
|
||||
// The first presented video output takes priority: park below it.
|
||||
stackWindowBelow(s.conn, s.window, w)
|
||||
// The first presented video output takes priority: park on our side
|
||||
// of it.
|
||||
stackWindowRelative(s.conn, s.window, w, stackModeForVideoAppear(s.role))
|
||||
return s.video.SetActive(true)
|
||||
case videoDisappeared:
|
||||
if !removeVideoWindow(s.videoWindows, w) {
|
||||
return false
|
||||
}
|
||||
// Raise the kiosk so the fallback returns visibly even if some
|
||||
// other window was stacked above it.
|
||||
// Only the below canvas raises itself so the fallback returns
|
||||
// visibly even if some other window was stacked above it; the OSD
|
||||
// overlay is already above video.
|
||||
if raisesOnVideoEnd(s.role) {
|
||||
xproto.ConfigureWindow(s.conn, s.window,
|
||||
xproto.ConfigWindowStackMode, []uint32{xproto.StackModeAbove})
|
||||
}
|
||||
return s.video.SetActive(false)
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -5,18 +5,25 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/BurntSushi/xgb/shape"
|
||||
"github.com/BurntSushi/xgb/xproto"
|
||||
)
|
||||
|
||||
func TestX11FullscreenArgsKeepCanvasBelowVideo(t *testing.T) {
|
||||
args := x11FullscreenArgs(0x1a2b)
|
||||
|
||||
joined := strings.Join(args, " ")
|
||||
if !strings.Contains(joined, "add,fullscreen,below") {
|
||||
t.Fatalf("args %v must request fullscreen with below stacking", args)
|
||||
func TestX11FullscreenArgsOrderByRole(t *testing.T) {
|
||||
below := strings.Join(x11FullscreenArgs(0x1a2b, RoleBelow), " ")
|
||||
if !strings.Contains(below, "add,fullscreen,below") {
|
||||
t.Fatalf("args %q must request fullscreen with below stacking", below)
|
||||
}
|
||||
if !strings.Contains(joined, "0x00001a2b") {
|
||||
t.Fatalf("args %v must reference the kiosk window id", args)
|
||||
if !strings.Contains(below, "0x00001a2b") {
|
||||
t.Fatalf("args %q must reference the kiosk window id", below)
|
||||
}
|
||||
|
||||
above := strings.Join(x11FullscreenArgs(0x1a2b, RoleAbove), " ")
|
||||
if strings.Contains(above, "below") {
|
||||
t.Fatalf("args %q must never stack the OSD overlay below video", above)
|
||||
}
|
||||
if !strings.Contains(above, "add,fullscreen,above") {
|
||||
t.Fatalf("args %q must request fullscreen with above stacking", above)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +43,7 @@ func TestX11OpenerAppliesFullscreenArgsThroughHook(t *testing.T) {
|
||||
return errors.New("wmctrl missing")
|
||||
}}
|
||||
|
||||
if err := opener.applyFullscreen(x11FullscreenArgs(0x42)); err == nil {
|
||||
if err := opener.applyFullscreen(x11FullscreenArgs(0x42, RoleBelow)); err == nil {
|
||||
t.Fatal("hook error must propagate for logging")
|
||||
}
|
||||
if len(got) != 1 || !strings.Contains(strings.Join(got[0], " "), "add,fullscreen,below") {
|
||||
@@ -189,3 +196,104 @@ func TestX11FontPatternRequestsUnicodeFixedFont(t *testing.T) {
|
||||
t.Fatalf("font pattern %q must select a Unicode encoding for the exact fallback text", iso10646FontPattern)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayerMarkerIdentifiesOwnLayerWindows(t *testing.T) {
|
||||
if layerMarkerValue(RoleBelow) == layerMarkerValue(RoleAbove) {
|
||||
t.Fatal("roles must carry distinct marker payloads")
|
||||
}
|
||||
for _, role := range []LayerRole{RoleBelow, RoleAbove} {
|
||||
if !markerIsGroundstation(layerMarkerValue(role)) {
|
||||
t.Fatalf("marker %q must identify our own window", layerMarkerValue(role))
|
||||
}
|
||||
}
|
||||
for _, foreign := range []string{"", "video", "desktop", "groundstation-fake"} {
|
||||
if markerIsGroundstation(foreign) {
|
||||
t.Fatalf("foreign marker payload %q must not identify our windows", foreign)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkerWalkBoundsStayBoundedAndCoverFrameDepth(t *testing.T) {
|
||||
depth, nodes := markerWalkBounds()
|
||||
if depth <= 0 || nodes <= 0 {
|
||||
t.Fatalf("marker walk bounds depth=%d nodes=%d must be positive", depth, nodes)
|
||||
}
|
||||
if nodes < depth {
|
||||
t.Fatalf("node budget %d must cover at least a depth-%d ancestor chain", nodes, depth)
|
||||
}
|
||||
// A reparenting window manager puts each of our two layer windows inside
|
||||
// its own frame, so the walk must reach our window below one frame.
|
||||
if depth < 2 {
|
||||
t.Fatalf("marker walk depth %d must cover our window below a window-manager frame", depth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEWMHWindowTypeAnnouncesOverlayRoleOnly(t *testing.T) {
|
||||
if name := ewmhWindowTypeName(RoleAbove); !strings.Contains(name, "DOCK") {
|
||||
t.Fatalf("OSD overlay window type %q must be the always-above helper role", name)
|
||||
}
|
||||
if name := ewmhWindowTypeName(RoleBelow); name != "" {
|
||||
t.Fatalf("fallback canvas must keep announcing no window type, got %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresentedVideoDecisionExcludesOwnLayersAndHelpers(t *testing.T) {
|
||||
video := windowFacts{inputOutput: true, viewable: true}
|
||||
if !isPresentedVideoWindow(video) {
|
||||
t.Fatal("a presented output window must count as video")
|
||||
}
|
||||
for name, facts := range map[string]windowFacts{
|
||||
"input-only": {viewable: true},
|
||||
"override-redirect": {inputOutput: true, viewable: true, overrideRedirect: true},
|
||||
"unmapped": {inputOutput: true},
|
||||
"auxiliary": {inputOutput: true, viewable: true, auxiliary: true},
|
||||
"groundstation-canvas": {inputOutput: true, viewable: true, groundstation: true},
|
||||
"groundstation-osd": {inputOutput: true, viewable: true, groundstation: true},
|
||||
} {
|
||||
if isPresentedVideoWindow(facts) {
|
||||
t.Fatalf("%s window must never count as video", name)
|
||||
}
|
||||
}
|
||||
// Window-manager frames around our layer windows carry no marker of
|
||||
// their own; groundstationLayerWindow finds the marker below the frame,
|
||||
// so framed Ground Station layers land in these same excluded facts.
|
||||
}
|
||||
|
||||
func TestStackOrderByRoleKeepsLayersOnTheirSideOfVideo(t *testing.T) {
|
||||
if mode := stackModeForVideoAppear(RoleBelow); mode != xproto.StackModeBelow {
|
||||
t.Fatalf("canvas must park below appearing video, got %v", mode)
|
||||
}
|
||||
if mode := stackModeForVideoAppear(RoleAbove); mode != xproto.StackModeAbove {
|
||||
t.Fatalf("OSD overlay must rise above appearing video, got %v", mode)
|
||||
}
|
||||
if !raisesOnVideoEnd(RoleBelow) {
|
||||
t.Fatal("canvas must raise itself when video ends so the fallback returns")
|
||||
}
|
||||
if raisesOnVideoEnd(RoleAbove) {
|
||||
t.Fatal("OSD overlay is already above video and must not restack on video end")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOSDEventMaskNeverSelectsDeviceInput(t *testing.T) {
|
||||
if osdEventMask&deviceInputEventMask != 0 {
|
||||
t.Fatalf("OSD event mask must not select pointer or key input: %b", osdEventMask)
|
||||
}
|
||||
if osdEventMask&xproto.EventMaskExposure == 0 {
|
||||
t.Fatal("OSD event mask must still select exposure for redraws")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransparentShapesEmptyInputAndOutput(t *testing.T) {
|
||||
var input, output bool
|
||||
for _, kind := range transparentShapeKinds() {
|
||||
switch kind {
|
||||
case shape.SkInput:
|
||||
input = true
|
||||
case shape.SkBounding:
|
||||
output = true
|
||||
}
|
||||
}
|
||||
if !input || !output {
|
||||
t.Fatalf("shape kinds %v must empty both the input and the bounding region", transparentShapeKinds())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user