feat: publish adaptive-link OSD contract and keep overlay above video (#3)
This commit is contained in:
+42
-8
@@ -1,6 +1,8 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"git.piksar.eu/piotr/groundstation/internal/osd"
|
||||
)
|
||||
|
||||
@@ -44,18 +46,50 @@ func (t OSDTemplate) Draw(canvas Canvas) {
|
||||
canvas.Flush()
|
||||
}
|
||||
|
||||
// drawOSD renders the deliberately minimal flight telemetry: two short
|
||||
// lines in the top-left corner. The full final RC state stays in the
|
||||
// snapshot; the pilot-facing overlay stays terse.
|
||||
// drawOSD renders the deliberately minimal flight telemetry: the arm state
|
||||
// and one compact adaptive-link line — the operator's target table, the
|
||||
// temporary CH6 maxPower ceiling, the requested runtime level and the
|
||||
// applied VTX state. Values whose producers do not exist yet render at
|
||||
// their explicit pending marks. The full final RC state stays in the
|
||||
// snapshot; the pilot-facing overlay stays terse, and static configuration
|
||||
// is never rendered as applied VTX state.
|
||||
func drawOSD(canvas Canvas, snapshot osd.Snapshot) {
|
||||
arm := "OFF"
|
||||
if snapshot.Armed {
|
||||
arm = "ARMED"
|
||||
}
|
||||
power := "OFF"
|
||||
if snapshot.Power {
|
||||
power = "ON"
|
||||
}
|
||||
canvas.Text(32, 32, "ARM "+arm)
|
||||
canvas.Text(32, 64, "PWR "+power)
|
||||
canvas.Text(32, 64, vtxLine(snapshot))
|
||||
}
|
||||
|
||||
// vtxLine is the compact adaptive-link contract line.
|
||||
func vtxLine(snapshot osd.Snapshot) string {
|
||||
return "VTX tbl:" + vtxTable(snapshot.OpConfig.Table) +
|
||||
" ceil:" + strconv.FormatUint(uint64(snapshot.OpConfig.MaxPower), 10) +
|
||||
" req:" + vtxLevel(snapshot.Policy.Pending, snapshot.Policy.Level) +
|
||||
" ack:" + vtxAck(snapshot.Ack)
|
||||
}
|
||||
|
||||
func vtxTable(table osd.Table) string {
|
||||
if !table.Selected() {
|
||||
return "--"
|
||||
}
|
||||
return strconv.FormatUint(uint64(table.Gen), 10)
|
||||
}
|
||||
|
||||
func vtxLevel(pending bool, level uint8) string {
|
||||
if pending {
|
||||
return "--"
|
||||
}
|
||||
return strconv.FormatUint(uint64(level), 10)
|
||||
}
|
||||
|
||||
func vtxAck(ack osd.VTXAck) string {
|
||||
if ack.Pending {
|
||||
return "--"
|
||||
}
|
||||
if ack.Lifecycle == osd.VTXLifecycleError {
|
||||
return "ERR"
|
||||
}
|
||||
return vtxLevel(false, ack.Level)
|
||||
}
|
||||
|
||||
@@ -14,14 +14,22 @@ type stubSource struct {
|
||||
|
||||
func (s stubSource) Latest() (osd.Snapshot, bool) { return s.snapshot, s.ok }
|
||||
|
||||
func armedSource(sequence uint16) stubSource {
|
||||
return stubSource{snapshot: osd.Snapshot{Sequence: sequence, Armed: true, Power: true}, ok: true}
|
||||
// armedPendingSource is the contract state until the adaptive-link
|
||||
// producers exist: armed with a CH6 ceiling, everything else pending.
|
||||
func armedPendingSource(ceiling uint8) stubSource {
|
||||
return stubSource{snapshot: osd.Snapshot{
|
||||
Armed: true,
|
||||
OpConfig: osd.OperatorConfig{MaxPower: ceiling},
|
||||
Policy: osd.GSPolicy{Pending: true},
|
||||
Ack: osd.VTXAck{Pending: true, Lifecycle: osd.VTXLifecyclePending},
|
||||
Link: osd.GSObservedLink{Known: false},
|
||||
}, ok: true}
|
||||
}
|
||||
|
||||
func TestOSDTemplateStaysTransparentWhileVideoAbsent(t *testing.T) {
|
||||
canvas := &fakeCanvas{width: 800, height: 600}
|
||||
|
||||
NewOSDTemplate(NewVideoPresence(), armedSource(1)).Draw(canvas)
|
||||
NewOSDTemplate(NewVideoPresence(), armedPendingSource(63)).Draw(canvas)
|
||||
|
||||
clears, flushes := canvas.drawCounts()
|
||||
if clears != 1 || flushes != 1 {
|
||||
@@ -32,19 +40,19 @@ func TestOSDTemplateStaysTransparentWhileVideoAbsent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOSDTemplateDrawsMinimalTelemetryWhileVideoPresent(t *testing.T) {
|
||||
func TestOSDTemplateDrawsArmStateAndPendingContractWhileVideoPresent(t *testing.T) {
|
||||
canvas := &fakeCanvas{width: 800, height: 600}
|
||||
presence := NewVideoPresence()
|
||||
presence.SetActive(true)
|
||||
|
||||
NewOSDTemplate(presence, armedSource(7)).Draw(canvas)
|
||||
NewOSDTemplate(presence, armedPendingSource(63)).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()
|
||||
want := []string{"ARM ARMED", "PWR ON"}
|
||||
want := []string{"ARM ARMED", "VTX tbl:-- ceil:63 req:-- ack:--"}
|
||||
if len(lines) != len(want) {
|
||||
t.Fatalf("lines=%q want %q", lines, want)
|
||||
}
|
||||
@@ -55,7 +63,7 @@ func TestOSDTemplateDrawsMinimalTelemetryWhileVideoPresent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOSDTemplateDrawsDisarmedAndPoweredOffState(t *testing.T) {
|
||||
func TestOSDTemplateDrawsDisarmedState(t *testing.T) {
|
||||
canvas := &fakeCanvas{width: 800, height: 600}
|
||||
presence := NewVideoPresence()
|
||||
presence.SetActive(true)
|
||||
@@ -63,7 +71,32 @@ func TestOSDTemplateDrawsDisarmedAndPoweredOffState(t *testing.T) {
|
||||
NewOSDTemplate(presence, stubSource{ok: true}).Draw(canvas)
|
||||
|
||||
lines := canvas.lines()
|
||||
want := []string{"ARM OFF", "PWR OFF"}
|
||||
if len(lines) != 2 || lines[0] != "ARM OFF" {
|
||||
t.Fatalf("lines=%q want the disarmed arm state and the pending contract line", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOSDTemplateRendersSelectedTableRequestedAndAppliedStates(t *testing.T) {
|
||||
canvas := &fakeCanvas{width: 800, height: 600}
|
||||
presence := NewVideoPresence()
|
||||
presence.SetActive(true)
|
||||
|
||||
NewOSDTemplate(presence, stubSource{snapshot: osd.Snapshot{
|
||||
Armed: true,
|
||||
OpConfig: osd.OperatorConfig{
|
||||
Table: osd.Table{Gen: 3, Hash: 0xcafe},
|
||||
MaxPower: 127,
|
||||
},
|
||||
Policy: osd.GSPolicy{Level: 42},
|
||||
Ack: osd.VTXAck{
|
||||
Table: osd.Table{Gen: 3},
|
||||
Level: 40,
|
||||
Lifecycle: osd.VTXLifecycleApplied,
|
||||
},
|
||||
}, ok: true}).Draw(canvas)
|
||||
|
||||
lines := canvas.lines()
|
||||
want := []string{"ARM ARMED", "VTX tbl:3 ceil:127 req:42 ack:40"}
|
||||
if len(lines) != len(want) {
|
||||
t.Fatalf("lines=%q want %q", lines, want)
|
||||
}
|
||||
@@ -74,6 +107,44 @@ func TestOSDTemplateDrawsDisarmedAndPoweredOffState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOSDTemplateRendersAckErrorState(t *testing.T) {
|
||||
canvas := &fakeCanvas{width: 800, height: 600}
|
||||
presence := NewVideoPresence()
|
||||
presence.SetActive(true)
|
||||
|
||||
NewOSDTemplate(presence, stubSource{snapshot: osd.Snapshot{
|
||||
OpConfig: osd.OperatorConfig{MaxPower: 10},
|
||||
Policy: osd.GSPolicy{Pending: true},
|
||||
Ack: osd.VTXAck{Lifecycle: osd.VTXLifecycleError},
|
||||
}, ok: true}).Draw(canvas)
|
||||
|
||||
lines := canvas.lines()
|
||||
if len(lines) != 2 || lines[1] != "VTX tbl:-- ceil:10 req:-- ack:ERR" {
|
||||
t.Fatalf("lines=%q want the acknowledged error state", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOSDTemplateRendersLevelZeroAsExplicitOff(t *testing.T) {
|
||||
// Level 0 in an available policy or acknowledgment is an explicit
|
||||
// off / no-runtime-command state and must render as 0; only a pending
|
||||
// value — no producer yet — renders as the pending mark.
|
||||
canvas := &fakeCanvas{width: 800, height: 600}
|
||||
presence := NewVideoPresence()
|
||||
presence.SetActive(true)
|
||||
|
||||
NewOSDTemplate(presence, stubSource{snapshot: osd.Snapshot{
|
||||
OpConfig: osd.OperatorConfig{MaxPower: 0},
|
||||
Policy: osd.GSPolicy{Level: 0},
|
||||
Ack: osd.VTXAck{Level: 0, Lifecycle: osd.VTXLifecycleApplied},
|
||||
}, ok: true}).Draw(canvas)
|
||||
|
||||
lines := canvas.lines()
|
||||
want := "VTX tbl:-- ceil:0 req:0 ack:0"
|
||||
if len(lines) != 2 || lines[1] != want {
|
||||
t.Fatalf("lines=%q want %q", lines, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOSDTemplateStaysTransparentWithoutASnapshotWhileVideoPresent(t *testing.T) {
|
||||
canvas := &fakeCanvas{width: 800, height: 600}
|
||||
presence := NewVideoPresence()
|
||||
@@ -111,7 +182,7 @@ func TestOSDTemplateGatesOnObservedVideoPresenceNotSnapshotVideo(t *testing.T) {
|
||||
func TestOSDTemplateNeverDrawsTheFallbackText(t *testing.T) {
|
||||
canvas := &fakeCanvas{width: 800, height: 600}
|
||||
presence := NewVideoPresence()
|
||||
template := NewOSDTemplate(presence, armedSource(3))
|
||||
template := NewOSDTemplate(presence, armedPendingSource(63))
|
||||
|
||||
for _, active := range []bool{false, true, false} {
|
||||
presence.SetActive(active)
|
||||
|
||||
+51
-6
@@ -99,6 +99,23 @@ func stackModeForVideoAppear(role LayerRole) uint32 {
|
||||
return xproto.StackModeBelow
|
||||
}
|
||||
|
||||
// stackReassertTarget reports which video window the OSD overlay must sit
|
||||
// above after an external restack, and whether one exists. Only the OSD
|
||||
// overlay re-asserts its place: window managers promote fullscreened video
|
||||
// to the top of their stacking order after the video-appear restack, which
|
||||
// would bury the overlay — and its text — for the rest of the flight. The
|
||||
// fallback canvas keeps its event-driven lifecycle, and the video window is
|
||||
// never moved.
|
||||
func stackReassertTarget(role LayerRole, videoActive bool, topVideo xproto.Window, videoWindows map[xproto.Window]struct{}) (xproto.Window, bool) {
|
||||
if role != RoleAbove || !videoActive || topVideo == 0 {
|
||||
return 0, false
|
||||
}
|
||||
if _, tracked := videoWindows[topVideo]; !tracked {
|
||||
return 0, false
|
||||
}
|
||||
return topVideo, true
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -321,9 +338,11 @@ func (o X11Opener) Open(ctx context.Context) (Session, error) {
|
||||
presence := o.presence()
|
||||
typeAtom := internWindowTypeAtom(conn)
|
||||
videoWindows := scanVideoWindows(conn, screen.Root, window, typeAtom, markerAtom)
|
||||
var topVideo xproto.Window
|
||||
if len(videoWindows) > 0 {
|
||||
presence.SetActive(true)
|
||||
stackWindowRelative(conn, window, videoWindows[len(videoWindows)-1],
|
||||
topVideo = videoWindows[len(videoWindows)-1]
|
||||
stackWindowRelative(conn, window, topVideo,
|
||||
stackModeForVideoAppear(o.Role))
|
||||
} else {
|
||||
presence.SetActive(false)
|
||||
@@ -380,6 +399,7 @@ func (o X11Opener) Open(ctx context.Context) (Session, error) {
|
||||
refresh: o.RefreshInterval,
|
||||
video: presence,
|
||||
videoWindows: videoWindowSet(videoWindows),
|
||||
topVideo: topVideo,
|
||||
typeAtom: typeAtom,
|
||||
markerAtom: markerAtom,
|
||||
role: o.Role,
|
||||
@@ -645,10 +665,14 @@ type x11Session struct {
|
||||
refresh time.Duration
|
||||
video *VideoPresence
|
||||
videoWindows map[xproto.Window]struct{}
|
||||
typeAtom xproto.Atom
|
||||
markerAtom xproto.Atom
|
||||
role LayerRole
|
||||
unicodeFont bool
|
||||
// topVideo is the video window the OSD overlay must sit above; it is
|
||||
// re-asserted on the overlay's refresh because window managers may
|
||||
// promote fullscreened video back on top after the appear restack.
|
||||
topVideo xproto.Window
|
||||
typeAtom xproto.Atom
|
||||
markerAtom xproto.Atom
|
||||
role LayerRole
|
||||
unicodeFont bool
|
||||
// fontAscent, fontDescent and fontCell describe the drawn font so the
|
||||
// OSD overlay can punch its output shape around each text's glyph cell.
|
||||
fontAscent int16
|
||||
@@ -767,6 +791,10 @@ func (s *x11Session) WaitEvent(ctx context.Context) (bool, error) {
|
||||
// event-driven.
|
||||
if s.refresh > 0 && time.Since(lastRefresh) >= s.refresh {
|
||||
lastRefresh = time.Now()
|
||||
// The redraw is also where the overlay re-claims its place
|
||||
// above fullscreen video: window managers may have promoted
|
||||
// the video window back on top since the appear restack.
|
||||
s.reassertStacking()
|
||||
return true, nil
|
||||
}
|
||||
// Round trip keeps the connection liveness visible even without
|
||||
@@ -783,6 +811,19 @@ func (s *x11Session) WaitEvent(ctx context.Context) (bool, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// reassertStacking re-claims the overlay's place above the presented video
|
||||
// window; a no-op for the canvas and whenever video is absent.
|
||||
func (s *x11Session) reassertStacking() {
|
||||
if s.video == nil || !s.video.Active() {
|
||||
return
|
||||
}
|
||||
target, ok := stackReassertTarget(s.role, s.video.Active(), s.topVideo, s.videoWindows)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
stackWindowRelative(s.conn, s.window, target, xproto.StackModeAbove)
|
||||
}
|
||||
|
||||
// applyVideoChange folds one root structure event into the shared video
|
||||
// presence and restacks this layer so video keeps rendering priority: the
|
||||
// canvas parks below video and the OSD overlay rises above it. It reports
|
||||
@@ -803,13 +844,17 @@ func (s *x11Session) applyVideoChange(event xgb.Event) bool {
|
||||
return false
|
||||
}
|
||||
// The first presented video output takes priority: park on our side
|
||||
// of it.
|
||||
// of it, and remember it as the restack target for later re-claims.
|
||||
s.topVideo = w
|
||||
stackWindowRelative(s.conn, s.window, w, stackModeForVideoAppear(s.role))
|
||||
return s.video.SetActive(true)
|
||||
case videoDisappeared:
|
||||
if !removeVideoWindow(s.videoWindows, w) {
|
||||
return false
|
||||
}
|
||||
if s.topVideo == w {
|
||||
s.topVideo = 0
|
||||
}
|
||||
// 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.
|
||||
|
||||
@@ -286,6 +286,30 @@ func TestOSDEventMaskNeverSelectsDeviceInput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStackReassertTargetOnlyAboveLiveTrackedVideo(t *testing.T) {
|
||||
// Window managers promote fullscreened video to the top of their
|
||||
// stacking order after the video-appear restack, burying the OSD
|
||||
// overlay; the overlay's refresh must re-assert its place above the
|
||||
// tracked video window — and only the OSD overlay may do so.
|
||||
windows := map[xproto.Window]struct{}{0x20: {}}
|
||||
|
||||
if target, ok := stackReassertTarget(RoleAbove, true, 0x20, windows); !ok || target != 0x20 {
|
||||
t.Fatalf("target=%v ok=%v want the tracked video window", target, ok)
|
||||
}
|
||||
if _, ok := stackReassertTarget(RoleBelow, true, 0x20, windows); ok {
|
||||
t.Fatal("the fallback canvas must never re-assert stacking")
|
||||
}
|
||||
if _, ok := stackReassertTarget(RoleAbove, false, 0x20, windows); ok {
|
||||
t.Fatal("no re-assert while video is absent")
|
||||
}
|
||||
if _, ok := stackReassertTarget(RoleAbove, true, 0x30, windows); ok {
|
||||
t.Fatal("a stale top video window must not be restacked against")
|
||||
}
|
||||
if _, ok := stackReassertTarget(RoleAbove, true, 0, windows); ok {
|
||||
t.Fatal("no tracked video window means nothing to re-assert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransparentShapesEmptyInputAndOutput(t *testing.T) {
|
||||
var input, output bool
|
||||
for _, kind := range transparentShapeKinds() {
|
||||
|
||||
Reference in New Issue
Block a user