From b169a64ad539f85f11cb49b36c1865733b87e018 Mon Sep 17 00:00:00 2001 From: Hermes Date: Thu, 1 Oct 2026 14:40:24 +0200 Subject: [PATCH] feat: show no-WFB kiosk fallback only while video absent (#2) --- README.md | 2 +- cmd/groundstation-display/main.go | 7 +- cmd/groundstation-display/main_test.go | 63 +++++ internal/display/display.go | 9 +- internal/display/fallback.go | 25 ++ internal/display/fallback_test.go | 57 ++++ internal/display/status.go | 8 +- internal/display/video.go | 30 ++ internal/display/video_test.go | 26 ++ internal/display/x11.go | 370 +++++++++++++++++++++++-- internal/display/x11_test.go | 146 ++++++++++ 11 files changed, 715 insertions(+), 28 deletions(-) create mode 100644 cmd/groundstation-display/main_test.go create mode 100644 internal/display/fallback.go create mode 100644 internal/display/fallback_test.go create mode 100644 internal/display/video.go create mode 100644 internal/display/video_test.go diff --git a/README.md b/README.md index 3bcd60f..77c5205 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/cmd/groundstation-display/main.go b/cmd/groundstation-display/main.go index 5290332..be751a8 100644 --- a/cmd/groundstation-display/main.go +++ b/cmd/groundstation-display/main.go @@ -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) } diff --git a/cmd/groundstation-display/main_test.go b/cmd/groundstation-display/main_test.go new file mode 100644 index 0000000..4a1a8f2 --- /dev/null +++ b/cmd/groundstation-display/main_test.go @@ -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") + } +} diff --git a/internal/display/display.go b/internal/display/display.go index 348a7b8..1927f89 100644 --- a/internal/display/display.go +++ b/internal/display/display.go @@ -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 ( diff --git a/internal/display/fallback.go b/internal/display/fallback.go new file mode 100644 index 0000000..4d4aebb --- /dev/null +++ b/internal/display/fallback.go @@ -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() +} diff --git a/internal/display/fallback_test.go b/internal/display/fallback_test.go new file mode 100644 index 0000000..c239b33 --- /dev/null +++ b/internal/display/fallback_test.go @@ -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) + } +} diff --git a/internal/display/status.go b/internal/display/status.go index 74e0ea9..813b220 100644 --- a/internal/display/status.go +++ b/internal/display/status.go @@ -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() } diff --git a/internal/display/video.go b/internal/display/video.go new file mode 100644 index 0000000..17a8dd7 --- /dev/null +++ b/internal/display/video.go @@ -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 +} diff --git a/internal/display/video_test.go b/internal/display/video_test.go new file mode 100644 index 0000000..03a63a7 --- /dev/null +++ b/internal/display/video_test.go @@ -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") + } +} diff --git a/internal/display/x11.go b/internal/display/x11.go index 2bc3f83..05221b5 100644 --- a/internal/display/x11.go +++ b/internal/display/x11.go @@ -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() diff --git a/internal/display/x11_test.go b/internal/display/x11_test.go index 0156444..7008067 100644 --- a/internal/display/x11_test.go +++ b/internal/display/x11_test.go @@ -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) + } +}