Files
silver_pose/v2/internal/ui/window.go
T
ilaandClaude Opus 4.8 ea24fc4c4b feat(v2): compact monitor status row, small buttons, screenshot gallery
Right panel now shows connection+detection status on one line, small
start/stop buttons on one line, event text with the red-note inline, and a
3x3 reverse-chronological gallery of recent confirmed screenshots filling
the rest. Double-clicking a thumbnail opens a full-image overlay. Gallery
items are collected off the UI thread from confirmed updates (deduped by
event id) and each ImageOp is built once and cached. Cross-compiles for
Windows; no new deps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 20:58:45 +08:00

548 lines
17 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"
"time"
"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"
)
const galleryCapacity = 9 // 3 columns x 3 rows
// 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: 0xB0}
)
type Settings struct {
SourceEnvironment string
ModelSHA256 string
RuntimeSummary string
EventSummary string
}
type Commands struct {
Start func()
Stop func()
}
// galleryIncoming is a confirmed screenshot queued off the UI thread. The Gio
// op is built once on the UI thread when it is promoted into galleryItem.
type galleryIncoming struct {
caption string
image *image.RGBA
}
type galleryItem struct {
caption string
op paint.ImageOp
click widget.Clickable
lastClick time.Time
}
// Window observes monitor updates. Background goroutines call Present/
// PresentAlert/ReportIssue, which only mutate shared state under a lock and
// Invalidate; the Gio event loop reads that state and renders.
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{}
incoming []galleryIncoming
seenEvents 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
gallery []*galleryItem
viewing *galleryItem
viewerClose widget.Clickable
}
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{}),
seenEvents: make(map[string]struct{}),
tabs: []string{"监控", "设置"},
}
window.tabButton = make([]widget.Clickable, len(window.tabs))
return window, nil
}
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.commands.Start != nil {
window.commands.Start()
}
}
func (window *Window) stop() {
if window.commands.Stop != nil {
window.commands.Stop()
}
}
// Present stores the newest analysed frame and queues any confirmed screenshot.
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++
}
if update.Image != nil {
for _, record := range update.Events {
id := record.Event.EventID
if id == "" {
continue
}
if _, seen := window.seenEvents[id]; seen {
continue
}
window.seenEvents[id] = struct{}{}
window.incoming = append(window.incoming, galleryIncoming{
caption: fmt.Sprintf("%s %s", record.Event.TrackID, time.Now().Format("15:04:05")),
image: update.Image,
})
}
}
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
frame := window.latestImage
gen := window.imageGen
pending := window.pending
incoming := window.incoming
window.incoming = nil
window.mu.Unlock()
for i := range incoming {
item := &galleryItem{caption: incoming[i].caption, op: paint.NewImageOp(incoming[i].image)}
window.gallery = append([]*galleryItem{item}, window.gallery...)
}
if len(window.gallery) > galleryCapacity {
window.gallery = window.gallery[:galleryCapacity]
}
hasFrame := frame != nil
if hasFrame && gen != window.imageShownGen {
window.imageOp = paint.NewImageOp(frame)
window.imageShownGen = gen
}
now := time.Now()
for _, item := range window.gallery {
if item.click.Clicked(gtx) {
if !item.lastClick.IsZero() && now.Sub(item.lastClick) < 350*time.Millisecond {
window.viewing = item
}
item.lastClick = now
}
}
if window.viewerClose.Clicked(gtx) {
window.viewing = nil
}
if window.ackButton.Clicked(gtx) {
window.mu.Lock()
window.pending = nil
window.mu.Unlock()
pending = nil
}
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, hasFrame)
}
return window.layoutSettings(gtx, model)
}),
)
}
children := []layout.StackChild{layout.Expanded(content)}
if window.viewing != nil {
children = append(children, layout.Expanded(scrim), layout.Expanded(window.layoutViewer))
}
if pending != nil {
record := *pending
children = append(children,
layout.Expanded(scrim),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return window.layoutDialog(gtx, record)
})
}),
)
}
if len(children) == 1 {
return content(gtx)
}
return layout.Stack{}.Layout(gtx, children...)
}
func scrim(gtx layout.Context) layout.Dimensions {
paint.FillShape(gtx.Ops, colorScrim, clip.Rect{Max: gtx.Constraints.Min}.Op())
return layout.Dimensions{Size: gtx.Constraints.Min}
}
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, hasFrame 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 !hasFrame {
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.layoutRightPanel(gtx, model)
}),
)
}
func (window *Window) layoutRightPanel(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(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
// Row 1: connection status and detection status on one line.
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(material.Body1(window.theme, model.ConnectionText).Layout),
layout.Rigid(layout.Spacer{Width: unit.Dp(16)}.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(layout.Spacer{Height: unit.Dp(8)}.Layout),
// Row 2: compact start/stop buttons on one line.
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return smallButton(gtx, window.theme, &window.startButton, "开始监控")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return smallButton(gtx, window.theme, &window.stopButton, "停止监控")
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
// Row 3: event text and the red-note on one line.
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(material.Body1(window.theme, model.EventText).Layout),
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
note := material.Body2(window.theme, "红色=确认摔倒")
note.Color = colorSubtle
return note.Layout(gtx)
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(material.Body2(window.theme, "最近确认截图(双击看原图)").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
// Remaining area: 3x3 reverse-chronological screenshot gallery.
layout.Flexed(1, window.layoutGallery),
)
})
}
func smallButton(gtx layout.Context, theme *material.Theme, click *widget.Clickable, label string) layout.Dimensions {
button := material.Button(theme, click, label)
button.Inset = layout.Inset{Top: unit.Dp(4), Bottom: unit.Dp(4), Left: unit.Dp(10), Right: unit.Dp(10)}
button.TextSize = unit.Sp(13)
return button.Layout(gtx)
}
func (window *Window) layoutGallery(gtx layout.Context) layout.Dimensions {
rows := make([]layout.FlexChild, 0, 3)
for r := 0; r < 3; r++ {
r := r
rows = append(rows, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
cols := make([]layout.FlexChild, 0, 3)
for c := 0; c < 3; c++ {
index := r*3 + c
cols = append(cols, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
if index < len(window.gallery) {
return window.layoutCell(gtx, window.gallery[index])
}
return layout.Dimensions{Size: gtx.Constraints.Min}
}))
}
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, cols...)
}))
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, rows...)
}
func (window *Window) layoutCell(gtx layout.Context, item *galleryItem) layout.Dimensions {
return item.click.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(3)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return widget.Image{Src: item.op, Fit: widget.Contain, Position: layout.Center}.Layout(gtx)
})
})
}
func (window *Window) layoutViewer(gtx layout.Context) layout.Dimensions {
item := window.viewing
if item == nil {
return layout.Dimensions{Size: gtx.Constraints.Min}
}
return layout.UniformInset(unit.Dp(24)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
caption := material.Body1(window.theme, item.caption)
caption.Color = colorSurface
return caption.Layout(gtx)
}),
layout.Flexed(1, layout.Spacer{}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return smallButton(gtx, window.theme, &window.viewerClose, "关闭")
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return widget.Image{Src: item.op, Fit: widget.Contain, Position: layout.Center}.Layout(gtx)
})
}),
)
})
}
func (window *Window) layoutDialog(gtx layout.Context, record alert.Record) layout.Dimensions {
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 (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 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]) + "…"
}