Build virtualized software list (T-203)

This commit is contained in:
ila
2026-07-16 17:10:42 +08:00
parent 819b1cdf88
commit db8d93e843
10 changed files with 1814 additions and 108 deletions
+662 -42
View File
@@ -1,39 +1,115 @@
package gio
import (
"fmt"
"image"
"image/color"
"gioui.org/io/semantic"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"softbox.local/core/application"
"softbox.local/core/domain"
)
var shellColors = struct {
background color.NRGBA
surface color.NRGBA
muted color.NRGBA
foreground color.NRGBA
secondary color.NRGBA
primary color.NRGBA
onPrimary color.NRGBA
secondary color.NRGBA
border color.NRGBA
success color.NRGBA
warning color.NRGBA
destructive color.NRGBA
}{
background: color.NRGBA{R: 248, G: 250, B: 252, A: 255},
surface: color.NRGBA{R: 255, G: 255, B: 255, A: 255},
muted: color.NRGBA{R: 240, G: 248, B: 246, A: 255},
foreground: color.NRGBA{R: 15, G: 23, B: 42, A: 255},
primary: color.NRGBA{R: 13, G: 148, B: 136, A: 255},
onPrimary: color.NRGBA{R: 255, G: 255, B: 255, A: 255},
secondary: color.NRGBA{R: 71, G: 85, B: 105, A: 255},
primary: color.NRGBA{R: 5, G: 150, B: 105, A: 255},
onPrimary: color.NRGBA{R: 255, G: 255, B: 255, A: 255},
border: color.NRGBA{R: 209, G: 229, B: 223, A: 255},
success: color.NRGBA{R: 4, G: 120, B: 87, A: 255},
warning: color.NRGBA{R: 180, G: 83, B: 9, A: 255},
destructive: color.NRGBA{R: 185, G: 28, B: 28, A: 255},
}
// AppShell is the modern window frame. Feature views are added in later tasks.
type rowControls struct {
open widget.Clickable
}
// AppShell is the modern software catalog window.
type AppShell struct {
edition string
model *application.CatalogListModel
search widget.Editor
appList layout.List
categoryList layout.List
viewAll widget.Clickable
viewInstalled widget.Clickable
viewUpdates widget.Clickable
resetFilters widget.Clickable
categoryControls map[string]*widget.Clickable
rows map[string]*rowControls
lastRendered int
}
// NewAppShell creates an empty shell for the supplied build edition.
func NewAppShell(edition string) *AppShell {
return &AppShell{edition: edition}
// NewAppShell creates the catalog shell with an optional in-memory snapshot.
func NewAppShell(
edition string,
items ...application.CatalogListItem,
) *AppShell {
shell := &AppShell{
edition: edition,
model: application.NewCatalogListModel(nil),
appList: layout.List{Axis: layout.Vertical},
categoryList: layout.List{Axis: layout.Horizontal},
categoryControls: make(map[string]*widget.Clickable),
rows: make(map[string]*rowControls),
}
shell.search.SingleLine = true
shell.SetItems(items)
return shell
}
// NewTheme creates the semantic palette shared by the modern shell.
// SetItems applies a prepared, IO-free catalog/status snapshot.
func (shell *AppShell) SetItems(items []application.CatalogListItem) {
shell.model.SetItems(items)
nextRows := make(map[string]*rowControls, len(items))
for _, item := range items {
controls := shell.rows[item.ID]
if controls == nil {
controls = new(rowControls)
}
nextRows[item.ID] = controls
}
shell.rows = nextRows
nextCategories := make(map[string]*widget.Clickable)
for _, category := range append([]string{""}, shell.model.Categories()...) {
control := shell.categoryControls[category]
if control == nil {
control = new(widget.Clickable)
}
nextCategories[category] = control
}
shell.categoryControls = nextCategories
}
// NewTheme creates the accessible semantic palette shared by the modern shell.
func NewTheme() *material.Theme {
theme := material.NewTheme()
theme.Palette = material.Palette{
@@ -46,45 +122,589 @@ func NewTheme() *material.Theme {
return theme
}
// Layout renders the shell without performing IO or submitting use cases.
func (shell *AppShell) Layout(context layout.Context, theme *material.Theme) layout.Dimensions {
paint.Fill(context.Ops, theme.Bg)
// Layout drains input first and performs no disk, network or hash IO.
func (shell *AppShell) Layout(gtx layout.Context, theme *material.Theme) layout.Dimensions {
shell.drainInput(gtx)
shell.lastRendered = 0
paint.Fill(gtx.Ops, shellColors.background)
return layout.UniformInset(unit.Dp(32)).Layout(context, func(context layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(20)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(
context,
layout.Rigid(func(context layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(
context,
layout.Rigid(material.H5(theme, "SoftBox").Layout),
layout.Rigid(layout.Spacer{Width: unit.Dp(16)}.Layout),
layout.Rigid(func(context layout.Context) layout.Dimensions {
label := material.Body2(theme, shell.edition)
label.Color = shellColors.primary
return label.Layout(context)
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return shell.layoutHeader(gtx, theme)
}),
)
layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return shell.layoutContent(gtx, theme)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(24)}.Layout),
layout.Flexed(1, func(context layout.Context) layout.Dimensions {
return layout.Center.Layout(context, func(context layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(
context,
layout.Rigid(material.H6(theme, "软件目录准备中").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(context layout.Context) layout.Dimensions {
label := material.Body1(theme, "当前窗口仅验证 Gio AppShell 与双构建链路。")
label.Color = shellColors.secondary
return label.Layout(context)
}),
)
})
}),
layout.Rigid(func(context layout.Context) layout.Dimensions {
label := material.Body2(theme, "Modern · Windows 10/11 x64")
label.Color = shellColors.secondary
return label.Layout(context)
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return shell.layoutFooter(gtx, theme)
}),
)
})
}
func (shell *AppShell) drainInput(gtx layout.Context) {
for {
if _, ok := shell.search.Update(gtx); !ok {
break
}
}
shell.model.SetQuery(shell.search.Text())
for shell.viewAll.Clicked(gtx) {
shell.model.SetView(application.CatalogViewAll)
}
for shell.viewInstalled.Clicked(gtx) {
shell.model.SetView(application.CatalogViewInstalled)
}
for shell.viewUpdates.Clicked(gtx) {
shell.model.SetView(application.CatalogViewUpdates)
}
for category, control := range shell.categoryControls {
for control.Clicked(gtx) {
shell.model.SetCategory(category)
}
}
for appID, controls := range shell.rows {
for controls.open.Clicked(gtx) {
shell.model.Select(appID)
}
}
for shell.resetFilters.Clicked(gtx) {
shell.search.SetText("")
shell.model.ResetFilters()
}
}
func (shell *AppShell) layoutHeader(
gtx layout.Context,
theme *material.Theme,
) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(material.H4(theme, "SoftBox").Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Body2(theme, "发现、安装并更新可信软件")
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(48)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
border := shellColors.border
if gtx.Focused(&shell.search) {
border = shellColors.primary
}
return outlinedPanel(
gtx,
border,
shellColors.surface,
unit.Dp(8),
layout.Inset{
Top: unit.Dp(10), Bottom: unit.Dp(10),
Left: unit.Dp(14), Right: unit.Dp(14),
},
func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(24))
editor := material.Editor(theme, &shell.search, "搜索名称、软件 ID 或标签")
editor.TextSize = unit.Sp(15)
return editor.Layout(gtx)
},
)
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return shell.layoutCategories(gtx, theme)
}),
)
}
func (shell *AppShell) layoutCategories(
gtx layout.Context,
theme *material.Theme,
) layout.Dimensions {
categories := append([]string{""}, shell.model.Categories()...)
height := gtx.Dp(unit.Dp(44))
gtx.Constraints.Min.Y = height
gtx.Constraints.Max.Y = height
return shell.categoryList.Layout(gtx, len(categories), func(
gtx layout.Context,
index int,
) layout.Dimensions {
category := categories[index]
label := category
if label == "" {
label = "全部分类"
}
return layout.Inset{Right: unit.Dp(8)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return shell.layoutFilterButton(
gtx,
theme,
shell.categoryControls[category],
label,
shell.model.Category() == category,
)
})
})
}
func (shell *AppShell) layoutContent(
gtx layout.Context,
theme *material.Theme,
) layout.Dimensions {
return layout.Flex{}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
width := gtx.Dp(unit.Dp(168))
gtx.Constraints.Min.X = width
gtx.Constraints.Max.X = width
return panel(
gtx,
shellColors.muted,
unit.Dp(10),
layout.UniformInset(unit.Dp(12)),
func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Caption(theme, "软件视图")
label.Color = shellColors.secondary
return layout.Inset{
Left: unit.Dp(8), Bottom: unit.Dp(8),
}.Layout(gtx, label.Layout)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return shell.layoutViewButton(
gtx,
theme,
&shell.viewAll,
"全部软件",
application.CatalogViewAll,
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return shell.layoutViewButton(
gtx,
theme,
&shell.viewInstalled,
"已安装",
application.CatalogViewInstalled,
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return shell.layoutViewButton(
gtx,
theme,
&shell.viewUpdates,
"可更新",
application.CatalogViewUpdates,
)
}),
)
},
)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(16)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return panel(
gtx,
shellColors.surface,
unit.Dp(10),
layout.UniformInset(unit.Dp(16)),
func(gtx layout.Context) layout.Dimensions {
return shell.layoutCatalog(gtx, theme)
},
)
}),
)
}
func (shell *AppShell) layoutCatalog(
gtx layout.Context,
theme *material.Theme,
) layout.Dimensions {
visible := shell.model.VisibleItems()
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(
gtx,
layout.Rigid(material.H6(theme, viewTitle(shell.model.View())).Layout),
layout.Flexed(1, layout.Spacer{}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Body2(
theme,
fmt.Sprintf("%d / %d 项", len(visible), shell.model.TotalCount()),
)
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
if len(visible) == 0 {
return shell.layoutEmptyState(gtx, theme)
}
return shell.appList.Layout(gtx, len(visible), func(
gtx layout.Context,
index int,
) layout.Dimensions {
shell.lastRendered++
return shell.layoutAppRow(gtx, theme, visible[index])
})
}),
)
}
func (shell *AppShell) layoutAppRow(
gtx layout.Context,
theme *material.Theme,
item application.CatalogListItem,
) layout.Dimensions {
controls := shell.rows[item.ID]
if controls == nil {
return layout.Dimensions{}
}
return layout.Inset{Bottom: unit.Dp(8)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(88))
return controls.open.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
semantic.Button.Add(gtx.Ops)
semantic.DescriptionOp(fmt.Sprintf(
"%s,版本 %s,状态 %s",
item.Name,
item.Version,
statusLabel(item.Status),
)).Add(gtx.Ops)
background := shellColors.muted
if controls.open.Hovered() || gtx.Focused(&controls.open) {
background = color.NRGBA{R: 236, G: 253, B: 245, A: 255}
}
if shell.model.SelectedID() == item.ID {
background = color.NRGBA{R: 220, G: 252, B: 231, A: 255}
}
return panel(
gtx,
background,
unit.Dp(8),
layout.UniformInset(unit.Dp(12)),
func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return shell.layoutLetterIcon(gtx, theme, item.Name)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(material.H6(theme, item.Name).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Body2(
theme,
fmt.Sprintf(
"%s · %s · %s",
item.ID,
item.Version,
item.Category,
),
)
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical, Alignment: layout.End}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Body1(theme, statusLabel(item.Status))
label.Color = statusColor(item.Status)
return label.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Caption(theme, actionLabel(item))
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
)
}),
)
},
)
})
})
}
func (shell *AppShell) layoutLetterIcon(
gtx layout.Context,
theme *material.Theme,
name string,
) layout.Dimensions {
size := gtx.Dp(unit.Dp(48))
gtx.Constraints.Min = image.Pt(size, size)
gtx.Constraints.Max = gtx.Constraints.Min
letter := "S"
for _, character := range name {
letter = string(character)
break
}
return panel(
gtx,
shellColors.primary,
unit.Dp(8),
layout.UniformInset(unit.Dp(0)),
func(gtx layout.Context) layout.Dimensions {
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
label := material.H6(theme, letter)
label.Color = shellColors.onPrimary
return label.Layout(gtx)
})
},
)
}
func (shell *AppShell) layoutEmptyState(
gtx layout.Context,
theme *material.Theme,
) layout.Dimensions {
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
title := "没有匹配的软件"
body := "尝试清除搜索词、分类或视图筛选。"
showReset := shell.model.TotalCount() > 0
if shell.model.TotalCount() == 0 {
title = "软件目录尚未加载"
body = "联网刷新或存在已验证缓存后,软件会显示在这里。"
}
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(
gtx,
layout.Rigid(material.H6(theme, title).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Body2(theme, body)
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showReset {
return layout.Dimensions{}
}
return layout.Inset{Top: unit.Dp(16)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return shell.layoutFilterButton(
gtx,
theme,
&shell.resetFilters,
"显示全部软件",
true,
)
})
}),
)
})
}
func (shell *AppShell) layoutViewButton(
gtx layout.Context,
theme *material.Theme,
clickable *widget.Clickable,
label string,
view application.CatalogView,
) layout.Dimensions {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return shell.layoutFilterButton(
gtx,
theme,
clickable,
label,
shell.model.View() == view,
)
}
func (shell *AppShell) layoutFilterButton(
gtx layout.Context,
theme *material.Theme,
clickable *widget.Clickable,
label string,
active bool,
) layout.Dimensions {
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(44))
button := material.Button(theme, clickable, label)
button.CornerRadius = unit.Dp(8)
button.Inset = layout.Inset{
Top: unit.Dp(10), Bottom: unit.Dp(10),
Left: unit.Dp(14), Right: unit.Dp(14),
}
if active {
button.Background = shellColors.primary
button.Color = shellColors.onPrimary
} else {
button.Background = shellColors.muted
button.Color = shellColors.foreground
}
return button.Layout(gtx)
}
func (shell *AppShell) layoutFooter(
gtx layout.Context,
theme *material.Theme,
) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Caption(theme, "目录状态:等待已验证 Catalog")
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
layout.Flexed(1, layout.Spacer{}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Caption(theme, shell.edition+" · Windows 10/11 x64")
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
)
}
func panel(
gtx layout.Context,
background color.NRGBA,
radius unit.Dp,
inset layout.Inset,
content layout.Widget,
) layout.Dimensions {
return layout.Background{}.Layout(
gtx,
func(gtx layout.Context) layout.Dimensions {
paint.FillShape(
gtx.Ops,
background,
clip.UniformRRect(
image.Rectangle{Max: gtx.Constraints.Min},
gtx.Dp(radius),
).Op(gtx.Ops),
)
return layout.Dimensions{Size: gtx.Constraints.Min}
},
func(gtx layout.Context) layout.Dimensions {
return inset.Layout(gtx, content)
},
)
}
func outlinedPanel(
gtx layout.Context,
border color.NRGBA,
background color.NRGBA,
radius unit.Dp,
inset layout.Inset,
content layout.Widget,
) layout.Dimensions {
return panel(
gtx,
border,
radius,
layout.UniformInset(unit.Dp(1)),
func(gtx layout.Context) layout.Dimensions {
return panel(gtx, background, radius-unit.Dp(1), inset, content)
},
)
}
func viewTitle(view application.CatalogView) string {
switch view {
case application.CatalogViewInstalled:
return "已安装软件"
case application.CatalogViewUpdates:
return "可更新软件"
default:
return "全部软件"
}
}
func statusLabel(status domain.AppStatus) string {
switch status {
case domain.StatusQueued:
return "排队中"
case domain.StatusDownloading:
return "下载中"
case domain.StatusVerifying:
return "校验中"
case domain.StatusExtracting:
return "解压中"
case domain.StatusInstalling:
return "安装中"
case domain.StatusInstalled:
return "已安装"
case domain.StatusUpdateAvailable:
return "可更新"
case domain.StatusRunning:
return "运行中"
case domain.StatusFailed:
return "失败"
case domain.StatusRollbackPending:
return "待恢复"
case domain.StatusIncompatible:
return "不兼容"
default:
return "未安装"
}
}
func statusColor(status domain.AppStatus) color.NRGBA {
switch status {
case domain.StatusFailed, domain.StatusRollbackPending:
return shellColors.destructive
case domain.StatusUpdateAvailable:
return shellColors.warning
case domain.StatusInstalled, domain.StatusRunning:
return shellColors.success
case domain.StatusIncompatible:
return shellColors.secondary
default:
return shellColors.primary
}
}
func actionLabel(item application.CatalogListItem) string {
if item.Reason != "" || item.Status == domain.StatusIncompatible {
return "查看不可用原因"
}
switch item.Status {
case domain.StatusInstalled:
return "查看或启动"
case domain.StatusUpdateAvailable:
return "查看更新"
case domain.StatusRunning:
return "查看运行状态"
case domain.StatusQueued,
domain.StatusDownloading,
domain.StatusVerifying,
domain.StatusExtracting,
domain.StatusInstalling:
return "查看任务"
case domain.StatusFailed, domain.StatusRollbackPending:
return "查看恢复选项"
default:
if item.Installable {
return "查看并安装"
}
return "查看详情"
}
}
+64
View File
@@ -1,12 +1,16 @@
package gio
import (
"fmt"
"image"
"testing"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/unit"
"softbox.local/core/application"
"softbox.local/core/domain"
)
func TestAppShellFillsWindow(t *testing.T) {
@@ -23,3 +27,63 @@ func TestAppShellFillsWindow(t *testing.T) {
t.Fatalf("Layout() size = %v, want %v", dimensions.Size, size)
}
}
func TestAppShellVirtualizesLargeCatalog(t *testing.T) {
items := make([]application.CatalogListItem, 500)
for index := range items {
items[index] = application.CatalogListItem{
ID: fmt.Sprintf("app-%03d", index),
Name: fmt.Sprintf("软件 %03d", index),
Version: "1.0.0",
Category: "工具",
Tags: []string{"工具"},
Status: domain.StatusNotInstalled,
Installable: true,
}
}
shell := NewAppShell("Modern", items...)
context := testContext(image.Pt(1080, 420))
shell.Layout(context, NewTheme())
if shell.lastRendered <= 0 || shell.lastRendered >= len(items) {
t.Fatalf(
"lastRendered = %d, want visible subset of %d",
shell.lastRendered,
len(items),
)
}
}
func TestAppShellKeepsRowControlsByAppID(t *testing.T) {
items := []application.CatalogListItem{
{ID: "app-one", Name: "One", Version: "1.0.0", Category: "工具"},
{ID: "app-two", Name: "Two", Version: "1.0.0", Category: "图像"},
}
shell := NewAppShell("Modern", items...)
original := shell.rows["app-two"]
shell.model.SetCategory("图像")
shell.Layout(testContext(image.Pt(1080, 720)), NewTheme())
if shell.rows["app-two"] != original {
t.Fatal("row controls were recreated after filtering")
}
shell.SetItems([]application.CatalogListItem{
{ID: "app-two", Name: "Two", Version: "1.1.0", Category: "图像"},
})
if shell.rows["app-two"] != original {
t.Fatal("row controls were recreated after snapshot update")
}
if _, exists := shell.rows["app-one"]; exists {
t.Fatal("removed app retained row controls")
}
}
func testContext(size image.Point) layout.Context {
var operations op.Ops
return layout.Context{
Ops: &operations,
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
Constraints: layout.Exact(size),
}
}
+577 -39
View File
@@ -1,39 +1,115 @@
package gio
import (
"fmt"
"image"
"image/color"
"gioui.org/io/semantic"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"softbox.local/core/application"
"softbox.local/core/domain"
)
var shellColors = struct {
background color.NRGBA
surface color.NRGBA
muted color.NRGBA
foreground color.NRGBA
secondary color.NRGBA
primary color.NRGBA
onPrimary color.NRGBA
secondary color.NRGBA
border color.NRGBA
success color.NRGBA
warning color.NRGBA
destructive color.NRGBA
}{
background: color.NRGBA{R: 248, G: 250, B: 252, A: 255},
surface: color.NRGBA{R: 255, G: 255, B: 255, A: 255},
muted: color.NRGBA{R: 240, G: 248, B: 246, A: 255},
foreground: color.NRGBA{R: 15, G: 23, B: 42, A: 255},
primary: color.NRGBA{R: 13, G: 148, B: 136, A: 255},
onPrimary: color.NRGBA{R: 255, G: 255, B: 255, A: 255},
secondary: color.NRGBA{R: 71, G: 85, B: 105, A: 255},
primary: color.NRGBA{R: 5, G: 150, B: 105, A: 255},
onPrimary: color.NRGBA{R: 255, G: 255, B: 255, A: 255},
border: color.NRGBA{R: 209, G: 229, B: 223, A: 255},
success: color.NRGBA{R: 4, G: 120, B: 87, A: 255},
warning: color.NRGBA{R: 180, G: 83, B: 9, A: 255},
destructive: color.NRGBA{R: 185, G: 28, B: 28, A: 255},
}
// AppShell is the Legacy window frame. Feature views are added in later tasks.
type rowControls struct {
open widget.Clickable
}
// AppShell is the Legacy software catalog window.
type AppShell struct {
edition string
model *application.CatalogListModel
search widget.Editor
appList layout.List
categoryList layout.List
viewAll widget.Clickable
viewInstalled widget.Clickable
viewUpdates widget.Clickable
resetFilters widget.Clickable
categoryControls map[string]*widget.Clickable
rows map[string]*rowControls
lastRendered int
}
// NewAppShell creates an empty shell for the supplied build edition.
func NewAppShell(edition string) *AppShell {
return &AppShell{edition: edition}
// NewAppShell creates the catalog shell with an optional in-memory snapshot.
func NewAppShell(
edition string,
items ...application.CatalogListItem,
) *AppShell {
shell := &AppShell{
edition: edition,
model: application.NewCatalogListModel(nil),
appList: layout.List{Axis: layout.Vertical},
categoryList: layout.List{Axis: layout.Horizontal},
categoryControls: make(map[string]*widget.Clickable),
rows: make(map[string]*rowControls),
}
shell.search.SingleLine = true
shell.SetItems(items)
return shell
}
// NewTheme creates the semantic palette shared by the Legacy shell.
// SetItems applies a prepared, IO-free catalog/status snapshot.
func (shell *AppShell) SetItems(items []application.CatalogListItem) {
shell.model.SetItems(items)
nextRows := make(map[string]*rowControls, len(items))
for _, item := range items {
controls := shell.rows[item.ID]
if controls == nil {
controls = new(rowControls)
}
nextRows[item.ID] = controls
}
shell.rows = nextRows
nextCategories := make(map[string]*widget.Clickable)
for _, category := range append([]string{""}, shell.model.Categories()...) {
control := shell.categoryControls[category]
if control == nil {
control = new(widget.Clickable)
}
nextCategories[category] = control
}
shell.categoryControls = nextCategories
}
// NewTheme creates the accessible palette shared by the Legacy shell.
func NewTheme() *material.Theme {
theme := material.NewTheme()
theme.Palette = material.Palette{
@@ -46,45 +122,507 @@ func NewTheme() *material.Theme {
return theme
}
// Layout renders the shell without performing IO or submitting use cases.
func (shell *AppShell) Layout(context layout.Context, theme *material.Theme) layout.Dimensions {
paint.Fill(context.Ops, theme.Bg)
// Layout drains input first and performs no disk, network or hash IO.
func (shell *AppShell) Layout(gtx layout.Context, theme *material.Theme) layout.Dimensions {
shell.drainInput(gtx)
shell.lastRendered = 0
paint.Fill(gtx.Ops, shellColors.background)
return layout.UniformInset(unit.Dp(32)).Layout(context, func(context 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(
context,
layout.Rigid(func(context layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(
context,
layout.Rigid(material.H5(theme, "SoftBox").Layout),
layout.Rigid(layout.Spacer{Width: unit.Dp(16)}.Layout),
layout.Rigid(func(context layout.Context) layout.Dimensions {
label := material.Body2(theme, shell.edition)
label.Color = shellColors.primary
return label.Layout(context)
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return shell.layoutHeader(gtx, theme)
}),
)
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return shell.layoutContent(gtx, theme)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(24)}.Layout),
layout.Flexed(1, func(context layout.Context) layout.Dimensions {
return layout.Center.Layout(context, func(context layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(
context,
layout.Rigid(material.H6(theme, "软件目录准备中").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(context layout.Context) layout.Dimensions {
label := material.Body1(theme, "Legacy 窗口已就绪,业务功能将在后续任务接入。")
label.Color = shellColors.secondary
return label.Layout(context)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Caption(
theme,
shell.edition+" · Legacy · Windows 7 SP1 x64",
)
})
}),
layout.Rigid(func(context layout.Context) layout.Dimensions {
label := material.Body2(theme, "Legacy · Windows 7 SP1 x64")
label.Color = shellColors.secondary
return label.Layout(context)
return label.Layout(gtx)
}),
)
})
}
func (shell *AppShell) drainInput(gtx layout.Context) {
for {
if _, ok := shell.search.Update(gtx); !ok {
break
}
}
shell.model.SetQuery(shell.search.Text())
for shell.viewAll.Clicked(gtx) {
shell.model.SetView(application.CatalogViewAll)
}
for shell.viewInstalled.Clicked(gtx) {
shell.model.SetView(application.CatalogViewInstalled)
}
for shell.viewUpdates.Clicked(gtx) {
shell.model.SetView(application.CatalogViewUpdates)
}
for category, control := range shell.categoryControls {
for control.Clicked(gtx) {
shell.model.SetCategory(category)
}
}
for appID, controls := range shell.rows {
for controls.open.Clicked(gtx) {
shell.model.Select(appID)
}
}
for shell.resetFilters.Clicked(gtx) {
shell.search.SetText("")
shell.model.ResetFilters()
}
}
func (shell *AppShell) layoutHeader(
gtx layout.Context,
theme *material.Theme,
) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(material.H5(theme, "SoftBox Legacy").Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Caption(theme, "兼容 Windows 7 SP1 的可信软件目录")
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(32)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
border := shellColors.border
if gtx.Focused(&shell.search) {
border = shellColors.primary
}
return outlinedPanel(
gtx,
border,
shellColors.surface,
unit.Dp(6),
layout.Inset{
Top: unit.Dp(9), Bottom: unit.Dp(9),
Left: unit.Dp(12), Right: unit.Dp(12),
},
func(gtx layout.Context) layout.Dimensions {
editor := material.Editor(theme, &shell.search, "搜索名称、ID 或标签")
editor.TextSize = unit.Sp(14)
return editor.Layout(gtx)
},
)
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
categories := append([]string{""}, shell.model.Categories()...)
height := gtx.Dp(unit.Dp(44))
gtx.Constraints.Min.Y = height
gtx.Constraints.Max.Y = height
return shell.categoryList.Layout(gtx, len(categories), func(
gtx layout.Context,
index int,
) layout.Dimensions {
category := categories[index]
label := category
if label == "" {
label = "全部分类"
}
return layout.Inset{Right: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return shell.layoutFilterButton(
gtx,
theme,
shell.categoryControls[category],
label,
shell.model.Category() == category,
)
})
})
}),
)
}
func (shell *AppShell) layoutContent(
gtx layout.Context,
theme *material.Theme,
) layout.Dimensions {
return layout.Flex{}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
width := gtx.Dp(unit.Dp(152))
gtx.Constraints.Min.X = width
gtx.Constraints.Max.X = width
return panel(
gtx,
shellColors.muted,
unit.Dp(6),
layout.UniformInset(unit.Dp(10)),
func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return shell.layoutViewButton(
gtx,
theme,
&shell.viewAll,
"全部软件",
application.CatalogViewAll,
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return shell.layoutViewButton(
gtx,
theme,
&shell.viewInstalled,
"已安装",
application.CatalogViewInstalled,
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return shell.layoutViewButton(
gtx,
theme,
&shell.viewUpdates,
"可更新",
application.CatalogViewUpdates,
)
}),
)
},
)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return panel(
gtx,
shellColors.surface,
unit.Dp(6),
layout.UniformInset(unit.Dp(12)),
func(gtx layout.Context) layout.Dimensions {
visible := shell.model.VisibleItems()
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(
gtx,
layout.Rigid(material.H6(theme, viewTitle(shell.model.View())).Layout),
layout.Flexed(1, layout.Spacer{}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Caption(
theme,
fmt.Sprintf("%d / %d 项", len(visible), shell.model.TotalCount()),
)
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
if len(visible) == 0 {
return shell.layoutEmptyState(gtx, theme)
}
return shell.appList.Layout(gtx, len(visible), func(
gtx layout.Context,
index int,
) layout.Dimensions {
shell.lastRendered++
return shell.layoutAppRow(gtx, theme, visible[index])
})
}),
)
},
)
}),
)
}
func (shell *AppShell) layoutAppRow(
gtx layout.Context,
theme *material.Theme,
item application.CatalogListItem,
) layout.Dimensions {
controls := shell.rows[item.ID]
if controls == nil {
return layout.Dimensions{}
}
return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(80))
return controls.open.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
semantic.Button.Add(gtx.Ops)
semantic.DescriptionOp(fmt.Sprintf(
"%s,版本 %s,状态 %s",
item.Name,
item.Version,
statusLabel(item.Status),
)).Add(gtx.Ops)
background := shellColors.muted
if controls.open.Hovered() || gtx.Focused(&controls.open) {
background = color.NRGBA{R: 236, G: 253, B: 245, A: 255}
}
if shell.model.SelectedID() == item.ID {
background = color.NRGBA{R: 220, G: 252, B: 231, A: 255}
}
return panel(
gtx,
background,
unit.Dp(5),
layout.UniformInset(unit.Dp(10)),
func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return shell.layoutLetterIcon(gtx, theme, item.Name)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(10)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(material.Body1(theme, item.Name).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(3)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Caption(
theme,
fmt.Sprintf("%s · %s · %s", item.ID, item.Version, item.Category),
)
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Body2(theme, statusLabel(item.Status))
label.Color = statusColor(item.Status)
return label.Layout(gtx)
}),
)
},
)
})
})
}
func (shell *AppShell) layoutLetterIcon(
gtx layout.Context,
theme *material.Theme,
name string,
) layout.Dimensions {
size := gtx.Dp(unit.Dp(40))
gtx.Constraints.Min = image.Pt(size, size)
gtx.Constraints.Max = gtx.Constraints.Min
letter := "S"
for _, character := range name {
letter = string(character)
break
}
return panel(
gtx,
shellColors.primary,
unit.Dp(5),
layout.UniformInset(unit.Dp(0)),
func(gtx layout.Context) layout.Dimensions {
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
label := material.Body1(theme, letter)
label.Color = shellColors.onPrimary
return label.Layout(gtx)
})
},
)
}
func (shell *AppShell) layoutEmptyState(
gtx layout.Context,
theme *material.Theme,
) layout.Dimensions {
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
title := "没有匹配的软件"
body := "清除搜索词、分类或视图筛选后重试。"
showReset := shell.model.TotalCount() > 0
if shell.model.TotalCount() == 0 {
title = "软件目录尚未加载"
body = "联网刷新或读取已验证缓存后会显示软件。"
}
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(
gtx,
layout.Rigid(material.H6(theme, title).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Caption(theme, body)
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showReset {
return layout.Dimensions{}
}
return layout.Inset{Top: unit.Dp(12)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return shell.layoutFilterButton(
gtx,
theme,
&shell.resetFilters,
"显示全部软件",
true,
)
})
}),
)
})
}
func (shell *AppShell) layoutViewButton(
gtx layout.Context,
theme *material.Theme,
clickable *widget.Clickable,
label string,
view application.CatalogView,
) layout.Dimensions {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return shell.layoutFilterButton(
gtx,
theme,
clickable,
label,
shell.model.View() == view,
)
}
func (shell *AppShell) layoutFilterButton(
gtx layout.Context,
theme *material.Theme,
clickable *widget.Clickable,
label string,
active bool,
) layout.Dimensions {
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(44))
button := material.Button(theme, clickable, label)
button.CornerRadius = unit.Dp(5)
button.Inset = layout.Inset{
Top: unit.Dp(10), Bottom: unit.Dp(10),
Left: unit.Dp(12), Right: unit.Dp(12),
}
if active {
button.Background = shellColors.primary
button.Color = shellColors.onPrimary
} else {
button.Background = shellColors.muted
button.Color = shellColors.foreground
}
return button.Layout(gtx)
}
func panel(
gtx layout.Context,
background color.NRGBA,
radius unit.Dp,
inset layout.Inset,
content layout.Widget,
) layout.Dimensions {
return layout.Background{}.Layout(
gtx,
func(gtx layout.Context) layout.Dimensions {
paint.FillShape(
gtx.Ops,
background,
clip.UniformRRect(
image.Rectangle{Max: gtx.Constraints.Min},
gtx.Dp(radius),
).Op(gtx.Ops),
)
return layout.Dimensions{Size: gtx.Constraints.Min}
},
func(gtx layout.Context) layout.Dimensions {
return inset.Layout(gtx, content)
},
)
}
func outlinedPanel(
gtx layout.Context,
border color.NRGBA,
background color.NRGBA,
radius unit.Dp,
inset layout.Inset,
content layout.Widget,
) layout.Dimensions {
return panel(
gtx,
border,
radius,
layout.UniformInset(unit.Dp(1)),
func(gtx layout.Context) layout.Dimensions {
return panel(gtx, background, radius-unit.Dp(1), inset, content)
},
)
}
func viewTitle(view application.CatalogView) string {
switch view {
case application.CatalogViewInstalled:
return "已安装软件"
case application.CatalogViewUpdates:
return "可更新软件"
default:
return "全部软件"
}
}
func statusLabel(status domain.AppStatus) string {
switch status {
case domain.StatusQueued:
return "排队中"
case domain.StatusDownloading:
return "下载中"
case domain.StatusVerifying:
return "校验中"
case domain.StatusExtracting:
return "解压中"
case domain.StatusInstalling:
return "安装中"
case domain.StatusInstalled:
return "已安装"
case domain.StatusUpdateAvailable:
return "可更新"
case domain.StatusRunning:
return "运行中"
case domain.StatusFailed:
return "失败"
case domain.StatusRollbackPending:
return "待恢复"
case domain.StatusIncompatible:
return "不兼容"
default:
return "未安装"
}
}
func statusColor(status domain.AppStatus) color.NRGBA {
switch status {
case domain.StatusFailed, domain.StatusRollbackPending:
return shellColors.destructive
case domain.StatusUpdateAvailable:
return shellColors.warning
case domain.StatusInstalled, domain.StatusRunning:
return shellColors.success
case domain.StatusIncompatible:
return shellColors.secondary
default:
return shellColors.primary
}
}
+62
View File
@@ -1,12 +1,16 @@
package gio
import (
"fmt"
"image"
"testing"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/unit"
"softbox.local/core/application"
"softbox.local/core/domain"
)
func TestAppShellFillsWindow(t *testing.T) {
@@ -23,3 +27,61 @@ func TestAppShellFillsWindow(t *testing.T) {
t.Fatalf("Layout() size = %v, want %v", dimensions.Size, size)
}
}
func TestAppShellVirtualizesLargeCatalog(t *testing.T) {
items := make([]application.CatalogListItem, 500)
for index := range items {
items[index] = application.CatalogListItem{
ID: fmt.Sprintf("app-%03d", index),
Name: fmt.Sprintf("软件 %03d", index),
Version: "1.0.0",
Category: "工具",
Tags: []string{"工具"},
Status: domain.StatusNotInstalled,
Installable: true,
}
}
shell := NewAppShell("Legacy", items...)
shell.Layout(testContext(image.Pt(1024, 380)), NewTheme())
if shell.lastRendered <= 0 || shell.lastRendered >= len(items) {
t.Fatalf(
"lastRendered = %d, want visible subset of %d",
shell.lastRendered,
len(items),
)
}
}
func TestAppShellKeepsRowControlsByAppID(t *testing.T) {
items := []application.CatalogListItem{
{ID: "app-one", Name: "One", Version: "1.0.0", Category: "工具"},
{ID: "app-two", Name: "Two", Version: "1.0.0", Category: "图像"},
}
shell := NewAppShell("Legacy", items...)
original := shell.rows["app-two"]
shell.model.SetCategory("图像")
shell.Layout(testContext(image.Pt(1024, 680)), NewTheme())
if shell.rows["app-two"] != original {
t.Fatal("row controls were recreated after filtering")
}
shell.SetItems([]application.CatalogListItem{
{ID: "app-two", Name: "Two", Version: "1.1.0", Category: "图像"},
})
if shell.rows["app-two"] != original {
t.Fatal("row controls were recreated after snapshot update")
}
if _, exists := shell.rows["app-one"]; exists {
t.Fatal("removed app retained row controls")
}
}
func testContext(size image.Point) layout.Context {
var operations op.Ops
return layout.Context{
Ops: &operations,
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
Constraints: layout.Exact(size),
}
}
+234
View File
@@ -0,0 +1,234 @@
package application
import (
"strings"
"softbox.local/core/domain"
)
// CatalogView is one primary software-list scope.
type CatalogView string
const (
CatalogViewAll CatalogView = "all"
CatalogViewInstalled CatalogView = "installed"
CatalogViewUpdates CatalogView = "updates"
)
// CatalogListItem is the IO-free data consumed by Gio list adapters.
type CatalogListItem struct {
ID string
Name string
Description string
Version string
Category string
Tags []string
Status domain.AppStatus
Installed bool
Installable bool
Reason string
}
// CatalogListModel owns source items and composable list filters.
type CatalogListModel struct {
items []CatalogListItem
visible []CatalogListItem
categories []string
query string
category string
view CatalogView
selectedID string
}
// NewCatalogListModel copies items and initializes the all view.
func NewCatalogListModel(items []CatalogListItem) *CatalogListModel {
model := &CatalogListModel{view: CatalogViewAll}
model.SetItems(items)
return model
}
// SetItems replaces the source snapshot and recomputes categories/visibility.
func (model *CatalogListModel) SetItems(items []CatalogListItem) {
model.items = cloneCatalogItems(items)
model.categories = collectCategories(model.items)
if model.category != "" && !containsString(model.categories, model.category) {
model.category = ""
}
if model.selectedID != "" && !containsItemID(model.items, model.selectedID) {
model.selectedID = ""
}
model.refilter()
}
// SetQuery applies a case-insensitive name/ID/tag search.
func (model *CatalogListModel) SetQuery(query string) {
normalized := strings.ToLower(strings.TrimSpace(query))
if model.query == normalized {
return
}
model.query = normalized
model.refilter()
}
// SetCategory selects one exact category. Empty means every category.
func (model *CatalogListModel) SetCategory(category string) {
if category != "" && !containsString(model.categories, category) {
return
}
if model.category == category {
return
}
model.category = category
model.refilter()
}
// SetView selects the all, installed or updates scope.
func (model *CatalogListModel) SetView(view CatalogView) {
if !view.Valid() || model.view == view {
return
}
model.view = view
model.refilter()
}
// Select records a stable app ID for row interaction state.
func (model *CatalogListModel) Select(appID string) {
if appID == "" || containsItemID(model.items, appID) {
model.selectedID = appID
}
}
// ResetFilters restores the full list while retaining source data.
func (model *CatalogListModel) ResetFilters() {
model.query = ""
model.category = ""
model.view = CatalogViewAll
model.refilter()
}
// VisibleItems returns the current immutable-by-convention snapshot.
func (model *CatalogListModel) VisibleItems() []CatalogListItem {
return model.visible
}
// Categories returns the stable first-seen category order.
func (model *CatalogListModel) Categories() []string {
return model.categories
}
// TotalCount returns the unfiltered source count.
func (model *CatalogListModel) TotalCount() int {
return len(model.items)
}
// Query returns the normalized active query.
func (model *CatalogListModel) Query() string {
return model.query
}
// Category returns the active exact category, or empty for all.
func (model *CatalogListModel) Category() string {
return model.category
}
// View returns the active primary scope.
func (model *CatalogListModel) View() CatalogView {
return model.view
}
// SelectedID returns the selected stable software ID.
func (model *CatalogListModel) SelectedID() string {
return model.selectedID
}
// Valid reports whether view is supported by the MVP list.
func (view CatalogView) Valid() bool {
return view == CatalogViewAll ||
view == CatalogViewInstalled ||
view == CatalogViewUpdates
}
func (model *CatalogListModel) refilter() {
model.visible = model.visible[:0]
for _, item := range model.items {
if model.category != "" && item.Category != model.category {
continue
}
if !matchesView(item, model.view) {
continue
}
if model.query != "" && !matchesQuery(item, model.query) {
continue
}
model.visible = append(model.visible, item)
}
}
func matchesView(item CatalogListItem, view CatalogView) bool {
switch view {
case CatalogViewAll:
return true
case CatalogViewInstalled:
return item.Installed
case CatalogViewUpdates:
return item.Status == domain.StatusUpdateAvailable
default:
return false
}
}
func matchesQuery(item CatalogListItem, query string) bool {
if strings.Contains(strings.ToLower(item.Name), query) ||
strings.Contains(strings.ToLower(item.ID), query) {
return true
}
for _, tag := range item.Tags {
if strings.Contains(strings.ToLower(tag), query) {
return true
}
}
return false
}
func collectCategories(items []CatalogListItem) []string {
seen := make(map[string]struct{})
categories := make([]string, 0)
for _, item := range items {
if item.Category == "" {
continue
}
if _, exists := seen[item.Category]; exists {
continue
}
seen[item.Category] = struct{}{}
categories = append(categories, item.Category)
}
return categories
}
func cloneCatalogItems(items []CatalogListItem) []CatalogListItem {
cloned := make([]CatalogListItem, len(items))
for index, item := range items {
cloned[index] = item
cloned[index].Tags = append([]string(nil), item.Tags...)
}
return cloned
}
func containsString(values []string, value string) bool {
for _, candidate := range values {
if candidate == value {
return true
}
}
return false
}
func containsItemID(items []CatalogListItem, appID string) bool {
for _, item := range items {
if item.ID == appID {
return true
}
}
return false
}
+108
View File
@@ -0,0 +1,108 @@
package application
import (
"testing"
"softbox.local/core/domain"
)
func TestCatalogListModelCombinesSearchCategoryAndView(t *testing.T) {
model := NewCatalogListModel([]CatalogListItem{
{
ID: "json-parser",
Name: "JSON解析工具",
Category: "开发工具",
Tags: []string{"JSON", "格式化"},
Status: domain.StatusInstalled,
Installed: true,
},
{
ID: "image-tool",
Name: "Image Tool",
Category: "图像",
Tags: []string{"PNG", "压缩"},
Status: domain.StatusUpdateAvailable,
Installed: true,
},
{
ID: "log-viewer",
Name: "日志查看器",
Category: "开发工具",
Tags: []string{"LOG", "诊断"},
Status: domain.StatusNotInstalled,
},
})
model.SetQuery(" png ")
assertVisibleIDs(t, model, "image-tool")
model.SetQuery("")
model.SetCategory("开发工具")
assertVisibleIDs(t, model, "json-parser", "log-viewer")
model.SetView(CatalogViewInstalled)
assertVisibleIDs(t, model, "json-parser")
model.SetCategory("")
model.SetView(CatalogViewUpdates)
assertVisibleIDs(t, model, "image-tool")
model.SetQuery("IMAGE-")
assertVisibleIDs(t, model, "image-tool")
}
func TestCatalogListModelPreservesStableOrderAndSelection(t *testing.T) {
items := []CatalogListItem{
{ID: "app-b", Name: "B", Category: "工具"},
{ID: "app-a", Name: "A", Category: "工具"},
}
model := NewCatalogListModel(items)
model.Select("app-a")
model.SetQuery("app")
assertVisibleIDs(t, model, "app-b", "app-a")
if model.SelectedID() != "app-a" {
t.Fatalf("SelectedID = %q", model.SelectedID())
}
model.SetItems([]CatalogListItem{{ID: "app-b", Name: "B", Category: "工具"}})
if model.SelectedID() != "" {
t.Fatalf("SelectedID after removal = %q, want empty", model.SelectedID())
}
}
func TestCatalogListModelCategoriesAndReset(t *testing.T) {
model := NewCatalogListModel([]CatalogListItem{
{ID: "one", Category: "开发"},
{ID: "two", Category: "图像"},
{ID: "three", Category: "开发"},
})
categories := model.Categories()
if len(categories) != 2 || categories[0] != "开发" || categories[1] != "图像" {
t.Fatalf("Categories = %#v", categories)
}
model.SetQuery("missing")
model.SetCategory("开发")
model.SetView(CatalogViewInstalled)
model.ResetFilters()
if model.Query() != "" ||
model.Category() != "" ||
model.View() != CatalogViewAll ||
len(model.VisibleItems()) != 3 {
t.Fatalf("model was not reset: %#v", model)
}
}
func assertVisibleIDs(t *testing.T, model *CatalogListModel, want ...string) {
t.Helper()
visible := model.VisibleItems()
if len(visible) != len(want) {
t.Fatalf("visible IDs length = %d, want %d: %#v", len(visible), len(want), visible)
}
for index, item := range visible {
if item.ID != want[index] {
t.Fatalf("visible[%d].ID = %q, want %q", index, item.ID, want[index])
}
}
}
+2
View File
@@ -59,6 +59,8 @@ UI 固定交互模式:
控件状态按**软件 ID**保存,不按列表序号;列表用惰性 `layout.List`;图标走内存 + 磁盘缓存。
T-203 已把共享列表状态落在 `core/application.CatalogListModel`:源快照、搜索、单分类、all/installed/updates 视图和 selected app ID 都是无 IO 纯内存状态。两个 Gio 适配分别保存 Editor、`layout.List` 与以 app ID 为键的 Clickable;500 项 viewport 测试验证只布局可见行。主循环或后台用例通过 `SetItems` 替换准备好的快照,Layout 不扫描 installed-app.json、不获取 Catalog。
## 三、仓库目录结构
```text
+9 -9
View File
@@ -13,26 +13,26 @@
## 当前快照
- 日期:2026-07-16
- 阶段:Phase 2 进行中;T-201/T-202 已完成,下一步 T-203 主界面软件列表
- 阶段:Phase 2 进行中;T-201~T-203 已完成,下一步 T-204 软件详情与图标缓存
- 技术栈:根 Go workspace 纳入 core/app-modern/app-win7 三模块;`app-win7/go.work` 隔离 Go 1.20.14 构建;modern Gio v0.10.1 与 win7 Gio v0.6.0 已实际接入
- 生产代码:core 已有状态/事件、Catalog 正式客户端、SemVer 与 12 状态推导、installed-app.json 原子存储、ZIP 安全解压和安装事务切换/恢复原型;modern/win7 均可打开最小 AppShell
- 测试:core 覆盖 Catalog、SemVer/12 状态、本地安装记录、ZIP 攻击矩阵及安装成功/回滚/多阶段崩溃恢复;两个 app 覆盖 AppShell 与平台 stub
- 生产代码:core 已有 Catalog/本地状态/存储与无 IO 软件列表模型;modern/win7 AppShell 已实现搜索、分类、全部/已安装/可更新切换和惰性软件列表;安装安全原型保持可用
- 测试:core 覆盖 Catalog、SemVer/12 状态、本地安装记录与列表筛选;两个 app 覆盖 AppShell、500 项虚拟列表、ID 控件稳定性与平台 stub;ZIP/安装恢复矩阵保持通过
- 数据:`schemas/` 已有 manifest/app.json/installed-app.json v1 Schema;`testdata/catalog/` 有公开虚构清单样例;`testdata/zip/` 记录运行时生成的 ZIP 攻击矩阵
- 标准启动路径:`./init.sh` / `./init.ps1`(同步依赖、执行完整 Phase 0 闸门、打印双目标构建命令)
- 标准验证路径:`bash scripts/verify_phase0.sh` / `./scripts/verify_phase0.ps1`
- 版本管理:git 已初始化,main 分支,远端 origin 为 Gitea `opc/soft_quay`;harness 文档已提交
- 当前 blocker:无;下一步按路线图落成并领取 T-203
- 当前 blocker:无;下一步按路线图落成并领取 T-204
## 当前目录要点
| 路径 | 状态 | 说明 |
| --- | --- | --- |
| `docs/` | 已有 | harness coding 文档集(本次初始化完成) |
| `docs/tasks/` | 已有 | Phase 0、Phase 1 与 T-201/T-202 已完成;T-203 待按路线图落成 |
| `docs/tasks/` | 已有 | Phase 0、Phase 1 与 T-201~T-203 已完成;T-204 待按路线图落成 |
| `scripts/` | 已有 | harness 治理、core 边界、Go 版本检查与 Phase 0 双平台验证入口 |
| `core/` | 已建 | Go 1.20 兼容;已有状态/事件、正式 Catalog、本地安装状态/存储与 Phase 1 安装安全原型 |
| `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;可打开 Modern AppShell |
| `app-win7/` | 已建 | Go 1.20 + Gio v0.6.0;可打开带 Legacy 标识的 AppShell |
| `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;Modern AppShell 已接入虚拟软件列表 |
| `app-win7/` | 已建 | Go 1.20 + Gio v0.6.0;Legacy AppShell 已接入低成本虚拟软件列表 |
| `schemas/` | 已建 | `manifest.schema.json`、`app.schema.json` 与 `installed-app.schema.json` |
| `testdata/` | 已建 | 当前包含 Catalog 假数据与恶意样例;后续任务继续扩展 |
@@ -40,9 +40,9 @@
任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要:
- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`、`T-202`。
- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`、`T-202`、`T-203`。
- 正在进行:无。
- 下一个可领取任务:按路线图落成并领取 `T-203 主界面软件列表`。
- 下一个可领取任务:按路线图落成并领取 `T-204 软件详情与图标缓存`。
## 当前可运行内容
+9 -1
View File
@@ -23,7 +23,7 @@
| 视图 | 职责 | MVP |
| --- | --- | --- |
| 软件列表(主视图) | 全部/已安装/可更新/下载中/最近使用切换;搜索、分类、多标签筛选;滚动位置稳定 | P0 |
| 软件列表(主视图) | T-203 已实现全部/已安装/可更新切换、名称/ID/tag 即时搜索、单分类筛选、稳定滚动与明确空状态;下载中/最近使用和多标签复选随对应用例后续接入 | P0 |
| 软件详情(弹层或右栏) | 版本、简介、教程链接、授权状态;下载/更新/启动/取消操作 | P0 |
| 下载队列 | 所有任务的进度、速度、剩余时间;暂停/取消/重试 | P0 |
| 设置 | 并发数、目录、代理、自动检查更新、beta 通道、日志级别、便携模式(V2) | P0(最小集) |
@@ -53,6 +53,14 @@
- 未验证/不兼容的软件显示原因,操作按钮禁用,不静默失败。
- Win7 版允许减少动画、阴影和高成本渲染,但交互语义与现代版一致,并明显展示 Legacy 标识。
T-203 已落地的列表交互约束:
- 共享 `core/application.CatalogListModel` 只消费预先准备的内存快照;搜索覆盖名称、稳定 ID 和 tags,分类与 all/installed/updates 可组合。
- modern/win7 都使用惰性 `layout.List`;500 项有限 viewport 测试必须证明不会布局全部行。
- Editor、视图按钮、分类按钮、软件行的键盘顺序与视觉顺序一致;搜索和软件行有可见焦点反馈,所有按钮最小高度 44dp。
- 行 Clickable 保存在以 app ID 为键的 map;筛选、视图切换或 Catalog 快照更新不按可见序号迁移控件状态。
- 无 Catalog 与过滤无结果是两个不同空状态;后者提供“显示全部软件”恢复操作。
## 导航规则
- 列表 → 详情:点击软件项;详情保留返回/关闭。
+70
View File
@@ -0,0 +1,70 @@
---
id: T-203
title: 主界面软件列表
phase: 2
deps: [T-202, T-003]
status: DONE
created: 2026-07-16
issue: null
context_ref: 819b1cda1bd155c5cb76e7b05d45507f67bdb42d
claim_branch: null
work_branch: agent/codex/T-203
write_paths:
- docs/tasks/T-203.md
- core/application/
- app-modern/ui/gio/
- app-modern/cmd/softbox/
- app-win7/ui/gio/
- app-win7/cmd/softbox/
- docs/routes.md
- docs/04-architecture.md
- docs/current-state.md
---
## 问题 / 背景
可信 Catalog 与本地状态已经可用,但两个客户端仍只显示空 AppShell。主视图需要在不把磁盘/网络 IO 放进 Layout 的前提下,支持数百项、搜索、分类和全部/已安装/可更新切换;若控件按列表索引保存,筛选或排序后点击状态会错位。
## 方案
1. 在 `core/application` 建立共享、无 IO 的 CatalogListModel,列表项使用稳定软件 ID;支持名称/ID/tag 搜索、分类集合与 all/installed/updates 三种视图。
2. 过滤结果保持 Catalog 原顺序;查询和分类变化不重建源数据,Gio 只消费当前可见切片。
3. modern/win7 分别实现 Gio AppShell:顶部搜索与分类、左侧视图切换、中部惰性 `layout.List`、有帮助文案的空状态和底部状态栏。
4. 每个 AppRow 的 Clickable 按软件 ID 存入 map,不按可见索引;每帧先 drain 点击,再执行布局。
5. 使用扁平、高对比、44dp 最小交互目标和 4/8dp 间距体系;Legacy 减少装饰但保持同一语义。
6. 测试共享筛选规则、ID 控件稳定性,并用数百项 + 有限 viewport 证明 layout.List 只布局可见行。
## 验收要点
- 搜索覆盖名称、ID 与 tags,忽略大小写和首尾空白。
- 分类筛选与 all/installed/updates 视图可组合;无结果显示清晰空状态。
- 两个 Gio 版本均使用惰性 layout.List;500 项在有限 viewport 中不会布局全部行。
- 行控件状态按 app ID 保持,过滤/切换视图后不串行。
- Layout 代码不读文件、不访问网络、不计算哈希。
- modern 与 win7 UI 测试/构建、Go 1.20 core vet/test、完整双目标闸门和治理校验通过。
## 边界(不改什么)
- 不实现详情弹层与图标缓存(T-204)。
- 不发起下载、安装、启动或真实 Catalog 网络装配;主操作仅展示当前状态语义。
- 不实现下载中/最近使用视图或多标签复选;路线图 MVP 本任务只冻结 all/installed/updates。
- 不添加动画、阴影、emoji 图标或新 UI 依赖。
## 协作约束
未启用 Gitea;本任务在 `agent/codex/T-203` 分支串行执行。`ui-ux-pro-max` 的本轮约束是高对比扁平界面、可见焦点/空状态、44dp 目标、虚拟化 50+ 项和键盘顺序与视觉顺序一致。
## 执行记录
- 2026-07-16:按 `ui-ux-pro-max` 先生成 SoftBox 设计系统并检索虚拟列表、键盘导航、空状态与焦点规则;最终采用高对比浅色、扁平无阴影、4/8dp 间距、44dp 控件和明显 Legacy 标识。
- 2026-07-16:在 `core/application` 建立无 IO `CatalogListModel`;搜索覆盖名称/ID/tags,单分类与 all/installed/updates 可组合,过滤保持 Catalog 原顺序并保存 selected app ID。
- 2026-07-16:modern Gio v0.10.1 与 win7 Gio v0.6.0 分别实现搜索栏、横向分类、左侧视图切换、惰性软件列表、状态/原因文案、两类空状态和恢复筛选操作。
- 2026-07-16:Editor/按钮/软件行均提供键盘焦点反馈;自定义行补充 semantic button/description,未使用 emoji、阴影或额外 UI 依赖。
- 2026-07-16:行 Clickable 以软件 ID 为键保存;筛选与 Catalog 快照更新后同 ID 控件保持原实例,移除的软件控件被释放。
- 2026-07-16:两个适配各用 500 项 + 有限 viewport 测试证明 `layout.List` 只布局可见子集;空 AppShell 仍保持窗口尺寸测试。
- 2026-07-16:同步 `docs/routes.md`、`docs/04-architecture.md` 与 `docs/current-state.md`;真实 Catalog 网络/密钥装配仍保持在 Layout 外。
- 定向验证通过:`go -C core test -count=1 ./application`、modern/win7 `go test -count=1 ./ui/gio`。
- Layout IO 扫描通过:`rg` 检查两个 `ui/gio` 下无 `os`、`net/http`、文件读写、filepath 或 sha256 调用。
- 完整验证通过:`./scripts/verify_phase0.ps1`,包含 Go 1.20.14 core vet/test、治理/边界/版本检查及 modern/win7 双目标测试与构建。
- 提交前检查通过:`git diff --check`。
- 工作区中的未跟踪 `soft_quay.code-workspace` 与本任务无关,已保留且未纳入提交。