diff --git a/progress.md b/progress.md index 72b7838..e0ad73c 100644 --- a/progress.md +++ b/progress.md @@ -595,3 +595,12 @@ - 阻塞:窗口目视需 Windows。 - 决策:画廊数据源用 `Present` 里的确认帧(`update.Image` 是 render 输出的截图内容)+ `update.Events` 记录,按 event_id 去重、UI 线程外收集;`paint.ImageOp` 每项只建一次并缓存,避免逐帧重解;双击用点击时间窗判定,全图弹窗复用遮罩 Stack。 - 下一步:改 window.go 布局与画廊,交叉编译通过后用户 Windows 目视。 + +## 【2026-07-23】T-307 V2 监控界面优化(代码完成 + 交叉编译验证) + +- 状态:DOING(代码完成 + Windows 交叉编译通过;窗口目视需 Windows) +- 变更:重写 `v2/internal/ui/window.go` 布局——右栏:①运行状态+检测状态压一行(Critical 红)②开始/停止用 `smallButton`(小内边距/字号)同一行 ③"事件"文本与"红色=确认摔倒"说明同一行 ④剩余区域 `layoutGallery` 3×3 倒序展示最近 9 张确认截图 ⑤双击缩略图(350ms 内两次点击)经 `layoutViewer` 全屏遮罩弹全图、"关闭"按钮退出。画廊数据源:`Present` 收到含确认事件的 `update`(`update.Image` 即 render 截图内容)时按 event_id 去重入队(UI 线程外),`layout` 里每项建一次 `paint.ImageOp` 缓存、超 9 张淘汰最老。确认弹窗与图片弹窗共用遮罩 Stack。 +- 验证:`GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -mod=vendor ./internal/ui/... ./cmd/ui-spike/...` 退出码 0。未引入新依赖(仅 stdlib `time`),vendor 不变。本环境无法运行 Gio,布局/双击目视需 Windows。 +- 阻塞:窗口目视需 Windows。 +- 决策:缩略图与全图复用同一 `ImageOp`(Gio 只上传一次纹理,缩略图 cell 用 `Fit:Contain` 缩放);`gallery` 存指针避免拷贝 `widget.Clickable`;`viewing` 存 `*galleryItem` 指针,列表重排也稳定。 +- 下一步:用户 Windows `go run ./cmd/silver-pose` 目视新布局与画廊、双击看原图;据反馈微调间距/比例后置 DONE。 diff --git a/v2/internal/ui/window.go b/v2/internal/ui/window.go index f9d13ec..2ac6c8e 100644 --- a/v2/internal/ui/window.go +++ b/v2/internal/ui/window.go @@ -8,6 +8,7 @@ import ( "os" "strings" "sync" + "time" "gioui.org/app" "gioui.org/font/gofont" @@ -25,6 +26,8 @@ import ( "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} @@ -33,7 +36,7 @@ var ( 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} + colorScrim = color.NRGBA{A: 0xB0} ) type Settings struct { @@ -48,11 +51,23 @@ type Commands struct { 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. +// 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 @@ -66,6 +81,8 @@ type Window struct { 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 @@ -76,7 +93,9 @@ type Window struct { startButton widget.Clickable stopButton widget.Clickable ackButton widget.Clickable - running bool + gallery []*galleryItem + viewing *galleryItem + viewerClose widget.Clickable } func NewWindow(settings Settings, commands Commands) (*Window, error) { @@ -105,15 +124,13 @@ func NewWindow(settings Settings, commands Commands) (*Window, error) { 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 } -// 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() @@ -142,10 +159,6 @@ func (window *Window) loop() error { } func (window *Window) start() { - if window.running { - return - } - window.running = true if window.commands.Start != nil { window.commands.Start() } @@ -155,10 +168,9 @@ 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. +// 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) @@ -170,6 +182,22 @@ func (window *Window) Present(update monitor.Update) { 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() } @@ -208,44 +236,82 @@ func (window *Window) layout(gtx layout.Context) layout.Dimensions { window.mu.Lock() model := window.model - image := window.latestImage + frame := window.latestImage gen := window.imageGen pending := window.pending + incoming := window.incoming + window.incoming = nil window.mu.Unlock() - hasImage := image != nil - if hasImage && gen != window.imageShownGen { - window.imageOp = paint.NewImageOp(image) + 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, hasImage) + return window.layoutMonitor(gtx, model, hasFrame) } return window.layoutSettings(gtx, model) }), ) } - if pending == nil { + 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, - 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) - }) - }), - ) + 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 { @@ -269,70 +335,147 @@ func (window *Window) layoutTabs(gtx layout.Context) layout.Dimensions { return layout.Flex{}.Layout(gtx, children...) } -func (window *Window) layoutMonitor(gtx layout.Context, model ViewModel, hasImage bool) layout.Dimensions { +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 !hasImage { + 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.layoutStatus(gtx, model) + return window.layoutRightPanel(gtx, model) }), ) } -func (window *Window) layoutStatus(gtx layout.Context, model ViewModel) layout.Dimensions { +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(12)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.UniformInset(unit.Dp(10)).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), + // Row 1: connection status and detection status on one line. 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) + 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(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), + // 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), - layout.Rigid(material.Body2(window.theme, "红色仅表示已确认摔倒。连接异常不会触发报警。").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 (window *Window) layoutSettings(gtx layout.Context, model ViewModel) layout.Dimensions { - return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { +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(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), + 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 { - 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 @@ -364,6 +507,19 @@ func (window *Window) layoutDialog(gtx layout.Context, record alert.Record) layo ) } +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 {