Files
silver_pose/v2/internal/ui/window.go
T

392 lines
12 KiB
Go
Raw Normal View History

package ui
import (
"fmt"
2026-07-23 14:26:19 +08:00
"image"
"image/color"
"log"
"os"
"strings"
2026-07-23 14:26:19 +08:00
"sync"
2026-07-23 14:26:19 +08:00
"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"
)
2026-07-23 14:26:19 +08:00
// 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()
}
2026-07-23 14:26:19 +08:00
// 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 {
2026-07-23 14:26:19 +08:00
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()
2026-07-23 14:26:19 +08:00
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{"监控", "设置"},
}
2026-07-23 14:26:19 +08:00
window.tabButton = make([]widget.Clickable, len(window.tabs))
return window, nil
}
2026-07-23 14:26:19 +08:00
// 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() {
2026-07-23 14:26:19 +08:00
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()
}
2026-07-23 14:26:19 +08:00
window.running = false
}
2026-07-23 14:26:19 +08:00
// Present stores the newest analysed frame and state, then requests a redraw.
func (window *Window) Present(update monitor.Update) {
2026-07-23 14:26:19 +08:00
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()
}
2026-07-23 14:26:19 +08:00
// PresentAlert queues one confirm popup per event ID and plays the alert sound.
func (window *Window) PresentAlert(record alert.Record) {
if !record.Written {
return
}
2026-07-23 14:26:19 +08:00
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
}
2026-07-23 14:26:19 +08:00
}
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),
)
})
}
2026-07-23 14:26:19 +08:00
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),
)
})
}
2026-07-23 14:26:19 +08:00
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]) + "…"
}