Route icon results through UI events (T-607)

This commit is contained in:
ila
2026-07-17 10:21:44 +08:00
parent fba672381e
commit 0945fe93dc
24 changed files with 2068 additions and 31 deletions
+33
View File
@@ -1,6 +1,8 @@
package main
import (
"context"
"errors"
"log"
"os"
@@ -11,8 +13,11 @@ import (
"softbox.local/app-win7/platform/windows"
softboxgio "softbox.local/app-win7/ui/gio"
"softbox.local/core"
"softbox.local/core/application"
)
const applicationEventCapacity = 32
func main() {
go func() {
if err := run(); err != nil {
@@ -33,6 +38,31 @@ func run() error {
theme := softboxgio.NewTheme()
shell := softboxgio.NewAppShell(string(platform.Edition()))
runtime := application.NewRuntime(applicationEventCapacity)
relay, err := application.NewEventRelay(applicationEventCapacity)
if err != nil {
return err
}
eventContext, cancelEvents := context.WithCancel(context.Background())
pumpDone := make(chan error, 1)
go func() {
pumpDone <- application.PumpEvents(
eventContext,
runtime.Events(),
relay,
window.Invalidate,
)
}()
defer func() {
cancelEvents()
runtime.Close()
relay.Close()
if pumpErr := <-pumpDone; pumpErr != nil &&
!errors.Is(pumpErr, context.Canceled) &&
!errors.Is(pumpErr, application.ErrEventRelayClosed) {
log.Printf("application event pump stopped: %v", pumpErr)
}
}()
var operations op.Ops
for {
@@ -40,6 +70,9 @@ func run() error {
case app.DestroyEvent:
return event.Err
case app.FrameEvent:
if err := relay.Drain(shell.ApplyEvent); err != nil {
log.Printf("apply application event: %v", err)
}
context := app.NewContext(&operations, event)
shell.Layout(context, theme)
event.Frame(context.Ops)
+102
View File
@@ -0,0 +1,102 @@
package gio
import (
"errors"
"fmt"
"softbox.local/core/application"
)
var ErrIconRequestStale = errors.New("icon request no longer matches catalog")
// ExpectIcon records the newest request identity on the UI goroutine.
func (shell *AppShell) ExpectIcon(identity application.IconEventIdentity) error {
validated, err := application.NewIconEventIdentity(
identity.RequestID,
identity.AppID,
identity.Reference,
identity.DPI,
)
if err != nil {
return err
}
currentReference, exists := shell.iconReferences[validated.AppID]
if !exists || currentReference != validated.Reference {
return fmt.Errorf(
"%w: app=%q reference=%q",
ErrIconRequestStale,
validated.AppID,
validated.Reference,
)
}
if applied, exists := shell.iconApplied[validated.AppID]; !exists || !sameIconResource(applied, validated) {
delete(shell.icons, validated.AppID)
delete(shell.iconApplied, validated.AppID)
}
shell.iconRequests[validated.AppID] = validated
delete(shell.iconFailures, validated.AppID)
return nil
}
// CancelIconRequest invalidates the matching pending request on the UI goroutine.
func (shell *AppShell) CancelIconRequest(appID, requestID string) bool {
pending, exists := shell.iconRequests[appID]
if !exists || pending.RequestID != requestID {
return false
}
delete(shell.iconRequests, appID)
return true
}
// ApplyEvent validates and applies an application event on the UI goroutine.
func (shell *AppShell) ApplyEvent(event application.Event) error {
iconEvent, handled, err := application.ParseIconEvent(event)
if err != nil || !handled {
return err
}
identity := iconEvent.Identity
pending, exists := shell.iconRequests[identity.AppID]
if !exists || pending != identity {
return nil
}
if shell.iconReferences[identity.AppID] != identity.Reference {
return nil
}
delete(shell.iconRequests, identity.AppID)
switch iconEvent.Type {
case application.EventIconReady:
shell.ApplyIcon(identity.AppID, iconEvent.Image)
shell.iconApplied[identity.AppID] = identity
case application.EventIconFailed:
applied, hasApplied := shell.iconApplied[identity.AppID]
if !hasApplied || !sameIconResource(applied, identity) {
shell.ApplyIcon(identity.AppID, nil)
}
shell.iconFailures[identity.AppID] = iconEvent.ErrorCode
}
return nil
}
// IconFailure exposes the last failure for diagnostics without raw network data.
func (shell *AppShell) IconFailure(appID string) (application.IconFailureCode, bool) {
failure, exists := shell.iconFailures[appID]
return failure, exists
}
func canonicalIconReference(reference string) string {
canonical, err := application.NormalizeIconReference(reference)
if err != nil {
return reference
}
return canonical
}
func sameIconResource(
left application.IconEventIdentity,
right application.IconEventIdentity,
) bool {
return left.AppID == right.AppID &&
left.Reference == right.Reference &&
left.DPI == right.DPI
}
+264
View File
@@ -0,0 +1,264 @@
package gio
import (
"context"
"errors"
"image"
"strings"
"testing"
"time"
"softbox.local/core/application"
)
func TestIconEventRelayAppliesOnlyDuringUIDrain(t *testing.T) {
reference := testIconReference("11")
shell := NewAppShell("Test", application.CatalogListItem{
ID: "app-one",
Name: "One",
IconRef: reference,
})
identity := testIconIdentity(t, "request-one", "app-one", reference, 96)
if err := shell.ExpectIcon(identity); err != nil {
t.Fatal(err)
}
ready, err := application.NewIconReadyEvent(
identity,
image.NewNRGBA(image.Rect(0, 0, 24, 24)),
)
if err != nil {
t.Fatal(err)
}
relay, err := application.NewEventRelay(1)
if err != nil {
t.Fatal(err)
}
submitted := make(chan error, 1)
go func() {
submitted <- relay.Submit(context.Background(), ready)
}()
if err := waitIconSubmit(submitted); err != nil {
t.Fatalf("Submit() error = %v", err)
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("background relay changed shell before UI drain")
}
if err := relay.Drain(shell.ApplyEvent); err != nil {
t.Fatalf("Drain() error = %v", err)
}
icon, exists := shell.icons["app-one"]
if !exists || icon.Size() != image.Pt(24, 24) {
t.Fatalf("applied icon = (%t, %v)", exists, icon.Size())
}
}
func TestAppShellAcceptsOnlyLatestIconRequest(t *testing.T) {
reference := testIconReference("22")
shell := NewAppShell("Test", application.CatalogListItem{
ID: "app-one",
Name: "One",
IconRef: reference,
})
oldIdentity := testIconIdentity(t, "request-old", "app-one", reference, 96)
newIdentity := testIconIdentity(t, "request-new", "app-one", reference, 96)
if err := shell.ExpectIcon(oldIdentity); err != nil {
t.Fatal(err)
}
if err := shell.ExpectIcon(newIdentity); err != nil {
t.Fatal(err)
}
oldReady, err := application.NewIconReadyEvent(
oldIdentity,
image.NewNRGBA(image.Rect(0, 0, 12, 12)),
)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(oldReady); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("stale request inserted an icon")
}
newReady, err := application.NewIconReadyEvent(
newIdentity,
image.NewNRGBA(image.Rect(0, 0, 30, 30)),
)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(newReady); err != nil {
t.Fatal(err)
}
if got := shell.icons["app-one"].Size(); got != image.Pt(30, 30) {
t.Fatalf("latest icon size = %v", got)
}
retryIdentity := testIconIdentity(t, "request-retry", "app-one", reference, 96)
if err := shell.ExpectIcon(retryIdentity); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; !exists {
t.Fatal("same-resource retry discarded an already valid icon")
}
failed, err := application.NewIconFailedEvent(
retryIdentity,
application.IconFailureUnavailable,
)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(failed); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; !exists {
t.Fatal("matching failure discarded an already valid icon")
}
if failure, exists := shell.IconFailure("app-one"); !exists || failure != application.IconFailureUnavailable {
t.Fatalf("IconFailure() = (%q, %t)", failure, exists)
}
dpiIdentity := testIconIdentity(t, "request-dpi", "app-one", reference, 144)
if err := shell.ExpectIcon(dpiIdentity); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("different-DPI request retained an unmatching image")
}
dpiFailed, err := application.NewIconFailedEvent(
dpiIdentity,
application.IconFailureUnavailable,
)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(dpiFailed); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("different-DPI failure restored an unmatching image")
}
}
func TestAppShellDropsChangedRemovedAndCanceledIconResults(t *testing.T) {
oldReference := testIconReference("33")
newReference := testIconReference("44")
shell := NewAppShell("Test", application.CatalogListItem{
ID: "app-one",
Name: "One",
IconRef: oldReference,
})
oldIdentity := testIconIdentity(t, "request-old", "app-one", oldReference, 96)
if err := shell.ExpectIcon(oldIdentity); err != nil {
t.Fatal(err)
}
oldReady, err := application.NewIconReadyEvent(
oldIdentity,
image.NewNRGBA(image.Rect(0, 0, 20, 20)),
)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(oldReady); err != nil {
t.Fatal(err)
}
shell.SetItems([]application.CatalogListItem{{
ID: "app-one",
Name: "One",
IconRef: newReference,
}})
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("IconRef change retained the previous image")
}
if err := shell.ApplyEvent(oldReady); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("old IconRef result was reinserted")
}
newIdentity := testIconIdentity(t, "request-new", "app-one", newReference, 96)
if err := shell.ExpectIcon(newIdentity); err != nil {
t.Fatal(err)
}
if !shell.CancelIconRequest("app-one", newIdentity.RequestID) {
t.Fatal("CancelIconRequest() did not cancel the latest request")
}
newReady, err := application.NewIconReadyEvent(
newIdentity,
image.NewNRGBA(image.Rect(0, 0, 22, 22)),
)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(newReady); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("canceled result was applied")
}
shell.SetItems(nil)
if err := shell.ExpectIcon(newIdentity); !errors.Is(err, ErrIconRequestStale) {
t.Fatalf("ExpectIcon(removed app) error = %v", err)
}
if err := shell.ApplyEvent(newReady); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("removed app was reinserted by a late result")
}
}
func TestAppShellRejectsMalformedIconEventAndIgnoresOtherEvents(t *testing.T) {
shell := NewAppShell("Test")
if err := shell.ApplyEvent(application.Event{Type: application.EventCatalogRefreshed}); err != nil {
t.Fatalf("ApplyEvent(non-icon) error = %v", err)
}
err := shell.ApplyEvent(application.Event{
Type: application.EventIconReady,
RequestID: "request",
AppID: "app-one",
Payload: "wrong",
})
if !errors.Is(err, application.ErrInvalidIconEvent) {
t.Fatalf("ApplyEvent(invalid payload) error = %v", err)
}
if len(shell.icons) != 0 {
t.Fatal("invalid payload polluted icon state")
}
}
func testIconReference(pair string) string {
return "sha256:" + strings.Repeat(pair, 32)
}
func testIconIdentity(
t *testing.T,
requestID string,
appID string,
reference string,
dpi int,
) application.IconEventIdentity {
t.Helper()
identity, err := application.NewIconEventIdentity(
requestID,
appID,
reference,
dpi,
)
if err != nil {
t.Fatal(err)
}
return identity
}
func waitIconSubmit(result <-chan error) error {
select {
case err := <-result:
return err
case <-time.After(2 * time.Second):
return errors.New("timed out waiting for icon relay")
}
}
+35 -3
View File
@@ -66,6 +66,10 @@ type AppShell struct {
categoryControls map[string]*widget.Clickable
rows map[string]*rowControls
icons map[string]paint.ImageOp
iconReferences map[string]string
iconRequests map[string]application.IconEventIdentity
iconApplied map[string]application.IconEventIdentity
iconFailures map[string]application.IconFailureCode
lastRendered int
detailRendered bool
}
@@ -83,14 +87,21 @@ func NewAppShell(
categoryControls: make(map[string]*widget.Clickable),
rows: make(map[string]*rowControls),
icons: make(map[string]paint.ImageOp),
iconReferences: make(map[string]string),
iconRequests: make(map[string]application.IconEventIdentity),
iconApplied: make(map[string]application.IconEventIdentity),
iconFailures: make(map[string]application.IconFailureCode),
}
shell.search.SingleLine = true
shell.SetItems(items)
return shell
}
// ApplyIcon stores a background-decoded image for future Layout calls.
// ApplyIcon stores a decoded image for future Layout calls.
// It is UI-goroutine-only; background workers must publish application events.
func (shell *AppShell) ApplyIcon(appID string, icon image.Image) {
delete(shell.iconApplied, appID)
delete(shell.iconFailures, appID)
if icon == nil {
delete(shell.icons, appID)
return
@@ -104,18 +115,39 @@ func (shell *AppShell) SetItems(items []application.CatalogListItem) {
nextRows := make(map[string]*rowControls, len(items))
nextIcons := make(map[string]paint.ImageOp, len(items))
nextReferences := make(map[string]string, len(items))
nextRequests := make(map[string]application.IconEventIdentity, len(items))
nextApplied := make(map[string]application.IconEventIdentity, len(items))
nextFailures := make(map[string]application.IconFailureCode, 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
reference := canonicalIconReference(item.IconRef)
nextReferences[item.ID] = reference
if previous, exists := shell.iconReferences[item.ID]; exists && previous == reference {
if icon, exists := shell.icons[item.ID]; exists {
nextIcons[item.ID] = icon
}
if request, exists := shell.iconRequests[item.ID]; exists && request.Reference == reference {
nextRequests[item.ID] = request
}
if applied, exists := shell.iconApplied[item.ID]; exists && applied.Reference == reference {
nextApplied[item.ID] = applied
}
if failure, exists := shell.iconFailures[item.ID]; exists {
nextFailures[item.ID] = failure
}
}
}
shell.rows = nextRows
shell.icons = nextIcons
shell.iconReferences = nextReferences
shell.iconRequests = nextRequests
shell.iconApplied = nextApplied
shell.iconFailures = nextFailures
nextCategories := make(map[string]*widget.Clickable)
for _, category := range append([]string{""}, shell.model.Categories()...) {