From 4188a2b06cbf4ea46d8f6f81d67f6978908a54ea Mon Sep 17 00:00:00 2001 From: ila Date: Thu, 23 Jul 2026 14:26:19 +0800 Subject: [PATCH] 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 --- progress.md | 9 + v2/go.mod | 5 +- v2/go.sum | 7 - v2/internal/ui/sound_other.go | 7 + v2/internal/ui/sound_windows.go | 17 ++ v2/internal/ui/window.go | 442 ++++++++++++++++++++++---------- 6 files changed, 340 insertions(+), 147 deletions(-) create mode 100644 v2/internal/ui/sound_other.go create mode 100644 v2/internal/ui/sound_windows.go diff --git a/progress.md b/progress.md index c5e22f3..0349c21 100644 --- a/progress.md +++ b/progress.md @@ -559,3 +559,12 @@ - 阻塞:真实窗口/声音/弹窗目视需 Windows。 - 决策:后台 `Present/PresentAlert/ReportIssue` 只在带锁共享区更新最新帧/状态/待弹窗并 `Invalidate()`,Gio 事件循环在独立 goroutine 只渲染最新帧(取代 Walk 的 `main.Synchronize`);声音用 Windows `MessageBeep` syscall(`sound_windows.go` + 非 Windows 空实现),弹窗自绘遮罩。`ViewModel` 逻辑复用、`spec_test.go` 不受影响。 - 下一步:写 Gio `window.go` + sound 文件,交叉编译通过后接 `cmd/silver-pose` 验证。 + +## 【2026-07-23】T-306 V2 UI 从 Walk 迁移到 Gio(代码完成 + 交叉编译验证) + +- 状态:DOING(迁移代码完成 + Windows 交叉编译通过;窗口/声音/弹窗目视需 Windows) +- 变更:用 Gio 重写 `v2/internal/ui/window.go`,保持 `Window` 公开接口(`NewWindow/Run/Present/PresentAlert/ReportIssue`)与 `spec.go` 的 `ViewModel`/`MonitorWindowSpec` 不变。后台 `Present/PresentAlert/ReportIssue` 只在带锁共享区更新最新帧/状态/待弹窗并 `Invalidate()`;Gio 事件循环在独立 goroutine 渲染最新帧(取代 Walk 的 `main.Synchronize`)。双 Tab(监控/设置)、`widget.Image` 贴 render 的 `*image.RGBA`、确认弹窗自绘遮罩+“我已知晓”、声音用 `MessageBeep`(`sound_windows.go` syscall + `sound_other.go` 空实现)。`cmd/silver-pose`/`monitor`/`alert` 未改。`go mod tidy` 移除 `lxn/walk`、`lxn/win`、`govaluate.v3`,新增 `golang.org/x/sys`(声音)。 +- 验证:`GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build ./internal/ui/... ./cmd/ui-spike/... ./cmd/gio-spike/...` → 全部退出码 0(Gio Windows 版无 CGo,可对真实 Windows API 型检)。`grep lxn/walk` 全仓库无剩余引用。`cmd/silver-pose` 全量构建需 Windows + `CGO_ENABLED=1`(onnxruntime_go 依赖 CGo,迁移前即如此),本环境无 mingw 故不构建。 +- 阻塞:真实窗口显示、声音、弹窗目视需 Windows。`go run ./cmd/ui-spike` 为 CGo-free,可单独目视迁移后的 Gio 窗口外壳;`go run ./cmd/silver-pose` 需 `CGO_ENABLED=1` 起全量管线。 +- 决策:`internal/ui` 正式由 Walk 迁至 Gio,彻底消除 comctl/manifest(`TTM_ADDTOOL`)问题;UI 只渲染已判定的最新帧,事件计算仍在后台。 +- 下一步:用户 Windows 目视 `go run ./cmd/ui-spike`(外壳)与 `go run ./cmd/silver-pose`(全量,`CGO_ENABLED=1`)确认起窗、贴视频、双 Tab、确认弹窗与声音;通过则 T-306 与 T-304 一并推进 DONE。 diff --git a/v2/go.mod b/v2/go.mod index f7820b4..a6962ea 100644 --- a/v2/go.mod +++ b/v2/go.mod @@ -4,10 +4,9 @@ go 1.24.0 require ( gioui.org v0.10.1 - github.com/lxn/walk v0.0.0-20210112085537-c389da54e794 - github.com/lxn/win v0.0.0-20210218163916-a377121e959e github.com/yalue/onnxruntime_go v1.31.0 golang.org/x/image v0.26.0 + golang.org/x/sys v0.39.0 ) require ( @@ -15,7 +14,5 @@ require ( github.com/go-text/typesetting v0.3.4 // indirect golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/net v0.48.0 // indirect - golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect - gopkg.in/Knetic/govaluate.v3 v3.0.0 // indirect ) diff --git a/v2/go.sum b/v2/go.sum index 2d78013..ca2497b 100644 --- a/v2/go.sum +++ b/v2/go.sum @@ -9,10 +9,6 @@ github.com/go-text/typesetting v0.3.4 h1:YYurUOtEb9kGSOz4uE3k4OpBGsp1dDL8+fjCeaF github.com/go-text/typesetting v0.3.4/go.mod h1:4qZCQphq4KSgGTAeI0uMEkVbROgfah8BuyF5LRYr7XY= github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3 h1:drBZzMgdYPbmyXqOto4YhhJGrFIQCX94FpR4MzTCsos= github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o= -github.com/lxn/walk v0.0.0-20210112085537-c389da54e794 h1:NVRJ0Uy0SOFcXSKLsS65OmI1sgCCfiDUPj+cwnH7GZw= -github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ= -github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc= -github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk= github.com/yalue/onnxruntime_go v1.31.0 h1:1ln4YW1SFOFfGJZXe3jNOb2JUSt+l2pEneZfV8HdtFA= github.com/yalue/onnxruntime_go v1.31.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= @@ -23,10 +19,7 @@ golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY= golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= -golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= -gopkg.in/Knetic/govaluate.v3 v3.0.0 h1:18mUyIt4ZlRlFZAAfVetz4/rzlJs9yhN+U02F4u1AOc= -gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E= diff --git a/v2/internal/ui/sound_other.go b/v2/internal/ui/sound_other.go new file mode 100644 index 0000000..5ba6f04 --- /dev/null +++ b/v2/internal/ui/sound_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package ui + +// playAlertSound is a no-op off Windows; the V2 UI targets Windows and uses the +// system beep there. This stub keeps the package building for cross-checks. +func playAlertSound() {} diff --git a/v2/internal/ui/sound_windows.go b/v2/internal/ui/sound_windows.go new file mode 100644 index 0000000..af121cb --- /dev/null +++ b/v2/internal/ui/sound_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package ui + +import "golang.org/x/sys/windows" + +// MB_ICONHAND selects the system critical-stop sound for a confirmed fall. +const mbIconHand = 0x00000010 + +var ( + user32 = windows.NewLazySystemDLL("user32.dll") + procMessageBeep = user32.NewProc("MessageBeep") +) + +func playAlertSound() { + _, _, _ = procMessageBeep.Call(uintptr(mbIconHand)) +} diff --git a/v2/internal/ui/window.go b/v2/internal/ui/window.go index ddb7995..f9d13ec 100644 --- a/v2/internal/ui/window.go +++ b/v2/internal/ui/window.go @@ -2,17 +2,40 @@ package ui import ( "fmt" + "image" + "image/color" + "log" + "os" "strings" + "sync" - "github.com/lxn/walk" - d "github.com/lxn/walk/declarative" - "github.com/lxn/win" + "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 @@ -25,175 +48,322 @@ type Commands struct { Stop func() } -// Window is an observer of monitor updates. It does not own a stream, runtime, -// fall engine, or event artifact writer. +// 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 { - main *walk.MainWindow - imageView *walk.ImageView - connectionLabel *walk.TextLabel - stateLabel *walk.TextLabel - eventLabel *walk.TextLabel - startButton *walk.PushButton - stopButton *walk.PushButton - model ViewModel - bitmap *walk.Bitmap - seenAlerts map[string]struct{} - commands Commands + 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() - window := &Window{model: NewViewModel(), seenAlerts: make(map[string]struct{}), commands: commands} - window.model.SetSourceReady(settings.SourceEnvironment, settings.SourceEnvironment != "") - background := d.SolidColorBrush{Color: walk.RGB(234, 241, 248)} - surface := d.SolidColorBrush{Color: walk.RGB(255, 255, 255)} - if err := (d.MainWindow{ - AssignTo: &window.main, - Title: spec.Title, - Size: d.Size{Width: spec.Width, Height: spec.Height}, - MinSize: d.Size{Width: 960, Height: 640}, - Background: background, - Layout: d.VBox{}, - Children: []d.Widget{ - d.TabWidget{ - Pages: []d.TabPage{ - { - Title: "监控", - Layout: d.HBox{}, - Children: []d.Widget{ - d.Composite{ - Background: surface, - Layout: d.VBox{}, - StretchFactor: 3, - Children: []d.Widget{ - d.TextLabel{Text: "实时画面", ToolTipText: "显示完成推理与姿态叠加后的最新画面"}, - d.ImageView{AssignTo: &window.imageView, Mode: d.ImageViewModeZoom, StretchFactor: 8, ToolTipText: "实时姿态画面"}, - }, - }, - d.Composite{ - Background: surface, - Layout: d.VBox{}, - StretchFactor: 2, - Children: []d.Widget{ - d.TextLabel{Text: "运行状态"}, - d.TextLabel{AssignTo: &window.connectionLabel, Text: window.model.ConnectionText}, - d.TextLabel{AssignTo: &window.stateLabel, Text: window.model.StateText}, - d.TextLabel{AssignTo: &window.eventLabel, Text: window.model.EventText}, - d.PushButton{AssignTo: &window.startButton, Text: "开始监控", ToolTipText: "使用当前配置启动单路监控", OnClicked: window.start}, - d.PushButton{AssignTo: &window.stopButton, Text: "停止监控", Enabled: false, ToolTipText: "停止监控并释放本地运行资源", OnClicked: window.stop}, - d.TextLabel{Text: "红色仅表示已确认摔倒。连接异常不会触发报警。"}, - }, - }, - }, - }, - { - Title: "设置", - Layout: d.VBox{}, - Children: []d.Widget{ - d.TextLabel{Text: "来源与运行依赖(只读,不显示 RTSP 地址或密码)"}, - d.TextLabel{Text: window.model.SettingsSummary}, - d.TextLabel{Text: "模型 SHA-256:" + shortHash(settings.ModelSHA256)}, - d.TextLabel{Text: "运行依赖:" + settings.RuntimeSummary}, - d.TextLabel{Text: "事件参数:" + settings.EventSummary}, - d.TextLabel{Text: "参数在配置文件中维护;修改后请停止并重新开始监控。"}, - }, - }, - }, - }, - }, - }).Create(); err != nil { - return nil, err + 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.main.Closing().Attach(func(canceled *bool, reason walk.CloseReason) { - window.stop() - if window.bitmap != nil { - window.bitmap.Dispose() - window.bitmap = nil - } - }) + window.tabButton = make([]widget.Clickable, len(window.tabs)) return window, nil } -func (window *Window) Run() int { return window.main.Run() } +// 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() } - window.startButton.SetEnabled(false) - window.stopButton.SetEnabled(true) } func (window *Window) stop() { if window.commands.Stop != nil { window.commands.Stop() } - if window.startButton != nil { - window.startButton.SetEnabled(true) - } - if window.stopButton != nil { - window.stopButton.SetEnabled(false) - } + window.running = false } -// Present runs only the final state assignment on the UI thread. +// Present stores the newest analysed frame and state, then requests a redraw. func (window *Window) Present(update monitor.Update) { - window.main.Synchronize(func() { - state := highestState(update.Result) - window.model.Apply(state, update.SourceStatus) - if update.SourceMessage != "" { - window.model.ConnectionText = "视频流:" + update.SourceMessage - } - _ = window.connectionLabel.SetText(window.model.ConnectionText) - _ = window.stateLabel.SetText(window.model.StateText) - _ = window.eventLabel.SetText(window.model.EventText) - if update.Image == nil { - return - } - bitmap, err := walk.NewBitmapFromImage(update.Image) - if err != nil { - return - } - if err := window.imageView.SetImage(bitmap); err != nil { - bitmap.Dispose() - return - } - previous := window.bitmap - window.bitmap = bitmap - if previous != nil { - previous.Dispose() - } - }) + 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 is called by a consumer of Monitor.Alerts. Event IDs are checked -// again at the UI boundary so a reconnect/repaint cannot replay sound or popup. +// 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.main.Synchronize(func() { - if _, seen := window.seenAlerts[record.Event.EventID]; seen { - 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 } - window.seenAlerts[record.Event.EventID] = struct{}{} - win.MessageBeep(win.MB_ICONHAND) - message := fmt.Sprintf("确认摔倒\n人员:%s\n延迟:%.2f 秒\n截图:%s", record.Event.TrackID, record.Event.LatencySeconds, record.ScreenshotPath) - walk.MsgBox(window.main, "Silver Pose V2 · 确认摔倒", message, walk.MsgBoxOK|walk.MsgBoxIconError) + } + 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), + ) }) } -// ReportIssue communicates a recoverable startup/worker issue without using -// critical-red fall language or exposing configuration values. -func (window *Window) ReportIssue(message string) { - window.main.Synchronize(func() { - window.model.ConnectionText = "视频流:" + message - window.model.Critical = false - _ = window.connectionLabel.SetText(window.model.ConnectionText) - _ = window.stateLabel.SetText("检测状态:正常") +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 {