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>
This commit is contained in:
+306
-136
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user