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
+669 -49
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
foreground color.NRGBA
primary color.NRGBA
onPrimary color.NRGBA
secondary color.NRGBA
background color.NRGBA
surface color.NRGBA
muted color.NRGBA
foreground color.NRGBA
secondary color.NRGBA
primary color.NRGBA
onPrimary 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},
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},
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},
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(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(layout.Spacer{Height: unit.Dp(16)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return shell.layoutContent(gtx, theme)
}),
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),
}
}
+587 -49
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
foreground color.NRGBA
primary color.NRGBA
onPrimary color.NRGBA
secondary color.NRGBA
background color.NRGBA
surface color.NRGBA
muted color.NRGBA
foreground color.NRGBA
secondary color.NRGBA
primary color.NRGBA
onPrimary 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},
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},
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},
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(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Caption(
theme,
shell.edition+" · Legacy · Windows 7 SP1 x64",
)
}),
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(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` 与本任务无关,已保留且未纳入提交。