From e9386d26e7150dd5d06c6e50234ed0e656577a0a Mon Sep 17 00:00:00 2001 From: ila Date: Sat, 18 Jul 2026 14:23:02 +0800 Subject: [PATCH] Split Gio shell responsibilities (T-610) --- app-modern/ui/gio/shell.go | 773 ----------------------------- app-modern/ui/gio/shell_catalog.go | 326 ++++++++++++ app-modern/ui/gio/shell_detail.go | 188 +++++++ app-modern/ui/gio/shell_header.go | 152 ++++++ app-modern/ui/gio/shell_style.go | 154 ++++++ app-win7/ui/gio/shell.go | 690 ------------------------- app-win7/ui/gio/shell_catalog.go | 299 +++++++++++ app-win7/ui/gio/shell_detail.go | 159 ++++++ app-win7/ui/gio/shell_header.go | 124 +++++ app-win7/ui/gio/shell_style.go | 154 ++++++ docs/00-ai-start-here.md | 4 +- docs/04-architecture.md | 6 +- docs/05-coding-rules.md | 1 + docs/current-state.md | 18 +- docs/review/phase2-review.md | 4 +- docs/routes.md | 12 + docs/tasks/T-610.md | 13 +- 17 files changed, 1596 insertions(+), 1481 deletions(-) create mode 100644 app-modern/ui/gio/shell_catalog.go create mode 100644 app-modern/ui/gio/shell_detail.go create mode 100644 app-modern/ui/gio/shell_header.go create mode 100644 app-modern/ui/gio/shell_style.go create mode 100644 app-win7/ui/gio/shell_catalog.go create mode 100644 app-win7/ui/gio/shell_detail.go create mode 100644 app-win7/ui/gio/shell_header.go create mode 100644 app-win7/ui/gio/shell_style.go diff --git a/app-modern/ui/gio/shell.go b/app-modern/ui/gio/shell.go index 5d61fce..a97e0c4 100644 --- a/app-modern/ui/gio/shell.go +++ b/app-modern/ui/gio/shell.go @@ -1,53 +1,17 @@ package gio import ( - "fmt" "image" - "image/color" - "strings" - "gioui.org/io/semantic" "gioui.org/layout" - "gioui.org/op/clip" "gioui.org/op/paint" "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" "softbox.local/core/application" - "softbox.local/core/domain" ) -var shellColors = struct { - background color.NRGBA - surface color.NRGBA - muted color.NRGBA - foreground color.NRGBA - secondary color.NRGBA - primary color.NRGBA - onPrimary color.NRGBA - border color.NRGBA - success color.NRGBA - warning color.NRGBA - destructive color.NRGBA -}{ - background: color.NRGBA{R: 248, G: 250, B: 252, A: 255}, - surface: color.NRGBA{R: 255, G: 255, B: 255, A: 255}, - muted: color.NRGBA{R: 240, G: 248, B: 246, A: 255}, - foreground: color.NRGBA{R: 15, G: 23, B: 42, A: 255}, - 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}, -} - -type rowControls struct { - open widget.Clickable -} - // AppShell is the modern software catalog window. type AppShell struct { edition string @@ -160,19 +124,6 @@ func (shell *AppShell) SetItems(items []application.CatalogListItem) { 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{ - Bg: shellColors.background, - Fg: shellColors.foreground, - ContrastBg: shellColors.primary, - ContrastFg: shellColors.onPrimary, - } - theme.FingerSize = unit.Dp(44) - return theme -} - // 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) @@ -233,727 +184,3 @@ func (shell *AppShell) drainInput(gtx layout.Context) { shell.model.Select("") } } - -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 { - selected, hasSelection := shell.model.SelectedItem() - return layout.Flex{}.Layout( - gtx, - 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) - }, - ) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !hasSelection { - return layout.Dimensions{} - } - return layout.Spacer{Width: unit.Dp(12)}.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !hasSelection { - return layout.Dimensions{} - } - width := gtx.Dp(unit.Dp(320)) - gtx.Constraints.Min.X = width - gtx.Constraints.Max.X = width - return shell.layoutDetail(gtx, theme, selected) - }), - ) - }), - ) -} - -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.layoutAppIcon( - gtx, - theme, - item.ID, - item.Name, - unit.Dp(48), - unit.Dp(8), - ) - }), - 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) layoutAppIcon( - gtx layout.Context, - theme *material.Theme, - appID string, - name string, - iconSize unit.Dp, - radius unit.Dp, -) layout.Dimensions { - 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) - break - } - return panel( - gtx, - shellColors.primary, - radius, - 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) layoutDetail( - gtx layout.Context, - theme *material.Theme, - item application.CatalogListItem, -) layout.Dimensions { - shell.detailRendered = true - return panel( - gtx, - shellColors.muted, - unit.Dp(10), - layout.UniformInset(unit.Dp(16)), - 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.H6(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(16)}.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(72), - unit.Dp(12), - ) - }) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return layout.Center.Layout(gtx, 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", item.ID, item.Version), - ) - label.Color = shellColors.secondary - return layout.Center.Layout(gtx, label.Layout) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.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(layout.Spacer{Height: unit.Dp(8)}.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) 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 "查看详情" - } -} - -func detailField( - gtx layout.Context, - theme *material.Theme, - labelText string, - value string, -) layout.Dimensions { - return layout.Inset{Bottom: unit.Dp(12)}.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(3)}.Layout), - layout.Rigid(material.Body2(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 - } -} diff --git a/app-modern/ui/gio/shell_catalog.go b/app-modern/ui/gio/shell_catalog.go new file mode 100644 index 0000000..e1190e1 --- /dev/null +++ b/app-modern/ui/gio/shell_catalog.go @@ -0,0 +1,326 @@ +package gio + +import ( + "fmt" + "image" + "image/color" + + "gioui.org/io/semantic" + "gioui.org/layout" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" + + "softbox.local/core/application" +) + +type rowControls struct { + open widget.Clickable +} + +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 { + selected, hasSelection := shell.model.SelectedItem() + return layout.Flex{}.Layout( + gtx, + 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) + }, + ) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !hasSelection { + return layout.Dimensions{} + } + return layout.Spacer{Width: unit.Dp(12)}.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !hasSelection { + return layout.Dimensions{} + } + width := gtx.Dp(unit.Dp(320)) + gtx.Constraints.Min.X = width + gtx.Constraints.Max.X = width + return shell.layoutDetail(gtx, theme, selected) + }), + ) + }), + ) +} + +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.layoutAppIcon( + gtx, + theme, + item.ID, + item.Name, + unit.Dp(48), + unit.Dp(8), + ) + }), + 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) layoutAppIcon( + gtx layout.Context, + theme *material.Theme, + appID string, + name string, + iconSize unit.Dp, + radius unit.Dp, +) layout.Dimensions { + 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) + break + } + return panel( + gtx, + shellColors.primary, + radius, + 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, + ) + }) + }), + ) + }) +} diff --git a/app-modern/ui/gio/shell_detail.go b/app-modern/ui/gio/shell_detail.go new file mode 100644 index 0000000..a9374ed --- /dev/null +++ b/app-modern/ui/gio/shell_detail.go @@ -0,0 +1,188 @@ +package gio + +import ( + "fmt" + "strings" + + "gioui.org/layout" + "gioui.org/unit" + "gioui.org/widget/material" + + "softbox.local/core/application" + "softbox.local/core/domain" +) + +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(10), + layout.UniformInset(unit.Dp(16)), + 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.H6(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(16)}.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(72), + unit.Dp(12), + ) + }) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Center.Layout(gtx, 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", item.ID, item.Version), + ) + label.Color = shellColors.secondary + return layout.Center.Layout(gtx, label.Layout) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.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(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + label := material.Caption(theme, actionLabel(item)+";实际操作将在后续用例接入") + label.Color = shellColors.secondary + return label.Layout(gtx) + }), + ) + }, + ) +} + +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 "查看详情" + } +} + +func detailField( + gtx layout.Context, + theme *material.Theme, + labelText string, + value string, +) layout.Dimensions { + return layout.Inset{Bottom: unit.Dp(12)}.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(3)}.Layout), + layout.Rigid(material.Body2(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 + } +} diff --git a/app-modern/ui/gio/shell_header.go b/app-modern/ui/gio/shell_header.go new file mode 100644 index 0000000..e87a1de --- /dev/null +++ b/app-modern/ui/gio/shell_header.go @@ -0,0 +1,152 @@ +package gio + +import ( + "gioui.org/layout" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" + + "softbox.local/core/application" +) + +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) 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) + }), + ) +} diff --git a/app-modern/ui/gio/shell_style.go b/app-modern/ui/gio/shell_style.go new file mode 100644 index 0000000..82dccc8 --- /dev/null +++ b/app-modern/ui/gio/shell_style.go @@ -0,0 +1,154 @@ +package gio + +import ( + "image" + "image/color" + + "gioui.org/layout" + "gioui.org/op/clip" + "gioui.org/op/paint" + "gioui.org/unit" + "gioui.org/widget/material" + + "softbox.local/core/application" + "softbox.local/core/domain" +) + +var shellColors = struct { + background color.NRGBA + surface color.NRGBA + muted color.NRGBA + foreground color.NRGBA + secondary color.NRGBA + primary color.NRGBA + onPrimary color.NRGBA + border color.NRGBA + success color.NRGBA + warning color.NRGBA + destructive color.NRGBA +}{ + background: color.NRGBA{R: 248, G: 250, B: 252, A: 255}, + surface: color.NRGBA{R: 255, G: 255, B: 255, A: 255}, + muted: color.NRGBA{R: 240, G: 248, B: 246, A: 255}, + foreground: color.NRGBA{R: 15, G: 23, B: 42, A: 255}, + 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}, +} + +// NewTheme creates the accessible semantic palette shared by the modern shell. +func NewTheme() *material.Theme { + theme := material.NewTheme() + theme.Palette = material.Palette{ + Bg: shellColors.background, + Fg: shellColors.foreground, + ContrastBg: shellColors.primary, + ContrastFg: shellColors.onPrimary, + } + theme.FingerSize = unit.Dp(44) + return theme +} + +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.go b/app-win7/ui/gio/shell.go index 74fc23d..f7f9106 100644 --- a/app-win7/ui/gio/shell.go +++ b/app-win7/ui/gio/shell.go @@ -1,53 +1,17 @@ package gio import ( - "fmt" "image" - "image/color" - "strings" - "gioui.org/io/semantic" "gioui.org/layout" - "gioui.org/op/clip" "gioui.org/op/paint" "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" "softbox.local/core/application" - "softbox.local/core/domain" ) -var shellColors = struct { - background color.NRGBA - surface color.NRGBA - muted color.NRGBA - foreground color.NRGBA - secondary color.NRGBA - primary color.NRGBA - onPrimary color.NRGBA - border color.NRGBA - success color.NRGBA - warning color.NRGBA - destructive color.NRGBA -}{ - background: color.NRGBA{R: 248, G: 250, B: 252, A: 255}, - surface: color.NRGBA{R: 255, G: 255, B: 255, A: 255}, - muted: color.NRGBA{R: 240, G: 248, B: 246, A: 255}, - foreground: color.NRGBA{R: 15, G: 23, B: 42, A: 255}, - 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}, -} - -type rowControls struct { - open widget.Clickable -} - // AppShell is the Legacy software catalog window. type AppShell struct { edition string @@ -160,19 +124,6 @@ func (shell *AppShell) SetItems(items []application.CatalogListItem) { shell.categoryControls = nextCategories } -// NewTheme creates the accessible palette shared by the Legacy shell. -func NewTheme() *material.Theme { - theme := material.NewTheme() - theme.Palette = material.Palette{ - Bg: shellColors.background, - Fg: shellColors.foreground, - ContrastBg: shellColors.primary, - ContrastFg: shellColors.onPrimary, - } - theme.FingerSize = unit.Dp(44) - return theme -} - // 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) @@ -237,644 +188,3 @@ func (shell *AppShell) drainInput(gtx layout.Context) { shell.model.Select("") } } - -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 { - selected, hasSelection := shell.model.SelectedItem() - return layout.Flex{}.Layout( - gtx, - 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]) - }) - }), - ) - }, - ) - }), - 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) - }), - ) - }), - ) -} - -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.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 { - 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) layoutAppIcon( - gtx layout.Context, - theme *material.Theme, - appID string, - name string, - iconSize unit.Dp, - radius unit.Dp, -) layout.Dimensions { - 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) - break - } - return panel( - gtx, - shellColors.primary, - radius, - 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) 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, -) 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 - } -} - -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 - } -} diff --git a/app-win7/ui/gio/shell_catalog.go b/app-win7/ui/gio/shell_catalog.go new file mode 100644 index 0000000..5bf957f --- /dev/null +++ b/app-win7/ui/gio/shell_catalog.go @@ -0,0 +1,299 @@ +package gio + +import ( + "fmt" + "image" + "image/color" + + "gioui.org/io/semantic" + "gioui.org/layout" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" + + "softbox.local/core/application" +) + +type rowControls struct { + open widget.Clickable +} + +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 { + selected, hasSelection := shell.model.SelectedItem() + return layout.Flex{}.Layout( + gtx, + 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]) + }) + }), + ) + }, + ) + }), + 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) + }), + ) + }), + ) +} + +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.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 { + 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) layoutAppIcon( + gtx layout.Context, + theme *material.Theme, + appID string, + name string, + iconSize unit.Dp, + radius unit.Dp, +) layout.Dimensions { + 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) + break + } + return panel( + gtx, + shellColors.primary, + radius, + 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, + ) + }) + }), + ) + }) +} diff --git a/app-win7/ui/gio/shell_detail.go b/app-win7/ui/gio/shell_detail.go new file mode 100644 index 0000000..c2aff31 --- /dev/null +++ b/app-win7/ui/gio/shell_detail.go @@ -0,0 +1,159 @@ +package gio + +import ( + "fmt" + "strings" + + "gioui.org/layout" + "gioui.org/unit" + "gioui.org/widget/material" + + "softbox.local/core/application" +) + +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 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 + } +} diff --git a/app-win7/ui/gio/shell_header.go b/app-win7/ui/gio/shell_header.go new file mode 100644 index 0000000..43be899 --- /dev/null +++ b/app-win7/ui/gio/shell_header.go @@ -0,0 +1,124 @@ +package gio + +import ( + "gioui.org/layout" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" + + "softbox.local/core/application" +) + +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) 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) +} diff --git a/app-win7/ui/gio/shell_style.go b/app-win7/ui/gio/shell_style.go new file mode 100644 index 0000000..1dd75a3 --- /dev/null +++ b/app-win7/ui/gio/shell_style.go @@ -0,0 +1,154 @@ +package gio + +import ( + "image" + "image/color" + + "gioui.org/layout" + "gioui.org/op/clip" + "gioui.org/op/paint" + "gioui.org/unit" + "gioui.org/widget/material" + + "softbox.local/core/application" + "softbox.local/core/domain" +) + +var shellColors = struct { + background color.NRGBA + surface color.NRGBA + muted color.NRGBA + foreground color.NRGBA + secondary color.NRGBA + primary color.NRGBA + onPrimary color.NRGBA + border color.NRGBA + success color.NRGBA + warning color.NRGBA + destructive color.NRGBA +}{ + background: color.NRGBA{R: 248, G: 250, B: 252, A: 255}, + surface: color.NRGBA{R: 255, G: 255, B: 255, A: 255}, + muted: color.NRGBA{R: 240, G: 248, B: 246, A: 255}, + foreground: color.NRGBA{R: 15, G: 23, B: 42, A: 255}, + 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}, +} + +// NewTheme creates the accessible palette shared by the Legacy shell. +func NewTheme() *material.Theme { + theme := material.NewTheme() + theme.Palette = material.Palette{ + Bg: shellColors.background, + Fg: shellColors.foreground, + ContrastBg: shellColors.primary, + ContrastFg: shellColors.onPrimary, + } + theme.FingerSize = unit.Dp(44) + return theme +} + +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/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 16c0689..9a30fb8 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -45,7 +45,7 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端, ## 当前阶段 -当前项目已完成 Phase 0~2、T-301 与审核整改 `T-604`~`T-609`。Windows 安全路径阻断项、图标缓存资源边界、后台结果回 UI 线程的事件接线、双适配器交互契约与 `VisibleItems` 快照生命周期均已关闭;`T-610` 已落成,下一步执行 modern/win7 `shell.go` 纯职责拆分,其余审核整改与 Phase 1 中央目录预扫描继续串行处理,T-302 暂后置。 +当前项目已完成 Phase 0~2、T-301 与审核整改 `T-604`~`T-610`。Windows 安全路径阻断项、图标缓存资源边界、后台结果回 UI 线程的事件接线、双适配器交互契约、`VisibleItems` 快照生命周期和双端 Gio shell 职责拆分均已关闭;下一步按 Phase 2 交叉审核顺序落成 unsafe cache 上层诊断/人工清理指引任务,其余 Phase 1 整改继续串行处理,T-302 暂后置。 优先路径: @@ -53,7 +53,7 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端, 2. 已完成 Phase 1:清单验签、ZIP 安全解压、原子切换回滚原型。 3. 已完成 Phase 2 与 T-301:清单/列表/详情/图标缓存 + 可恢复下载队列。 4. 已完成 T-604:modern/Win7 workspace 与 Gio 版本解析彻底隔离。 -5. 已完成 T-606~T-609:图标缓存资源边界、UI 线程事件接线、双 Gio 适配器交互契约,以及“变更时发布新 backing、同 generation 读取零复制”的 `VisibleItems` 生命周期;下一步执行已落成的 T-610 双端 `shell.go` 职责拆分,再串行处理其余整改与 T-302/T-303、Phase 4-6。 +5. 已完成 T-606~T-610:图标缓存资源边界、UI 线程事件接线、双 Gio 适配器交互契约、`VisibleItems` generation 生命周期和双端 `shell.go` 同 package 镜像职责拆分;下一步落成 unsafe cache 上层诊断/人工清理指引任务,再串行处理其余整改与 T-302/T-303、Phase 4-6。 ## 领取任务规则 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 167018d..777011e 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -61,6 +61,8 @@ UI 固定交互模式: T-203 已把共享列表状态落在 `core/application.CatalogListModel`:源快照、搜索、单分类、all/installed/updates 视图和 selected app ID 都是无 IO 纯内存状态。两个 Gio 适配分别保存 Editor、`layout.List` 与以 app ID 为键的 Clickable;主循环或后台用例通过 `SetItems` 替换准备好的快照,Layout 不扫描 installed-app.json、不获取 Catalog。T-608 在两个隔离 workspace 以同场景交互契约验证 Editor/Clickable 经 `Layout`/`drainInput` 更新共享 model、重排后行点击仍按 AppID、关闭详情保留筛选与列表位置、500 项只布局 `layout.List.Position.Count` 所示可见子集,并通过语义树区分空 Catalog 与过滤无结果;不重复 ViewModel 纯逻辑。T-609 让每次实际 refilter 在局部新 backing array 完整构造后发布 `VisibleItems` generation,旧 generation 可安全保留到后续帧且每帧读取不复制;返回值严格只读,model 仍由单 owner goroutine 串行操作,不承诺并发安全。 +T-610 在两个隔离 `ui/gio` package 内采用相同文件职责:`shell.go` 只保存 AppShell 状态/生命周期和根编排,`shell_header.go` 保存 header/navigation,`shell_catalog.go` 保存 content/list/row/icon/empty state,`shell_detail.go` 保存详情,`shell_style.go` 保存主题与绘制 helper。该拆分没有增加 package/API/状态边界,modern 与 Win7 的 Gio 版本特有布局继续分别实现;适配器交互契约仍负责证明两端事件接线和可见行为一致。 + T-204/T-606/T-607 图标链路为 `Catalog icon digest + DPI → 32 MiB/256-key memory LRU → verified disk → 流式 IconFetcher(maxBytes+1) → SHA-256/图片资源限制校验 → 原子磁盘缓存 → 后台 DecodeIcon → IconReady/IconFailed application event → bounded FIFO relay + Window.Invalidate → Frame/UI ApplyEvent → ApplyIcon(paint.ImageOp)`。同一 key 由一个 in-flight leader 去重,不同 key 的磁盘/网络工作并行;全局锁只保护 memory/LRU/in-flight 元数据。relay 队列满时无损背压且可由 context/close 取消,后台从不修改 shell map。UI 只接受当前 app 最新且 icon_ref/DPI 匹配的 request_id;删除 app、替换 IconRef 或取消会使迟到结果失效,替换 IconRef 同时清除旧 ImageOp。磁盘与远端都重新校验,断网只使用已验证磁盘缓存;详情右栏只读取 `CatalogListModel.SelectedItem` 与内存 ImageOp,关闭详情不清空筛选或列表位置。 ## 三、仓库目录结构 @@ -79,11 +81,11 @@ soft_quay/ │ └─ updater/ # 盒子自更新编排 ├─ app-modern/ # 现代版(go.mod,Go 1.25 + Gio v0.10.1) │ ├─ cmd/softbox/ -│ ├─ ui/gio/ +│ ├─ ui/gio/ # shell 根编排 + header/catalog/detail/style 职责文件 │ └─ platform/windows/ ├─ app-win7/ # Win7 遗留版(go.mod,Go 1.20 + Gio v0.6.0) │ ├─ cmd/softbox/ -│ ├─ ui/gio/ +│ ├─ ui/gio/ # 同名职责文件,保留 Legacy Gio 实现差异 │ └─ platform/windows/ ├─ schemas/ # manifest / app.json / files.json / license JSON Schema ├─ scripts/ # 构建、打包、签名、校验脚本 diff --git a/docs/05-coding-rules.md b/docs/05-coding-rules.md index 679ccc5..026feb1 100644 --- a/docs/05-coding-rules.md +++ b/docs/05-coding-rules.md @@ -24,6 +24,7 @@ - 依赖方向只允许 `app-modern`/`app-win7` → `core`;任何反向 import 都是返工。 - `core/` 与 `app-win7/` 只使用 **Go 1.20 可编译**的语法与依赖;新增依赖前检查其 go.mod 的 `go` 指令。泛型可用(1.18+),但 1.21+ 的标准库函数(如 `slices`、`maps`、`min/max` 内建)不得进入这两个模块。 - Gio 代码只出现在 `ui/gio/`;Windows 调用只出现在 `platform/windows/`,且必须有非 Windows stub,保证 `go test ./...` 在 Linux CI 可跑。 +- modern/Win7 的 Gio shell 都按同 package 镜像职责维护:`shell.go` 只放 AppShell 状态/生命周期和根编排,header/navigation、catalog/list、detail、theme/style 分别进入 `shell_header.go`、`shell_catalog.go`、`shell_detail.go`、`shell_style.go`;版本特有声明留在对应文件,不得为减少重复而跨 workspace 共享不兼容 Gio 代码。 - 仅 Win10+ 存在的 Windows API 必须 LoadLibrary 动态加载、失败降级,不得成为 EXE 导入表强依赖。 - Gio Layout 每帧禁止 IO(磁盘/网络/哈希/图片解码);后台任务只发布 application.Event,不得直接调用 `ApplyIcon` 或改控件/map。后台 event pump 只入有界 relay 并调用 `Window.Invalidate`;只有 Frame/UI goroutine可以 drain `ApplyEvent`。relay 满队列不得静默丢事件,关闭/取消必须解除背压等待。 - `CatalogListModel.VisibleItems()` 返回当前只读 snapshot generation:refilter 只在状态变化时构造新 backing array 后发布,同 generation 的每帧读取不得复制;调用方不得修改 slice/item/Tags。旧 generation 在后续 model 变化后保持稳定,但 model 仍是单 owner、非并发安全对象。 diff --git a/docs/current-state.md b/docs/current-state.md index f3f8502..64b89d6 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -12,27 +12,27 @@ ## 当前快照 -- 日期:2026-07-17 -- 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列已完成;审核整改 T-604~T-609 已完成,T-610 已落成待执行,T-302 继续暂后置 +- 日期:2026-07-18 +- 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列已完成;审核整改 T-604~T-610 已完成,T-302 继续暂后置 - 技术栈:根 Go 1.25 workspace 只纳入 core/app-modern,`app-win7/go.work` 独立纳入 core/app-win7;版本闸门证明 modern Gio v0.10.1 与 win7 Gio v0.6.0 不交叉解析 -- 生产代码:core 已有 Catalog/本地状态/存储、共享 Windows 安全相对路径策略、安全 ZIP 解压/回滚原型、发布稳定只读 generation 的无 IO 软件列表模型、按 key in-flight + 流式有界读取 + 32 MiB/256-key LRU 的可信图标缓存、图标 Load/Decode 事件发布用例、有界 application event relay,以及默认并发 2 的持久可恢复下载队列;modern/win7 主循环已接 relay/Invalidate,AppShell 已实现搜索/分类/视图、惰性列表、详情右栏、图标请求身份与 UI-only ApplyEvent/ApplyIcon +- 生产代码:core 已有 Catalog/本地状态/存储、共享 Windows 安全相对路径策略、安全 ZIP 解压/回滚原型、发布稳定只读 generation 的无 IO 软件列表模型、按 key in-flight + 流式有界读取 + 32 MiB/256-key LRU 的可信图标缓存、图标 Load/Decode 事件发布用例、有界 application event relay,以及默认并发 2 的持久可恢复下载队列;modern/win7 主循环已接 relay/Invalidate,AppShell 已实现搜索/分类/视图、惰性列表、详情右栏、图标请求身份与 UI-only ApplyEvent/ApplyIcon,并按 root/header/catalog/detail/style 同 package 镜像职责拆文件 - 测试:core 覆盖 Catalog、列表快照 generation/零复制、SemVer/12 状态、本地安装记录、Windows dot-space/设备名/Unicode 折叠路径攻击、ZIP destination 包含性、图标并发/取消/读取边界/LRU、图标事件身份/失败分类/relay 背压与关闭、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 Editor/视图/分类/行/恢复/关闭接线、500 项 viewport、AppID 控件与分类控件生命周期、详情上下文、空状态语义、UI drain 前后、最新/取消/换引用图标结果与平台 stub;安装恢复矩阵保持通过 - 数据:`schemas/` 已有 manifest/app.json/installed-app.json/download-task.json v1 Schema并注明 Windows 路径运行时权威规则;`testdata/catalog/` 有公开虚构清单样例;`testdata/zip/` 与 `testdata/download/` 记录运行时生成的攻击/传输矩阵 - 标准启动路径:`./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-610`,在各自 Gio package 内按镜像职责拆分 modern/win7 `shell.go`;Phase 1 中央目录预扫描等继续串行,T-302 继续后置 +- 当前 blocker:无;下一步按 `docs/review/phase2-review.md` 最终顺序落成 unsafe cache 上层诊断/人工清理指引任务,自动 quarantine 另做威胁模型;Phase 1 中央目录预扫描等继续串行,T-302 继续后置 ## 当前目录要点 | 路径 | 状态 | 说明 | | --- | --- | --- | | `docs/` | 已有 | harness coding 文档集(本次初始化完成) | -| `docs/tasks/` | 已有 | Phase 0~2、T-301 与 T-604~T-609 已完成;T-610 已落成待执行,其余审核整改尚未编号,T-302 暂后置 | +| `docs/tasks/` | 已有 | Phase 0~2、T-301 与 T-604~T-610 已完成;其余审核整改尚未编号,T-302 暂后置 | | `scripts/` | 已有 | harness 治理、core 边界、Go 版本检查与 Phase 0 双平台验证入口 | | `core/` | 已建 | Go 1.20 兼容;已有正式 Catalog、本地状态/存储、共享 Windows safepath、列表模型、有界并发图标缓存、图标事件/relay、可恢复下载队列与 Phase 1 安装安全原型 | -| `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;Modern AppShell 已接入虚拟列表、详情、图标事件 drain/过期拒绝和内存 ImageOp | -| `app-win7/` | 已建 | Go 1.20 + Gio v0.6.0;Legacy AppShell 已接入低成本列表、详情、图标事件 drain/过期拒绝和内存 ImageOp | +| `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;Modern AppShell 已接入虚拟列表、详情、图标事件 drain/过期拒绝和内存 ImageOp,并拆为五类 shell 职责文件 | +| `app-win7/` | 已建 | Go 1.20 + Gio v0.6.0;Legacy AppShell 已接入低成本列表、详情、图标事件 drain/过期拒绝和内存 ImageOp,并保持同名职责文件与版本差异 | | `schemas/` | 已建 | `manifest.schema.json`、`app.schema.json`、`installed-app.schema.json` 与 `download-task.schema.json` | | `testdata/` | 已建 | 包含 Catalog 假数据、ZIP 恶意矩阵与下载协议测试说明;后续任务继续扩展 | @@ -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-204`;Phase 3 的 `T-301`;审核整改 `T-604`~`T-609`。 +- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`~`T-204`;Phase 3 的 `T-301`;审核整改 `T-604`~`T-610`。 - 正在进行:无。 -- 下一个可领取任务:`T-610`(依赖 `T-609` 已完成),双端 `shell.go` 纯职责拆分。 +- 下一个可领取任务:无;先按 `docs/review/phase2-review.md` 最终顺序落成 unsafe cache 上层诊断/人工清理指引任务。 ## 当前可运行内容 diff --git a/docs/review/phase2-review.md b/docs/review/phase2-review.md index ab62842..a224fe7 100644 --- a/docs/review/phase2-review.md +++ b/docs/review/phase2-review.md @@ -287,5 +287,5 @@ modern/win7 的 `ApplyIcon` 都直接写 `shell.icons` map,Layout 同时读取 - `T-607` 已完成最终处理顺序第 2 项:后台图标 Load/Decode 只发布强类型 application event,有界 FIFO relay 无损背压并请求重绘,由 Gio Frame/UI goroutine drain 后执行 `ApplyEvent`/`ApplyIcon`;最新请求、删除 app、IconRef/DPI 变化和取消都阻断迟到结果回写。core 与双 workspace 定向测试及完整闸门通过。 - `T-608` 已完成最终处理顺序第 3 项:modern/win7 使用除 edition/窗口尺寸外一致的场景矩阵,验证 Editor/Clickable 经 Layout 更新共享 model、重排后 AppID 行身份、详情关闭上下文、500 项 viewport、app/category controls 释放及两类空状态语义;双端定向重复测试与完整闸门通过,生产 `shell.go` 无需修正。 - `T-609` 已完成最终处理顺序第 4 项:`refilter` 在局部新 backing array 完整构造后发布,旧 generation 跨五类公开 model mutation 保持稳定;同 generation 读取共享 backing 且零分配。core 定向重复测试、双 Gio 回归与完整闸门通过,未扩展为并发安全或防御性深拷贝。 -- `T-610` 已按最终处理顺序第 5 项落成待执行:仅在各自 `gio` package 内把双端 `shell.go` 拆为状态/根编排、header/navigation、catalog/list、detail、style 镜像职责文件,保留版本差异且不改变行为。 -- unsafe cache 诊断尚未编号;待 T-610 完成、验证并提交后再按顺序串行落成。 +- `T-610` 已完成最终处理顺序第 5 项:双端 `shell.go` 只保留 AppShell 状态/生命周期和根编排,其余声明进入 header/navigation、catalog/list、detail、style 同 package 镜像职责文件;原 54 个声明与根调用链保持不变,双端适配器/图标回归及完整闸门通过。 +- unsafe cache 诊断尚未编号;下一任务按最终处理顺序第 6 项正式裁定上层诊断与人工清理指引,自动 quarantine 仍需独立威胁模型。 diff --git a/docs/routes.md b/docs/routes.md index ba9140b..1537b00 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -43,6 +43,18 @@ | LicensePanel | 授权 | 导入与状态展示 | | Toast/Dialog | 全局 | 错误与确认交互 | +T-610 固定 modern 与 Win7 两个 `ui/gio` 适配器的同 package 文件职责;文件名镜像,但不跨 workspace 共享 Gio 代码: + +| 文件 | 职责 | +| --- | --- | +| `shell.go` | `AppShell` 状态/构造、`SetItems`/`ApplyIcon` 生命周期、根 `Layout` 与输入 drain | +| `shell_header.go` | 顶部栏、分类、view/filter 导航与版本特有 footer | +| `shell_catalog.go` | content/catalog、惰性列表、app row/icon 与空状态 | +| `shell_detail.go` | 详情布局、详情字段与动作/回退文案 helper | +| `shell_style.go` | palette/theme、panel 绘制、view/status 文案与颜色 helper | + +这些文件仍共同实现一个 `gio` package,不是新增页面 API 或状态层。modern-only 的 `layoutCatalog`/`layoutFooter`/`actionLabel` 等保留在对应职责文件,Win7 不增加空壳;后续视图应进入相应职责文件,不能重新把布局链堆回根 `shell.go`。 + 每个页面用独立 struct 保存:`widget.Clickable`、`widget.Editor`、`layout.List`、过滤条件、当前 ViewModel、pending RequestID、临时提示和错误状态。 ## 交互规则(硬约束) diff --git a/docs/tasks/T-610.md b/docs/tasks/T-610.md index cb6abaf..f5a0b7a 100644 --- a/docs/tasks/T-610.md +++ b/docs/tasks/T-610.md @@ -3,12 +3,12 @@ id: T-610 title: 拆分双端 Gio shell 职责 phase: 2 deps: [T-609] -status: TODO +status: DONE created: 2026-07-17 issue: null -context_ref: null +context_ref: 75b1803564dd95284dc5bc25719ccdb5b3e55274 claim_branch: null -work_branch: null +work_branch: agent/codex/T-610 write_paths: - docs/tasks/T-610.md - app-modern/ui/gio/ @@ -80,3 +80,10 @@ modern 与 Win7 的 `ui/gio/shell.go` 分别达到 959 行和 880 行,同时承 - 2026-07-17:根据 `docs/review/phase2-review.md` 交叉复核定稿的第五优先级维护项落成任务;现有全局最大任务为 T-609,因此取 T-610,依赖已完成的 T-609。 - 2026-07-17:代码图确认 modern `shell.go` 为 959 行、29 个声明,Win7 `shell.go` 为 880 行、25 个声明;两端都把状态/根编排、header、catalog/list、detail 和 style 聚合在单文件,主要差异为 modern 独有的 `layoutCatalog`、`layoutFooter` 与 `actionLabel` 等实现。 - 2026-07-17:冻结为各自 `gio` package 内五文件镜像职责拆分;保留版本特有差异,不共享 Gio 代码、不改变行为,并以 T-607/T-608/T-609 已有事件、适配器和 snapshot 契约作为回归保护面。 +- 2026-07-18:在 `agent/codex/T-610` 分支领取任务,基线为 `75b1803564dd95284dc5bc25719ccdb5b3e55274`;保持单 Agent 串行执行。 +- 2026-07-18:基线 `./init.ps1` 通过,包含治理/上下文/边界/依赖版本检查、Go 1.20.14 core vet/test、modern Go 1.25 与 Win7 Go 1.20.14 的 UI/平台测试和 Windows amd64 构建。 +- 2026-07-18:modern 与 Win7 各自新增 `shell_header.go`、`shell_catalog.go`、`shell_detail.go`、`shell_style.go`;根 `shell.go` 分别从 959/880 行收敛到 186/190 行,只保留 AppShell 状态/构造、SetItems/ApplyIcon 生命周期、根 Layout 与 drainInput。所有函数体按完整声明机械移动并由 `gofmt` 收敛 import,未改名或调整调用顺序。 +- 2026-07-18:重新索引代码图后确认拆分前后的声明总数均为 54,每个 qualified name 恰有一个定义;modern 的 Layout 仍直接调用 drainInput/header/content/footer,Win7 仍直接调用 drainInput/header/content,二阶 catalog/detail/style 调用链保持不变。 +- 2026-07-18:modern Go 1.25.0 与 Win7 Go 1.20.14 的 `go test -count=10 ./ui/gio` 分别通过;T-608 适配器矩阵、T-607 图标事件/过期拒绝及既有 shell 测试无需修改。 +- 2026-07-18:完整 `./scripts/verify_phase0.ps1` 通过,覆盖治理/上下文/链接/任务校验、core 边界与 Go/Gio pin、Go 1.20.14 core vet/test、modern/Win7 UI 与平台测试及 Windows amd64 双目标构建;路由、架构、编码规则、审核追踪和当前状态已同步。 +- 2026-07-18:提交前 modern/Win7 `go vet ./ui/gio`、`validate_agent_context.py`、`validate_harness_governance.py` 与 `git diff --check` 均通过;工作区唯一范围外文件仍是用户未跟踪的 `soft_quay.code-workspace`,未修改或暂存。