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:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user