Add app details and icon cache (T-204)
Harness governance / validate (push) Has been cancelled
Phase 0 build gate / verify (push) Has been cancelled

This commit is contained in:
ila
2026-07-16 17:22:37 +08:00
parent db8d93e843
commit a52acbf926
13 changed files with 1224 additions and 58 deletions
+250 -35
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"image"
"image/color"
"strings"
"gioui.org/io/semantic"
"gioui.org/layout"
@@ -60,10 +61,13 @@ type AppShell struct {
viewInstalled widget.Clickable
viewUpdates widget.Clickable
resetFilters widget.Clickable
closeDetail widget.Clickable
categoryControls map[string]*widget.Clickable
rows map[string]*rowControls
icons map[string]paint.ImageOp
lastRendered int
detailRendered bool
}
// NewAppShell creates the catalog shell with an optional in-memory snapshot.
@@ -78,12 +82,22 @@ func NewAppShell(
categoryList: layout.List{Axis: layout.Horizontal},
categoryControls: make(map[string]*widget.Clickable),
rows: make(map[string]*rowControls),
icons: make(map[string]paint.ImageOp),
}
shell.search.SingleLine = true
shell.SetItems(items)
return shell
}
// ApplyIcon stores a background-decoded image for future Layout calls.
func (shell *AppShell) ApplyIcon(appID string, icon image.Image) {
if icon == nil {
delete(shell.icons, appID)
return
}
shell.icons[appID] = paint.NewImageOp(icon)
}
// SetItems applies a prepared, IO-free catalog/status snapshot.
func (shell *AppShell) SetItems(items []application.CatalogListItem) {
shell.model.SetItems(items)
@@ -126,6 +140,7 @@ func NewTheme() *material.Theme {
func (shell *AppShell) Layout(gtx layout.Context, theme *material.Theme) layout.Dimensions {
shell.drainInput(gtx)
shell.lastRendered = 0
shell.detailRendered = false
paint.Fill(gtx.Ops, shellColors.background)
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
@@ -181,6 +196,9 @@ func (shell *AppShell) drainInput(gtx layout.Context) {
shell.search.SetText("")
shell.model.ResetFilters()
}
for shell.closeDetail.Clicked(gtx) {
shell.model.Select("")
}
}
func (shell *AppShell) layoutHeader(
@@ -309,45 +327,70 @@ func (shell *AppShell) layoutContent(
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return panel(
selected, hasSelection := shell.model.SelectedItem()
return layout.Flex{}.Layout(
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(
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return panel(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(
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(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()),
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)
}),
)
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])
})
}),
)
}),
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])
})
}),
},
)
},
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !hasSelection {
return layout.Dimensions{}
}
return layout.Spacer{Width: unit.Dp(8)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !hasSelection {
return layout.Dimensions{}
}
width := gtx.Dp(unit.Dp(280))
gtx.Constraints.Min.X = width
gtx.Constraints.Max.X = width
return shell.layoutDetail(gtx, theme, selected)
}),
)
}),
)
@@ -388,7 +431,14 @@ func (shell *AppShell) layoutAppRow(
return layout.Flex{Alignment: layout.Middle}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return shell.layoutLetterIcon(gtx, theme, item.Name)
return shell.layoutAppIcon(
gtx,
theme,
item.ID,
item.Name,
unit.Dp(40),
unit.Dp(5),
)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(10)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
@@ -419,14 +469,32 @@ func (shell *AppShell) layoutAppRow(
})
}
func (shell *AppShell) layoutLetterIcon(
func (shell *AppShell) layoutAppIcon(
gtx layout.Context,
theme *material.Theme,
appID string,
name string,
iconSize unit.Dp,
radius unit.Dp,
) layout.Dimensions {
size := gtx.Dp(unit.Dp(40))
size := gtx.Dp(iconSize)
gtx.Constraints.Min = image.Pt(size, size)
gtx.Constraints.Max = gtx.Constraints.Min
if icon, exists := shell.icons[appID]; exists {
return panel(
gtx,
shellColors.surface,
radius,
layout.UniformInset(unit.Dp(2)),
func(gtx layout.Context) layout.Dimensions {
return widget.Image{
Src: icon,
Fit: widget.Contain,
Position: layout.Center,
}.Layout(gtx)
},
)
}
letter := "S"
for _, character := range name {
letter = string(character)
@@ -435,7 +503,7 @@ func (shell *AppShell) layoutLetterIcon(
return panel(
gtx,
shellColors.primary,
unit.Dp(5),
radius,
layout.UniformInset(unit.Dp(0)),
func(gtx layout.Context) layout.Dimensions {
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
@@ -447,6 +515,113 @@ func (shell *AppShell) layoutLetterIcon(
)
}
func (shell *AppShell) layoutDetail(
gtx layout.Context,
theme *material.Theme,
item application.CatalogListItem,
) layout.Dimensions {
shell.detailRendered = true
return panel(
gtx,
shellColors.muted,
unit.Dp(6),
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 {
return layout.Flex{Alignment: layout.Middle}.Layout(
gtx,
layout.Rigid(material.Body1(theme, "软件详情").Layout),
layout.Flexed(1, layout.Spacer{}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return shell.layoutFilterButton(
gtx,
theme,
&shell.closeDetail,
"关闭",
false,
)
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return shell.layoutAppIcon(
gtx,
theme,
item.ID,
item.Name,
unit.Dp(64),
unit.Dp(7),
)
})
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Center.Layout(gtx, 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", item.ID, item.Version),
)
label.Color = shellColors.secondary
return layout.Center.Layout(gtx, label.Layout)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return detailField(gtx, theme, "状态", statusLabel(item.Status))
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return detailField(gtx, theme, "分类", fallbackText(item.Category, "未分类"))
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return detailField(
gtx,
theme,
"标签",
fallbackText(strings.Join(item.Tags, " · "), "无"),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return detailField(
gtx,
theme,
"简介",
fallbackText(item.Description, "暂无简介"),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if item.Reason == "" {
return layout.Dimensions{}
}
return detailField(gtx, theme, "不可用原因", reasonLabel(item.Reason))
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if item.Tutorial == "" {
return layout.Dimensions{}
}
return detailField(gtx, theme, "教程", item.Tutorial)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if item.Homepage == "" {
return layout.Dimensions{}
}
return detailField(gtx, theme, "主页", item.Homepage)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Caption(theme, "实际安装/启动操作将在后续用例接入")
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
)
},
)
}
func (shell *AppShell) layoutEmptyState(
gtx layout.Context,
theme *material.Theme,
@@ -626,3 +801,43 @@ func statusColor(status domain.AppStatus) color.NRGBA {
return shellColors.primary
}
}
func detailField(
gtx layout.Context,
theme *material.Theme,
labelText string,
value string,
) layout.Dimensions {
return layout.Inset{Bottom: unit.Dp(8)}.Layout(gtx, 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, labelText)
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(material.Caption(theme, value).Layout),
)
})
}
func fallbackText(value, fallback string) string {
if value == "" {
return fallback
}
return value
}
func reasonLabel(reason string) string {
switch reason {
case "deprecated":
return "软件已停止发布"
case "minimum_os":
return "Windows 版本低于最低要求"
case "architecture":
return "没有当前架构的软件包"
default:
return reason
}
}
+32
View File
@@ -77,6 +77,38 @@ func TestAppShellKeepsRowControlsByAppID(t *testing.T) {
}
}
func TestAppShellRendersSelectedDetailAndAppliedIcon(t *testing.T) {
item := application.CatalogListItem{
ID: "json-parser",
Name: "JSON解析工具",
Description: "格式化并检查 JSON",
Version: "1.2.0",
Category: "开发工具",
Tags: []string{"JSON", "格式化"},
Homepage: "https://example.invalid/json-parser",
Tutorial: "https://example.invalid/json-parser/tutorial",
Status: domain.StatusInstalled,
Installed: true,
}
shell := NewAppShell("Legacy", item)
icon := image.NewNRGBA(image.Rect(0, 0, 32, 32))
shell.ApplyIcon(item.ID, icon)
shell.model.Select(item.ID)
shell.Layout(testContext(image.Pt(1200, 760)), NewTheme())
if !shell.detailRendered {
t.Fatal("selected app detail was not rendered")
}
if _, exists := shell.icons[item.ID]; !exists {
t.Fatal("ApplyIcon did not retain the prepared image operation")
}
shell.ApplyIcon(item.ID, nil)
if _, exists := shell.icons[item.ID]; exists {
t.Fatal("ApplyIcon(nil) did not remove the image")
}
}
func testContext(size image.Point) layout.Context {
var operations op.Ops
return layout.Context{