From db8d93e84337489a1a138a6f624df359add3ed36 Mon Sep 17 00:00:00 2001 From: ila Date: Thu, 16 Jul 2026 17:10:42 +0800 Subject: [PATCH] Build virtualized software list (T-203) --- app-modern/ui/gio/shell.go | 718 ++++++++++++++++++++++++-- app-modern/ui/gio/shell_test.go | 64 +++ app-win7/ui/gio/shell.go | 636 +++++++++++++++++++++-- app-win7/ui/gio/shell_test.go | 62 +++ core/application/catalog_list.go | 234 +++++++++ core/application/catalog_list_test.go | 108 ++++ docs/04-architecture.md | 2 + docs/current-state.md | 18 +- docs/routes.md | 10 +- docs/tasks/T-203.md | 70 +++ 10 files changed, 1814 insertions(+), 108 deletions(-) create mode 100644 core/application/catalog_list.go create mode 100644 core/application/catalog_list_test.go create mode 100644 docs/tasks/T-203.md diff --git a/app-modern/ui/gio/shell.go b/app-modern/ui/gio/shell.go index 14c2073..800b379 100644 --- a/app-modern/ui/gio/shell.go +++ b/app-modern/ui/gio/shell.go @@ -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 "查看详情" + } +} diff --git a/app-modern/ui/gio/shell_test.go b/app-modern/ui/gio/shell_test.go index f110137..1c0874c 100644 --- a/app-modern/ui/gio/shell_test.go +++ b/app-modern/ui/gio/shell_test.go @@ -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), + } +} diff --git a/app-win7/ui/gio/shell.go b/app-win7/ui/gio/shell.go index d4c83c1..dc3faec 100644 --- a/app-win7/ui/gio/shell.go +++ b/app-win7/ui/gio/shell.go @@ -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 + } +} diff --git a/app-win7/ui/gio/shell_test.go b/app-win7/ui/gio/shell_test.go index 26b3773..0e57186 100644 --- a/app-win7/ui/gio/shell_test.go +++ b/app-win7/ui/gio/shell_test.go @@ -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), + } +} diff --git a/core/application/catalog_list.go b/core/application/catalog_list.go new file mode 100644 index 0000000..ba52d7a --- /dev/null +++ b/core/application/catalog_list.go @@ -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 +} diff --git a/core/application/catalog_list_test.go b/core/application/catalog_list_test.go new file mode 100644 index 0000000..cb4284b --- /dev/null +++ b/core/application/catalog_list_test.go @@ -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]) + } + } +} diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 4353476..3a3663b 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -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 diff --git a/docs/current-state.md b/docs/current-state.md index 699223a..0d7a51e 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -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 软件详情与图标缓存`。 ## 当前可运行内容 diff --git a/docs/routes.md b/docs/routes.md index 7095eca..a706b8d 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -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 与过滤无结果是两个不同空状态;后者提供“显示全部软件”恢复操作。 + ## 导航规则 - 列表 → 详情:点击软件项;详情保留返回/关闭。 diff --git a/docs/tasks/T-203.md b/docs/tasks/T-203.md new file mode 100644 index 0000000..5949d53 --- /dev/null +++ b/docs/tasks/T-203.md @@ -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` 与本任务无关,已保留且未纳入提交。