Files
silver_pose/v2/internal/ui/window.go
T
ilaandClaude Opus 4.8 4188a2b06c feat(v2): migrate UI from Walk to Gio
Rewrites internal/ui/window.go in Gio, keeping the Window interface
(NewWindow/Run/Present/PresentAlert/ReportIssue) and the pure-Go ViewModel.
Background updates mutate shared state under a lock and Invalidate; the Gio
loop renders the latest frame. Dual tabs, widget.Image for render's RGBA, a
self-drawn confirm dialog and a MessageBeep sound. Drops lxn/walk (and the
TTM_ADDTOOL/comctl class of problems). Verified via GOOS=windows CGO_ENABLED=0
build of the UI packages; full app build needs Windows+CGo for onnxruntime.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 14:26:19 +08:00

392 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package ui
import (
"fmt"
"image"
"image/color"
"log"
"os"
"strings"
"sync"
"gioui.org/app"
"gioui.org/font/gofont"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"silverpose/v2/internal/alert"
"silverpose/v2/internal/fall"
"silverpose/v2/internal/monitor"
)
// Light Windows theme tokens (see docs/ui/silver-pose-ui-ux-spec.md).
var (
colorBg = color.NRGBA{R: 0xEA, G: 0xF1, B: 0xF8, A: 0xFF}
colorSurface = color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF}
colorText = color.NRGBA{R: 0x1E, G: 0x29, B: 0x3B, A: 0xFF}
colorSubtle = color.NRGBA{R: 0x52, G: 0x65, B: 0x7C, A: 0xFF}
colorAccent = color.NRGBA{R: 0x25, G: 0x63, B: 0xEB, A: 0xFF}
colorAlert = color.NRGBA{R: 0xC6, G: 0x28, B: 0x28, A: 0xFF}
colorScrim = color.NRGBA{A: 0x8C}
)
type Settings struct {
SourceEnvironment string
ModelSHA256 string
RuntimeSummary string
EventSummary string
}
type Commands struct {
Start func()
Stop func()
}
// Window is an observer of monitor updates. It owns no stream, runtime, fall
// engine or artifact writer. Background goroutines call Present/PresentAlert/
// ReportIssue, which only mutate shared state under a lock and Invalidate; the
// Gio event loop (its own goroutine) reads that state and renders the newest
// frame. This replaces Walk's main.Synchronize and its comctl/manifest coupling.
type Window struct {
appWindow *app.Window
theme *material.Theme
settings Settings
commands Commands
spec WindowSpec
mu sync.Mutex
model ViewModel
latestImage *image.RGBA
imageGen uint64
pending *alert.Record
seenAlerts map[string]struct{}
// Touched only by the event-loop goroutine.
imageOp paint.ImageOp
imageShownGen uint64
tabs []string
tabButton []widget.Clickable
active int
startButton widget.Clickable
stopButton widget.Clickable
ackButton widget.Clickable
running bool
}
func NewWindow(settings Settings, commands Commands) (*Window, error) {
spec := MonitorWindowSpec()
theme := material.NewTheme()
theme.Shaper = text.NewShaper(text.WithCollection(gofont.Collection()))
theme.Palette.Bg = colorBg
theme.Palette.Fg = colorText
theme.Palette.ContrastBg = colorAccent
theme.Palette.ContrastFg = color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF}
model := NewViewModel()
model.SetSourceReady(settings.SourceEnvironment, settings.SourceEnvironment != "")
appWindow := new(app.Window)
appWindow.Option(
app.Title(spec.Title),
app.Size(unit.Dp(spec.Width), unit.Dp(spec.Height)),
app.MinSize(unit.Dp(960), unit.Dp(640)),
)
window := &Window{
appWindow: appWindow,
theme: theme,
settings: settings,
commands: commands,
spec: spec,
model: model,
seenAlerts: make(map[string]struct{}),
tabs: []string{"监控", "设置"},
}
window.tabButton = make([]widget.Clickable, len(window.tabs))
return window, nil
}
// Run drives the platform event loop on the calling (main) goroutine and the
// window loop on a separate goroutine. It does not return; the process exits
// after the window is closed and Stop cleanup has run.
func (window *Window) Run() int {
go func() {
err := window.loop()
window.stop()
if err != nil {
log.Print(err)
}
os.Exit(0)
}()
app.Main()
return 0
}
func (window *Window) loop() error {
var ops op.Ops
for {
switch event := window.appWindow.Event().(type) {
case app.DestroyEvent:
return event.Err
case app.FrameEvent:
gtx := app.NewContext(&ops, event)
window.layout(gtx)
event.Frame(gtx.Ops)
}
}
}
func (window *Window) start() {
if window.running {
return
}
window.running = true
if window.commands.Start != nil {
window.commands.Start()
}
}
func (window *Window) stop() {
if window.commands.Stop != nil {
window.commands.Stop()
}
window.running = false
}
// Present stores the newest analysed frame and state, then requests a redraw.
func (window *Window) Present(update monitor.Update) {
window.mu.Lock()
state := highestState(update.Result)
window.model.Apply(state, update.SourceStatus)
if update.SourceMessage != "" {
window.model.ConnectionText = "视频流:" + update.SourceMessage
}
if update.Image != nil {
window.latestImage = update.Image
window.imageGen++
}
window.mu.Unlock()
window.appWindow.Invalidate()
}
// PresentAlert queues one confirm popup per event ID and plays the alert sound.
func (window *Window) PresentAlert(record alert.Record) {
if !record.Written {
return
}
window.mu.Lock()
if _, seen := window.seenAlerts[record.Event.EventID]; seen {
window.mu.Unlock()
return
}
window.seenAlerts[record.Event.EventID] = struct{}{}
queued := record
window.pending = &queued
window.mu.Unlock()
playAlertSound()
window.appWindow.Invalidate()
}
// ReportIssue surfaces a recoverable startup/worker issue without red fall
// language or exposing configuration values.
func (window *Window) ReportIssue(message string) {
window.mu.Lock()
window.model.ConnectionText = "视频流:" + message
window.model.Critical = false
window.model.StateText = "检测状态:正常"
window.mu.Unlock()
window.appWindow.Invalidate()
}
func (window *Window) layout(gtx layout.Context) layout.Dimensions {
paint.Fill(gtx.Ops, colorBg)
window.mu.Lock()
model := window.model
image := window.latestImage
gen := window.imageGen
pending := window.pending
window.mu.Unlock()
hasImage := image != nil
if hasImage && gen != window.imageShownGen {
window.imageOp = paint.NewImageOp(image)
window.imageShownGen = gen
}
content := func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(window.layoutTabs),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
if window.active == 0 {
return window.layoutMonitor(gtx, model, hasImage)
}
return window.layoutSettings(gtx, model)
}),
)
}
if pending == nil {
return content(gtx)
}
return layout.Stack{}.Layout(gtx,
layout.Expanded(content),
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
paint.FillShape(gtx.Ops, colorScrim, clip.Rect{Max: gtx.Constraints.Min}.Op())
return layout.Dimensions{Size: gtx.Constraints.Min}
}),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return window.layoutDialog(gtx, *pending)
})
}),
)
}
func (window *Window) layoutTabs(gtx layout.Context) layout.Dimensions {
for i := range window.tabButton {
if window.tabButton[i].Clicked(gtx) {
window.active = i
}
}
children := make([]layout.FlexChild, 0, len(window.tabs))
for i := range window.tabs {
i := i
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
button := material.Button(window.theme, &window.tabButton[i], window.tabs[i])
if i != window.active {
button.Background = colorSurface
button.Color = colorSubtle
}
return layout.UniformInset(unit.Dp(6)).Layout(gtx, button.Layout)
}))
}
return layout.Flex{}.Layout(gtx, children...)
}
func (window *Window) layoutMonitor(gtx layout.Context, model ViewModel, hasImage bool) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Flexed(3, func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(8)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
if !hasImage {
return layout.Center.Layout(gtx, material.Body1(window.theme, model.ConnectionText).Layout)
}
return widget.Image{Src: window.imageOp, Fit: widget.Contain, Position: layout.Center}.Layout(gtx)
})
}),
layout.Flexed(2, func(gtx layout.Context) layout.Dimensions {
return window.layoutStatus(gtx, model)
}),
)
}
func (window *Window) layoutStatus(gtx layout.Context, model ViewModel) layout.Dimensions {
if window.startButton.Clicked(gtx) {
window.start()
}
if window.stopButton.Clicked(gtx) {
window.stop()
}
return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H6(window.theme, "运行状态").Layout),
layout.Rigid(material.Body1(window.theme, model.ConnectionText).Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Body1(window.theme, model.StateText)
if model.Critical {
label.Color = colorAlert
}
return label.Layout(gtx)
}),
layout.Rigid(material.Body1(window.theme, model.EventText).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(material.Button(window.theme, &window.startButton, "开始监控").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(material.Button(window.theme, &window.stopButton, "停止监控").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(material.Body2(window.theme, "红色仅表示已确认摔倒。连接异常不会触发报警。").Layout),
)
})
}
func (window *Window) layoutSettings(gtx layout.Context, model ViewModel) layout.Dimensions {
return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H6(window.theme, "来源与运行依赖(只读,不显示 RTSP 地址或密码)").Layout),
layout.Rigid(material.Body1(window.theme, model.SettingsSummary).Layout),
layout.Rigid(material.Body1(window.theme, "模型 SHA-256:"+shortHash(window.settings.ModelSHA256)).Layout),
layout.Rigid(material.Body1(window.theme, "运行依赖:"+window.settings.RuntimeSummary).Layout),
layout.Rigid(material.Body1(window.theme, "事件参数:"+window.settings.EventSummary).Layout),
layout.Rigid(material.Body2(window.theme, "参数在配置文件中维护;修改后请停止并重新开始监控。").Layout),
)
})
}
func (window *Window) layoutDialog(gtx layout.Context, record alert.Record) layout.Dimensions {
if window.ackButton.Clicked(gtx) {
window.mu.Lock()
window.pending = nil
window.mu.Unlock()
}
width := gtx.Dp(420)
if width > gtx.Constraints.Max.X {
width = gtx.Constraints.Max.X
}
gtx.Constraints.Min.X = width
gtx.Constraints.Max.X = width
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
rect := image.Rectangle{Max: gtx.Constraints.Min}
paint.FillShape(gtx.Ops, colorSurface, clip.UniformRRect(rect, gtx.Dp(8)).Op(gtx.Ops))
return layout.Dimensions{Size: gtx.Constraints.Min}
}),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
title := material.H6(window.theme, "确认摔倒")
title.Color = colorAlert
return title.Layout(gtx)
}),
layout.Rigid(material.Body1(window.theme, "人员:"+record.Event.TrackID).Layout),
layout.Rigid(material.Body1(window.theme, fmt.Sprintf("延迟:%.2f 秒", record.Event.LatencySeconds)).Layout),
layout.Rigid(material.Body2(window.theme, "截图:"+record.ScreenshotPath).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(material.Button(window.theme, &window.ackButton, "我已知晓").Layout),
)
})
}),
)
}
func highestState(result fall.FrameResult) fall.State {
state := fall.Normal
for _, person := range result.People {
switch person.State {
case fall.Confirmed:
return fall.Confirmed
case fall.Suspect:
if state == fall.Normal || state == fall.Recovering {
state = fall.Suspect
}
case fall.Recovering:
if state == fall.Normal {
state = fall.Recovering
}
}
}
return state
}
func shortHash(value string) string {
if len(value) <= 12 {
return value
}
return strings.ToLower(value[:12]) + "…"
}