feat: show no-WFB kiosk fallback only while video absent (#2)
Build Ground Station / build (push) Successful in 44s
Build Ground Station / build (push) Successful in 44s
This commit was merged in pull request #9.
This commit is contained in:
@@ -8,7 +8,7 @@ Orange Pi Zero 3W application for the custom OpenIPC/WFB ground station.
|
||||
- validates protocol version, frame length and CRC-16;
|
||||
- logs final mixed channel values;
|
||||
- starts an independent kiosk display process that maps a fullscreen black canvas on `DISPLAY=:0` before video, tolerates X11 downtime without restarting the supervisor, and stops deterministically on shutdown;
|
||||
- video keeps rendering priority: the kiosk canvas stays below the `fpv-video` window, and future display targets (MIPI/touch) get their own target-specific templates instead of one global layout.
|
||||
- video keeps rendering priority: the kiosk canvas stays below the `fpv-video` window, shows the exact fallback text `Brak połączenia WFB` only while no video output is presented, and future display targets (MIPI/touch) get their own target-specific templates instead of one global layout.
|
||||
|
||||
The first baseline is intentionally small. It does **not** yet control an AUX, manipulate the flight-control link, or replace the primary video pipeline.
|
||||
|
||||
|
||||
@@ -18,10 +18,15 @@ import (
|
||||
|
||||
// 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) {
|
||||
switch name {
|
||||
case "x11":
|
||||
return display.X11Opener{}, display.NewStatusTemplate("Brak polaczenia WFB"), nil
|
||||
video := display.NewVideoPresence()
|
||||
return display.X11Opener{Video: video},
|
||||
display.NewFallbackTemplate(video, "Brak połączenia WFB"), nil
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("unknown display target %q (supported: x11)", name)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.piksar.eu/piotr/groundstation/internal/display"
|
||||
)
|
||||
|
||||
type recordingCanvas struct {
|
||||
width, height int
|
||||
lines []string
|
||||
clears, flushes int
|
||||
}
|
||||
|
||||
func (c *recordingCanvas) Size() (int, int) { return c.width, c.height }
|
||||
func (c *recordingCanvas) Clear() { c.clears++ }
|
||||
func (c *recordingCanvas) Text(_, _ int, message string) {
|
||||
c.lines = append(c.lines, message)
|
||||
}
|
||||
func (c *recordingCanvas) Flush() { c.flushes++ }
|
||||
|
||||
func TestBuildTargetWiresSharedVideoPresenceToFallback(t *testing.T) {
|
||||
opener, template, err := buildTarget("x11")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
x11, ok := opener.(display.X11Opener)
|
||||
if !ok {
|
||||
t.Fatalf("opener %T must be the X11 target", opener)
|
||||
}
|
||||
if x11.Video == nil {
|
||||
t.Fatal("x11 opener must observe the video presence signal")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTargetRejectsUnknownTarget(t *testing.T) {
|
||||
if _, _, err := buildTarget("hdmi-mirror"); err == nil {
|
||||
t.Fatal("unknown target must fail")
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
// 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. Each target (X11/Viture today, MIPI/touch
|
||||
// later) provides its own opener and templates instead of one global layout.
|
||||
// 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
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package display
|
||||
|
||||
// FallbackTemplate is the no-video kiosk layout: a cleared black canvas with
|
||||
// the fallback lines. While decoded video is present the fallback
|
||||
// disappears: the canvas stays plain black so the video window keeps
|
||||
// rendering priority without stale text around or behind it, and the exact
|
||||
// fallback lines return when video ends.
|
||||
type FallbackTemplate struct {
|
||||
fallback StatusTemplate
|
||||
video *VideoPresence
|
||||
}
|
||||
|
||||
// NewFallbackTemplate builds a template that draws the given fallback lines
|
||||
// only while the shared presence reports no video.
|
||||
func NewFallbackTemplate(video *VideoPresence, lines ...string) FallbackTemplate {
|
||||
return FallbackTemplate{fallback: NewStatusTemplate(lines...), video: video}
|
||||
}
|
||||
|
||||
func (t FallbackTemplate) Draw(canvas Canvas) {
|
||||
canvas.Clear()
|
||||
if !t.video.Active() {
|
||||
drawLines(canvas, t.fallback.lines)
|
||||
}
|
||||
canvas.Flush()
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package display
|
||||
|
||||
import "testing"
|
||||
|
||||
const wantFallbackText = "Brak połączenia WFB"
|
||||
|
||||
func TestFallbackTemplateShowsExactTextWhileVideoAbsent(t *testing.T) {
|
||||
canvas := &fakeCanvas{width: 800, height: 600}
|
||||
presence := NewVideoPresence()
|
||||
|
||||
NewFallbackTemplate(presence, wantFallbackText).Draw(canvas)
|
||||
|
||||
clears, flushes := canvas.drawCounts()
|
||||
if clears != 1 || flushes != 1 {
|
||||
t.Fatalf("clears=%d flushes=%d want 1 and 1", clears, flushes)
|
||||
}
|
||||
lines := canvas.lines()
|
||||
if len(lines) != 1 || lines[0] != wantFallbackText {
|
||||
t.Fatalf("lines=%q want [%q]", lines, wantFallbackText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallbackTemplateDisappearsWhileVideoPresent(t *testing.T) {
|
||||
canvas := &fakeCanvas{width: 800, height: 600}
|
||||
presence := NewVideoPresence()
|
||||
presence.SetActive(true)
|
||||
|
||||
NewFallbackTemplate(presence, wantFallbackText).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("video has priority: fallback lines %q must disappear", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallbackTemplateReturnsWhenVideoEnds(t *testing.T) {
|
||||
canvas := &fakeCanvas{width: 800, height: 600}
|
||||
presence := NewVideoPresence()
|
||||
presence.SetActive(true)
|
||||
template := NewFallbackTemplate(presence, wantFallbackText)
|
||||
|
||||
template.Draw(canvas)
|
||||
if lines := canvas.lines(); len(lines) != 0 {
|
||||
t.Fatalf("lines=%q must stay empty while video is present", lines)
|
||||
}
|
||||
|
||||
presence.SetActive(false)
|
||||
template.Draw(canvas)
|
||||
|
||||
lines := canvas.lines()
|
||||
if len(lines) != 1 || lines[0] != wantFallbackText {
|
||||
t.Fatalf("lines=%q want fallback [%q] after video end", lines, wantFallbackText)
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,15 @@ func NewStatusTemplate(lines ...string) StatusTemplate {
|
||||
|
||||
func (t StatusTemplate) Draw(canvas Canvas) {
|
||||
canvas.Clear()
|
||||
drawLines(canvas, t.lines)
|
||||
canvas.Flush()
|
||||
}
|
||||
|
||||
func drawLines(canvas Canvas, lines []string) {
|
||||
_, height := canvas.Size()
|
||||
y := height / 2
|
||||
for _, line := range t.lines {
|
||||
for _, line := range lines {
|
||||
canvas.Text(32, y, line)
|
||||
y += 32
|
||||
}
|
||||
canvas.Flush()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package display
|
||||
|
||||
import "sync/atomic"
|
||||
|
||||
// VideoPresence is the shared decoded-video signal between a display target
|
||||
// session and the templates it drives. Sessions update it from
|
||||
// display-server events about the video output itself: a presented video
|
||||
// window is presence, its unmap or destroy is video end. Nothing here
|
||||
// inspects WFB or launcher processes, so a running-but-silent receiver never
|
||||
// hides the fallback and the video pipeline is never touched.
|
||||
type VideoPresence struct {
|
||||
active atomic.Bool
|
||||
}
|
||||
|
||||
// NewVideoPresence starts with video absent, so the fallback is visible
|
||||
// before the first presented video output.
|
||||
func NewVideoPresence() *VideoPresence {
|
||||
return &VideoPresence{}
|
||||
}
|
||||
|
||||
// Active reports whether decoded video output is being presented.
|
||||
func (p *VideoPresence) Active() bool {
|
||||
return p.active.Load()
|
||||
}
|
||||
|
||||
// SetActive records video presence and reports whether the state changed, so
|
||||
// callers can trigger exactly one redraw per lifecycle transition.
|
||||
func (p *VideoPresence) SetActive(active bool) bool {
|
||||
return p.active.Swap(active) != active
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package display
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestVideoPresenceTransitionsReportChange(t *testing.T) {
|
||||
presence := NewVideoPresence()
|
||||
|
||||
if presence.Active() {
|
||||
t.Fatal("video must be absent before any signal")
|
||||
}
|
||||
if !presence.SetActive(true) {
|
||||
t.Fatal("first video appearance must report a change")
|
||||
}
|
||||
if !presence.Active() {
|
||||
t.Fatal("video must be active after appearance")
|
||||
}
|
||||
if presence.SetActive(true) {
|
||||
t.Fatal("repeated appearance must not report a change")
|
||||
}
|
||||
if !presence.SetActive(false) {
|
||||
t.Fatal("video end must report a change")
|
||||
}
|
||||
if presence.Active() {
|
||||
t.Fatal("video must be inactive after end")
|
||||
}
|
||||
}
|
||||
+348
-22
@@ -2,15 +2,51 @@ package display
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf16"
|
||||
|
||||
"github.com/BurntSushi/xgb"
|
||||
"github.com/BurntSushi/xgb/xproto"
|
||||
)
|
||||
|
||||
const (
|
||||
// iso10646FontPattern selects a Unicode-capable fixed font so the exact
|
||||
// fallback text keeps its diacritics on screen.
|
||||
iso10646FontPattern = "*fixed*iso10646-1"
|
||||
// asciiFontName is the degradation path when no Unicode font exists;
|
||||
// text drawn with it is folded to ASCII.
|
||||
asciiFontName = "fixed"
|
||||
// maxImageText16Units bounds one ImageText16 request to the protocol's
|
||||
// 1-byte string length (2 bytes per unit).
|
||||
maxImageText16Units = 127
|
||||
// windowTypePropertyName is the EWMH property naming a window's role.
|
||||
windowTypePropertyName = "_NET_WM_WINDOW_TYPE"
|
||||
)
|
||||
|
||||
// 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
|
||||
// for plain video output — count as video.
|
||||
var ewmhNonVideoTypes = map[string]bool{
|
||||
"DESKTOP": true,
|
||||
"DOCK": true,
|
||||
"TOOLBAR": true,
|
||||
"MENU": true,
|
||||
"UTILITY": true,
|
||||
"SPLASH": true,
|
||||
"DIALOG": true,
|
||||
"NOTIFICATION": true,
|
||||
"TOOLTIP": true,
|
||||
"COMBO": true,
|
||||
"DROPDOWN_MENU": true,
|
||||
"POPUP_MENU": true,
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -23,6 +59,18 @@ type X11Opener struct {
|
||||
// shelling out to wmctrl; tests may replace it. A failure is logged and
|
||||
// the canvas 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.
|
||||
Video *VideoPresence
|
||||
}
|
||||
|
||||
func (o X11Opener) presence() *VideoPresence {
|
||||
if o.Video != nil {
|
||||
return o.Video
|
||||
}
|
||||
return NewVideoPresence()
|
||||
}
|
||||
|
||||
func (o X11Opener) applyFullscreen(args []string) error {
|
||||
@@ -60,16 +108,35 @@ func (o X11Opener) Open(ctx context.Context) (Session, error) {
|
||||
xproto.WindowClassInputOutput, screen.RootVisual, values,
|
||||
[]uint32{screen.BlackPixel, xproto.EventMaskExposure})
|
||||
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
|
||||
// destroy is video end. WFB and launcher processes are never consulted.
|
||||
xproto.ChangeWindowAttributes(conn, screen.Root,
|
||||
xproto.CwEventMask, []uint32{xproto.EventMaskSubstructureNotify})
|
||||
// Round trip confirms the window is mapped and the server is alive.
|
||||
if err := xproto.GetInputFocus(conn).Check(); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("map kiosk window: %w", err)
|
||||
}
|
||||
|
||||
// Fullscreen on the root screen, but kept below other windows: pilot
|
||||
// video owns the rendering priority whenever it is available. The kiosk
|
||||
// never raises itself, so video always stays on top. A missing wmctrl
|
||||
// only degrades to a plain window and must not kill the kiosk.
|
||||
// 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.
|
||||
presence := o.presence()
|
||||
typeAtom := internWindowTypeAtom(conn)
|
||||
videoWindows := scanVideoWindows(conn, screen.Root, window, typeAtom)
|
||||
if len(videoWindows) > 0 {
|
||||
presence.SetActive(true)
|
||||
stackWindowBelow(conn, window, videoWindows[len(videoWindows)-1])
|
||||
} 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)
|
||||
}
|
||||
@@ -84,37 +151,184 @@ func (o X11Opener) Open(ctx context.Context) (Session, error) {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("allocate font: %w", err)
|
||||
}
|
||||
xproto.OpenFont(conn, font, uint16(len("fixed")), "fixed")
|
||||
// Prefer a Unicode font so the exact fallback text renders with its
|
||||
// diacritics; without one, drawn text degrades to ASCII.
|
||||
fontName, unicodeFont := selectFontName(conn)
|
||||
xproto.OpenFont(conn, font, uint16(len(fontName)), fontName)
|
||||
xproto.CreateGC(conn, gc, xproto.Drawable(window),
|
||||
xproto.GcForeground|xproto.GcBackground|xproto.GcFont,
|
||||
[]uint32{screen.WhitePixel, screen.BlackPixel, uint32(font)})
|
||||
|
||||
return &x11Session{
|
||||
conn: conn,
|
||||
window: window,
|
||||
gc: gc,
|
||||
width: int(screen.WidthInPixels),
|
||||
height: int(screen.HeightInPixels),
|
||||
black: screen.BlackPixel,
|
||||
white: screen.WhitePixel,
|
||||
poll: o.pollInterval(),
|
||||
conn: conn,
|
||||
window: window,
|
||||
gc: gc,
|
||||
width: int(screen.WidthInPixels),
|
||||
height: int(screen.HeightInPixels),
|
||||
black: screen.BlackPixel,
|
||||
white: screen.WhitePixel,
|
||||
poll: o.pollInterval(),
|
||||
video: presence,
|
||||
videoWindows: videoWindowSet(videoWindows),
|
||||
typeAtom: typeAtom,
|
||||
unicodeFont: unicodeFont,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func videoWindowSet(windows []xproto.Window) map[xproto.Window]struct{} {
|
||||
set := make(map[xproto.Window]struct{}, len(windows))
|
||||
for _, w := range windows {
|
||||
set[w] = struct{}{}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func x11FullscreenArgs(window uint32) []string {
|
||||
return []string{"-i", "-r", fmt.Sprintf("0x%08x", window), "-b", "add,fullscreen,below"}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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) {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
reply, err := xproto.InternAtom(conn, false,
|
||||
uint16(len(windowTypePropertyName)), windowTypePropertyName).Reply()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return reply.Atom
|
||||
}
|
||||
|
||||
// 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.
|
||||
func auxiliaryWindowType(conn *xgb.Conn, w xproto.Window, typeAtom xproto.Atom) bool {
|
||||
if typeAtom == 0 {
|
||||
return false
|
||||
}
|
||||
reply, err := xproto.GetProperty(conn, false, w, typeAtom,
|
||||
xproto.GetPropertyTypeAny, 0, 32).Reply()
|
||||
if err != nil || reply.ValueLen < 4 {
|
||||
return false
|
||||
}
|
||||
for i := 0; i+4 <= len(reply.Value); i += 4 {
|
||||
atom := xproto.Atom(binary.LittleEndian.Uint32(reply.Value[i : i+4]))
|
||||
name, err := xproto.GetAtomName(conn, atom).Reply()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if ewmhNonVideoTypes[name.Name] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func stackWindowBelow(conn *xgb.Conn, window, sibling xproto.Window) {
|
||||
xproto.ConfigureWindow(conn, window,
|
||||
xproto.ConfigWindowSibling|xproto.ConfigWindowStackMode,
|
||||
[]uint32{uint32(sibling), xproto.StackModeBelow})
|
||||
}
|
||||
|
||||
// videoChange is the video-lifecycle meaning of one root structure event.
|
||||
type videoChange int
|
||||
|
||||
const (
|
||||
videoUnrelated videoChange = iota
|
||||
videoAppeared
|
||||
videoDisappeared
|
||||
)
|
||||
|
||||
// classifyVideoChange maps a root structure event onto the video lifecycle.
|
||||
// own is the kiosk window. The video output windows themselves are the
|
||||
// presence signal: their maps are the first presented video output and
|
||||
// their unmaps or destroys are video ends, so neither WFB nor launcher
|
||||
// processes are ever consulted. Whether an announced window actually is
|
||||
// video is decided by the session using the presented-window checks.
|
||||
func classifyVideoChange(event xgb.Event, own xproto.Window) (videoChange, xproto.Window) {
|
||||
switch e := event.(type) {
|
||||
case xproto.MapNotifyEvent:
|
||||
if e.Window == own {
|
||||
return videoUnrelated, 0
|
||||
}
|
||||
return videoAppeared, e.Window
|
||||
case xproto.UnmapNotifyEvent:
|
||||
if e.Window == own {
|
||||
return videoUnrelated, 0
|
||||
}
|
||||
return videoDisappeared, e.Window
|
||||
case xproto.DestroyNotifyEvent:
|
||||
return videoDisappeared, e.Window
|
||||
}
|
||||
return videoUnrelated, 0
|
||||
}
|
||||
|
||||
// addVideoWindow records w as presented video output and reports whether
|
||||
// presence changed (the set went from empty to non-empty).
|
||||
func addVideoWindow(windows map[xproto.Window]struct{}, w xproto.Window) bool {
|
||||
if _, tracked := windows[w]; tracked {
|
||||
return false
|
||||
}
|
||||
wasEmpty := len(windows) == 0
|
||||
windows[w] = struct{}{}
|
||||
return wasEmpty
|
||||
}
|
||||
|
||||
// removeVideoWindow drops w from the presented video windows and reports
|
||||
// whether presence changed (the set became empty).
|
||||
func removeVideoWindow(windows map[xproto.Window]struct{}, w xproto.Window) bool {
|
||||
if _, tracked := windows[w]; !tracked {
|
||||
return false
|
||||
}
|
||||
delete(windows, w)
|
||||
return len(windows) == 0
|
||||
}
|
||||
|
||||
// x11Session implements both Session and Canvas for one X11 connection.
|
||||
type x11Session struct {
|
||||
conn *xgb.Conn
|
||||
window xproto.Window
|
||||
gc xproto.Gcontext
|
||||
width int
|
||||
height int
|
||||
black uint32
|
||||
white uint32
|
||||
poll time.Duration
|
||||
conn *xgb.Conn
|
||||
window xproto.Window
|
||||
gc xproto.Gcontext
|
||||
width int
|
||||
height int
|
||||
black uint32
|
||||
white uint32
|
||||
poll time.Duration
|
||||
video *VideoPresence
|
||||
videoWindows map[xproto.Window]struct{}
|
||||
typeAtom xproto.Atom
|
||||
unicodeFont bool
|
||||
}
|
||||
|
||||
func (s *x11Session) Canvas() Canvas { return s }
|
||||
@@ -129,10 +343,17 @@ func (s *x11Session) Clear() {
|
||||
}
|
||||
|
||||
func (s *x11Session) Text(x, y int, message string) {
|
||||
xproto.ChangeGC(s.conn, s.gc, xproto.GcForeground, []uint32{s.white})
|
||||
if s.unicodeFont {
|
||||
chars := utf16Char2b(message, maxImageText16Units)
|
||||
xproto.ImageText16(s.conn, byte(len(chars)), xproto.Drawable(s.window), s.gc,
|
||||
int16(x), int16(y), chars)
|
||||
return
|
||||
}
|
||||
message = foldToASCII(message)
|
||||
if len(message) > 255 {
|
||||
message = message[:255]
|
||||
}
|
||||
xproto.ChangeGC(s.conn, s.gc, xproto.GcForeground, []uint32{s.white})
|
||||
xproto.ImageText8(s.conn, byte(len(message)), xproto.Drawable(s.window), s.gc,
|
||||
int16(x), int16(y), message)
|
||||
}
|
||||
@@ -156,6 +377,9 @@ func (s *x11Session) WaitEvent(ctx context.Context) (bool, error) {
|
||||
if x11WantsRedraw(event) {
|
||||
return true, nil
|
||||
}
|
||||
if s.applyVideoChange(event) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
// Round trip keeps the connection liveness visible even without
|
||||
// incoming events.
|
||||
@@ -170,6 +394,41 @@ 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.
|
||||
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) {
|
||||
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)
|
||||
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.
|
||||
xproto.ConfigureWindow(s.conn, s.window,
|
||||
xproto.ConfigWindowStackMode, []uint32{xproto.StackModeAbove})
|
||||
return s.video.SetActive(false)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func x11WantsRedraw(event xgb.Event) bool {
|
||||
switch event.(type) {
|
||||
case xproto.ExposeEvent:
|
||||
@@ -179,6 +438,73 @@ func x11WantsRedraw(event xgb.Event) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// selectFontName resolves a Unicode-capable font for the fallback text. It
|
||||
// reports false when none is installed, in which case drawn text is folded
|
||||
// to ASCII.
|
||||
func selectFontName(conn *xgb.Conn) (string, bool) {
|
||||
reply, err := xproto.ListFontsWithInfo(conn, 1,
|
||||
uint16(len(iso10646FontPattern)), iso10646FontPattern).Reply()
|
||||
if err != nil || reply.NameLen == 0 {
|
||||
return asciiFontName, false
|
||||
}
|
||||
return reply.Name, true
|
||||
}
|
||||
|
||||
// utf16Char2b encodes the message as UTF-16 units for ImageText16 so the
|
||||
// exact fallback text keeps its diacritics, truncating to maxUnits because
|
||||
// the request carries a 1-byte string length. A cut never splits a
|
||||
// surrogate pair.
|
||||
func utf16Char2b(message string, maxUnits int) []xproto.Char2b {
|
||||
if maxUnits <= 0 {
|
||||
return nil
|
||||
}
|
||||
units := utf16.Encode([]rune(message))
|
||||
if len(units) > maxUnits {
|
||||
units = units[:maxUnits]
|
||||
if last := units[len(units)-1]; last >= 0xD800 && last <= 0xDBFF {
|
||||
units = units[:len(units)-1]
|
||||
}
|
||||
}
|
||||
chars := make([]xproto.Char2b, len(units))
|
||||
for i, unit := range units {
|
||||
chars[i] = xproto.Char2b{Byte1: byte(unit >> 8), Byte2: byte(unit)}
|
||||
}
|
||||
return chars
|
||||
}
|
||||
|
||||
// latinFolds maps the Polish diacritics to plain ASCII for fonts without
|
||||
// Unicode glyphs.
|
||||
var latinFolds = map[rune]byte{
|
||||
'ą': 'a', 'ć': 'c', 'ę': 'e', 'ł': 'l', 'ń': 'n', 'ó': 'o', 'ś': 's', 'ź': 'z', 'ż': 'z',
|
||||
'Ą': 'A', 'Ć': 'C', 'Ę': 'E', 'Ł': 'L', 'Ń': 'N', 'Ó': 'O', 'Ś': 'S', 'Ź': 'Z', 'Ż': 'Z',
|
||||
}
|
||||
|
||||
// foldToASCII degrades text for legacy fonts: Polish diacritics fold to
|
||||
// their base letters and other non-ASCII runes are dropped.
|
||||
func foldToASCII(message string) string {
|
||||
ascii := true
|
||||
for _, r := range message {
|
||||
if r >= 0x80 {
|
||||
ascii = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if ascii {
|
||||
return message
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range message {
|
||||
if r < 0x80 {
|
||||
b.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
if folded, ok := latinFolds[r]; ok {
|
||||
b.WriteByte(folded)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (s *x11Session) Close() error {
|
||||
// Closing the connection makes the X server destroy the kiosk window.
|
||||
s.conn.Close()
|
||||
|
||||
@@ -43,3 +43,149 @@ func TestX11OpenerAppliesFullscreenArgsThroughHook(t *testing.T) {
|
||||
t.Fatalf("hook args %v must request fullscreen with below stacking", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyVideoChangeTracksVideoWindowLifecycle(t *testing.T) {
|
||||
const own = xproto.Window(0x10)
|
||||
const video = xproto.Window(0x20)
|
||||
const other = xproto.Window(0x30)
|
||||
|
||||
change, w := classifyVideoChange(xproto.MapNotifyEvent{Window: video}, own)
|
||||
if change != videoAppeared || w != video {
|
||||
t.Fatalf("map of a foreign window must announce video, got %v %v", change, w)
|
||||
}
|
||||
|
||||
change, _ = classifyVideoChange(xproto.MapNotifyEvent{Window: own}, own)
|
||||
if change != videoUnrelated {
|
||||
t.Fatalf("map of the kiosk window must be unrelated, got %v", change)
|
||||
}
|
||||
|
||||
change, w = classifyVideoChange(xproto.UnmapNotifyEvent{Window: video}, own)
|
||||
if change != videoDisappeared || w != video {
|
||||
t.Fatalf("unmap must announce a video end candidate, got %v %v", change, w)
|
||||
}
|
||||
|
||||
change, _ = classifyVideoChange(xproto.UnmapNotifyEvent{Window: own}, own)
|
||||
if change != videoUnrelated {
|
||||
t.Fatalf("unmap of the kiosk window must be unrelated, got %v", change)
|
||||
}
|
||||
|
||||
change, w = classifyVideoChange(xproto.UnmapNotifyEvent{Window: other}, own)
|
||||
if change != videoDisappeared || w != other {
|
||||
t.Fatalf("unmap of a foreign window must be an end candidate, got %v %v", change, w)
|
||||
}
|
||||
|
||||
change, w = classifyVideoChange(xproto.DestroyNotifyEvent{Window: video}, own)
|
||||
if change != videoDisappeared || w != video {
|
||||
t.Fatalf("destroy must announce a video end candidate, got %v %v", change, w)
|
||||
}
|
||||
|
||||
change, _ = classifyVideoChange(xproto.ExposeEvent{}, own)
|
||||
if change != videoUnrelated {
|
||||
t.Fatalf("expose must not change video presence, got %v", change)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddAndRemoveVideoWindowTrackPresenceTransitions(t *testing.T) {
|
||||
const video = xproto.Window(0x20)
|
||||
const second = xproto.Window(0x30)
|
||||
windows := make(map[xproto.Window]struct{})
|
||||
|
||||
if !addVideoWindow(windows, video) {
|
||||
t.Fatal("first video window must change presence")
|
||||
}
|
||||
if addVideoWindow(windows, video) {
|
||||
t.Fatal("re-adding the tracked window must not change presence")
|
||||
}
|
||||
if addVideoWindow(windows, second) {
|
||||
t.Fatal("a second video window must not change presence")
|
||||
}
|
||||
if removeVideoWindow(windows, second) {
|
||||
t.Fatal("removing one of two video windows must not change presence")
|
||||
}
|
||||
if !removeVideoWindow(windows, video) {
|
||||
t.Fatal("removing the last video window must change presence")
|
||||
}
|
||||
if removeVideoWindow(windows, video) {
|
||||
t.Fatal("removing an untracked window must not change presence")
|
||||
}
|
||||
if len(windows) != 0 {
|
||||
t.Fatalf("set %v must be empty after all video ended", windows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEWMHNonVideoWindowTypesExcludeDesktopHelpers(t *testing.T) {
|
||||
for _, name := range []string{"DOCK", "DESKTOP", "TOOLBAR", "MENU", "UTILITY", "SPLASH", "DIALOG", "NOTIFICATION", "TOOLTIP"} {
|
||||
if !ewmhNonVideoTypes[name] {
|
||||
t.Fatalf("window type %q must not count as video", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"NORMAL", "", "VIDEO_PLAY"} {
|
||||
if ewmhNonVideoTypes[name] {
|
||||
t.Fatalf("window type %q must count as video", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUTF16Char2bEncodesPolishFallbackText(t *testing.T) {
|
||||
chars := utf16Char2b(wantFallbackText, maxImageText16Units)
|
||||
want := []xproto.Char2b{
|
||||
{Byte1: 0, Byte2: 'B'}, {Byte1: 0, Byte2: 'r'}, {Byte1: 0, Byte2: 'a'}, {Byte1: 0, Byte2: 'k'}, {Byte1: 0, Byte2: ' '},
|
||||
{Byte1: 0, Byte2: 'p'}, {Byte1: 0, Byte2: 'o'},
|
||||
{Byte1: 0x01, Byte2: 0x42}, // ł
|
||||
{Byte1: 0x01, Byte2: 0x05}, // ą
|
||||
{Byte1: 0, Byte2: 'c'}, {Byte1: 0, Byte2: 'z'}, {Byte1: 0, Byte2: 'e'}, {Byte1: 0, Byte2: 'n'}, {Byte1: 0, Byte2: 'i'}, {Byte1: 0, Byte2: 'a'}, {Byte1: 0, Byte2: ' '},
|
||||
{Byte1: 0, Byte2: 'W'}, {Byte1: 0, Byte2: 'F'}, {Byte1: 0, Byte2: 'B'},
|
||||
}
|
||||
if len(chars) != len(want) {
|
||||
t.Fatalf("units=%v want %v", chars, want)
|
||||
}
|
||||
for i := range want {
|
||||
if chars[i] != want[i] {
|
||||
t.Fatalf("unit %d=%v want %v", i, chars[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUTF16Char2bTruncatesToOneRequestLength(t *testing.T) {
|
||||
if chars := utf16Char2b(wantFallbackText, 10); len(chars) != 10 {
|
||||
t.Fatalf("units=%d want 10", len(chars))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUTF16Char2bNeverSplitsSurrogatePairs(t *testing.T) {
|
||||
// U+1F3A5 encodes as a surrogate pair (0xD83C, 0xDFA5): a cut that would
|
||||
// land between the two units must back off instead of splitting it.
|
||||
if chars := utf16Char2b("a🎥", 2); len(chars) != 1 || chars[0].Byte2 != 'a' {
|
||||
t.Fatalf("units=%v want only the leading ASCII unit", chars)
|
||||
}
|
||||
if chars := utf16Char2b("🎥", 1); len(chars) != 0 {
|
||||
t.Fatalf("units=%v want none, the pair does not fit", chars)
|
||||
}
|
||||
if chars := utf16Char2b("🎥", 2); len(chars) != 2 {
|
||||
t.Fatalf("units=%v want the intact pair", chars)
|
||||
}
|
||||
if chars := utf16Char2b(wantFallbackText, 0); len(chars) != 0 {
|
||||
t.Fatalf("units=%v want none for a zero limit", chars)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFoldToASCIIStripsPolishDiacritics(t *testing.T) {
|
||||
if got := foldToASCII(wantFallbackText); got != "Brak polaczenia WFB" {
|
||||
t.Fatalf("foldToASCII=%q want %q", got, "Brak polaczenia WFB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFoldToASCIIKeepsPlainASCIIUntouched(t *testing.T) {
|
||||
if got := foldToASCII("plain text"); got != "plain text" {
|
||||
t.Fatalf("foldToASCII=%q want %q", got, "plain text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestX11FontPatternRequestsUnicodeFixedFont(t *testing.T) {
|
||||
if !strings.Contains(iso10646FontPattern, "fixed") {
|
||||
t.Fatalf("font pattern %q must select a fixed font", iso10646FontPattern)
|
||||
}
|
||||
if !strings.Contains(iso10646FontPattern, "iso10646") {
|
||||
t.Fatalf("font pattern %q must select a Unicode encoding for the exact fallback text", iso10646FontPattern)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user