Build virtualized software list (T-203)
This commit is contained in:
+669
-49
@@ -1,39 +1,115 @@
|
||||
package gio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
|
||||
"gioui.org/io/semantic"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
|
||||
"softbox.local/core/application"
|
||||
"softbox.local/core/domain"
|
||||
)
|
||||
|
||||
var shellColors = struct {
|
||||
background color.NRGBA
|
||||
foreground color.NRGBA
|
||||
primary color.NRGBA
|
||||
onPrimary color.NRGBA
|
||||
secondary color.NRGBA
|
||||
background color.NRGBA
|
||||
surface color.NRGBA
|
||||
muted color.NRGBA
|
||||
foreground color.NRGBA
|
||||
secondary color.NRGBA
|
||||
primary color.NRGBA
|
||||
onPrimary color.NRGBA
|
||||
border color.NRGBA
|
||||
success color.NRGBA
|
||||
warning color.NRGBA
|
||||
destructive color.NRGBA
|
||||
}{
|
||||
background: color.NRGBA{R: 248, G: 250, B: 252, A: 255},
|
||||
foreground: color.NRGBA{R: 15, G: 23, B: 42, A: 255},
|
||||
primary: color.NRGBA{R: 13, G: 148, B: 136, A: 255},
|
||||
onPrimary: color.NRGBA{R: 255, G: 255, B: 255, A: 255},
|
||||
secondary: color.NRGBA{R: 71, G: 85, B: 105, A: 255},
|
||||
background: color.NRGBA{R: 248, G: 250, B: 252, A: 255},
|
||||
surface: color.NRGBA{R: 255, G: 255, B: 255, A: 255},
|
||||
muted: color.NRGBA{R: 240, G: 248, B: 246, A: 255},
|
||||
foreground: color.NRGBA{R: 15, G: 23, B: 42, A: 255},
|
||||
secondary: color.NRGBA{R: 71, G: 85, B: 105, A: 255},
|
||||
primary: color.NRGBA{R: 5, G: 150, B: 105, A: 255},
|
||||
onPrimary: color.NRGBA{R: 255, G: 255, B: 255, A: 255},
|
||||
border: color.NRGBA{R: 209, G: 229, B: 223, A: 255},
|
||||
success: color.NRGBA{R: 4, G: 120, B: 87, A: 255},
|
||||
warning: color.NRGBA{R: 180, G: 83, B: 9, A: 255},
|
||||
destructive: color.NRGBA{R: 185, G: 28, B: 28, A: 255},
|
||||
}
|
||||
|
||||
// AppShell is the modern window frame. Feature views are added in later tasks.
|
||||
type rowControls struct {
|
||||
open widget.Clickable
|
||||
}
|
||||
|
||||
// AppShell is the modern software catalog window.
|
||||
type AppShell struct {
|
||||
edition string
|
||||
model *application.CatalogListModel
|
||||
|
||||
search widget.Editor
|
||||
appList layout.List
|
||||
categoryList layout.List
|
||||
|
||||
viewAll widget.Clickable
|
||||
viewInstalled widget.Clickable
|
||||
viewUpdates widget.Clickable
|
||||
resetFilters widget.Clickable
|
||||
|
||||
categoryControls map[string]*widget.Clickable
|
||||
rows map[string]*rowControls
|
||||
lastRendered int
|
||||
}
|
||||
|
||||
// NewAppShell creates an empty shell for the supplied build edition.
|
||||
func NewAppShell(edition string) *AppShell {
|
||||
return &AppShell{edition: edition}
|
||||
// NewAppShell creates the catalog shell with an optional in-memory snapshot.
|
||||
func NewAppShell(
|
||||
edition string,
|
||||
items ...application.CatalogListItem,
|
||||
) *AppShell {
|
||||
shell := &AppShell{
|
||||
edition: edition,
|
||||
model: application.NewCatalogListModel(nil),
|
||||
appList: layout.List{Axis: layout.Vertical},
|
||||
categoryList: layout.List{Axis: layout.Horizontal},
|
||||
categoryControls: make(map[string]*widget.Clickable),
|
||||
rows: make(map[string]*rowControls),
|
||||
}
|
||||
shell.search.SingleLine = true
|
||||
shell.SetItems(items)
|
||||
return shell
|
||||
}
|
||||
|
||||
// NewTheme creates the semantic palette shared by the modern shell.
|
||||
// SetItems applies a prepared, IO-free catalog/status snapshot.
|
||||
func (shell *AppShell) SetItems(items []application.CatalogListItem) {
|
||||
shell.model.SetItems(items)
|
||||
|
||||
nextRows := make(map[string]*rowControls, len(items))
|
||||
for _, item := range items {
|
||||
controls := shell.rows[item.ID]
|
||||
if controls == nil {
|
||||
controls = new(rowControls)
|
||||
}
|
||||
nextRows[item.ID] = controls
|
||||
}
|
||||
shell.rows = nextRows
|
||||
|
||||
nextCategories := make(map[string]*widget.Clickable)
|
||||
for _, category := range append([]string{""}, shell.model.Categories()...) {
|
||||
control := shell.categoryControls[category]
|
||||
if control == nil {
|
||||
control = new(widget.Clickable)
|
||||
}
|
||||
nextCategories[category] = control
|
||||
}
|
||||
shell.categoryControls = nextCategories
|
||||
}
|
||||
|
||||
// NewTheme creates the accessible semantic palette shared by the modern shell.
|
||||
func NewTheme() *material.Theme {
|
||||
theme := material.NewTheme()
|
||||
theme.Palette = material.Palette{
|
||||
@@ -46,45 +122,589 @@ func NewTheme() *material.Theme {
|
||||
return theme
|
||||
}
|
||||
|
||||
// Layout renders the shell without performing IO or submitting use cases.
|
||||
func (shell *AppShell) Layout(context layout.Context, theme *material.Theme) layout.Dimensions {
|
||||
paint.Fill(context.Ops, theme.Bg)
|
||||
// Layout drains input first and performs no disk, network or hash IO.
|
||||
func (shell *AppShell) Layout(gtx layout.Context, theme *material.Theme) layout.Dimensions {
|
||||
shell.drainInput(gtx)
|
||||
shell.lastRendered = 0
|
||||
paint.Fill(gtx.Ops, shellColors.background)
|
||||
|
||||
return layout.UniformInset(unit.Dp(32)).Layout(context, func(context layout.Context) layout.Dimensions {
|
||||
return layout.UniformInset(unit.Dp(20)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||
context,
|
||||
layout.Rigid(func(context layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(
|
||||
context,
|
||||
layout.Rigid(material.H5(theme, "SoftBox").Layout),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(16)}.Layout),
|
||||
layout.Rigid(func(context layout.Context) layout.Dimensions {
|
||||
label := material.Body2(theme, shell.edition)
|
||||
label.Color = shellColors.primary
|
||||
return label.Layout(context)
|
||||
}),
|
||||
)
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutHeader(gtx, theme)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(24)}.Layout),
|
||||
layout.Flexed(1, func(context layout.Context) layout.Dimensions {
|
||||
return layout.Center.Layout(context, func(context layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(
|
||||
context,
|
||||
layout.Rigid(material.H6(theme, "软件目录准备中").Layout),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(context layout.Context) layout.Dimensions {
|
||||
label := material.Body1(theme, "当前窗口仅验证 Gio AppShell 与双构建链路。")
|
||||
label.Color = shellColors.secondary
|
||||
return label.Layout(context)
|
||||
}),
|
||||
)
|
||||
})
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutContent(gtx, theme)
|
||||
}),
|
||||
layout.Rigid(func(context layout.Context) layout.Dimensions {
|
||||
label := material.Body2(theme, "Modern · Windows 10/11 x64")
|
||||
label.Color = shellColors.secondary
|
||||
return label.Layout(context)
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutFooter(gtx, theme)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (shell *AppShell) drainInput(gtx layout.Context) {
|
||||
for {
|
||||
if _, ok := shell.search.Update(gtx); !ok {
|
||||
break
|
||||
}
|
||||
}
|
||||
shell.model.SetQuery(shell.search.Text())
|
||||
|
||||
for shell.viewAll.Clicked(gtx) {
|
||||
shell.model.SetView(application.CatalogViewAll)
|
||||
}
|
||||
for shell.viewInstalled.Clicked(gtx) {
|
||||
shell.model.SetView(application.CatalogViewInstalled)
|
||||
}
|
||||
for shell.viewUpdates.Clicked(gtx) {
|
||||
shell.model.SetView(application.CatalogViewUpdates)
|
||||
}
|
||||
for category, control := range shell.categoryControls {
|
||||
for control.Clicked(gtx) {
|
||||
shell.model.SetCategory(category)
|
||||
}
|
||||
}
|
||||
for appID, controls := range shell.rows {
|
||||
for controls.open.Clicked(gtx) {
|
||||
shell.model.Select(appID)
|
||||
}
|
||||
}
|
||||
for shell.resetFilters.Clicked(gtx) {
|
||||
shell.search.SetText("")
|
||||
shell.model.ResetFilters()
|
||||
}
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutHeader(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(material.H4(theme, "SoftBox").Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Body2(theme, "发现、安装并更新可信软件")
|
||||
label.Color = shellColors.secondary
|
||||
return label.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(48)}.Layout),
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
border := shellColors.border
|
||||
if gtx.Focused(&shell.search) {
|
||||
border = shellColors.primary
|
||||
}
|
||||
return outlinedPanel(
|
||||
gtx,
|
||||
border,
|
||||
shellColors.surface,
|
||||
unit.Dp(8),
|
||||
layout.Inset{
|
||||
Top: unit.Dp(10), Bottom: unit.Dp(10),
|
||||
Left: unit.Dp(14), Right: unit.Dp(14),
|
||||
},
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(24))
|
||||
editor := material.Editor(theme, &shell.search, "搜索名称、软件 ID 或标签")
|
||||
editor.TextSize = unit.Sp(15)
|
||||
return editor.Layout(gtx)
|
||||
},
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutCategories(gtx, theme)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutCategories(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
) layout.Dimensions {
|
||||
categories := append([]string{""}, shell.model.Categories()...)
|
||||
height := gtx.Dp(unit.Dp(44))
|
||||
gtx.Constraints.Min.Y = height
|
||||
gtx.Constraints.Max.Y = height
|
||||
return shell.categoryList.Layout(gtx, len(categories), func(
|
||||
gtx layout.Context,
|
||||
index int,
|
||||
) layout.Dimensions {
|
||||
category := categories[index]
|
||||
label := category
|
||||
if label == "" {
|
||||
label = "全部分类"
|
||||
}
|
||||
return layout.Inset{Right: unit.Dp(8)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutFilterButton(
|
||||
gtx,
|
||||
theme,
|
||||
shell.categoryControls[category],
|
||||
label,
|
||||
shell.model.Category() == category,
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutContent(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
) layout.Dimensions {
|
||||
return layout.Flex{}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
width := gtx.Dp(unit.Dp(168))
|
||||
gtx.Constraints.Min.X = width
|
||||
gtx.Constraints.Max.X = width
|
||||
return panel(
|
||||
gtx,
|
||||
shellColors.muted,
|
||||
unit.Dp(10),
|
||||
layout.UniformInset(unit.Dp(12)),
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Caption(theme, "软件视图")
|
||||
label.Color = shellColors.secondary
|
||||
return layout.Inset{
|
||||
Left: unit.Dp(8), Bottom: unit.Dp(8),
|
||||
}.Layout(gtx, label.Layout)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutViewButton(
|
||||
gtx,
|
||||
theme,
|
||||
&shell.viewAll,
|
||||
"全部软件",
|
||||
application.CatalogViewAll,
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutViewButton(
|
||||
gtx,
|
||||
theme,
|
||||
&shell.viewInstalled,
|
||||
"已安装",
|
||||
application.CatalogViewInstalled,
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutViewButton(
|
||||
gtx,
|
||||
theme,
|
||||
&shell.viewUpdates,
|
||||
"可更新",
|
||||
application.CatalogViewUpdates,
|
||||
)
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(16)}.Layout),
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
return panel(
|
||||
gtx,
|
||||
shellColors.surface,
|
||||
unit.Dp(10),
|
||||
layout.UniformInset(unit.Dp(16)),
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutCatalog(gtx, theme)
|
||||
},
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutCatalog(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
) layout.Dimensions {
|
||||
visible := shell.model.VisibleItems()
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(material.H6(theme, viewTitle(shell.model.View())).Layout),
|
||||
layout.Flexed(1, layout.Spacer{}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Body2(
|
||||
theme,
|
||||
fmt.Sprintf("%d / %d 项", len(visible), shell.model.TotalCount()),
|
||||
)
|
||||
label.Color = shellColors.secondary
|
||||
return label.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
if len(visible) == 0 {
|
||||
return shell.layoutEmptyState(gtx, theme)
|
||||
}
|
||||
return shell.appList.Layout(gtx, len(visible), func(
|
||||
gtx layout.Context,
|
||||
index int,
|
||||
) layout.Dimensions {
|
||||
shell.lastRendered++
|
||||
return shell.layoutAppRow(gtx, theme, visible[index])
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutAppRow(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
item application.CatalogListItem,
|
||||
) layout.Dimensions {
|
||||
controls := shell.rows[item.ID]
|
||||
if controls == nil {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Inset{Bottom: unit.Dp(8)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(88))
|
||||
return controls.open.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
semantic.Button.Add(gtx.Ops)
|
||||
semantic.DescriptionOp(fmt.Sprintf(
|
||||
"%s,版本 %s,状态 %s",
|
||||
item.Name,
|
||||
item.Version,
|
||||
statusLabel(item.Status),
|
||||
)).Add(gtx.Ops)
|
||||
|
||||
background := shellColors.muted
|
||||
if controls.open.Hovered() || gtx.Focused(&controls.open) {
|
||||
background = color.NRGBA{R: 236, G: 253, B: 245, A: 255}
|
||||
}
|
||||
if shell.model.SelectedID() == item.ID {
|
||||
background = color.NRGBA{R: 220, G: 252, B: 231, A: 255}
|
||||
}
|
||||
return panel(
|
||||
gtx,
|
||||
background,
|
||||
unit.Dp(8),
|
||||
layout.UniformInset(unit.Dp(12)),
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutLetterIcon(gtx, theme, item.Name)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(material.H6(theme, item.Name).Layout),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Body2(
|
||||
theme,
|
||||
fmt.Sprintf(
|
||||
"%s · %s · %s",
|
||||
item.ID,
|
||||
item.Version,
|
||||
item.Category,
|
||||
),
|
||||
)
|
||||
label.Color = shellColors.secondary
|
||||
return label.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical, Alignment: layout.End}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Body1(theme, statusLabel(item.Status))
|
||||
label.Color = statusColor(item.Status)
|
||||
return label.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Caption(theme, actionLabel(item))
|
||||
label.Color = shellColors.secondary
|
||||
return label.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutLetterIcon(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
name string,
|
||||
) layout.Dimensions {
|
||||
size := gtx.Dp(unit.Dp(48))
|
||||
gtx.Constraints.Min = image.Pt(size, size)
|
||||
gtx.Constraints.Max = gtx.Constraints.Min
|
||||
letter := "S"
|
||||
for _, character := range name {
|
||||
letter = string(character)
|
||||
break
|
||||
}
|
||||
return panel(
|
||||
gtx,
|
||||
shellColors.primary,
|
||||
unit.Dp(8),
|
||||
layout.UniformInset(unit.Dp(0)),
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.H6(theme, letter)
|
||||
label.Color = shellColors.onPrimary
|
||||
return label.Layout(gtx)
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutEmptyState(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
) layout.Dimensions {
|
||||
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
title := "没有匹配的软件"
|
||||
body := "尝试清除搜索词、分类或视图筛选。"
|
||||
showReset := shell.model.TotalCount() > 0
|
||||
if shell.model.TotalCount() == 0 {
|
||||
title = "软件目录尚未加载"
|
||||
body = "联网刷新或存在已验证缓存后,软件会显示在这里。"
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(material.H6(theme, title).Layout),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Body2(theme, body)
|
||||
label.Color = shellColors.secondary
|
||||
return label.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showReset {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Inset{Top: unit.Dp(16)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutFilterButton(
|
||||
gtx,
|
||||
theme,
|
||||
&shell.resetFilters,
|
||||
"显示全部软件",
|
||||
true,
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutViewButton(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
clickable *widget.Clickable,
|
||||
label string,
|
||||
view application.CatalogView,
|
||||
) layout.Dimensions {
|
||||
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
||||
return shell.layoutFilterButton(
|
||||
gtx,
|
||||
theme,
|
||||
clickable,
|
||||
label,
|
||||
shell.model.View() == view,
|
||||
)
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutFilterButton(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
clickable *widget.Clickable,
|
||||
label string,
|
||||
active bool,
|
||||
) layout.Dimensions {
|
||||
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(44))
|
||||
button := material.Button(theme, clickable, label)
|
||||
button.CornerRadius = unit.Dp(8)
|
||||
button.Inset = layout.Inset{
|
||||
Top: unit.Dp(10), Bottom: unit.Dp(10),
|
||||
Left: unit.Dp(14), Right: unit.Dp(14),
|
||||
}
|
||||
if active {
|
||||
button.Background = shellColors.primary
|
||||
button.Color = shellColors.onPrimary
|
||||
} else {
|
||||
button.Background = shellColors.muted
|
||||
button.Color = shellColors.foreground
|
||||
}
|
||||
return button.Layout(gtx)
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutFooter(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
) layout.Dimensions {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Caption(theme, "目录状态:等待已验证 Catalog")
|
||||
label.Color = shellColors.secondary
|
||||
return label.Layout(gtx)
|
||||
}),
|
||||
layout.Flexed(1, layout.Spacer{}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Caption(theme, shell.edition+" · Windows 10/11 x64")
|
||||
label.Color = shellColors.secondary
|
||||
return label.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func panel(
|
||||
gtx layout.Context,
|
||||
background color.NRGBA,
|
||||
radius unit.Dp,
|
||||
inset layout.Inset,
|
||||
content layout.Widget,
|
||||
) layout.Dimensions {
|
||||
return layout.Background{}.Layout(
|
||||
gtx,
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
paint.FillShape(
|
||||
gtx.Ops,
|
||||
background,
|
||||
clip.UniformRRect(
|
||||
image.Rectangle{Max: gtx.Constraints.Min},
|
||||
gtx.Dp(radius),
|
||||
).Op(gtx.Ops),
|
||||
)
|
||||
return layout.Dimensions{Size: gtx.Constraints.Min}
|
||||
},
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return inset.Layout(gtx, content)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func outlinedPanel(
|
||||
gtx layout.Context,
|
||||
border color.NRGBA,
|
||||
background color.NRGBA,
|
||||
radius unit.Dp,
|
||||
inset layout.Inset,
|
||||
content layout.Widget,
|
||||
) layout.Dimensions {
|
||||
return panel(
|
||||
gtx,
|
||||
border,
|
||||
radius,
|
||||
layout.UniformInset(unit.Dp(1)),
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return panel(gtx, background, radius-unit.Dp(1), inset, content)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func viewTitle(view application.CatalogView) string {
|
||||
switch view {
|
||||
case application.CatalogViewInstalled:
|
||||
return "已安装软件"
|
||||
case application.CatalogViewUpdates:
|
||||
return "可更新软件"
|
||||
default:
|
||||
return "全部软件"
|
||||
}
|
||||
}
|
||||
|
||||
func statusLabel(status domain.AppStatus) string {
|
||||
switch status {
|
||||
case domain.StatusQueued:
|
||||
return "排队中"
|
||||
case domain.StatusDownloading:
|
||||
return "下载中"
|
||||
case domain.StatusVerifying:
|
||||
return "校验中"
|
||||
case domain.StatusExtracting:
|
||||
return "解压中"
|
||||
case domain.StatusInstalling:
|
||||
return "安装中"
|
||||
case domain.StatusInstalled:
|
||||
return "已安装"
|
||||
case domain.StatusUpdateAvailable:
|
||||
return "可更新"
|
||||
case domain.StatusRunning:
|
||||
return "运行中"
|
||||
case domain.StatusFailed:
|
||||
return "失败"
|
||||
case domain.StatusRollbackPending:
|
||||
return "待恢复"
|
||||
case domain.StatusIncompatible:
|
||||
return "不兼容"
|
||||
default:
|
||||
return "未安装"
|
||||
}
|
||||
}
|
||||
|
||||
func statusColor(status domain.AppStatus) color.NRGBA {
|
||||
switch status {
|
||||
case domain.StatusFailed, domain.StatusRollbackPending:
|
||||
return shellColors.destructive
|
||||
case domain.StatusUpdateAvailable:
|
||||
return shellColors.warning
|
||||
case domain.StatusInstalled, domain.StatusRunning:
|
||||
return shellColors.success
|
||||
case domain.StatusIncompatible:
|
||||
return shellColors.secondary
|
||||
default:
|
||||
return shellColors.primary
|
||||
}
|
||||
}
|
||||
|
||||
func actionLabel(item application.CatalogListItem) string {
|
||||
if item.Reason != "" || item.Status == domain.StatusIncompatible {
|
||||
return "查看不可用原因"
|
||||
}
|
||||
switch item.Status {
|
||||
case domain.StatusInstalled:
|
||||
return "查看或启动"
|
||||
case domain.StatusUpdateAvailable:
|
||||
return "查看更新"
|
||||
case domain.StatusRunning:
|
||||
return "查看运行状态"
|
||||
case domain.StatusQueued,
|
||||
domain.StatusDownloading,
|
||||
domain.StatusVerifying,
|
||||
domain.StatusExtracting,
|
||||
domain.StatusInstalling:
|
||||
return "查看任务"
|
||||
case domain.StatusFailed, domain.StatusRollbackPending:
|
||||
return "查看恢复选项"
|
||||
default:
|
||||
if item.Installable {
|
||||
return "查看并安装"
|
||||
}
|
||||
return "查看详情"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package gio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"testing"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/unit"
|
||||
|
||||
"softbox.local/core/application"
|
||||
"softbox.local/core/domain"
|
||||
)
|
||||
|
||||
func TestAppShellFillsWindow(t *testing.T) {
|
||||
@@ -23,3 +27,63 @@ func TestAppShellFillsWindow(t *testing.T) {
|
||||
t.Fatalf("Layout() size = %v, want %v", dimensions.Size, size)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppShellVirtualizesLargeCatalog(t *testing.T) {
|
||||
items := make([]application.CatalogListItem, 500)
|
||||
for index := range items {
|
||||
items[index] = application.CatalogListItem{
|
||||
ID: fmt.Sprintf("app-%03d", index),
|
||||
Name: fmt.Sprintf("软件 %03d", index),
|
||||
Version: "1.0.0",
|
||||
Category: "工具",
|
||||
Tags: []string{"工具"},
|
||||
Status: domain.StatusNotInstalled,
|
||||
Installable: true,
|
||||
}
|
||||
}
|
||||
shell := NewAppShell("Modern", items...)
|
||||
context := testContext(image.Pt(1080, 420))
|
||||
|
||||
shell.Layout(context, NewTheme())
|
||||
if shell.lastRendered <= 0 || shell.lastRendered >= len(items) {
|
||||
t.Fatalf(
|
||||
"lastRendered = %d, want visible subset of %d",
|
||||
shell.lastRendered,
|
||||
len(items),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppShellKeepsRowControlsByAppID(t *testing.T) {
|
||||
items := []application.CatalogListItem{
|
||||
{ID: "app-one", Name: "One", Version: "1.0.0", Category: "工具"},
|
||||
{ID: "app-two", Name: "Two", Version: "1.0.0", Category: "图像"},
|
||||
}
|
||||
shell := NewAppShell("Modern", items...)
|
||||
original := shell.rows["app-two"]
|
||||
|
||||
shell.model.SetCategory("图像")
|
||||
shell.Layout(testContext(image.Pt(1080, 720)), NewTheme())
|
||||
if shell.rows["app-two"] != original {
|
||||
t.Fatal("row controls were recreated after filtering")
|
||||
}
|
||||
|
||||
shell.SetItems([]application.CatalogListItem{
|
||||
{ID: "app-two", Name: "Two", Version: "1.1.0", Category: "图像"},
|
||||
})
|
||||
if shell.rows["app-two"] != original {
|
||||
t.Fatal("row controls were recreated after snapshot update")
|
||||
}
|
||||
if _, exists := shell.rows["app-one"]; exists {
|
||||
t.Fatal("removed app retained row controls")
|
||||
}
|
||||
}
|
||||
|
||||
func testContext(size image.Point) layout.Context {
|
||||
var operations op.Ops
|
||||
return layout.Context{
|
||||
Ops: &operations,
|
||||
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
|
||||
Constraints: layout.Exact(size),
|
||||
}
|
||||
}
|
||||
|
||||
+587
-49
@@ -1,39 +1,115 @@
|
||||
package gio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
|
||||
"gioui.org/io/semantic"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
|
||||
"softbox.local/core/application"
|
||||
"softbox.local/core/domain"
|
||||
)
|
||||
|
||||
var shellColors = struct {
|
||||
background color.NRGBA
|
||||
foreground color.NRGBA
|
||||
primary color.NRGBA
|
||||
onPrimary color.NRGBA
|
||||
secondary color.NRGBA
|
||||
background color.NRGBA
|
||||
surface color.NRGBA
|
||||
muted color.NRGBA
|
||||
foreground color.NRGBA
|
||||
secondary color.NRGBA
|
||||
primary color.NRGBA
|
||||
onPrimary color.NRGBA
|
||||
border color.NRGBA
|
||||
success color.NRGBA
|
||||
warning color.NRGBA
|
||||
destructive color.NRGBA
|
||||
}{
|
||||
background: color.NRGBA{R: 248, G: 250, B: 252, A: 255},
|
||||
foreground: color.NRGBA{R: 15, G: 23, B: 42, A: 255},
|
||||
primary: color.NRGBA{R: 13, G: 148, B: 136, A: 255},
|
||||
onPrimary: color.NRGBA{R: 255, G: 255, B: 255, A: 255},
|
||||
secondary: color.NRGBA{R: 71, G: 85, B: 105, A: 255},
|
||||
background: color.NRGBA{R: 248, G: 250, B: 252, A: 255},
|
||||
surface: color.NRGBA{R: 255, G: 255, B: 255, A: 255},
|
||||
muted: color.NRGBA{R: 240, G: 248, B: 246, A: 255},
|
||||
foreground: color.NRGBA{R: 15, G: 23, B: 42, A: 255},
|
||||
secondary: color.NRGBA{R: 71, G: 85, B: 105, A: 255},
|
||||
primary: color.NRGBA{R: 5, G: 150, B: 105, A: 255},
|
||||
onPrimary: color.NRGBA{R: 255, G: 255, B: 255, A: 255},
|
||||
border: color.NRGBA{R: 209, G: 229, B: 223, A: 255},
|
||||
success: color.NRGBA{R: 4, G: 120, B: 87, A: 255},
|
||||
warning: color.NRGBA{R: 180, G: 83, B: 9, A: 255},
|
||||
destructive: color.NRGBA{R: 185, G: 28, B: 28, A: 255},
|
||||
}
|
||||
|
||||
// AppShell is the Legacy window frame. Feature views are added in later tasks.
|
||||
type rowControls struct {
|
||||
open widget.Clickable
|
||||
}
|
||||
|
||||
// AppShell is the Legacy software catalog window.
|
||||
type AppShell struct {
|
||||
edition string
|
||||
model *application.CatalogListModel
|
||||
|
||||
search widget.Editor
|
||||
appList layout.List
|
||||
categoryList layout.List
|
||||
|
||||
viewAll widget.Clickable
|
||||
viewInstalled widget.Clickable
|
||||
viewUpdates widget.Clickable
|
||||
resetFilters widget.Clickable
|
||||
|
||||
categoryControls map[string]*widget.Clickable
|
||||
rows map[string]*rowControls
|
||||
lastRendered int
|
||||
}
|
||||
|
||||
// NewAppShell creates an empty shell for the supplied build edition.
|
||||
func NewAppShell(edition string) *AppShell {
|
||||
return &AppShell{edition: edition}
|
||||
// NewAppShell creates the catalog shell with an optional in-memory snapshot.
|
||||
func NewAppShell(
|
||||
edition string,
|
||||
items ...application.CatalogListItem,
|
||||
) *AppShell {
|
||||
shell := &AppShell{
|
||||
edition: edition,
|
||||
model: application.NewCatalogListModel(nil),
|
||||
appList: layout.List{Axis: layout.Vertical},
|
||||
categoryList: layout.List{Axis: layout.Horizontal},
|
||||
categoryControls: make(map[string]*widget.Clickable),
|
||||
rows: make(map[string]*rowControls),
|
||||
}
|
||||
shell.search.SingleLine = true
|
||||
shell.SetItems(items)
|
||||
return shell
|
||||
}
|
||||
|
||||
// NewTheme creates the semantic palette shared by the Legacy shell.
|
||||
// SetItems applies a prepared, IO-free catalog/status snapshot.
|
||||
func (shell *AppShell) SetItems(items []application.CatalogListItem) {
|
||||
shell.model.SetItems(items)
|
||||
|
||||
nextRows := make(map[string]*rowControls, len(items))
|
||||
for _, item := range items {
|
||||
controls := shell.rows[item.ID]
|
||||
if controls == nil {
|
||||
controls = new(rowControls)
|
||||
}
|
||||
nextRows[item.ID] = controls
|
||||
}
|
||||
shell.rows = nextRows
|
||||
|
||||
nextCategories := make(map[string]*widget.Clickable)
|
||||
for _, category := range append([]string{""}, shell.model.Categories()...) {
|
||||
control := shell.categoryControls[category]
|
||||
if control == nil {
|
||||
control = new(widget.Clickable)
|
||||
}
|
||||
nextCategories[category] = control
|
||||
}
|
||||
shell.categoryControls = nextCategories
|
||||
}
|
||||
|
||||
// NewTheme creates the accessible palette shared by the Legacy shell.
|
||||
func NewTheme() *material.Theme {
|
||||
theme := material.NewTheme()
|
||||
theme.Palette = material.Palette{
|
||||
@@ -46,45 +122,507 @@ func NewTheme() *material.Theme {
|
||||
return theme
|
||||
}
|
||||
|
||||
// Layout renders the shell without performing IO or submitting use cases.
|
||||
func (shell *AppShell) Layout(context layout.Context, theme *material.Theme) layout.Dimensions {
|
||||
paint.Fill(context.Ops, theme.Bg)
|
||||
// Layout drains input first and performs no disk, network or hash IO.
|
||||
func (shell *AppShell) Layout(gtx layout.Context, theme *material.Theme) layout.Dimensions {
|
||||
shell.drainInput(gtx)
|
||||
shell.lastRendered = 0
|
||||
paint.Fill(gtx.Ops, shellColors.background)
|
||||
|
||||
return layout.UniformInset(unit.Dp(32)).Layout(context, func(context layout.Context) layout.Dimensions {
|
||||
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||
context,
|
||||
layout.Rigid(func(context layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(
|
||||
context,
|
||||
layout.Rigid(material.H5(theme, "SoftBox").Layout),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(16)}.Layout),
|
||||
layout.Rigid(func(context layout.Context) layout.Dimensions {
|
||||
label := material.Body2(theme, shell.edition)
|
||||
label.Color = shellColors.primary
|
||||
return label.Layout(context)
|
||||
}),
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutHeader(gtx, theme)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutContent(gtx, theme)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Caption(
|
||||
theme,
|
||||
shell.edition+" · Legacy · Windows 7 SP1 x64",
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(24)}.Layout),
|
||||
layout.Flexed(1, func(context layout.Context) layout.Dimensions {
|
||||
return layout.Center.Layout(context, func(context layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(
|
||||
context,
|
||||
layout.Rigid(material.H6(theme, "软件目录准备中").Layout),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(context layout.Context) layout.Dimensions {
|
||||
label := material.Body1(theme, "Legacy 窗口已就绪,业务功能将在后续任务接入。")
|
||||
label.Color = shellColors.secondary
|
||||
return label.Layout(context)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}),
|
||||
layout.Rigid(func(context layout.Context) layout.Dimensions {
|
||||
label := material.Body2(theme, "Legacy · Windows 7 SP1 x64")
|
||||
label.Color = shellColors.secondary
|
||||
return label.Layout(context)
|
||||
return label.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (shell *AppShell) drainInput(gtx layout.Context) {
|
||||
for {
|
||||
if _, ok := shell.search.Update(gtx); !ok {
|
||||
break
|
||||
}
|
||||
}
|
||||
shell.model.SetQuery(shell.search.Text())
|
||||
for shell.viewAll.Clicked(gtx) {
|
||||
shell.model.SetView(application.CatalogViewAll)
|
||||
}
|
||||
for shell.viewInstalled.Clicked(gtx) {
|
||||
shell.model.SetView(application.CatalogViewInstalled)
|
||||
}
|
||||
for shell.viewUpdates.Clicked(gtx) {
|
||||
shell.model.SetView(application.CatalogViewUpdates)
|
||||
}
|
||||
for category, control := range shell.categoryControls {
|
||||
for control.Clicked(gtx) {
|
||||
shell.model.SetCategory(category)
|
||||
}
|
||||
}
|
||||
for appID, controls := range shell.rows {
|
||||
for controls.open.Clicked(gtx) {
|
||||
shell.model.Select(appID)
|
||||
}
|
||||
}
|
||||
for shell.resetFilters.Clicked(gtx) {
|
||||
shell.search.SetText("")
|
||||
shell.model.ResetFilters()
|
||||
}
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutHeader(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(material.H5(theme, "SoftBox Legacy").Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Caption(theme, "兼容 Windows 7 SP1 的可信软件目录")
|
||||
label.Color = shellColors.secondary
|
||||
return label.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(32)}.Layout),
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
border := shellColors.border
|
||||
if gtx.Focused(&shell.search) {
|
||||
border = shellColors.primary
|
||||
}
|
||||
return outlinedPanel(
|
||||
gtx,
|
||||
border,
|
||||
shellColors.surface,
|
||||
unit.Dp(6),
|
||||
layout.Inset{
|
||||
Top: unit.Dp(9), Bottom: unit.Dp(9),
|
||||
Left: unit.Dp(12), Right: unit.Dp(12),
|
||||
},
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
editor := material.Editor(theme, &shell.search, "搜索名称、ID 或标签")
|
||||
editor.TextSize = unit.Sp(14)
|
||||
return editor.Layout(gtx)
|
||||
},
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
categories := append([]string{""}, shell.model.Categories()...)
|
||||
height := gtx.Dp(unit.Dp(44))
|
||||
gtx.Constraints.Min.Y = height
|
||||
gtx.Constraints.Max.Y = height
|
||||
return shell.categoryList.Layout(gtx, len(categories), func(
|
||||
gtx layout.Context,
|
||||
index int,
|
||||
) layout.Dimensions {
|
||||
category := categories[index]
|
||||
label := category
|
||||
if label == "" {
|
||||
label = "全部分类"
|
||||
}
|
||||
return layout.Inset{Right: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutFilterButton(
|
||||
gtx,
|
||||
theme,
|
||||
shell.categoryControls[category],
|
||||
label,
|
||||
shell.model.Category() == category,
|
||||
)
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutContent(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
) layout.Dimensions {
|
||||
return layout.Flex{}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
width := gtx.Dp(unit.Dp(152))
|
||||
gtx.Constraints.Min.X = width
|
||||
gtx.Constraints.Max.X = width
|
||||
return panel(
|
||||
gtx,
|
||||
shellColors.muted,
|
||||
unit.Dp(6),
|
||||
layout.UniformInset(unit.Dp(10)),
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutViewButton(
|
||||
gtx,
|
||||
theme,
|
||||
&shell.viewAll,
|
||||
"全部软件",
|
||||
application.CatalogViewAll,
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutViewButton(
|
||||
gtx,
|
||||
theme,
|
||||
&shell.viewInstalled,
|
||||
"已安装",
|
||||
application.CatalogViewInstalled,
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutViewButton(
|
||||
gtx,
|
||||
theme,
|
||||
&shell.viewUpdates,
|
||||
"可更新",
|
||||
application.CatalogViewUpdates,
|
||||
)
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
return panel(
|
||||
gtx,
|
||||
shellColors.surface,
|
||||
unit.Dp(6),
|
||||
layout.UniformInset(unit.Dp(12)),
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
visible := shell.model.VisibleItems()
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(material.H6(theme, viewTitle(shell.model.View())).Layout),
|
||||
layout.Flexed(1, layout.Spacer{}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Caption(
|
||||
theme,
|
||||
fmt.Sprintf("%d / %d 项", len(visible), shell.model.TotalCount()),
|
||||
)
|
||||
label.Color = shellColors.secondary
|
||||
return label.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
if len(visible) == 0 {
|
||||
return shell.layoutEmptyState(gtx, theme)
|
||||
}
|
||||
return shell.appList.Layout(gtx, len(visible), func(
|
||||
gtx layout.Context,
|
||||
index int,
|
||||
) layout.Dimensions {
|
||||
shell.lastRendered++
|
||||
return shell.layoutAppRow(gtx, theme, visible[index])
|
||||
})
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutAppRow(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
item application.CatalogListItem,
|
||||
) layout.Dimensions {
|
||||
controls := shell.rows[item.ID]
|
||||
if controls == nil {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(80))
|
||||
return controls.open.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
semantic.Button.Add(gtx.Ops)
|
||||
semantic.DescriptionOp(fmt.Sprintf(
|
||||
"%s,版本 %s,状态 %s",
|
||||
item.Name,
|
||||
item.Version,
|
||||
statusLabel(item.Status),
|
||||
)).Add(gtx.Ops)
|
||||
background := shellColors.muted
|
||||
if controls.open.Hovered() || gtx.Focused(&controls.open) {
|
||||
background = color.NRGBA{R: 236, G: 253, B: 245, A: 255}
|
||||
}
|
||||
if shell.model.SelectedID() == item.ID {
|
||||
background = color.NRGBA{R: 220, G: 252, B: 231, A: 255}
|
||||
}
|
||||
return panel(
|
||||
gtx,
|
||||
background,
|
||||
unit.Dp(5),
|
||||
layout.UniformInset(unit.Dp(10)),
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutLetterIcon(gtx, theme, item.Name)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(10)}.Layout),
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(material.Body1(theme, item.Name).Layout),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(3)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Caption(
|
||||
theme,
|
||||
fmt.Sprintf("%s · %s · %s", item.ID, item.Version, item.Category),
|
||||
)
|
||||
label.Color = shellColors.secondary
|
||||
return label.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Body2(theme, statusLabel(item.Status))
|
||||
label.Color = statusColor(item.Status)
|
||||
return label.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutLetterIcon(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
name string,
|
||||
) layout.Dimensions {
|
||||
size := gtx.Dp(unit.Dp(40))
|
||||
gtx.Constraints.Min = image.Pt(size, size)
|
||||
gtx.Constraints.Max = gtx.Constraints.Min
|
||||
letter := "S"
|
||||
for _, character := range name {
|
||||
letter = string(character)
|
||||
break
|
||||
}
|
||||
return panel(
|
||||
gtx,
|
||||
shellColors.primary,
|
||||
unit.Dp(5),
|
||||
layout.UniformInset(unit.Dp(0)),
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Body1(theme, letter)
|
||||
label.Color = shellColors.onPrimary
|
||||
return label.Layout(gtx)
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutEmptyState(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
) layout.Dimensions {
|
||||
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
title := "没有匹配的软件"
|
||||
body := "清除搜索词、分类或视图筛选后重试。"
|
||||
showReset := shell.model.TotalCount() > 0
|
||||
if shell.model.TotalCount() == 0 {
|
||||
title = "软件目录尚未加载"
|
||||
body = "联网刷新或读取已验证缓存后会显示软件。"
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(material.H6(theme, title).Layout),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Caption(theme, body)
|
||||
label.Color = shellColors.secondary
|
||||
return label.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showReset {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Inset{Top: unit.Dp(12)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutFilterButton(
|
||||
gtx,
|
||||
theme,
|
||||
&shell.resetFilters,
|
||||
"显示全部软件",
|
||||
true,
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutViewButton(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
clickable *widget.Clickable,
|
||||
label string,
|
||||
view application.CatalogView,
|
||||
) layout.Dimensions {
|
||||
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
||||
return shell.layoutFilterButton(
|
||||
gtx,
|
||||
theme,
|
||||
clickable,
|
||||
label,
|
||||
shell.model.View() == view,
|
||||
)
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutFilterButton(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
clickable *widget.Clickable,
|
||||
label string,
|
||||
active bool,
|
||||
) layout.Dimensions {
|
||||
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(44))
|
||||
button := material.Button(theme, clickable, label)
|
||||
button.CornerRadius = unit.Dp(5)
|
||||
button.Inset = layout.Inset{
|
||||
Top: unit.Dp(10), Bottom: unit.Dp(10),
|
||||
Left: unit.Dp(12), Right: unit.Dp(12),
|
||||
}
|
||||
if active {
|
||||
button.Background = shellColors.primary
|
||||
button.Color = shellColors.onPrimary
|
||||
} else {
|
||||
button.Background = shellColors.muted
|
||||
button.Color = shellColors.foreground
|
||||
}
|
||||
return button.Layout(gtx)
|
||||
}
|
||||
|
||||
func panel(
|
||||
gtx layout.Context,
|
||||
background color.NRGBA,
|
||||
radius unit.Dp,
|
||||
inset layout.Inset,
|
||||
content layout.Widget,
|
||||
) layout.Dimensions {
|
||||
return layout.Background{}.Layout(
|
||||
gtx,
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
paint.FillShape(
|
||||
gtx.Ops,
|
||||
background,
|
||||
clip.UniformRRect(
|
||||
image.Rectangle{Max: gtx.Constraints.Min},
|
||||
gtx.Dp(radius),
|
||||
).Op(gtx.Ops),
|
||||
)
|
||||
return layout.Dimensions{Size: gtx.Constraints.Min}
|
||||
},
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return inset.Layout(gtx, content)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func outlinedPanel(
|
||||
gtx layout.Context,
|
||||
border color.NRGBA,
|
||||
background color.NRGBA,
|
||||
radius unit.Dp,
|
||||
inset layout.Inset,
|
||||
content layout.Widget,
|
||||
) layout.Dimensions {
|
||||
return panel(
|
||||
gtx,
|
||||
border,
|
||||
radius,
|
||||
layout.UniformInset(unit.Dp(1)),
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return panel(gtx, background, radius-unit.Dp(1), inset, content)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func viewTitle(view application.CatalogView) string {
|
||||
switch view {
|
||||
case application.CatalogViewInstalled:
|
||||
return "已安装软件"
|
||||
case application.CatalogViewUpdates:
|
||||
return "可更新软件"
|
||||
default:
|
||||
return "全部软件"
|
||||
}
|
||||
}
|
||||
|
||||
func statusLabel(status domain.AppStatus) string {
|
||||
switch status {
|
||||
case domain.StatusQueued:
|
||||
return "排队中"
|
||||
case domain.StatusDownloading:
|
||||
return "下载中"
|
||||
case domain.StatusVerifying:
|
||||
return "校验中"
|
||||
case domain.StatusExtracting:
|
||||
return "解压中"
|
||||
case domain.StatusInstalling:
|
||||
return "安装中"
|
||||
case domain.StatusInstalled:
|
||||
return "已安装"
|
||||
case domain.StatusUpdateAvailable:
|
||||
return "可更新"
|
||||
case domain.StatusRunning:
|
||||
return "运行中"
|
||||
case domain.StatusFailed:
|
||||
return "失败"
|
||||
case domain.StatusRollbackPending:
|
||||
return "待恢复"
|
||||
case domain.StatusIncompatible:
|
||||
return "不兼容"
|
||||
default:
|
||||
return "未安装"
|
||||
}
|
||||
}
|
||||
|
||||
func statusColor(status domain.AppStatus) color.NRGBA {
|
||||
switch status {
|
||||
case domain.StatusFailed, domain.StatusRollbackPending:
|
||||
return shellColors.destructive
|
||||
case domain.StatusUpdateAvailable:
|
||||
return shellColors.warning
|
||||
case domain.StatusInstalled, domain.StatusRunning:
|
||||
return shellColors.success
|
||||
case domain.StatusIncompatible:
|
||||
return shellColors.secondary
|
||||
default:
|
||||
return shellColors.primary
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package gio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"testing"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/unit"
|
||||
|
||||
"softbox.local/core/application"
|
||||
"softbox.local/core/domain"
|
||||
)
|
||||
|
||||
func TestAppShellFillsWindow(t *testing.T) {
|
||||
@@ -23,3 +27,61 @@ func TestAppShellFillsWindow(t *testing.T) {
|
||||
t.Fatalf("Layout() size = %v, want %v", dimensions.Size, size)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppShellVirtualizesLargeCatalog(t *testing.T) {
|
||||
items := make([]application.CatalogListItem, 500)
|
||||
for index := range items {
|
||||
items[index] = application.CatalogListItem{
|
||||
ID: fmt.Sprintf("app-%03d", index),
|
||||
Name: fmt.Sprintf("软件 %03d", index),
|
||||
Version: "1.0.0",
|
||||
Category: "工具",
|
||||
Tags: []string{"工具"},
|
||||
Status: domain.StatusNotInstalled,
|
||||
Installable: true,
|
||||
}
|
||||
}
|
||||
shell := NewAppShell("Legacy", items...)
|
||||
shell.Layout(testContext(image.Pt(1024, 380)), NewTheme())
|
||||
if shell.lastRendered <= 0 || shell.lastRendered >= len(items) {
|
||||
t.Fatalf(
|
||||
"lastRendered = %d, want visible subset of %d",
|
||||
shell.lastRendered,
|
||||
len(items),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppShellKeepsRowControlsByAppID(t *testing.T) {
|
||||
items := []application.CatalogListItem{
|
||||
{ID: "app-one", Name: "One", Version: "1.0.0", Category: "工具"},
|
||||
{ID: "app-two", Name: "Two", Version: "1.0.0", Category: "图像"},
|
||||
}
|
||||
shell := NewAppShell("Legacy", items...)
|
||||
original := shell.rows["app-two"]
|
||||
|
||||
shell.model.SetCategory("图像")
|
||||
shell.Layout(testContext(image.Pt(1024, 680)), NewTheme())
|
||||
if shell.rows["app-two"] != original {
|
||||
t.Fatal("row controls were recreated after filtering")
|
||||
}
|
||||
|
||||
shell.SetItems([]application.CatalogListItem{
|
||||
{ID: "app-two", Name: "Two", Version: "1.1.0", Category: "图像"},
|
||||
})
|
||||
if shell.rows["app-two"] != original {
|
||||
t.Fatal("row controls were recreated after snapshot update")
|
||||
}
|
||||
if _, exists := shell.rows["app-one"]; exists {
|
||||
t.Fatal("removed app retained row controls")
|
||||
}
|
||||
}
|
||||
|
||||
func testContext(size image.Point) layout.Context {
|
||||
var operations op.Ops
|
||||
return layout.Context{
|
||||
Ops: &operations,
|
||||
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
|
||||
Constraints: layout.Exact(size),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"softbox.local/core/domain"
|
||||
)
|
||||
|
||||
// CatalogView is one primary software-list scope.
|
||||
type CatalogView string
|
||||
|
||||
const (
|
||||
CatalogViewAll CatalogView = "all"
|
||||
CatalogViewInstalled CatalogView = "installed"
|
||||
CatalogViewUpdates CatalogView = "updates"
|
||||
)
|
||||
|
||||
// CatalogListItem is the IO-free data consumed by Gio list adapters.
|
||||
type CatalogListItem struct {
|
||||
ID string
|
||||
Name string
|
||||
Description string
|
||||
Version string
|
||||
Category string
|
||||
Tags []string
|
||||
Status domain.AppStatus
|
||||
Installed bool
|
||||
Installable bool
|
||||
Reason string
|
||||
}
|
||||
|
||||
// CatalogListModel owns source items and composable list filters.
|
||||
type CatalogListModel struct {
|
||||
items []CatalogListItem
|
||||
visible []CatalogListItem
|
||||
categories []string
|
||||
query string
|
||||
category string
|
||||
view CatalogView
|
||||
selectedID string
|
||||
}
|
||||
|
||||
// NewCatalogListModel copies items and initializes the all view.
|
||||
func NewCatalogListModel(items []CatalogListItem) *CatalogListModel {
|
||||
model := &CatalogListModel{view: CatalogViewAll}
|
||||
model.SetItems(items)
|
||||
return model
|
||||
}
|
||||
|
||||
// SetItems replaces the source snapshot and recomputes categories/visibility.
|
||||
func (model *CatalogListModel) SetItems(items []CatalogListItem) {
|
||||
model.items = cloneCatalogItems(items)
|
||||
model.categories = collectCategories(model.items)
|
||||
if model.category != "" && !containsString(model.categories, model.category) {
|
||||
model.category = ""
|
||||
}
|
||||
if model.selectedID != "" && !containsItemID(model.items, model.selectedID) {
|
||||
model.selectedID = ""
|
||||
}
|
||||
model.refilter()
|
||||
}
|
||||
|
||||
// SetQuery applies a case-insensitive name/ID/tag search.
|
||||
func (model *CatalogListModel) SetQuery(query string) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(query))
|
||||
if model.query == normalized {
|
||||
return
|
||||
}
|
||||
model.query = normalized
|
||||
model.refilter()
|
||||
}
|
||||
|
||||
// SetCategory selects one exact category. Empty means every category.
|
||||
func (model *CatalogListModel) SetCategory(category string) {
|
||||
if category != "" && !containsString(model.categories, category) {
|
||||
return
|
||||
}
|
||||
if model.category == category {
|
||||
return
|
||||
}
|
||||
model.category = category
|
||||
model.refilter()
|
||||
}
|
||||
|
||||
// SetView selects the all, installed or updates scope.
|
||||
func (model *CatalogListModel) SetView(view CatalogView) {
|
||||
if !view.Valid() || model.view == view {
|
||||
return
|
||||
}
|
||||
model.view = view
|
||||
model.refilter()
|
||||
}
|
||||
|
||||
// Select records a stable app ID for row interaction state.
|
||||
func (model *CatalogListModel) Select(appID string) {
|
||||
if appID == "" || containsItemID(model.items, appID) {
|
||||
model.selectedID = appID
|
||||
}
|
||||
}
|
||||
|
||||
// ResetFilters restores the full list while retaining source data.
|
||||
func (model *CatalogListModel) ResetFilters() {
|
||||
model.query = ""
|
||||
model.category = ""
|
||||
model.view = CatalogViewAll
|
||||
model.refilter()
|
||||
}
|
||||
|
||||
// VisibleItems returns the current immutable-by-convention snapshot.
|
||||
func (model *CatalogListModel) VisibleItems() []CatalogListItem {
|
||||
return model.visible
|
||||
}
|
||||
|
||||
// Categories returns the stable first-seen category order.
|
||||
func (model *CatalogListModel) Categories() []string {
|
||||
return model.categories
|
||||
}
|
||||
|
||||
// TotalCount returns the unfiltered source count.
|
||||
func (model *CatalogListModel) TotalCount() int {
|
||||
return len(model.items)
|
||||
}
|
||||
|
||||
// Query returns the normalized active query.
|
||||
func (model *CatalogListModel) Query() string {
|
||||
return model.query
|
||||
}
|
||||
|
||||
// Category returns the active exact category, or empty for all.
|
||||
func (model *CatalogListModel) Category() string {
|
||||
return model.category
|
||||
}
|
||||
|
||||
// View returns the active primary scope.
|
||||
func (model *CatalogListModel) View() CatalogView {
|
||||
return model.view
|
||||
}
|
||||
|
||||
// SelectedID returns the selected stable software ID.
|
||||
func (model *CatalogListModel) SelectedID() string {
|
||||
return model.selectedID
|
||||
}
|
||||
|
||||
// Valid reports whether view is supported by the MVP list.
|
||||
func (view CatalogView) Valid() bool {
|
||||
return view == CatalogViewAll ||
|
||||
view == CatalogViewInstalled ||
|
||||
view == CatalogViewUpdates
|
||||
}
|
||||
|
||||
func (model *CatalogListModel) refilter() {
|
||||
model.visible = model.visible[:0]
|
||||
for _, item := range model.items {
|
||||
if model.category != "" && item.Category != model.category {
|
||||
continue
|
||||
}
|
||||
if !matchesView(item, model.view) {
|
||||
continue
|
||||
}
|
||||
if model.query != "" && !matchesQuery(item, model.query) {
|
||||
continue
|
||||
}
|
||||
model.visible = append(model.visible, item)
|
||||
}
|
||||
}
|
||||
|
||||
func matchesView(item CatalogListItem, view CatalogView) bool {
|
||||
switch view {
|
||||
case CatalogViewAll:
|
||||
return true
|
||||
case CatalogViewInstalled:
|
||||
return item.Installed
|
||||
case CatalogViewUpdates:
|
||||
return item.Status == domain.StatusUpdateAvailable
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func matchesQuery(item CatalogListItem, query string) bool {
|
||||
if strings.Contains(strings.ToLower(item.Name), query) ||
|
||||
strings.Contains(strings.ToLower(item.ID), query) {
|
||||
return true
|
||||
}
|
||||
for _, tag := range item.Tags {
|
||||
if strings.Contains(strings.ToLower(tag), query) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func collectCategories(items []CatalogListItem) []string {
|
||||
seen := make(map[string]struct{})
|
||||
categories := make([]string, 0)
|
||||
for _, item := range items {
|
||||
if item.Category == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[item.Category]; exists {
|
||||
continue
|
||||
}
|
||||
seen[item.Category] = struct{}{}
|
||||
categories = append(categories, item.Category)
|
||||
}
|
||||
return categories
|
||||
}
|
||||
|
||||
func cloneCatalogItems(items []CatalogListItem) []CatalogListItem {
|
||||
cloned := make([]CatalogListItem, len(items))
|
||||
for index, item := range items {
|
||||
cloned[index] = item
|
||||
cloned[index].Tags = append([]string(nil), item.Tags...)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func containsString(values []string, value string) bool {
|
||||
for _, candidate := range values {
|
||||
if candidate == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsItemID(items []CatalogListItem, appID string) bool {
|
||||
for _, item := range items {
|
||||
if item.ID == appID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"softbox.local/core/domain"
|
||||
)
|
||||
|
||||
func TestCatalogListModelCombinesSearchCategoryAndView(t *testing.T) {
|
||||
model := NewCatalogListModel([]CatalogListItem{
|
||||
{
|
||||
ID: "json-parser",
|
||||
Name: "JSON解析工具",
|
||||
Category: "开发工具",
|
||||
Tags: []string{"JSON", "格式化"},
|
||||
Status: domain.StatusInstalled,
|
||||
Installed: true,
|
||||
},
|
||||
{
|
||||
ID: "image-tool",
|
||||
Name: "Image Tool",
|
||||
Category: "图像",
|
||||
Tags: []string{"PNG", "压缩"},
|
||||
Status: domain.StatusUpdateAvailable,
|
||||
Installed: true,
|
||||
},
|
||||
{
|
||||
ID: "log-viewer",
|
||||
Name: "日志查看器",
|
||||
Category: "开发工具",
|
||||
Tags: []string{"LOG", "诊断"},
|
||||
Status: domain.StatusNotInstalled,
|
||||
},
|
||||
})
|
||||
|
||||
model.SetQuery(" png ")
|
||||
assertVisibleIDs(t, model, "image-tool")
|
||||
|
||||
model.SetQuery("")
|
||||
model.SetCategory("开发工具")
|
||||
assertVisibleIDs(t, model, "json-parser", "log-viewer")
|
||||
|
||||
model.SetView(CatalogViewInstalled)
|
||||
assertVisibleIDs(t, model, "json-parser")
|
||||
|
||||
model.SetCategory("")
|
||||
model.SetView(CatalogViewUpdates)
|
||||
assertVisibleIDs(t, model, "image-tool")
|
||||
|
||||
model.SetQuery("IMAGE-")
|
||||
assertVisibleIDs(t, model, "image-tool")
|
||||
}
|
||||
|
||||
func TestCatalogListModelPreservesStableOrderAndSelection(t *testing.T) {
|
||||
items := []CatalogListItem{
|
||||
{ID: "app-b", Name: "B", Category: "工具"},
|
||||
{ID: "app-a", Name: "A", Category: "工具"},
|
||||
}
|
||||
model := NewCatalogListModel(items)
|
||||
model.Select("app-a")
|
||||
model.SetQuery("app")
|
||||
|
||||
assertVisibleIDs(t, model, "app-b", "app-a")
|
||||
if model.SelectedID() != "app-a" {
|
||||
t.Fatalf("SelectedID = %q", model.SelectedID())
|
||||
}
|
||||
|
||||
model.SetItems([]CatalogListItem{{ID: "app-b", Name: "B", Category: "工具"}})
|
||||
if model.SelectedID() != "" {
|
||||
t.Fatalf("SelectedID after removal = %q, want empty", model.SelectedID())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogListModelCategoriesAndReset(t *testing.T) {
|
||||
model := NewCatalogListModel([]CatalogListItem{
|
||||
{ID: "one", Category: "开发"},
|
||||
{ID: "two", Category: "图像"},
|
||||
{ID: "three", Category: "开发"},
|
||||
})
|
||||
categories := model.Categories()
|
||||
if len(categories) != 2 || categories[0] != "开发" || categories[1] != "图像" {
|
||||
t.Fatalf("Categories = %#v", categories)
|
||||
}
|
||||
|
||||
model.SetQuery("missing")
|
||||
model.SetCategory("开发")
|
||||
model.SetView(CatalogViewInstalled)
|
||||
model.ResetFilters()
|
||||
if model.Query() != "" ||
|
||||
model.Category() != "" ||
|
||||
model.View() != CatalogViewAll ||
|
||||
len(model.VisibleItems()) != 3 {
|
||||
t.Fatalf("model was not reset: %#v", model)
|
||||
}
|
||||
}
|
||||
|
||||
func assertVisibleIDs(t *testing.T, model *CatalogListModel, want ...string) {
|
||||
t.Helper()
|
||||
visible := model.VisibleItems()
|
||||
if len(visible) != len(want) {
|
||||
t.Fatalf("visible IDs length = %d, want %d: %#v", len(visible), len(want), visible)
|
||||
}
|
||||
for index, item := range visible {
|
||||
if item.ID != want[index] {
|
||||
t.Fatalf("visible[%d].ID = %q, want %q", index, item.ID, want[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,8 @@ UI 固定交互模式:
|
||||
|
||||
控件状态按**软件 ID**保存,不按列表序号;列表用惰性 `layout.List`;图标走内存 + 磁盘缓存。
|
||||
|
||||
T-203 已把共享列表状态落在 `core/application.CatalogListModel`:源快照、搜索、单分类、all/installed/updates 视图和 selected app ID 都是无 IO 纯内存状态。两个 Gio 适配分别保存 Editor、`layout.List` 与以 app ID 为键的 Clickable;500 项 viewport 测试验证只布局可见行。主循环或后台用例通过 `SetItems` 替换准备好的快照,Layout 不扫描 installed-app.json、不获取 Catalog。
|
||||
|
||||
## 三、仓库目录结构
|
||||
|
||||
```text
|
||||
|
||||
@@ -13,26 +13,26 @@
|
||||
## 当前快照
|
||||
|
||||
- 日期:2026-07-16
|
||||
- 阶段:Phase 2 进行中;T-201/T-202 已完成,下一步 T-203 主界面软件列表
|
||||
- 阶段:Phase 2 进行中;T-201~T-203 已完成,下一步 T-204 软件详情与图标缓存
|
||||
- 技术栈:根 Go workspace 纳入 core/app-modern/app-win7 三模块;`app-win7/go.work` 隔离 Go 1.20.14 构建;modern Gio v0.10.1 与 win7 Gio v0.6.0 已实际接入
|
||||
- 生产代码:core 已有状态/事件、Catalog 正式客户端、SemVer 与 12 状态推导、installed-app.json 原子存储、ZIP 安全解压和安装事务切换/恢复原型;modern/win7 均可打开最小 AppShell
|
||||
- 测试:core 覆盖 Catalog、SemVer/12 状态、本地安装记录、ZIP 攻击矩阵及安装成功/回滚/多阶段崩溃恢复;两个 app 覆盖 AppShell 与平台 stub
|
||||
- 生产代码:core 已有 Catalog/本地状态/存储与无 IO 软件列表模型;modern/win7 AppShell 已实现搜索、分类、全部/已安装/可更新切换和惰性软件列表;安装安全原型保持可用
|
||||
- 测试:core 覆盖 Catalog、SemVer/12 状态、本地安装记录与列表筛选;两个 app 覆盖 AppShell、500 项虚拟列表、ID 控件稳定性与平台 stub;ZIP/安装恢复矩阵保持通过
|
||||
- 数据:`schemas/` 已有 manifest/app.json/installed-app.json v1 Schema;`testdata/catalog/` 有公开虚构清单样例;`testdata/zip/` 记录运行时生成的 ZIP 攻击矩阵
|
||||
- 标准启动路径:`./init.sh` / `./init.ps1`(同步依赖、执行完整 Phase 0 闸门、打印双目标构建命令)
|
||||
- 标准验证路径:`bash scripts/verify_phase0.sh` / `./scripts/verify_phase0.ps1`
|
||||
- 版本管理:git 已初始化,main 分支,远端 origin 为 Gitea `opc/soft_quay`;harness 文档已提交
|
||||
- 当前 blocker:无;下一步按路线图落成并领取 T-203
|
||||
- 当前 blocker:无;下一步按路线图落成并领取 T-204
|
||||
|
||||
## 当前目录要点
|
||||
|
||||
| 路径 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `docs/` | 已有 | harness coding 文档集(本次初始化完成) |
|
||||
| `docs/tasks/` | 已有 | Phase 0、Phase 1 与 T-201/T-202 已完成;T-203 待按路线图落成 |
|
||||
| `docs/tasks/` | 已有 | Phase 0、Phase 1 与 T-201~T-203 已完成;T-204 待按路线图落成 |
|
||||
| `scripts/` | 已有 | harness 治理、core 边界、Go 版本检查与 Phase 0 双平台验证入口 |
|
||||
| `core/` | 已建 | Go 1.20 兼容;已有状态/事件、正式 Catalog、本地安装状态/存储与 Phase 1 安装安全原型 |
|
||||
| `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;可打开 Modern AppShell |
|
||||
| `app-win7/` | 已建 | Go 1.20 + Gio v0.6.0;可打开带 Legacy 标识的 AppShell |
|
||||
| `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;Modern AppShell 已接入虚拟软件列表 |
|
||||
| `app-win7/` | 已建 | Go 1.20 + Gio v0.6.0;Legacy AppShell 已接入低成本虚拟软件列表 |
|
||||
| `schemas/` | 已建 | `manifest.schema.json`、`app.schema.json` 与 `installed-app.schema.json` |
|
||||
| `testdata/` | 已建 | 当前包含 Catalog 假数据与恶意样例;后续任务继续扩展 |
|
||||
|
||||
@@ -40,9 +40,9 @@
|
||||
|
||||
任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要:
|
||||
|
||||
- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`、`T-202`。
|
||||
- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`、`T-202`、`T-203`。
|
||||
- 正在进行:无。
|
||||
- 下一个可领取任务:按路线图落成并领取 `T-203 主界面软件列表`。
|
||||
- 下一个可领取任务:按路线图落成并领取 `T-204 软件详情与图标缓存`。
|
||||
|
||||
## 当前可运行内容
|
||||
|
||||
|
||||
+9
-1
@@ -23,7 +23,7 @@
|
||||
|
||||
| 视图 | 职责 | MVP |
|
||||
| --- | --- | --- |
|
||||
| 软件列表(主视图) | 全部/已安装/可更新/下载中/最近使用切换;搜索、分类、多标签筛选;滚动位置稳定 | P0 |
|
||||
| 软件列表(主视图) | T-203 已实现全部/已安装/可更新切换、名称/ID/tag 即时搜索、单分类筛选、稳定滚动与明确空状态;下载中/最近使用和多标签复选随对应用例后续接入 | P0 |
|
||||
| 软件详情(弹层或右栏) | 版本、简介、教程链接、授权状态;下载/更新/启动/取消操作 | P0 |
|
||||
| 下载队列 | 所有任务的进度、速度、剩余时间;暂停/取消/重试 | P0 |
|
||||
| 设置 | 并发数、目录、代理、自动检查更新、beta 通道、日志级别、便携模式(V2) | P0(最小集) |
|
||||
@@ -53,6 +53,14 @@
|
||||
- 未验证/不兼容的软件显示原因,操作按钮禁用,不静默失败。
|
||||
- Win7 版允许减少动画、阴影和高成本渲染,但交互语义与现代版一致,并明显展示 Legacy 标识。
|
||||
|
||||
T-203 已落地的列表交互约束:
|
||||
|
||||
- 共享 `core/application.CatalogListModel` 只消费预先准备的内存快照;搜索覆盖名称、稳定 ID 和 tags,分类与 all/installed/updates 可组合。
|
||||
- modern/win7 都使用惰性 `layout.List`;500 项有限 viewport 测试必须证明不会布局全部行。
|
||||
- Editor、视图按钮、分类按钮、软件行的键盘顺序与视觉顺序一致;搜索和软件行有可见焦点反馈,所有按钮最小高度 44dp。
|
||||
- 行 Clickable 保存在以 app ID 为键的 map;筛选、视图切换或 Catalog 快照更新不按可见序号迁移控件状态。
|
||||
- 无 Catalog 与过滤无结果是两个不同空状态;后者提供“显示全部软件”恢复操作。
|
||||
|
||||
## 导航规则
|
||||
|
||||
- 列表 → 详情:点击软件项;详情保留返回/关闭。
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
id: T-203
|
||||
title: 主界面软件列表
|
||||
phase: 2
|
||||
deps: [T-202, T-003]
|
||||
status: DONE
|
||||
created: 2026-07-16
|
||||
issue: null
|
||||
context_ref: 819b1cda1bd155c5cb76e7b05d45507f67bdb42d
|
||||
claim_branch: null
|
||||
work_branch: agent/codex/T-203
|
||||
write_paths:
|
||||
- docs/tasks/T-203.md
|
||||
- core/application/
|
||||
- app-modern/ui/gio/
|
||||
- app-modern/cmd/softbox/
|
||||
- app-win7/ui/gio/
|
||||
- app-win7/cmd/softbox/
|
||||
- docs/routes.md
|
||||
- docs/04-architecture.md
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
## 问题 / 背景
|
||||
|
||||
可信 Catalog 与本地状态已经可用,但两个客户端仍只显示空 AppShell。主视图需要在不把磁盘/网络 IO 放进 Layout 的前提下,支持数百项、搜索、分类和全部/已安装/可更新切换;若控件按列表索引保存,筛选或排序后点击状态会错位。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 在 `core/application` 建立共享、无 IO 的 CatalogListModel,列表项使用稳定软件 ID;支持名称/ID/tag 搜索、分类集合与 all/installed/updates 三种视图。
|
||||
2. 过滤结果保持 Catalog 原顺序;查询和分类变化不重建源数据,Gio 只消费当前可见切片。
|
||||
3. modern/win7 分别实现 Gio AppShell:顶部搜索与分类、左侧视图切换、中部惰性 `layout.List`、有帮助文案的空状态和底部状态栏。
|
||||
4. 每个 AppRow 的 Clickable 按软件 ID 存入 map,不按可见索引;每帧先 drain 点击,再执行布局。
|
||||
5. 使用扁平、高对比、44dp 最小交互目标和 4/8dp 间距体系;Legacy 减少装饰但保持同一语义。
|
||||
6. 测试共享筛选规则、ID 控件稳定性,并用数百项 + 有限 viewport 证明 layout.List 只布局可见行。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 搜索覆盖名称、ID 与 tags,忽略大小写和首尾空白。
|
||||
- 分类筛选与 all/installed/updates 视图可组合;无结果显示清晰空状态。
|
||||
- 两个 Gio 版本均使用惰性 layout.List;500 项在有限 viewport 中不会布局全部行。
|
||||
- 行控件状态按 app ID 保持,过滤/切换视图后不串行。
|
||||
- Layout 代码不读文件、不访问网络、不计算哈希。
|
||||
- modern 与 win7 UI 测试/构建、Go 1.20 core vet/test、完整双目标闸门和治理校验通过。
|
||||
|
||||
## 边界(不改什么)
|
||||
|
||||
- 不实现详情弹层与图标缓存(T-204)。
|
||||
- 不发起下载、安装、启动或真实 Catalog 网络装配;主操作仅展示当前状态语义。
|
||||
- 不实现下载中/最近使用视图或多标签复选;路线图 MVP 本任务只冻结 all/installed/updates。
|
||||
- 不添加动画、阴影、emoji 图标或新 UI 依赖。
|
||||
|
||||
## 协作约束
|
||||
|
||||
未启用 Gitea;本任务在 `agent/codex/T-203` 分支串行执行。`ui-ux-pro-max` 的本轮约束是高对比扁平界面、可见焦点/空状态、44dp 目标、虚拟化 50+ 项和键盘顺序与视觉顺序一致。
|
||||
|
||||
## 执行记录
|
||||
|
||||
- 2026-07-16:按 `ui-ux-pro-max` 先生成 SoftBox 设计系统并检索虚拟列表、键盘导航、空状态与焦点规则;最终采用高对比浅色、扁平无阴影、4/8dp 间距、44dp 控件和明显 Legacy 标识。
|
||||
- 2026-07-16:在 `core/application` 建立无 IO `CatalogListModel`;搜索覆盖名称/ID/tags,单分类与 all/installed/updates 可组合,过滤保持 Catalog 原顺序并保存 selected app ID。
|
||||
- 2026-07-16:modern Gio v0.10.1 与 win7 Gio v0.6.0 分别实现搜索栏、横向分类、左侧视图切换、惰性软件列表、状态/原因文案、两类空状态和恢复筛选操作。
|
||||
- 2026-07-16:Editor/按钮/软件行均提供键盘焦点反馈;自定义行补充 semantic button/description,未使用 emoji、阴影或额外 UI 依赖。
|
||||
- 2026-07-16:行 Clickable 以软件 ID 为键保存;筛选与 Catalog 快照更新后同 ID 控件保持原实例,移除的软件控件被释放。
|
||||
- 2026-07-16:两个适配各用 500 项 + 有限 viewport 测试证明 `layout.List` 只布局可见子集;空 AppShell 仍保持窗口尺寸测试。
|
||||
- 2026-07-16:同步 `docs/routes.md`、`docs/04-architecture.md` 与 `docs/current-state.md`;真实 Catalog 网络/密钥装配仍保持在 Layout 外。
|
||||
- 定向验证通过:`go -C core test -count=1 ./application`、modern/win7 `go test -count=1 ./ui/gio`。
|
||||
- Layout IO 扫描通过:`rg` 检查两个 `ui/gio` 下无 `os`、`net/http`、文件读写、filepath 或 sha256 调用。
|
||||
- 完整验证通过:`./scripts/verify_phase0.ps1`,包含 Go 1.20.14 core vet/test、治理/边界/版本检查及 modern/win7 双目标测试与构建。
|
||||
- 提交前检查通过:`git diff --check`。
|
||||
- 工作区中的未跟踪 `soft_quay.code-workspace` 与本任务无关,已保留且未纳入提交。
|
||||
Reference in New Issue
Block a user