From 1e5565e440977eeceb6d2e5f532e90b74b209e99 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 22 Jul 2026 16:37:34 +0800 Subject: [PATCH] feat: add compact instance actions --- internal/ui/shell.go | 80 +++++++++++++++++++++++++++++++++------ internal/ui/shell_test.go | 39 +++++++++++++++++-- 2 files changed, 103 insertions(+), 16 deletions(-) diff --git a/internal/ui/shell.go b/internal/ui/shell.go index 42978c7..cfc1baf 100644 --- a/internal/ui/shell.go +++ b/internal/ui/shell.go @@ -10,6 +10,7 @@ import ( "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" + "golang.org/x/exp/shiny/materialdesign/icons" ) type page uint8 @@ -21,11 +22,16 @@ const ( ) const ( - instanceNameColumnWeight = 25 + instanceNameColumnWeight = 24 instanceBrowserColumnWeight = 12 - instanceDirectoryColumnWeight = 39 - instanceStatusColumnWeight = 14 - instanceStartColumnWeight = 10 + instanceDirectoryColumnWeight = 38 + instanceStatusColumnWeight = 12 + instanceActionColumnWeight = 14 +) + +var ( + instanceStartIcon = mustIcon(icons.AVPlayArrow) + instanceDeleteIcon = mustIcon(icons.ActionDelete) ) type PathField string @@ -123,6 +129,7 @@ type Shell struct { list widget.List rows []InstanceRow startClicks map[string]*widget.Clickable + deleteClicks map[string]*widget.Clickable nextInstance uint64 instanceFeedback string onSave func(SettingsState) @@ -146,7 +153,7 @@ func NewShell(theme *material.Theme) *Shell { PathEdgeExecutable: {}, PathDefaultUserData: {}, PathLogDirectory: {}, - }, startClicks: make(map[string]*widget.Clickable), nextInstance: 4, searchResults: make(chan pathSearchResult, 8), directoryResults: make(chan directoryPickResult, 1)} + }, startClicks: make(map[string]*widget.Clickable), deleteClicks: make(map[string]*widget.Clickable), nextInstance: 4, searchResults: make(chan pathSearchResult, 8), directoryResults: make(chan directoryPickResult, 1)} s.chromePath.SetText(`C:\Program Files\Google\Chrome\Application\chrome.exe`) s.edgePath.SetText(`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`) s.dataDir.SetText(`C:\Users\Public\chub\profiles`) @@ -222,6 +229,11 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions { s.requestStart(row.ID) } } + for _, row := range append([]InstanceRow(nil), s.rows...) { + for s.deleteClickFor(row.ID).Clicked(gtx) { + s.deleteInstance(row.ID) + } + } return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, layout.Rigid(s.sidebar), layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { @@ -279,7 +291,7 @@ func (s *Shell) instances(gtx layout.Context) layout.Dimensions { layout.Flexed(instanceBrowserColumnWeight, material.Body2(s.theme, row.Browser).Layout), layout.Flexed(instanceDirectoryColumnWeight, pathCell(s.theme, row.UserDataDir)), layout.Flexed(instanceStatusColumnWeight, statusLabel(s.theme, row.Status)), - layout.Flexed(instanceStartColumnWeight, s.instanceStartButton(s.startClickFor(row.ID))), + layout.Flexed(instanceActionColumnWeight, s.instanceActionButtons(row)), ) }) }) @@ -296,8 +308,8 @@ func (s *Shell) instanceHeader(gtx layout.Context) layout.Dimensions { layout.Flexed(instanceBrowserColumnWeight, material.Caption(s.theme, "浏览器类型").Layout), layout.Flexed(instanceDirectoryColumnWeight, material.Caption(s.theme, "用户数据目录").Layout), layout.Flexed(instanceStatusColumnWeight, material.Caption(s.theme, "状态").Layout), - layout.Flexed(instanceStartColumnWeight, func(gtx layout.Context) layout.Dimensions { - return layout.E.Layout(gtx, material.Caption(s.theme, "启动").Layout) + layout.Flexed(instanceActionColumnWeight, func(gtx layout.Context) layout.Dimensions { + return layout.E.Layout(gtx, material.Caption(s.theme, "操作").Layout) }), ) }) @@ -309,16 +321,29 @@ func pathCell(theme *material.Theme, path string) layout.Widget { return style.Layout } -func (s *Shell) instanceStartButton(click *widget.Clickable) layout.Widget { +func (s *Shell) instanceActionButtons(row InstanceRow) layout.Widget { return func(gtx layout.Context) layout.Dimensions { return layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - style := material.Button(s.theme, click, "启动") - style.Inset = layout.Inset{Top: unit.Dp(6), Right: unit.Dp(10), Bottom: unit.Dp(6), Left: unit.Dp(10)} - return style.Layout(gtx) + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(s.instanceIconButton(s.startClickFor(row.ID), instanceStartIcon, "启动 "+row.Name, s.theme.Palette.ContrastBg)), + layout.Rigid(layout.Spacer{Width: unit.Dp(4)}.Layout), + layout.Rigid(s.instanceIconButton(s.deleteClickFor(row.ID), instanceDeleteIcon, "删除 "+row.Name, color.NRGBA{R: 188, G: 51, B: 51, A: 255})), + ) }) } } +func (s *Shell) instanceIconButton(click *widget.Clickable, icon *widget.Icon, description string, iconColor color.NRGBA) layout.Widget { + return func(gtx layout.Context) layout.Dimensions { + style := material.IconButton(s.theme, click, icon, description) + style.Size = unit.Dp(18) + style.Inset = layout.UniformInset(unit.Dp(7)) + style.Background = color.NRGBA{} + style.Color = iconColor + return style.Layout(gtx) + } +} + func statusLabel(theme *material.Theme, status string) layout.Widget { style := material.Label(theme, unit.Sp(13), status) style.Color = map[string]color.NRGBA{ @@ -574,6 +599,15 @@ func (s *Shell) startClickFor(id string) *widget.Clickable { return click } +func (s *Shell) deleteClickFor(id string) *widget.Clickable { + if click := s.deleteClicks[id]; click != nil { + return click + } + click := new(widget.Clickable) + s.deleteClicks[id] = click + return click +} + func (s *Shell) requestStart(id string) { for _, row := range s.rows { if row.ID != id { @@ -585,6 +619,28 @@ func (s *Shell) requestStart(id string) { s.instanceFeedback = "找不到要启动的实例。" } +func (s *Shell) deleteInstance(id string) { + for i, row := range s.rows { + if row.ID != id { + continue + } + s.rows = append(s.rows[:i], s.rows[i+1:]...) + delete(s.startClicks, id) + delete(s.deleteClicks, id) + s.instanceFeedback = fmt.Sprintf("已删除实例“%s”;其 User Data Dir 未被删除。", row.Name) + return + } + s.instanceFeedback = "找不到要删除的实例。" +} + +func mustIcon(data []byte) *widget.Icon { + icon, err := widget.NewIcon(data) + if err != nil { + panic(err) + } + return icon +} + func (s *Shell) chooseInstanceDirectory() { if s.directoryPick.running { s.formFeedback = "目录选择器已打开,请在系统窗口中选择或取消。" diff --git a/internal/ui/shell_test.go b/internal/ui/shell_test.go index 772c91e..becaba3 100644 --- a/internal/ui/shell_test.go +++ b/internal/ui/shell_test.go @@ -21,7 +21,7 @@ func TestShellStartsWithSemanticInstanceStatuses(t *testing.T) { } } -func TestShellInstanceRowsContainRequiredColumnsAndIndependentStartControls(t *testing.T) { +func TestShellInstanceRowsContainRequiredColumnsAndIndependentActionControls(t *testing.T) { shell := NewShell(material.NewTheme()) if len(shell.rows) < 2 { t.Fatal("expected fixture rows") @@ -36,18 +36,49 @@ func TestShellInstanceRowsContainRequiredColumnsAndIndependentStartControls(t *t if first == second { t.Fatal("rows share a start button state") } + if shell.deleteClickFor(shell.rows[0].ID) == shell.deleteClickFor(shell.rows[1].ID) { + t.Fatal("rows share a delete button state") + } } func TestInstanceListColumnWeightsPrioritizeDirectoryAndCompactAction(t *testing.T) { - got := instanceNameColumnWeight + instanceBrowserColumnWeight + instanceDirectoryColumnWeight + instanceStatusColumnWeight + instanceStartColumnWeight + got := instanceNameColumnWeight + instanceBrowserColumnWeight + instanceDirectoryColumnWeight + instanceStatusColumnWeight + instanceActionColumnWeight if got != 100 { t.Fatalf("instance column weights = %d, want 100", got) } if instanceDirectoryColumnWeight <= instanceNameColumnWeight { t.Fatal("user data directory must have the widest instance-list column") } - if instanceStartColumnWeight >= instanceStatusColumnWeight { - t.Fatal("start action column must remain compact") + if instanceActionColumnWeight <= instanceStatusColumnWeight { + t.Fatal("actions column must fit both compact icon commands") + } +} + +func TestShellDeletesOnlyTheRequestedInstance(t *testing.T) { + shell := NewShell(material.NewTheme()) + target := shell.rows[1] + remaining := shell.rows[2].ID + shell.startClickFor(target.ID) + shell.deleteClickFor(target.ID) + + shell.deleteInstance(target.ID) + + if len(shell.rows) != 3 { + t.Fatalf("instance count = %d, want 3", len(shell.rows)) + } + for _, row := range shell.rows { + if row.ID == target.ID { + t.Fatalf("deleted instance %q remains in the list", target.ID) + } + } + if _, exists := shell.startClicks[target.ID]; exists { + t.Fatal("deleted instance start control was not released") + } + if _, exists := shell.deleteClicks[target.ID]; exists { + t.Fatal("deleted instance delete control was not released") + } + if shell.rows[1].ID != remaining { + t.Fatalf("remaining instance id = %q, want %q", shell.rows[1].ID, remaining) } }