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 model *application.CatalogListModel search widget.Editor appList layout.List categoryList layout.List viewAll widget.Clickable viewInstalled widget.Clickable viewUpdates widget.Clickable resetFilters widget.Clickable closeDetail widget.Clickable categoryControls map[string]*widget.Clickable rows map[string]*rowControls icons map[string]paint.ImageOp lastRendered int detailRendered bool } // NewAppShell creates the catalog shell with an optional in-memory snapshot. func NewAppShell( edition string, items ...application.CatalogListItem, ) *AppShell { shell := &AppShell{ edition: edition, model: application.NewCatalogListModel(nil), appList: layout.List{Axis: layout.Vertical}, categoryList: layout.List{Axis: layout.Horizontal}, categoryControls: make(map[string]*widget.Clickable), rows: make(map[string]*rowControls), icons: make(map[string]paint.ImageOp), } shell.search.SingleLine = true shell.SetItems(items) return shell } // ApplyIcon stores a background-decoded image for future Layout calls. func (shell *AppShell) ApplyIcon(appID string, icon image.Image) { if icon == nil { delete(shell.icons, appID) return } shell.icons[appID] = paint.NewImageOp(icon) } // SetItems applies a prepared, IO-free catalog/status snapshot. func (shell *AppShell) SetItems(items []application.CatalogListItem) { shell.model.SetItems(items) nextRows := make(map[string]*rowControls, len(items)) nextIcons := make(map[string]paint.ImageOp, len(items)) for _, item := range items { controls := shell.rows[item.ID] if controls == nil { controls = new(rowControls) } nextRows[item.ID] = controls if icon, exists := shell.icons[item.ID]; exists { nextIcons[item.ID] = icon } } shell.rows = nextRows shell.icons = nextIcons nextCategories := make(map[string]*widget.Clickable) for _, category := range append([]string{""}, shell.model.Categories()...) { control := shell.categoryControls[category] if control == nil { control = new(widget.Clickable) } nextCategories[category] = control } shell.categoryControls = nextCategories } // NewTheme creates the accessible palette shared by the Legacy shell. func NewTheme() *material.Theme { theme := material.NewTheme() theme.Palette = material.Palette{ 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) shell.lastRendered = 0 shell.detailRendered = false paint.Fill(gtx.Ops, shellColors.background) return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout( gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return shell.layoutHeader(gtx, theme) }), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return shell.layoutContent(gtx, theme) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { label := material.Caption( theme, shell.edition+" · Legacy · Windows 7 SP1 x64", ) label.Color = shellColors.secondary return label.Layout(gtx) }), ) }) } func (shell *AppShell) drainInput(gtx layout.Context) { for { if _, ok := shell.search.Update(gtx); !ok { break } } shell.model.SetQuery(shell.search.Text()) for shell.viewAll.Clicked(gtx) { shell.model.SetView(application.CatalogViewAll) } for shell.viewInstalled.Clicked(gtx) { shell.model.SetView(application.CatalogViewInstalled) } for shell.viewUpdates.Clicked(gtx) { shell.model.SetView(application.CatalogViewUpdates) } for category, control := range shell.categoryControls { for control.Clicked(gtx) { shell.model.SetCategory(category) } } for appID, controls := range shell.rows { for controls.open.Clicked(gtx) { shell.model.Select(appID) } } for shell.resetFilters.Clicked(gtx) { shell.search.SetText("") shell.model.ResetFilters() } for shell.closeDetail.Clicked(gtx) { 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 } }