chore: remove deprecated client #33

Merged
developer merged 1 commits from chore/cleanup-code-and-repo into development 2026-05-23 14:57:29 +00:00
56 changed files with 18 additions and 18545 deletions
Showing only changes of commit 8e0a1c39c0 - Show all commits
+1 -1
View File
@@ -7,6 +7,7 @@ require (
galaxy/model v0.0.0
galaxy/postgres v0.0.0
galaxy/util v0.0.0-00010101000000-000000000000
github.com/abadojack/whatlanggo v1.0.1
github.com/disciplinedware/go-confusables v0.1.1
github.com/getkin/kin-openapi v0.135.0
github.com/gin-gonic/gin v1.12.0
@@ -36,7 +37,6 @@ require (
)
require (
github.com/abadojack/whatlanggo v1.0.1 // indirect
github.com/oschwald/geoip2-golang/v2 v2.1.0 // indirect
github.com/oschwald/maxminddb-golang/v2 v2.1.1 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
-10
View File
@@ -1,10 +0,0 @@
# Client for Galaxy Plus
UI Client is capable of:
- Register a new player and login for an existing player using only e-mail and one-time codes,
- Enlist to a new Game from available onboard Games list,
- Request list of Games in which Player participating,
- Request, store and display particular Game data,
- Use push-like mechanism for receiving asynchronous updates from Server,
- Offline mode when no internet connection is available or user desired to work offline.
-10
View File
@@ -1,10 +0,0 @@
// Package appmeta provides shared application metadata used by both the
// bootstrap loader process and the standalone UI client process.
package appmeta
const (
// AppID is the shared Fyne application identifier used for a common storage root.
AppID = "GalaxyPlus"
// DefaultBackendURL is the default backend HTTP endpoint used by local runs.
DefaultBackendURL = "http://127.0.0.1:8080"
)
-116
View File
@@ -1,116 +0,0 @@
package client
import (
"fmt"
"time"
gerr "galaxy/error"
)
var (
checkConnectionInterval = 5 * time.Second
checkVersionInterval = time.Hour
statePersistInterval = time.Second
)
func (e *client) startBackground() {
if e.conn == nil || e.updater == nil {
return
}
go e.backgroundLoop()
}
func (e *client) stopBackground() {
e.backgroundOnce.Do(func() {
close(e.backgroundStop)
})
}
func (e *client) backgroundLoop() {
checkConnTimer := time.NewTimer(checkConnectionInterval)
checkVersionTimer := time.NewTimer(checkVersionInterval)
persistStateTimer := time.NewTimer(statePersistInterval)
defer func() {
checkConnTimer.Stop()
checkVersionTimer.Stop()
persistStateTimer.Stop()
}()
for {
select {
case <-e.backgroundStop:
return
case <-checkConnTimer.C:
if e.conn != nil {
e.OnConnection(e.conn.CheckConnection())
}
checkConnTimer.Reset(checkConnectionInterval)
case <-checkVersionTimer.C:
if e.updater != nil {
if err := e.updater.CheckAndPrepareLatest(); err != nil {
e.handlerError(err)
}
}
checkVersionTimer.Reset(checkVersionInterval)
case <-persistStateTimer.C:
e.ensureStatePersist()
persistStateTimer.Reset(statePersistInterval)
}
}
}
func (e *client) ensureStatePersist() {
param := e.GetParams()
needSaving := false
e.stateMu.Lock()
if e.world != nil {
if param.CameraZoom > 0 && param.CameraZoom != e.state.CameraZoom {
e.state.CameraZoom = param.CameraZoom
needSaving = true
}
if param.CameraXWorldFp != e.state.CameraXFp {
e.state.CameraXFp = param.CameraXWorldFp
needSaving = true
}
if param.CameraYWorldFp != e.state.CameraYFp {
e.state.CameraYFp = param.CameraYWorldFp
needSaving = true
}
}
if e.mapSplitter != nil && e.mapSplitter.Offset != e.state.MapSplitterOffset {
e.state.MapSplitterOffset = e.mapSplitter.Offset
needSaving = true
}
if e.accInfo.Open != e.state.AccordionInfoOpen {
e.state.AccordionInfoOpen = e.accInfo.Open
needSaving = true
}
if e.accCalc.Open != e.state.AccordionCalcOpen {
e.state.AccordionCalcOpen = e.accCalc.Open
needSaving = true
}
if needSaving {
if err := e.s.SaveState(*e.state); err != nil {
e.handlerError(err)
}
}
e.stateMu.Unlock()
}
func (e *client) handlerError(err error) {
if err == nil {
return
}
fmt.Printf("ERROR: %s\n", err)
switch {
case gerr.IsConnection(err):
e.OnConnectionError(err)
case gerr.IsStorage(err):
e.OnStorageError(err)
default:
e.OnServiceError(err)
}
}
-39
View File
@@ -1,39 +0,0 @@
package client
import (
"errors"
"testing"
gerr "galaxy/error"
"github.com/stretchr/testify/require"
)
func TestHandlerErrorDispatchesByClass(t *testing.T) {
t.Parallel()
tests := []struct {
name string
err error
wantEvent string
}{
{name: "connection", err: gerr.WrapConnection(errors.New("dial")), wantEvent: "connection"},
{name: "storage", err: gerr.WrapStorage(errors.New("write file")), wantEvent: "storage"},
{name: "service", err: gerr.WrapService(errors.New("bad response")), wantEvent: "service"},
{name: "unclassified defaults to service", err: errors.New("plain"), wantEvent: "service"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got string
c := &client{
onConnectionErrFn: func(error) { got = "connection" },
onStorageErrFn: func(error) { got = "storage" },
onServiceErrFn: func(error) { got = "service" },
}
c.handlerError(tt.err)
require.Equal(t, tt.wantEvent, got)
})
}
}
-101
View File
@@ -1,101 +0,0 @@
package client
import (
"image/color"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/widget"
)
type interactiveRaster struct {
widget.BaseWidget
min fyne.Size
raster *canvas.Raster
onLayout func(fyne.Size)
onScrolled func(*fyne.ScrollEvent)
onDragged func(*fyne.DragEvent)
onDragEnd func()
onTapped func(*fyne.PointEvent)
}
func (r *interactiveRaster) SetMinSize(size fyne.Size) {
r.min = size
r.Resize(size)
}
func (r *interactiveRaster) MinSize() fyne.Size {
return r.min
}
func (r *interactiveRaster) CreateRenderer() fyne.WidgetRenderer {
return &rasterWidgetRender{
canvas: r,
bg: canvas.NewRasterWithPixels(bgPattern),
onLayout: r.onLayout,
}
}
// Tapped is a left-click event
func (r *interactiveRaster) Tapped(ev *fyne.PointEvent) {
if r.onTapped == nil {
return
}
r.onTapped(ev)
}
// TappedSecondary is a right-click event
func (r *interactiveRaster) TappedSecondary(*fyne.PointEvent) {}
func newInteractiveRaster(
raster *canvas.Raster,
onLayout func(fyne.Size),
onScrolled func(*fyne.ScrollEvent),
onDragged func(*fyne.DragEvent),
onDragEnd func(),
onTapped func(*fyne.PointEvent),
) *interactiveRaster {
r := &interactiveRaster{
raster: raster,
onLayout: onLayout,
onScrolled: onScrolled,
onDragged: onDragged,
onDragEnd: onDragEnd,
onTapped: onTapped,
}
r.ExtendBaseWidget(r)
return r
}
func bgPattern(x, y, _, _ int) color.Color {
const boxSize = 25
if (x/boxSize)%2 == (y/boxSize)%2 {
return color.Gray{Y: 58}
}
return color.Gray{Y: 84}
}
func (r *interactiveRaster) Scrolled(e *fyne.ScrollEvent) {
if r.onScrolled == nil {
return
}
r.onScrolled(e)
}
func (r *interactiveRaster) Dragged(e *fyne.DragEvent) {
if r.onDragged == nil {
return
}
r.onDragged(e)
}
func (r *interactiveRaster) DragEnd() {
if r.onDragEnd == nil {
return
}
r.onDragEnd()
}
-289
View File
@@ -1,289 +0,0 @@
package client
import (
"image"
"sync"
"galaxy/client/updater"
"galaxy/client/widget/calculator"
"galaxy/client/world"
"galaxy/connector"
mc "galaxy/model/client"
"galaxy/storage"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/lang"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
const version = "1.0.0"
type client struct {
s storage.Storage
conn connector.Connector
app fyne.App
window fyne.Window
state *mc.State
stateMu sync.RWMutex
reg *registry
calculator *calculator.Calculator
mapSplitter *container.Split
accInfo *widget.AccordionItem
accCalc *widget.AccordionItem
// loadReportFunc func(uint)
world *world.World
drawer *world.GGDrawer
raster *canvas.Raster
co *RasterCoalescer[world.RenderParams]
pan *PanController
// Protected camera/options state (UI-facing). This is the "base" params snapshot.
// Viewport/margins are NOT stored here; they come from raster draw callback.
mu sync.RWMutex
wp *world.RenderParams
canvasScale float32
// Latest raster geometry metadata for correct event->pixel conversion:
// - logical size: raster.Size() (Fyne units)
// - pixel size: last (wPx,hPx) passed to draw callback
metaMu sync.RWMutex
lastRasterLogicW float32
lastRasterLogicH float32
lastRasterPxW int
lastRasterPxH int
lastCanvasScale float32 // optional, useful for debugging
// Snapshot of params actually used for the last render (includes viewport/margins).
// Used for HitTest and to keep UI interactions consistent with what the user sees.
lastRenderedMu sync.RWMutex
lastRenderedParams world.RenderParams
// Indexing / backing-canvas caches (owned by client because it depends on UI geometry)
lastIndexedViewportW int
lastIndexedViewportH int
lastIndexedZoomFp int
lastCanvasW int
lastCanvasH int
viewportImg *image.RGBA
viewportW int
viewportH int
hits []world.Hit
updater *updater.Manager
backgroundStop chan struct{}
backgroundOnce sync.Once
onConnectionFn func(bool)
onConnectionErrFn func(error)
onStorageErrFn func(error)
onServiceErrFn func(error)
}
func NewClient(s storage.Storage, conn connector.Connector, app fyne.App) (mc.Client, error) {
e := &client{
s: s,
conn: conn,
app: app,
window: app.NewWindow("Galaxy Plus"),
reg: newRegistry(),
lastCanvasScale: 1.0,
world: nil,
hits: make([]world.Hit, 5),
backgroundStop: make(chan struct{}),
}
e.calculator = calculator.NewCaclulator(calculator.WithCreateHandler(e.createShipClass))
e.updater = updater.NewManager(e.s, e.conn)
stateExists, err := e.s.StateExists()
if err != nil {
return nil, err
}
if stateExists {
state, err := e.s.LoadState()
if err != nil {
return nil, err
}
e.state = &state
} else {
e.state = &mc.State{
ClientCurrentVersion: e.Version(),
CameraZoom: 1.0,
MapSplitterOffset: 0.5,
AccordionInfoOpen: false,
AccordionCalcOpen: false,
}
if err := e.s.SaveState(*e.state); err != nil {
return nil, err
}
}
if e.state.CameraZoom <= 0 {
e.state.CameraZoom = 1.0
}
if e.state.MapSplitterOffset <= 0 {
e.state.MapSplitterOffset = 0.5
}
e.wp = &world.RenderParams{
Options: &world.RenderOptions{DisableWrapScroll: false},
CameraZoom: e.state.CameraZoom,
CameraXWorldFp: e.state.CameraXFp,
CameraYWorldFp: e.state.CameraYFp,
}
e.drawer = &world.GGDrawer{DC: nil}
e.raster = canvas.NewRaster(func(wPx, hPx int) image.Image {
return e.draw(wPx, hPx)
})
e.pan = NewPanController(e)
e.co = NewRasterCoalescer(
FyneExecutor{},
e.raster,
func(wPx, hPx int, p world.RenderParams) image.Image {
return e.renderRasterImage(wPx, hPx, p)
},
)
return e, nil
}
func (e *client) BuildUI(w fyne.Window) {
mapCanvasObject := newInteractiveRaster(e.raster, e.onRasterWidgetLayout, e.onScrolled, e.onDragged, e.onDradEnd, e.onTapped)
toolbar := widget.NewToolbar(
widget.NewToolbarAction(
theme.FolderIcon(),
func() { e.initReportAsync("GAME_ID", 0) }),
widget.NewToolbarSeparator(),
widget.NewToolbarAction(
theme.NavigateBackIcon(),
func() {}),
widget.NewToolbarAction(
theme.NavigateNextIcon(),
func() {}),
)
e.accInfo = widget.NewAccordionItem(lang.L("title.info"), container.NewStack())
e.accInfo.Open = e.state.AccordionInfoOpen
e.accCalc = widget.NewAccordionItem(lang.L("title.calculator"), e.calculator.CanvasObject)
e.accCalc.Open = e.state.AccordionCalcOpen
accordion := widget.NewAccordion()
accordion.MultiOpen = true
accordion.Append(e.accCalc)
accordion.Append(e.accInfo)
e.mapSplitter = container.NewHSplit(mapCanvasObject, container.NewHScroll(accordion))
e.mapSplitter.SetOffset(e.state.MapSplitterOffset)
tabs := container.NewAppTabs(
container.NewTabItemWithIcon(
lang.L("title.map"),
theme.GridIcon(),
e.mapSplitter),
container.NewTabItemWithIcon(
"Calculator",
theme.ComputerIcon(),
container.NewStack(widget.NewButton("Calc", func() {})),
),
)
th := tabs.Theme()
icon := canvas.NewImageFromResource(th.Icon(theme.IconNameInfo))
statusLeft := widget.NewTextGridFromString("Status")
statusAd := widget.NewTextGridFromString("")
statusBar := container.NewBorder(
nil, // top
nil, // bottom
container.NewHBox(statusLeft, widget.NewSeparator()), // left
container.NewHBox(widget.NewSeparator(), icon), // right
statusAd, // center
)
content := container.NewBorder(
toolbar, // top
statusBar, // bottom
nil, // left
nil, // right
tabs, // center
)
w.CenterOnScreen()
w.SetContent(content)
s := statusBar.Size()
icon.SetMinSize(fyne.NewSize(s.Height, s.Height))
e.initLatestReport()
}
func (e *client) loadWorld(w *world.World) {
if w == nil {
return
}
w.SetCircleRadiusScaleFp(world.SCALE / 1000)
e.world = w
// TODO: store camera position in user settings
e.wp.CameraXWorldFp = w.W / 2
e.wp.CameraYWorldFp = w.H / 2
e.world.SetTheme(world.ThemeDark)
e.RequestRefresh()
}
func (e *client) Run() error {
e.BuildUI(e.window)
e.startBackground()
e.RequestRefresh()
e.window.SetMaster()
e.window.Resize(fyne.NewSize(800, 600))
e.window.CenterOnScreen()
e.window.SetOnClosed(e.Shutdown)
e.window.ShowAndRun()
return nil
}
func (e *client) Shutdown() {
e.stopBackground()
e.ensureStatePersist()
e.window.Close()
}
// TODO: remove func?
func (e *client) Version() string { return version }
func (e *client) OnConnection(isGood bool) {
if e.onConnectionFn != nil {
e.onConnectionFn(isGood)
}
}
func (e *client) OnConnectionError(err error) {
if e.onConnectionErrFn != nil {
e.onConnectionErrFn(err)
}
}
func (e *client) OnStorageError(err error) {
if e.onStorageErrFn != nil {
e.onStorageErrFn(err)
}
}
func (e *client) OnServiceError(err error) {
if e.onServiceErrFn != nil {
e.onServiceErrFn(err)
}
}
-227
View File
@@ -1,227 +0,0 @@
package client
import (
"image"
"sync"
"testing"
"github.com/stretchr/testify/require"
)
type testExecutor struct {
mu sync.Mutex
queue []func()
}
func (e *testExecutor) Post(fn func()) {
e.mu.Lock()
e.queue = append(e.queue, fn)
e.mu.Unlock()
}
func (e *testExecutor) FlushAll() {
for {
var fn func()
e.mu.Lock()
if len(e.queue) > 0 {
fn = e.queue[0]
e.queue = e.queue[1:]
}
e.mu.Unlock()
if fn == nil {
return
}
fn()
}
}
type testRefresher struct {
mu sync.Mutex
count int
}
func (r *testRefresher) Refresh() {
r.mu.Lock()
r.count++
r.mu.Unlock()
}
func (r *testRefresher) Count() int {
r.mu.Lock()
defer r.mu.Unlock()
return r.count
}
func TestRasterCoalescer_RequestBeforeDraw_CoalescesToLatest(t *testing.T) {
t.Parallel()
exec := &testExecutor{}
ref := &testRefresher{}
var got []int
co := NewRasterCoalescer(exec, ref, func(w, h int, p int) image.Image {
got = append(got, p)
return image.NewRGBA(image.Rect(0, 0, w, h))
})
co.Request(1)
co.Request(2)
co.Request(3)
// Only a single refresh should be scheduled before the next Draw().
exec.FlushAll()
require.Equal(t, 1, ref.Count())
_ = co.Draw(10, 10)
require.Equal(t, []int{3}, got)
}
func TestRasterCoalescer_RequestDuringDraw_SchedulesOneFollowUpRefresh(t *testing.T) {
t.Parallel()
exec := &testExecutor{}
ref := &testRefresher{}
var got []int
var co *RasterCoalescer[int]
co = NewRasterCoalescer(exec, ref, func(w, h int, p int) image.Image {
got = append(got, p)
if p == 1 {
co.Request(2)
co.Request(3)
}
return image.NewRGBA(image.Rect(0, 0, w, h))
})
co.Request(1)
exec.FlushAll()
require.Equal(t, 1, ref.Count())
// First draw renders 1 and schedules exactly one additional refresh.
_ = co.Draw(10, 10)
exec.FlushAll()
require.Equal(t, 2, ref.Count())
// Second draw renders latest (3).
_ = co.Draw(10, 10)
require.Equal(t, []int{1, 3}, got)
}
func TestRasterCoalescer_ManyRequestsWhileDrawing_StillOnlyOneExtraRefresh(t *testing.T) {
t.Parallel()
exec := &testExecutor{}
ref := &testRefresher{}
var got []int
var co *RasterCoalescer[int]
co = NewRasterCoalescer(exec, ref, func(w, h int, p int) image.Image {
got = append(got, p)
if p == 1 {
for i := 2; i <= 50; i++ {
co.Request(i)
}
}
return image.NewRGBA(image.Rect(0, 0, w, h))
})
co.Request(1)
exec.FlushAll()
require.Equal(t, 1, ref.Count())
_ = co.Draw(10, 10)
exec.FlushAll()
require.Equal(t, 2, ref.Count())
_ = co.Draw(10, 10)
require.Equal(t, []int{1, 50}, got)
}
func TestCopyViewportRGBA_CopiesROIAndIsIndependentFromSource(t *testing.T) {
t.Parallel()
src := image.NewRGBA(image.Rect(0, 0, 20, 20))
dst := image.NewRGBA(image.Rect(0, 0, 5, 6))
// Fill src with a pattern: pixel (x,y) has RGBA = (x, y, 0, 255).
for y := 0; y < 20; y++ {
for x := 0; x < 20; x++ {
off := y*src.Stride + x*4
src.Pix[off+0] = byte(x)
src.Pix[off+1] = byte(y)
src.Pix[off+2] = 0
src.Pix[off+3] = 255
}
}
marginX, marginY := 7, 9
copyViewportRGBA(dst, src, marginX, marginY, 5, 6)
// Verify a few pixels in dst match the expected source ROI.
// dst(0,0) == src(marginX, marginY)
{
off := 0*dst.Stride + 0*4
require.Equal(t, byte(marginX), dst.Pix[off+0])
require.Equal(t, byte(marginY), dst.Pix[off+1])
require.Equal(t, byte(255), dst.Pix[off+3])
}
// dst(4,5) == src(marginX+4, marginY+5)
{
off := 5*dst.Stride + 4*4
require.Equal(t, byte(marginX+4), dst.Pix[off+0])
require.Equal(t, byte(marginY+5), dst.Pix[off+1])
require.Equal(t, byte(255), dst.Pix[off+3])
}
// Mutate src ROI after copy and ensure dst is unchanged (no aliasing).
{
off := (marginY+0)*src.Stride + (marginX+0)*4
src.Pix[off+0] = 200
src.Pix[off+1] = 201
src.Pix[off+3] = 123
}
offDst := 0*dst.Stride + 0*4
require.Equal(t, byte(marginX), dst.Pix[offDst+0])
require.Equal(t, byte(marginY), dst.Pix[offDst+1])
require.Equal(t, byte(255), dst.Pix[offDst+3])
}
func TestEventPosToPixel_FloorMapping(t *testing.T) {
t.Parallel()
e := &client{}
// Pretend raster logical is 100x50, pixel is 1000x500.
e.metaMu.Lock()
e.lastRasterLogicW = 100
e.lastRasterLogicH = 50
e.lastRasterPxW = 1000
e.lastRasterPxH = 500
e.metaMu.Unlock()
x, y, ok := e.eventPosToPixel(0, 0)
require.True(t, ok)
require.Equal(t, 0, x)
require.Equal(t, 0, y)
// Middle
x, y, ok = e.eventPosToPixel(50, 25)
require.True(t, ok)
require.Equal(t, 500, x)
require.Equal(t, 250, y)
// Near max logical should map near max pixel with floor.
x, y, ok = e.eventPosToPixel(99.9, 49.9)
require.True(t, ok)
require.GreaterOrEqual(t, x, 998)
require.GreaterOrEqual(t, y, 498)
// Clamp
x, y, ok = e.eventPosToPixel(-10, 999)
require.True(t, ok)
require.Equal(t, 0, x)
require.Equal(t, 500, y)
}
-53
View File
@@ -1,53 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
"galaxy/client"
"galaxy/client/appmeta"
"galaxy/client/loader"
"galaxy/connector/http"
"galaxy/storage/fs"
"os"
"os/signal"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/lang"
)
func main() {
var err error
defer func() {
if err == nil {
if r := recover(); r != nil {
err = errors.Join(err, fmt.Errorf("panic: %v", r))
}
}
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}()
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
app := app.NewWithID(appmeta.AppID)
if err = lang.AddTranslationsFS(client.Translations, "resource/lang"); err != nil {
return
}
s, err := fs.NewFS(app.Storage().RootURI().Path())
if err != nil {
return
}
c, err := http.NewHttpConnector(ctx, appmeta.DefaultBackendURL)
if err != nil {
return
}
l, err := loader.NewLoader(s, c, app)
if err != nil {
return
}
err = l.Run(ctx)
}
-51
View File
@@ -1,51 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
"galaxy/client"
"galaxy/client/appmeta"
"galaxy/connector/http"
"galaxy/storage/fs"
"os"
"os/signal"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/lang"
)
func main() {
var err error
defer func() {
if err == nil {
if r := recover(); r != nil {
err = errors.Join(err, fmt.Errorf("panic: %v", r))
}
}
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}()
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
app := app.NewWithID(appmeta.AppID)
if err = lang.AddTranslationsFS(client.Translations, "resource/lang"); err != nil {
return
}
s, err := fs.NewFS(app.Storage().RootURI().Path())
if err != nil {
return
}
conn, err := http.NewHttpConnector(ctx, appmeta.DefaultBackendURL)
if err != nil {
return
}
c, err := client.NewClient(s, conn, app)
if err != nil {
return
}
err = c.Run()
}
-133
View File
@@ -1,133 +0,0 @@
package client
/*
Fyne-friendly latest-wins coalescing for canvas.NewRaster(draw func(w,h int) image.Image).
Key property:
- draw() renders at most once per invocation (never loops).
- if new requests arrived while drawing, we schedule exactly one extra Refresh.
*/
import (
"image"
"sync"
)
// UIExecutor posts a function to run on the UI/main thread.
type UIExecutor interface {
Post(fn func())
}
// Refresher is the minimal interface we need from fyne.CanvasObject / Raster.
type Refresher interface {
Refresh()
}
// RasterRenderer renders the latest params and returns an image.
// Must be called on the UI thread (inside draw callback).
type RasterRenderer[P any] func(wPx, hPx int, params P) image.Image
// RasterCoalescer implements latest-wins coalescing for raster rendering.
// It is designed specifically for toolkits like fyne where the system calls draw(w,h)
// and expects a returned image.
type RasterCoalescer[P any] struct {
exec UIExecutor
refresher Refresher
renderer RasterRenderer[P]
mu sync.Mutex
// inDraw == true while Draw() is running on UI thread.
inDraw bool
// refreshQueued == true when we have already posted a Refresh() that has not yet
// resulted in a Draw() call (or is expected to call Draw soon).
refreshQueued bool
// pending == true when new params arrived while inDraw==true.
// Draw() will schedule exactly one follow-up Refresh after it returns.
pending bool
latest P
have bool
}
// NewRasterCoalescer creates a new coalescer.
// - exec.Post must run fn on UI thread.
// - refresher.Refresh will trigger the framework to call draw(w,h).
func NewRasterCoalescer[P any](exec UIExecutor, refresher Refresher, renderer RasterRenderer[P]) *RasterCoalescer[P] {
if exec == nil {
panic("RasterCoalescer: nil executor")
}
if refresher == nil {
panic("RasterCoalescer: nil refresher")
}
if renderer == nil {
panic("RasterCoalescer: nil renderer")
}
return &RasterCoalescer[P]{exec: exec, refresher: refresher, renderer: renderer}
}
// Request stores the latest params and schedules exactly one refresh (latest-wins).
// Can be called from any goroutine.
func (c *RasterCoalescer[P]) Request(params P) {
c.mu.Lock()
c.latest = params
c.have = true
// If we are currently inside Draw(), don't schedule refresh immediately.
// Just mark pending; Draw() will schedule one follow-up refresh after it returns.
if c.inDraw {
c.pending = true
c.mu.Unlock()
return
}
// Not drawing. Schedule at most one refresh until the next Draw() happens.
if c.refreshQueued {
c.mu.Unlock()
return
}
c.refreshQueued = true
c.mu.Unlock()
c.exec.Post(c.refresher.Refresh)
}
// Draw must be called from the raster draw callback on the UI thread.
// It renders exactly once with the latest snapshot.
// If more requests arrived while drawing, it schedules exactly one extra refresh.
func (c *RasterCoalescer[P]) Draw(wPx, hPx int) image.Image {
c.mu.Lock()
// A Draw call corresponds to a previously scheduled refresh being serviced.
c.refreshQueued = false
if !c.have {
c.mu.Unlock()
return image.NewRGBA(image.Rect(0, 0, wPx, hPx))
}
c.inDraw = true
c.pending = false
params := c.latest
c.mu.Unlock()
img := c.renderer(wPx, hPx, params)
c.mu.Lock()
needAnother := c.pending
c.pending = false
c.inDraw = false
// If we need another frame, schedule exactly one refresh (if not already queued).
if needAnother && !c.refreshQueued {
c.refreshQueued = true
c.mu.Unlock()
c.exec.Post(c.refresher.Refresh)
return img
}
c.mu.Unlock()
return img
}
-6
View File
@@ -1,6 +0,0 @@
package client
import "embed"
//go:embed resource/lang
var Translations embed.FS
-46
View File
@@ -1,46 +0,0 @@
module galaxy/client
go 1.26.0
require (
fyne.io/fyne/v2 v2.7.3
github.com/fogleman/gg v1.3.0
github.com/stretchr/testify v1.11.1
)
require (
fyne.io/systray v1.12.0 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fredbi/uri v1.1.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fyne-io/gl-js v0.2.0 // indirect
github.com/fyne-io/glfw-js v0.3.0 // indirect
github.com/fyne-io/image v0.1.1 // indirect
github.com/fyne-io/oksvg v0.2.0 // indirect
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728 // indirect
github.com/go-text/render v0.2.0 // indirect
github.com/go-text/typesetting v0.3.3 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
github.com/hack-pad/safejs v0.1.1 // indirect
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/rymdport/portal v0.4.2 // indirect
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
github.com/yuin/goldmark v1.7.16 // indirect
golang.org/x/image v0.36.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
-82
View File
@@ -1,82 +0,0 @@
fyne.io/fyne/v2 v2.7.3 h1:xBT/iYbdnNHONWO38fZMBrVBiJG8rV/Jypmy4tVfRWE=
fyne.io/fyne/v2 v2.7.3/go.mod h1:gu+dlIcZWSzKZmnrY8Fbnj2Hirabv2ek+AKsfQ2bBlw=
fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM=
fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/fredbi/uri v1.1.1 h1:xZHJC08GZNIUhbP5ImTHnt5Ya0T8FI2VAwI/37kh2Ko=
github.com/fredbi/uri v1.1.1/go.mod h1:4+DZQ5zBjEwQCDmXW5JdIjz0PUA+yJbvtBv+u+adr5o=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fyne-io/gl-js v0.2.0 h1:+EXMLVEa18EfkXBVKhifYB6OGs3HwKO3lUElA0LlAjs=
github.com/fyne-io/gl-js v0.2.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
github.com/fyne-io/glfw-js v0.3.0 h1:d8k2+Y7l+zy2pc7wlGRyPfTgZoqDf3AI4G+2zOWhWUk=
github.com/fyne-io/glfw-js v0.3.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk=
github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8=
github.com/fyne-io/oksvg v0.2.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728 h1:RkGhqHxEVAvPM0/R+8g7XRwQnHatO0KAuVcwHo8q9W8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728/go.mod h1:SyRD8YfuKk+ZXlDqYiqe1qMSqjNgtHzBTG810KUagMc=
github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc=
github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU=
github.com/go-text/typesetting v0.3.3 h1:ihGNJU9KzdK2QRDy1Bm7FT5RFQoYb+3n3EIhI/4eaQc=
github.com/go-text/typesetting v0.3.3/go.mod h1:vIRUT25mLQaSh4C8H/lIsKppQz/Gdb8Pu/tNwpi52ts=
github.com/go-text/typesetting-utils v0.0.0-20250618110550-c820a94c77b8 h1:4KCscI9qYWMGTuz6BpJtbUSRzcBrUSSE0ENMJbNSrFs=
github.com/go-text/typesetting-utils v0.0.0-20250618110550-c820a94c77b8/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
github.com/hack-pad/safejs v0.1.1 h1:d5qPO0iQ7h2oVtpzGnLExE+Wn9AtytxIfltcS2b9KD8=
github.com/hack-pad/safejs v0.1.1/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU=
github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-61
View File
@@ -1,61 +0,0 @@
package client
import (
"galaxy/client/world"
"fyne.io/fyne/v2"
)
var m = func(v int) float64 { return float64(v) / float64(world.SCALE) }
func (e *client) onTapped(ev *fyne.PointEvent) {
if e.world == nil || ev == nil {
return
}
xPx, yPx, ok := e.eventPosToPixel(ev.Position.X, ev.Position.Y)
if !ok {
return
}
params := e.getLastRenderedParams()
hits, err := e.world.HitTest(e.hits, &params, xPx, yPx)
if err != nil {
e.handlerError(err)
return
}
if len(hits) == 0 {
e.calculator.UnloadPlanet()
return
}
for i := range hits {
e.onHit(hits[i])
}
}
func (e *client) onHit(hit world.Hit) {
// var coord string
// if hit.Kind == world.KindLine {
// coord = fmt.Sprintf("{%f,%f - %f,%f}", m(hit.X1), m(hit.Y1), m(hit.X2), m(hit.Y2))
// } else {
// coord = fmt.Sprintf("{%f,%f}", m(hit.X), m(hit.Y))
// }
// fmt.Println("hit:", hit.ID, "Coord:", coord)
switch hit.Kind {
case world.KindPoint:
case world.KindCircle:
e.onHitCircle(hit.ID)
case world.KindLine:
}
}
func (e *client) onHitCircle(id world.PrimitiveID) {
p, ok := e.reg.localPlanet(id)
if !ok {
return
}
e.calculator.LoadPlanet(p.Name, p.Number, p.FreeIndustry.F(), p.Material.F(), p.Resources.F())
e.calculator.Refresh()
}
-214
View File
@@ -1,214 +0,0 @@
package loader
import (
"context"
"errors"
"fmt"
"sync"
"galaxy/client/updater"
"galaxy/connector"
"galaxy/storage"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
const (
loaderLogViewportColumns = 80
loaderLogViewportRows = 12
)
type loader struct {
app fyne.App
storage storage.Storage
connector connector.Connector
updater *updater.Manager
runner uiRunner
debugWindow fyne.Window
textGrid *widget.TextGrid
btn *widget.Button
ctx context.Context
resultMu sync.Mutex
result error
closeMu sync.Mutex
closeQuits bool
}
// loaderLogViewportMinSize derives a stable monospace TextGrid viewport size
// from the active Fyne text metrics.
func loaderLogViewportMinSize(app fyne.App) fyne.Size {
if app == nil || app.Driver() == nil {
return fyne.NewSize(0, 0)
}
cellSize, _ := app.Driver().RenderedTextSize(
"M",
theme.TextSize(),
fyne.TextStyle{Monospace: true},
nil,
)
return fyne.NewSize(
cellSize.Width*loaderLogViewportColumns,
cellSize.Height*loaderLogViewportRows,
)
}
func NewLoader(s storage.Storage, conn connector.Connector, app fyne.App) (*loader, error) {
l := &loader{
app: app,
connector: conn,
storage: s,
updater: updater.NewManager(s, conn),
runner: execRunner{},
textGrid: widget.NewTextGrid(),
debugWindow: app.NewWindow("Loader"),
}
l.btn = widget.NewButton("Retry", l.onButtonAction)
l.btn.Disable()
l.textGrid.Scroll = fyne.ScrollNone
l.debugWindow.SetCloseIntercept(l.onWindowClose)
logScroll := container.NewScroll(l.textGrid)
logScroll.Direction = container.ScrollBoth
logScroll.SetMinSize(loaderLogViewportMinSize(app))
actionBar := container.NewCenter(container.NewHBox(l.btn))
content := container.NewBorder(nil, actionBar, nil, nil, logScroll)
l.debugWindow.SetContent(content)
l.debugWindow.Resize(content.MinSize())
l.debugWindow.SetFixedSize(true)
l.debugWindow.CenterOnScreen()
return l, nil
}
func (l *loader) runOnce(ctx context.Context) error {
target, err := l.updater.EnsureLaunchTarget()
if err != nil {
return err
}
l.logText(fmt.Sprintf("Starting UI client v%s", target.Version))
l.logText(fmt.Sprintf("Executable: %s", target.Path))
exitCode, runErr := l.runner.Run(ctx, target.Path)
markErr := l.updater.MarkLaunchResult(target.Version, exitCode, runErr)
switch {
case runErr != nil:
return errors.Join(fmt.Errorf("launch UI client v%s: %w", target.Version, runErr), markErr)
case exitCode != 0:
return errors.Join(fmt.Errorf("UI client v%s exited with code %d", target.Version, exitCode), markErr)
default:
return markErr
}
}
// init prepares and launches the standalone UI client, or shows a retry button on failure.
func (l *loader) init(ctx context.Context) {
l.setCloseQuits(false)
fyne.Do(func() {
l.textGrid.SetText("")
l.btn.Hide()
l.btn.Disable()
// show debugWindow can be done with future debug mode, e.g. with -debug flag
l.debugWindow.Hide()
})
err := l.runOnce(ctx)
if err == nil || errors.Is(err, context.Canceled) {
l.setResult(nil)
fyne.Do(func() {
l.debugWindow.Hide()
l.app.Quit()
})
return
}
l.setCloseQuits(true)
l.setResult(err)
l.logError(err)
fyne.Do(func() {
l.btn.SetText("Retry")
l.btn.Enable()
l.btn.Show()
l.debugWindow.Show()
})
}
func (l *loader) onButtonAction() {
if l.ctx == nil {
return
}
go l.init(l.ctx)
}
func (l *loader) onWindowClose() {
if l.getCloseQuits() {
l.app.Quit()
return
}
l.debugWindow.Hide()
}
func (l *loader) logText(v string) {
if l.textGrid == nil {
return
}
fyne.Do(func() { l.textGrid.Append(v) })
}
func (l *loader) logError(err error) {
l.logText(fmt.Sprintf("ERROR: %s", err))
}
func (l *loader) setResult(err error) {
l.resultMu.Lock()
defer l.resultMu.Unlock()
l.result = err
}
func (l *loader) getResult() error {
l.resultMu.Lock()
defer l.resultMu.Unlock()
return l.result
}
func (l *loader) setCloseQuits(v bool) {
l.closeMu.Lock()
defer l.closeMu.Unlock()
l.closeQuits = v
}
func (l *loader) getCloseQuits() bool {
l.closeMu.Lock()
defer l.closeMu.Unlock()
return l.closeQuits
}
// Run starts the loader window, launches the standalone UI process, and returns
// the final launch result once the loader application exits.
func (l *loader) Run(ctx context.Context) error {
l.ctx = ctx
go l.init(ctx)
go func() {
<-ctx.Done()
fyne.Do(l.app.Quit)
}()
l.app.Run()
if errors.Is(ctx.Err(), context.Canceled) {
return nil
}
return l.getResult()
}
-211
View File
@@ -1,211 +0,0 @@
package loader
import (
"context"
"errors"
"path/filepath"
"testing"
"galaxy/client/updater"
"galaxy/connector"
mc "galaxy/model/client"
"galaxy/model/report"
"galaxy/storage"
"galaxy/storage/fs"
"github.com/stretchr/testify/require"
)
type stubConnector struct {
versions []connector.VersionInfo
versionErr error
downloads map[string][]byte
downloadErr error
}
func (c *stubConnector) CheckConnection() bool {
return true
}
func (c *stubConnector) CheckVersion() ([]connector.VersionInfo, error) {
if c.versionErr != nil {
return nil, c.versionErr
}
return c.versions, nil
}
func (c *stubConnector) DownloadVersion(url string) ([]byte, error) {
if c.downloadErr != nil {
return nil, c.downloadErr
}
data, ok := c.downloads[url]
if !ok {
return nil, errors.New("missing download payload")
}
return data, nil
}
func (c *stubConnector) FetchReport(mc.GameID, uint, func(report.Report, error)) {}
type stubRunner struct {
paths []string
exitCode int
err error
}
func (r *stubRunner) Run(_ context.Context, path string) (int, error) {
r.paths = append(r.paths, path)
return r.exitCode, r.err
}
func TestRunOnceFirstLaunchDownloadsAndPromotesVersion(t *testing.T) {
t.Parallel()
s := newTestStorage(t)
payload := []byte("ui-binary-1.2.3")
info := connector.VersionInfo{
OS: "windows",
Arch: "amd64",
Kind: connector.ArtifactKindExecutable,
Version: "1.2.3",
URL: "https://example.com/ui-1.2.3.exe",
Checksum: connector.NewSHA256Digest(payload),
}
conn := &stubConnector{
versions: []connector.VersionInfo{info},
downloads: map[string][]byte{info.URL: payload},
}
runner := &stubRunner{}
l := &loader{
storage: s,
connector: conn,
updater: updater.NewManager(s, conn, updater.WithPlatform("windows", "amd64")),
runner: runner,
}
err := l.runOnce(context.Background())
require.NoError(t, err)
state, err := s.LoadState()
require.NoError(t, err)
require.Equal(t, "1.2.3", state.ClientCurrentVersion)
require.Nil(t, state.ClientNextVersion)
expectedPath := filepath.Join(s.StorageRoot(), updater.ArtifactPath("1.2.3", "windows", "amd64", connector.ArtifactKindExecutable))
require.Equal(t, []string{expectedPath}, runner.paths)
}
func TestRunOnceSpawnFailureClearsPendingAndKeepsCurrent(t *testing.T) {
t.Parallel()
s := newTestStorage(t)
currentPath := updater.ArtifactPath("1.0.0", "windows", "amd64", connector.ArtifactKindExecutable)
require.NoError(t, s.WriteFile(currentPath, []byte("current")))
require.NoError(t, s.SaveState(mc.State{ClientCurrentVersion: "1.0.0"}))
payload := []byte("ui-binary-1.1.0")
info := connector.VersionInfo{
OS: "windows",
Arch: "amd64",
Kind: connector.ArtifactKindExecutable,
Version: "1.1.0",
URL: "https://example.com/ui-1.1.0.exe",
Checksum: connector.NewSHA256Digest(payload),
}
conn := &stubConnector{
versions: []connector.VersionInfo{info},
downloads: map[string][]byte{info.URL: payload},
}
manager := updater.NewManager(s, conn, updater.WithPlatform("windows", "amd64"))
require.NoError(t, manager.CheckAndPrepareLatest())
l := &loader{
storage: s,
connector: conn,
updater: manager,
runner: &stubRunner{
err: errors.New("spawn failed"),
},
}
err := l.runOnce(context.Background())
require.Error(t, err)
state, err := s.LoadState()
require.NoError(t, err)
require.Equal(t, "1.0.0", state.ClientCurrentVersion)
require.Nil(t, state.ClientNextVersion)
currentExists, _, err := s.FileExists(currentPath)
require.NoError(t, err)
require.True(t, currentExists)
nextExists, _, err := s.FileExists(updater.ArtifactPath("1.1.0", "windows", "amd64", connector.ArtifactKindExecutable))
require.NoError(t, err)
require.False(t, nextExists)
}
func TestRunOnceNonZeroExitClearsPendingAndKeepsCurrent(t *testing.T) {
t.Parallel()
s := newTestStorage(t)
currentPath := updater.ArtifactPath("1.0.0", "windows", "amd64", connector.ArtifactKindExecutable)
require.NoError(t, s.WriteFile(currentPath, []byte("current")))
require.NoError(t, s.SaveState(mc.State{ClientCurrentVersion: "1.0.0"}))
payload := []byte("ui-binary-1.1.0")
info := connector.VersionInfo{
OS: "windows",
Arch: "amd64",
Kind: connector.ArtifactKindExecutable,
Version: "1.1.0",
URL: "https://example.com/ui-1.1.0.exe",
Checksum: connector.NewSHA256Digest(payload),
}
conn := &stubConnector{
versions: []connector.VersionInfo{info},
downloads: map[string][]byte{info.URL: payload},
}
manager := updater.NewManager(s, conn, updater.WithPlatform("windows", "amd64"))
require.NoError(t, manager.CheckAndPrepareLatest())
l := &loader{
storage: s,
connector: conn,
updater: manager,
runner: &stubRunner{
exitCode: 23,
},
}
err := l.runOnce(context.Background())
require.Error(t, err)
state, err := s.LoadState()
require.NoError(t, err)
require.Equal(t, "1.0.0", state.ClientCurrentVersion)
require.Nil(t, state.ClientNextVersion)
nextExists, _, err := s.FileExists(updater.ArtifactPath("1.1.0", "windows", "amd64", connector.ArtifactKindExecutable))
require.NoError(t, err)
require.False(t, nextExists)
}
func newTestStorage(t *testing.T) *testStorage {
t.Helper()
root := t.TempDir()
s, err := fs.NewFS(root)
require.NoError(t, err)
return &testStorage{Storage: s, root: root}
}
type testStorage struct {
storage.Storage
root string
}
func (s *testStorage) StorageRoot() string {
return s.root
}
-181
View File
@@ -1,181 +0,0 @@
package loader
import (
"fmt"
"testing"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
fynetest "fyne.io/fyne/v2/test"
"fyne.io/fyne/v2/theme"
"github.com/stretchr/testify/require"
)
func TestNewLoaderConfiguresWindowGeometry(t *testing.T) {
app := fynetest.NewApp()
spy := &spyApp{App: app}
l, err := NewLoader(newTestStorage(t), &stubConnector{}, spy)
require.NoError(t, err)
require.NotNil(t, spy.window)
require.Same(t, spy.window, l.debugWindow)
require.True(t, spy.window.setContentCalled)
require.True(t, spy.window.resizeCalled)
require.Equal(t, spy.window.content.MinSize(), spy.window.resizeSize)
require.True(t, spy.window.fixedSizeCalled)
require.True(t, spy.window.fixedSize)
require.True(t, spy.window.centerOnScreenCalled)
}
func TestNewLoaderBuildsScrollableBorderLayout(t *testing.T) {
app := fynetest.NewApp()
l, err := NewLoader(newTestStorage(t), &stubConnector{}, app)
require.NoError(t, err)
content, ok := l.debugWindow.Content().(*fyne.Container)
require.True(t, ok)
require.Equal(t, "*layout.borderLayout", fmt.Sprintf("%T", content.Layout))
require.Len(t, content.Objects, 2)
logScroll, ok := content.Objects[0].(*container.Scroll)
require.True(t, ok)
require.Same(t, l.textGrid, logScroll.Content)
require.Equal(t, container.ScrollBoth, logScroll.Direction)
require.Equal(t, loaderLogViewportMinSize(app), logScroll.MinSize())
require.Equal(t, fyne.ScrollNone, l.textGrid.Scroll)
actionBar, ok := content.Objects[1].(*fyne.Container)
require.True(t, ok)
require.Len(t, actionBar.Objects, 1)
actionRow, ok := actionBar.Objects[0].(*fyne.Container)
require.True(t, ok)
require.Len(t, actionRow.Objects, 1)
require.Same(t, l.btn, actionRow.Objects[0])
content.Resize(content.MinSize())
require.Equal(t, fyne.NewPos(0, 0), logScroll.Position())
require.Equal(t, content.Size().Width, logScroll.Size().Width)
require.Equal(
t,
content.Size().Height-actionBar.MinSize().Height-theme.Padding(),
logScroll.Size().Height,
)
require.Equal(
t,
fyne.NewPos(0, content.Size().Height-actionBar.MinSize().Height),
actionBar.Position(),
)
require.Equal(t, content.Size().Width, actionBar.Size().Width)
require.Equal(t, actionRow.MinSize().Width, actionRow.Size().Width)
require.Equal(t, l.btn.MinSize().Width, l.btn.Size().Width)
require.Equal(t, l.btn.MinSize().Height, l.btn.Size().Height)
require.Equal(t, (content.Size().Width-actionRow.Size().Width)/2, actionRow.Position().X)
}
func TestNewLoaderInterceptsWindowCloseByHidingWindow(t *testing.T) {
app := fynetest.NewApp()
spy := &spyApp{App: app}
l, err := NewLoader(newTestStorage(t), &stubConnector{}, spy)
require.NoError(t, err)
require.NotNil(t, spy.window)
require.Same(t, spy.window, l.debugWindow)
require.NotNil(t, spy.window.closeIntercept)
spy.window.closeIntercept()
require.Equal(t, 1, spy.window.hideCalls)
require.Zero(t, spy.window.closeCalls)
require.Zero(t, spy.quitCalls)
}
func TestLoaderWindowCloseQuitsApplicationAfterLaunchFailure(t *testing.T) {
app := fynetest.NewApp()
spy := &spyApp{App: app}
l, err := NewLoader(newTestStorage(t), &stubConnector{}, spy)
require.NoError(t, err)
l.setCloseQuits(true)
spy.window.closeIntercept()
require.Zero(t, spy.window.hideCalls)
require.Zero(t, spy.window.closeCalls)
require.Equal(t, 1, spy.quitCalls)
}
type spyApp struct {
fyne.App
window *spyWindow
quitCalls int
}
func (a *spyApp) NewWindow(title string) fyne.Window {
a.window = &spyWindow{Window: a.App.NewWindow(title)}
return a.window
}
func (a *spyApp) Quit() {
a.quitCalls++
a.App.Quit()
}
type spyWindow struct {
fyne.Window
content fyne.CanvasObject
closeIntercept func()
resizeSize fyne.Size
hideCalls int
closeCalls int
setContentCalled bool
resizeCalled bool
fixedSize bool
fixedSizeCalled bool
centerOnScreenCalled bool
}
func (w *spyWindow) CenterOnScreen() {
w.centerOnScreenCalled = true
w.Window.CenterOnScreen()
}
func (w *spyWindow) Close() {
w.closeCalls++
w.Window.Close()
}
func (w *spyWindow) Hide() {
w.hideCalls++
w.Window.Hide()
}
func (w *spyWindow) Resize(size fyne.Size) {
w.resizeCalled = true
w.resizeSize = size
w.Window.Resize(size)
}
func (w *spyWindow) SetContent(content fyne.CanvasObject) {
w.setContentCalled = true
w.content = content
w.Window.SetContent(content)
}
func (w *spyWindow) SetCloseIntercept(callback func()) {
w.closeIntercept = callback
w.Window.SetCloseIntercept(callback)
}
func (w *spyWindow) SetFixedSize(fixed bool) {
w.fixedSizeCalled = true
w.fixedSize = fixed
w.Window.SetFixedSize(fixed)
}
-34
View File
@@ -1,34 +0,0 @@
package loader
import (
"context"
"errors"
"os"
"os/exec"
)
// uiRunner executes the standalone UI artifact and returns its exit code.
type uiRunner interface {
Run(context.Context, string) (int, error)
}
type execRunner struct{}
func (execRunner) Run(ctx context.Context, path string) (int, error) {
cmd := exec.CommandContext(ctx, path)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
err := cmd.Run()
if err == nil {
return 0, nil
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return exitErr.ExitCode(), nil
}
return -1, err
}
-14
View File
@@ -1,14 +0,0 @@
package loader
import "crypto/sha256"
// SumSHA256 calculates SHA-256 for the provided byte slice and returns
// the raw 32-byte digest as a fixed-size array.
func SumSHA256(data []byte) [32]byte {
return sha256.Sum256(data)
}
// EqualSHA256 returns true when both SHA-256 digests are identical.
func EqualSHA256(a, b [32]byte) bool {
return a == b
}
-56
View File
@@ -1,56 +0,0 @@
package loader
import (
"crypto/sha256"
"testing"
"github.com/stretchr/testify/require"
)
// TestSumSHA256 verifies that SumSHA256 returns the same digest
// as the standard library implementation for a non-empty payload.
func TestSumSHA256(t *testing.T) {
t.Parallel()
data := []byte("hello world")
expected := sha256.Sum256(data)
actual := SumSHA256(data)
require.Equal(t, expected, actual)
}
// TestSumSHA256Empty verifies that SumSHA256 correctly handles
// an empty byte slice.
func TestSumSHA256Empty(t *testing.T) {
t.Parallel()
data := []byte{}
expected := sha256.Sum256(data)
actual := SumSHA256(data)
require.Equal(t, expected, actual)
}
// TestEqualSHA256Same verifies that two identical digests
// are considered equal.
func TestEqualSHA256Same(t *testing.T) {
t.Parallel()
data := []byte("hello")
digest := sha256.Sum256(data)
require.True(t, EqualSHA256(digest, digest))
}
// TestEqualSHA256Different verifies that different digests
// are considered not equal.
func TestEqualSHA256Different(t *testing.T) {
t.Parallel()
digestA := sha256.Sum256([]byte("hello"))
digestB := sha256.Sum256([]byte("world"))
require.False(t, EqualSHA256(digestA, digestB))
}
-48
View File
@@ -1,48 +0,0 @@
package client
import (
"image/color"
"galaxy/client/world"
)
func mockWorld() *world.World {
w := world.NewWorld(300, 300)
mockWorldInit(w)
return w
}
func mockWorldInit(w *world.World) {
lineStyle := w.AddStyleLine(world.StyleOverride{
StrokeColor: color.RGBA{R: 0, G: 255, B: 0, A: 255},
StrokeWidthPx: new(3.0),
StrokeDashes: new([]float64{10.}),
})
if _, err := w.AddCircle(150, 150, 50); err != nil {
panic(err)
}
if _, err := w.AddCircle(150, 299, 30); err != nil {
panic(err)
}
if _, err := w.AddCircle(299, 150, 30); err != nil {
panic(err)
}
if _, err := w.AddLine(100, 20, 200, 30, world.LineWithStyleID(lineStyle), world.LineWithPriority(500)); err != nil {
panic(err)
}
if _, err := w.AddLine(50, 50, 250, 100); err != nil {
panic(err)
}
if _, err := w.AddPoint(10, 10); err != nil {
panic(err)
}
if _, err := w.AddPoint(25, 255); err != nil {
panic(err)
}
}
-76
View File
@@ -1,76 +0,0 @@
package client
import (
"galaxy/client/world"
"galaxy/model/report"
)
const (
entityClassUnknown int = iota - 1
entityClassLocalPlanet
entityClassOthersPlanet
entityClassFreePlanet
entityClassUnidentifiedPlanet
)
type registry struct {
report *report.Report
localPlanetIndex map[world.PrimitiveID]int
unidentifiedPlanetIndex map[world.PrimitiveID]int
}
func newRegistry() *registry {
return &registry{
localPlanetIndex: make(map[world.PrimitiveID]int),
unidentifiedPlanetIndex: make(map[world.PrimitiveID]int),
}
}
func (r *registry) clear(report *report.Report) {
r.report = report
clear(r.localPlanetIndex)
clear(r.unidentifiedPlanetIndex)
}
func (r *registry) entityClass(id world.PrimitiveID) int {
if r.isLocalPlanet(id) {
return entityClassLocalPlanet
}
if r.isUnidentifiedPlanet(id) {
return entityClassUnidentifiedPlanet
}
return entityClassUnknown
}
func (r *registry) registerLocalPlanet(id world.PrimitiveID, index int) {
r.localPlanetIndex[id] = index
}
func (r *registry) isLocalPlanet(id world.PrimitiveID) bool {
_, ok := r.localPlanetIndex[id]
return ok
}
func (r *registry) localPlanet(id world.PrimitiveID) (*report.LocalPlanet, bool) {
i, ok := r.localPlanetIndex[id]
if !ok {
return nil, false
}
if i > len(r.report.LocalPlanet)-1 {
return nil, false
}
return &r.report.LocalPlanet[i], true
}
func (r *registry) registerUnidentifiedPlanet(id world.PrimitiveID, index int) {
r.unidentifiedPlanetIndex[id] = index
}
func (r *registry) isUnidentifiedPlanet(id world.PrimitiveID) bool {
_, ok := r.unidentifiedPlanetIndex[id]
return ok
}
func (c *client) createShipClass(n string, D float64, A uint, W float64, S float64, C float64) {
}
-119
View File
@@ -1,119 +0,0 @@
package client
import (
"fmt"
"galaxy/client/widget/calculator"
"galaxy/client/world"
mc "galaxy/model/client"
"galaxy/model/report"
"slices"
"fyne.io/fyne/v2"
)
func (e *client) initLatestReport() {
e.stateMu.Lock()
if e.state.ActiveGameID != nil {
if stateIdx := slices.IndexFunc(e.state.GameState, func(gs mc.GameState) bool { return gs.ID == *e.state.ActiveGameID }); stateIdx >= 0 {
e.initReportAsync(*e.state.ActiveGameID, e.state.GameState[stateIdx].ActiveTurn)
}
}
e.stateMu.Unlock()
}
func (e *client) initReportAsync(gid mc.GameID, t uint) {
e.s.ReportExistsAsync(gid, t, func(b bool, err error) { e.reportAtStorageExists(gid, t, b, err) })
}
func (e *client) reportAtStorageExists(gid mc.GameID, t uint, exists bool, err error) {
if err != nil {
e.handlerError(err)
return
}
if exists {
e.s.LoadReportAsync(gid, t, func(r report.Report, err error) { e.loadReportHandler(gid, r, err) })
return
}
e.conn.FetchReport(gid, t, func(r report.Report, err error) { e.fetchReportHandler(gid, r, err) })
}
func (e *client) fetchReportHandler(gid mc.GameID, r report.Report, err error) {
if err != nil {
e.handlerError(err)
return
}
e.s.SaveReportAsync(gid, r.Turn, r, func(err error) { e.loadReportHandler(gid, r, err) })
}
func (e *client) loadReportHandler(gid mc.GameID, r report.Report, err error) {
if err != nil {
e.handlerError(err)
return
}
e.stateMu.Lock()
needSaveState := false
stateIdx := slices.IndexFunc(e.state.GameState, func(gs mc.GameState) bool { return gs.ID == gid })
if stateIdx < 0 {
e.state.GameState = append(e.state.GameState, mc.GameState{ID: gid, LastTurn: r.Turn, ActiveTurn: r.Turn})
stateIdx = len(e.state.GameState) - 1
needSaveState = true
}
if e.state.ActiveGameID == nil {
e.state.ActiveGameID = new(gid)
needSaveState = true
}
if e.state.GameState[stateIdx].LastTurn < r.Turn {
e.state.GameState[stateIdx].LastTurn = r.Turn
e.state.GameState[stateIdx].ActiveTurn = r.Turn
needSaveState = true
}
if needSaveState {
if err := e.s.SaveState(*e.state); err != nil {
e.handlerError(err)
return
}
}
e.stateMu.Unlock()
e.setReport(r)
}
func (e *client) setReport(r report.Report) {
w := world.NewWorld(int(r.Width), int(r.Height))
e.reg.clear(&r)
for i := range r.LocalPlanet {
p := r.LocalPlanet[i]
id, err := w.AddCircle(p.X.F(), p.Y.F(), p.Size.F(), world.CircleWithClass(world.CircleClassLocalPlanet))
if err != nil {
e.handlerError(err)
return
}
e.reg.registerLocalPlanet(id, i)
}
for i := range r.UnidentifiedPlanet {
p := r.UnidentifiedPlanet[i]
id, err := w.AddPoint(p.X.F(), p.Y.F(), world.PointWithClass(world.PointClassTrackIncoming))
if err != nil {
e.handlerError(err)
return
}
e.reg.registerUnidentifiedPlanet(id, i)
}
e.loadWorld(w)
selfIdx := slices.IndexFunc(r.Player, func(p report.Player) bool { return p.Name == r.Race })
if selfIdx >= 0 {
fyne.Do(func() {
e.calculator.Init(
calculator.WithPlayerDrives(r.Player[selfIdx].Drive.F()),
calculator.WithPlayerWeapons(r.Player[selfIdx].Weapons.F()),
calculator.WithPlayerShields(r.Player[selfIdx].Shields.F()),
calculator.WithPlayerCargo(r.Player[selfIdx].Cargo.F()),
)
})
} else {
e.OnServiceError(fmt.Errorf("race %q not found at report players list", r.Race))
}
}
-30
View File
@@ -1,30 +0,0 @@
{
"title": {
"map": "Map",
"calculator": "Ship Calculator",
"info": "Info"
},
"planet": {
"title": "Planet #{{.Number}} '{{.Name}}' production fot this ship:",
"mat": "Materials",
"prod.mass": "Prod. Mass",
"prod.ships": "Ships"
},
"tech": {
"d": "Drive",
"w": "Weapons",
"s": "Shields",
"c": "Cargo"
},
"ship": {
"action.create": "Create",
"mass": "Mass",
"speed": "Speed",
"attack": "Attack",
"defense": "Defense",
"load": "Load"
},
"label": {
"max": "Max."
}
}
-334
View File
@@ -1,334 +0,0 @@
package client
import (
"image"
"math"
"galaxy/client/world"
"fyne.io/fyne/v2"
"github.com/fogleman/gg"
)
/*
Fyne integration notes:
- canvas.NewRaster calls draw(w,h) on the UI thread.
- We MUST keep draw() cheap and never loop re-rendering inside it.
- Coalescing must therefore schedule refreshes and render at most once per draw call.
- The world renderer expects:
- RenderParams.ViewportWidthPx/HeightPx: the size of the visible viewport.
- RenderParams.MarginXPx/MarginYPx: margins around viewport.
- RenderParams.CameraXWorldFp/YWorldFp: camera center in world-fixed units.
- RenderParams.CameraZoom: float zoom (converted inside world).
- world.Render draws on the full expanded canvas (viewport + 2*margins on each axis).
This adapter enforces:
- viewport sizes come from draw(w,h)
- margins are computed from viewport sizes (w/4 and h/4)
- gg context backing image is resized to the expanded canvas size
- IndexOnViewportChange is called when viewport sizes changed (you can also include zoom if desired)
*/
var (
blankImage image.Image = image.NewRGBA(image.Rect(0, 0, 0, 0))
)
// FyneExecutor posts functions onto the Fyne UI thread.
type FyneExecutor struct{}
func (FyneExecutor) Post(fn func()) {
fyne.Do(fn)
}
// GetParams returns a copy of current render params for external reads.
func (e *client) GetParams() world.RenderParams {
e.mu.RLock()
defer e.mu.RUnlock()
return *e.wp
}
// UpdateParams applies a modification function to render params and schedules a refresh.
// This is a safe way to mutate camera/zoom from event handlers.
func (e *client) UpdateParams(fn func(p *world.RenderParams)) {
e.mu.Lock()
fn(e.wp)
p := *e.wp
e.mu.Unlock()
e.co.Request(p)
}
// RequestRefresh schedules a refresh with the current params snapshot.
// Useful if you changed world objects and want to redraw.
func (e *client) RequestRefresh() {
e.mu.RLock()
p := *e.wp
e.mu.RUnlock()
e.co.Request(p)
}
// draw is the raster callback. It must be cheap and must not block on multiple re-renders.
// It delegates coalescing + rendering decision to RasterCoalescer.
func (e *client) draw(wPx, hPx int) image.Image {
return e.co.Draw(wPx, hPx)
}
// renderRasterImage renders the expanded canvas into the GGDrawer backing image,
// then copies only the viewport ROI into a reusable viewport buffer and returns it.
func (e *client) renderRasterImage(viewportW, viewportH int, p world.RenderParams) image.Image {
if e.world == nil {
return image.NewRGBA(image.Rect(0, 0, 0, 0))
}
// Keep the incoming zoom snapshot so we can safely sync corrected zoom back
// to base params only when no newer zoom was written concurrently.
inputZoom := p.CameraZoom
// Record current raster pixel size (used for event coordinate conversion).
e.metaMu.Lock()
e.lastRasterPxW = viewportW
e.lastRasterPxH = viewportH
e.metaMu.Unlock()
// Fill viewport/margins derived from draw callback.
p.ViewportWidthPx = viewportW
p.ViewportHeightPx = viewportH
p.MarginXPx = viewportW / 4
p.MarginYPx = viewportH / 4
// Correct zoom for viewport/world constraints, and clamp camera for no-wrap.
correctedZoom := e.world.CorrectCameraZoom(inputZoom, viewportW, viewportH)
p.CameraZoom = correctedZoom
// Sync corrected zoom to the canonical UI-facing params snapshot.
// Guard prevents stale render snapshots from overwriting a newer zoom value
// that may have been set by another UI event.
e.mu.Lock()
if e.wp.CameraZoom == inputZoom {
e.wp.CameraZoom = correctedZoom
}
e.mu.Unlock()
// Ensure indexing is up-to-date when viewport size or zoom changes.
zoomFp, err := p.CameraZoomFp()
if err == nil {
if viewportW != e.lastIndexedViewportW || viewportH != e.lastIndexedViewportH || zoomFp != e.lastIndexedZoomFp {
e.world.IndexOnViewportChange(viewportW, viewportH, p.CameraZoom)
e.lastIndexedViewportW = viewportW
e.lastIndexedViewportH = viewportH
e.lastIndexedZoomFp = zoomFp
}
}
e.world.ClampRenderParamsNoWrap(&p)
// Ensure backing expanded canvas (gg context) is sized properly.
canvasW := p.CanvasWidthPx()
canvasH := p.CanvasHeightPx()
e.ensureDrawerCanvas(canvasW, canvasH)
// Render into expanded canvas backing.
_ = e.world.Render(e.drawer, p) // TODO: handle error
// Save snapshot of params actually used for this render (for HitTest consistency).
e.lastRenderedMu.Lock()
e.lastRenderedParams = p
e.lastRenderedMu.Unlock()
// Copy viewport ROI into reusable viewport buffer and return it.
e.ensureViewportBuffer(viewportW, viewportH)
src, ok := e.drawer.DC.Image().(*image.RGBA)
if !ok || src == nil {
return image.NewRGBA(image.Rect(0, 0, viewportW, viewportH))
}
copyViewportRGBA(e.viewportImg, src, p.MarginXPx, p.MarginYPx, viewportW, viewportH)
return e.viewportImg
}
// ensureDrawerCanvas ensures drawer has a gg.Context sized to canvasW x canvasH.
func (e *client) ensureDrawerCanvas(canvasW, canvasH int) {
if e.drawer.DC != nil && e.lastCanvasW == canvasW && e.lastCanvasH == canvasH {
return
}
// world.NewGGContextRGBA should return *gg.Context backed by *image.RGBA (gg.NewContext does).
e.drawer.DC = NewGGContextRGBA(canvasW, canvasH)
e.lastCanvasW = canvasW
e.lastCanvasH = canvasH
}
func (e *client) ensureViewportBuffer(w, h int) {
if e.viewportImg != nil && e.viewportW == w && e.viewportH == h {
return
}
e.viewportImg = image.NewRGBA(image.Rect(0, 0, w, h))
e.viewportW = w
e.viewportH = h
}
func (e *client) getLastRenderedParams() world.RenderParams {
e.lastRenderedMu.RLock()
defer e.lastRenderedMu.RUnlock()
return e.lastRenderedParams
}
// eventPosToPixel converts event logical coordinates (Fyne units) into pixel coordinates,
// using the last known raster logical size and the last draw callback pixel size.
//
// pixelX = floor(eventX * rasterPixelWidth / rasterLogicalWidth)
func (e *client) eventPosToPixel(eventX, eventY float32) (xPx, yPx int, ok bool) {
e.metaMu.RLock()
logW := e.lastRasterLogicW
logH := e.lastRasterLogicH
pxW := e.lastRasterPxW
pxH := e.lastRasterPxH
e.metaMu.RUnlock()
if logW <= 0 || logH <= 0 || pxW <= 0 || pxH <= 0 {
return 0, 0, false
}
x := int(math.Floor(float64(eventX) * float64(pxW) / float64(logW)))
y := int(math.Floor(float64(eventY) * float64(pxH) / float64(logH)))
// Clamp to viewport bounds.
if x < 0 {
x = 0
} else if x > pxW {
x = pxW
}
if y < 0 {
y = 0
} else if y > pxH {
y = pxH
}
return x, y, true
}
func (e *client) CanvasScale() float32 {
e.metaMu.RLock()
defer e.metaMu.RUnlock()
if e.lastCanvasScale <= 0 {
return 1
}
return e.lastCanvasScale
}
func (e *client) ForceFullRedraw() {
if e.world == nil {
return
}
e.world.ForceFullRedrawNext()
}
func (e *client) onRasterWidgetLayout(fyne.Size) {
e.updateSizes()
}
// updateSizes updates only metadata we need for event->pixel conversion and schedules a redraw.
// It must NOT try to compute pixel viewport sizes (those are known in raster draw callback).
func (e *client) updateSizes() {
canvasObj := fyne.CurrentApp().Driver().CanvasForObject(e.raster)
if canvasObj == nil {
return
}
sz := e.raster.Size() // logical (Fyne units)
scale := canvasObj.Scale()
e.metaMu.Lock()
e.lastRasterLogicW = sz.Width
e.lastRasterLogicH = sz.Height
e.lastCanvasScale = scale
e.metaMu.Unlock()
e.RequestRefresh()
}
func (e *client) onDragged(ev *fyne.DragEvent) {
e.pan.Dragged(ev)
}
func (e *client) onDradEnd() {
e.pan.DragEnd()
}
func (e *client) onScrolled(s *fyne.ScrollEvent) {
if e.world == nil || s == nil {
return
}
// Use last rendered viewport sizes (pixel) for zoom logic.
e.metaMu.RLock()
vw := e.lastRasterPxW
vh := e.lastRasterPxH
e.metaMu.RUnlock()
if vw <= 0 || vh <= 0 {
return
}
cxPx, cyPx, ok := e.eventPosToPixel(s.Position.X, s.Position.Y)
if !ok {
return
}
e.mu.Lock()
oldZoom := e.wp.CameraZoom
// Exponential zoom factor; tune later.
const base = 1.005
delta := float64(s.Scrolled.DY)
newZoom := oldZoom * math.Pow(base, delta)
newZoom = e.world.CorrectCameraZoom(newZoom, vw, vh)
if newZoom == oldZoom {
e.mu.Unlock()
return
}
oldZoomFp, err := world.CameraZoomToWorldFixed(oldZoom)
if err != nil {
e.mu.Unlock()
return
}
newZoomFp, err := world.CameraZoomToWorldFixed(newZoom)
if err != nil {
e.mu.Unlock()
return
}
// Pivot zoom for no-wrap behavior.
newCamX, newCamY := world.PivotZoomCameraNoWrap(
e.wp.CameraXWorldFp, e.wp.CameraYWorldFp,
vw, vh,
cxPx, cyPx,
oldZoomFp, newZoomFp,
)
e.wp.CameraZoom = newZoom
e.wp.CameraXWorldFp = newCamX
e.wp.CameraYWorldFp = newCamY
e.mu.Unlock()
// Any zoom change should rebuild index and force full redraw.
e.world.ForceFullRedrawNext()
e.RequestRefresh()
}
// copyViewportRGBA copies a viewport rectangle from src RGBA into dst RGBA.
// dst must be sized exactly (0,0)-(vw,vh). This is allocation-free.
// It avoids SubImage aliasing issues: dst becomes independent from src backing memory.
func copyViewportRGBA(dst, src *image.RGBA, marginX, marginY, vw, vh int) {
for y := 0; y < vh; y++ {
srcOff := (marginY+y)*src.Stride + marginX*4
dstOff := y * dst.Stride
n := vw * 4
copy(dst.Pix[dstOff:dstOff+n], src.Pix[srcOff:srcOff+n])
}
}
func NewGGContextRGBA(w, h int) *gg.Context {
return gg.NewContext(w, h)
}
-110
View File
@@ -1,110 +0,0 @@
package client
import (
"math"
"fyne.io/fyne/v2"
"galaxy/client/world"
)
/*
Client pan integration for Fyne Draggable:
- DragEvent.Dragged provides per-event delta in Fyne logical units.
- Client knows canvasScale (pixels per Fyne unit) and converts to pixels.
- We update camera center in world-fixed (CameraXWorldFp/YWorldFp).
Sign convention (map follows pointer):
- Drag right (dxPx > 0): move world content right => move camera left => CameraXWorldFp -= dxWorldFp
- Drag down (dyPx > 0): move world content down => move camera up => CameraYWorldFp -= dyWorldFp
*/
// draggableClient is the minimal interface we need from your client implementation.
// If your Client already has these methods/fields, you can fold the code directly into it.
type draggableClient interface {
// CanvasScale returns pixels per Fyne logical unit.
CanvasScale() float32
// UpdateParams applies a mutation and schedules refresh through your coalescer.
UpdateParams(fn func(p *world.RenderParams))
// RequestRefresh schedules a refresh with current params (no mutation).
RequestRefresh()
// ForceFullRedraw forces a full redraw on next Render (used on DragEnd).
ForceFullRedraw()
}
// PanController holds per-drag transient state.
type PanController struct {
ed draggableClient
dragging bool
lastFx float32 // last absolute position in Fyne units
lastFy float32
// Remainders to keep subpixel fyne->px conversion stable across many events.
remPxX float32
remPxY float32
}
func NewPanController(ed draggableClient) *PanController {
return &PanController{ed: ed}
}
// Dragged processes one drag event, updates camera center by delta, and schedules redraw.
func (p *PanController) Dragged(ev *fyne.DragEvent) {
if ev == nil {
return
}
scale := p.ed.CanvasScale()
if scale <= 0 {
return
}
// DragEvent.Dragged is delta in Fyne logical units (device independent).
// Convert to pixels by multiplying by canvas scale.
dxPxF := ev.Dragged.DX * scale
dyPxF := ev.Dragged.DY * scale
// accumulate subpixel remainder in pixels
dxPxF += p.remPxX
dyPxF += p.remPxY
dxPx := int(math.Round(float64(dxPxF)))
dyPx := int(math.Round(float64(dyPxF)))
p.remPxX = dxPxF - float32(dxPx)
p.remPxY = dyPxF - float32(dyPx)
if dxPx == 0 && dyPx == 0 {
return
}
p.ed.UpdateParams(func(rp *world.RenderParams) {
zoomFp, err := rp.CameraZoomFp()
if err != nil || zoomFp <= 0 {
return
}
dxWorldFp := world.PixelSpanToWorldFixed(dxPx, zoomFp)
dyWorldFp := world.PixelSpanToWorldFixed(dyPx, zoomFp)
// Map follows pointer
rp.CameraXWorldFp -= dxWorldFp
rp.CameraYWorldFp -= dyWorldFp
})
}
// DragEnd ends the drag gesture. We force a full redraw next to eliminate any
// possible artifacts from incremental shifting and to "settle" the final state.
func (p *PanController) DragEnd() {
p.dragging = false
p.remPxX = 0
p.remPxY = 0
p.ed.ForceFullRedraw()
p.ed.RequestRefresh()
}
-201
View File
@@ -1,201 +0,0 @@
package client
import (
"image"
"testing"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/test"
"github.com/stretchr/testify/require"
"galaxy/client/world"
)
type fakeClient struct {
scale float32
p world.RenderParams
forced bool
updates int
refresh int
}
func (e *fakeClient) CanvasScale() float32 { return e.scale }
func (e *fakeClient) UpdateParams(fn func(p *world.RenderParams)) {
fn(&e.p)
e.updates++
}
func (e *fakeClient) RequestRefresh() { e.refresh++ }
func (e *fakeClient) ForceFullRedraw() { e.forced = true }
func TestPanController_DraggedUpdatesCameraByDeltaPx(t *testing.T) {
t.Parallel()
fe := &fakeClient{
scale: 1.0, // 1 fyne unit == 1 px for the test
p: world.RenderParams{
CameraZoom: 1.0,
CameraXWorldFp: 5 * world.SCALE,
CameraYWorldFp: 5 * world.SCALE,
},
}
pc := NewPanController(fe)
// Drag right by +3 px and down by +2 px.
pc.Dragged(&fyne.DragEvent{
Dragged: fyne.Delta{DX: 3, DY: 2},
})
require.Equal(t, 1, fe.updates)
// Map follows pointer => camera moves opposite to pointer delta.
require.Equal(t, 5*world.SCALE-3*world.SCALE, fe.p.CameraXWorldFp)
require.Equal(t, 5*world.SCALE-2*world.SCALE, fe.p.CameraYWorldFp)
}
func TestPanController_DraggedUsesCanvasScaleByMultiplying(t *testing.T) {
t.Parallel()
fe := &fakeClient{
scale: 2.0, // 2 px per fyne unit
p: world.RenderParams{
CameraZoom: 1.0,
CameraXWorldFp: 0,
CameraYWorldFp: 0,
},
}
pc := NewPanController(fe)
// Dragged.DX=1 fyne unit => 2 px after scaling.
pc.Dragged(&fyne.DragEvent{
Dragged: fyne.Delta{DX: 1, DY: 0},
})
require.Equal(t, -2*world.SCALE, fe.p.CameraXWorldFp)
}
func TestPanController_DragEndForcesFullRedrawAndRefresh(t *testing.T) {
t.Parallel()
fe := &fakeClient{
scale: 1.0,
p: world.RenderParams{
CameraZoom: 1.0,
CameraXWorldFp: 0,
CameraYWorldFp: 0,
},
}
pc := NewPanController(fe)
// Simulate a drag start.
pc.Dragged(&fyne.DragEvent{PointEvent: fyne.PointEvent{Position: fyne.Position{X: 1, Y: 1}}})
pc.DragEnd()
require.True(t, fe.forced)
require.Equal(t, 1, fe.refresh)
}
// Optional: demonstrate use of fyne/test package to ensure types are available.
// (Not strictly needed, but keeps fyne dependency "active" in tests.)
func TestFyneTestDriverIsUsable(t *testing.T) {
t.Parallel()
_ = test.NewApp()
}
type immediateExecutor struct{}
func (immediateExecutor) Post(fn func()) {
if fn != nil {
fn()
}
}
type noopRefresher struct{}
func (noopRefresher) Refresh() {}
func newZoomSyncTestClient(t *testing.T, worldW, worldH int, cameraZoom float64) *client {
t.Helper()
w := world.NewWorld(worldW, worldH)
e := &client{
world: w,
drawer: &world.GGDrawer{},
wp: &world.RenderParams{
CameraZoom: cameraZoom,
CameraXWorldFp: w.W / 2,
CameraYWorldFp: w.H / 2,
Options: &world.RenderOptions{DisableWrapScroll: false},
},
hits: make([]world.Hit, 5),
}
e.co = NewRasterCoalescer(
immediateExecutor{},
noopRefresher{},
func(wPx, hPx int, _ world.RenderParams) image.Image {
return image.NewRGBA(image.Rect(0, 0, wPx, hPx))
},
)
return e
}
func TestRenderRasterImage_SyncsCorrectedZoomToBaseParams(t *testing.T) {
t.Parallel()
e := newZoomSyncTestClient(t, 10, 10, 1.0)
p := *e.wp
correctedZoom := e.world.CorrectCameraZoom(p.CameraZoom, 100, 100)
require.NotEqual(t, p.CameraZoom, correctedZoom)
_ = e.renderRasterImage(100, 100, p)
require.Equal(t, correctedZoom, e.wp.CameraZoom)
}
func TestRenderRasterImage_DoesNotOverrideNewerBaseZoom(t *testing.T) {
t.Parallel()
e := newZoomSyncTestClient(t, 10, 10, 1.0)
p := *e.wp
// Simulate a newer UI update that happened after this render snapshot was taken.
e.wp.CameraZoom = 3.0
_ = e.renderRasterImage(100, 100, p)
require.Equal(t, 3.0, e.wp.CameraZoom)
}
func TestPanController_Dragged_AfterRenderZoomCorrection_UsesSyncedZoom(t *testing.T) {
t.Parallel()
e := newZoomSyncTestClient(t, 10, 10, 1.0)
// Initial render corrects zoom and syncs it into base params.
_ = e.renderRasterImage(100, 100, *e.wp)
syncedZoom := e.wp.CameraZoom
require.NotEqual(t, 1.0, syncedZoom)
zoomFp, err := world.CameraZoomToWorldFixed(syncedZoom)
require.NoError(t, err)
startX := e.wp.CameraXWorldFp
pan := NewPanController(e)
pan.Dragged(&fyne.DragEvent{
Dragged: fyne.Delta{DX: 1, DY: 0},
})
expectedShift := world.PixelSpanToWorldFixed(1, zoomFp)
require.Equal(t, startX-expectedShift, e.wp.CameraXWorldFp)
}
-367
View File
@@ -1,367 +0,0 @@
// Package updater manages standalone UI client artifacts, version selection,
// and persisted update state shared by the loader and the UI process.
package updater
import (
"errors"
"fmt"
"path/filepath"
"runtime"
"slices"
"strings"
"galaxy/connector"
gerr "galaxy/error"
mc "galaxy/model/client"
"galaxy/storage"
"galaxy/util"
)
const (
// ArtifactDir keeps versioned UI executables isolated from user data files.
ArtifactDir = "ui"
// ArtifactPrefix is the file name prefix used for all managed UI artifacts.
ArtifactPrefix = "client-ui"
)
// LaunchTarget describes the executable artifact selected for the next UI run.
type LaunchTarget struct {
Version string
Path string
Pending bool
}
// Manager coordinates client update state, artifact downloads, and cleanup.
type Manager struct {
storage storage.Storage
connector connector.Connector
goos string
goarch string
kind string
}
// Option customizes Manager construction.
type Option func(*Manager)
// WithPlatform overrides the runtime platform used for version matching.
func WithPlatform(goos, goarch string) Option {
return func(m *Manager) {
if goos != "" {
m.goos = goos
}
if goarch != "" {
m.goarch = goarch
}
}
}
// WithArtifactKind overrides the artifact kind accepted by the manager.
func WithArtifactKind(kind string) Option {
return func(m *Manager) {
if kind != "" {
m.kind = kind
}
}
}
// NewManager constructs an update manager for standalone executable artifacts.
func NewManager(s storage.Storage, c connector.Connector, opts ...Option) *Manager {
m := &Manager{
storage: s,
connector: c,
goos: runtime.GOOS,
goarch: runtime.GOARCH,
kind: connector.ArtifactKindExecutable,
}
for _, opt := range opts {
opt(m)
}
return m
}
// ArtifactPath returns the deterministic local storage path for the given versioned artifact.
func ArtifactPath(version, goos, goarch, kind string) string {
name := fmt.Sprintf("%s-%s-%s-%s-%s", ArtifactPrefix, version, goos, goarch, kind)
if goos == "windows" {
name += ".exe"
}
return filepath.Join(ArtifactDir, name)
}
// LatestCompatibleVersion returns the latest supported version for the selected platform and kind.
func LatestCompatibleVersion(versions []connector.VersionInfo, goos, goarch, kind string) (connector.VersionInfo, bool, error) {
platformMatches := make([]connector.VersionInfo, 0, len(versions))
for _, version := range versions {
if version.OS == goos && version.Arch == goarch {
platformMatches = append(platformMatches, version)
}
}
if len(platformMatches) == 0 {
return connector.VersionInfo{}, false, nil
}
candidates := make([]connector.VersionInfo, 0, len(platformMatches))
unsupportedKinds := make(map[string]struct{})
seenVersion := make(map[string]struct{})
for _, version := range platformMatches {
if version.Kind != kind {
unsupportedKinds[version.Kind] = struct{}{}
continue
}
if _, ok := seenVersion[version.Version]; ok {
return connector.VersionInfo{}, false, gerr.WrapService(
fmt.Errorf("ambiguous client artifact version %q for %s/%s", version.Version, goos, goarch),
)
}
seenVersion[version.Version] = struct{}{}
candidates = append(candidates, version)
}
if len(candidates) == 0 {
kinds := make([]string, 0, len(unsupportedKinds))
for kind := range unsupportedKinds {
kinds = append(kinds, kind)
}
slices.Sort(kinds)
return connector.VersionInfo{}, false, gerr.WrapService(
fmt.Errorf("unsupported client artifact kind(s) for %s/%s: %s", goos, goarch, strings.Join(kinds, ", ")),
)
}
type semVersion struct {
info connector.VersionInfo
sem util.SemVer
}
semvers := make([]semVersion, len(candidates))
for i, candidate := range candidates {
semver, err := util.ParseSemver(candidate.Version)
if err != nil {
return connector.VersionInfo{}, false, gerr.WrapService(
fmt.Errorf("parse client version %q: %w", candidate.Version, err),
)
}
semvers[i] = semVersion{info: candidate, sem: semver}
}
slices.SortFunc(semvers, func(a, b semVersion) int {
return util.CompareSemver(a.sem, b.sem)
})
return semvers[0].info, true, nil
}
// EnsureLaunchTarget returns the versioned executable that should be launched next.
// On the very first run, when no current or pending version exists yet, it downloads
// the latest compatible artifact and marks it as pending.
func (m *Manager) EnsureLaunchTarget() (LaunchTarget, error) {
state, err := m.ensureState()
if err != nil {
return LaunchTarget{}, err
}
if state.ClientNextVersion != nil {
return m.launchTargetForVersion(*state.ClientNextVersion, true)
}
if state.ClientCurrentVersion != "" {
return m.launchTargetForVersion(state.ClientCurrentVersion, false)
}
if err := m.CheckAndPrepareLatest(); err != nil {
return LaunchTarget{}, err
}
state, err = m.ensureState()
if err != nil {
return LaunchTarget{}, err
}
if state.ClientNextVersion == nil {
return LaunchTarget{}, gerr.WrapService(errors.New("latest client version was not prepared for launch"))
}
return m.launchTargetForVersion(*state.ClientNextVersion, true)
}
// CheckAndPrepareLatest checks the backend manifest and downloads a newer compatible
// artifact when one exists.
func (m *Manager) CheckAndPrepareLatest() error {
if m.connector == nil {
return gerr.WrapService(errors.New("client updater connector is not configured"))
}
versions, err := m.connector.CheckVersion()
if err != nil {
return err
}
latest, ok, err := LatestCompatibleVersion(versions, m.goos, m.goarch, m.kind)
if err != nil {
return err
}
if !ok {
return gerr.WrapService(
fmt.Errorf("server did not provide a compatible %s client for %s/%s", m.kind, m.goos, m.goarch),
)
}
state, err := m.ensureState()
if err != nil {
return err
}
latestSemver, err := util.ParseSemver(latest.Version)
if err != nil {
return gerr.WrapService(fmt.Errorf("parse latest client version %q: %w", latest.Version, err))
}
if state.ClientCurrentVersion != "" {
currentSemver, err := util.ParseSemver(state.ClientCurrentVersion)
if err != nil {
return gerr.WrapService(fmt.Errorf("parse current client version %q: %w", state.ClientCurrentVersion, err))
}
if util.CompareSemver(currentSemver, latestSemver) >= 0 {
return nil
}
}
if state.ClientNextVersion != nil {
nextSemver, err := util.ParseSemver(*state.ClientNextVersion)
if err != nil {
return gerr.WrapService(fmt.Errorf("parse pending client version %q: %w", *state.ClientNextVersion, err))
}
if util.CompareSemver(nextSemver, latestSemver) >= 0 {
return nil
}
}
if err := m.downloadArtifact(latest); err != nil {
return err
}
state.ClientNextVersion = &latest.Version
return m.saveState(state)
}
// MarkLaunchResult records the outcome of a launched artifact and promotes
// pending versions to current only after a successful run.
func (m *Manager) MarkLaunchResult(version string, exitCode int, runErr error) error {
state, err := m.ensureState()
if err != nil {
return err
}
if state.ClientNextVersion != nil && *state.ClientNextVersion == version {
if runErr == nil && exitCode == 0 {
state.ClientCurrentVersion = version
}
state.ClientNextVersion = nil
if err := m.saveState(state); err != nil {
return err
}
return m.cleanupArtifacts(state)
}
if runErr == nil && exitCode == 0 {
return m.cleanupArtifacts(state)
}
return nil
}
func (m *Manager) launchTargetForVersion(version string, pending bool) (LaunchTarget, error) {
path := ArtifactPath(version, m.goos, m.goarch, m.kind)
exists, absPath, err := m.storage.FileExists(path)
if err != nil {
return LaunchTarget{}, err
}
if !exists {
return LaunchTarget{}, gerr.WrapStorage(
fmt.Errorf("client artifact for version %q not found at %q", version, path),
)
}
return LaunchTarget{
Version: version,
Path: absPath,
Pending: pending,
}, nil
}
func (m *Manager) ensureState() (mc.State, error) {
if m.storage == nil {
return mc.State{}, gerr.WrapStorage(errors.New("client updater storage is not configured"))
}
exists, err := m.storage.StateExists()
if err != nil {
return mc.State{}, err
}
if !exists {
state := mc.State{}
if err := m.storage.SaveState(state); err != nil {
return mc.State{}, err
}
return state, nil
}
return m.storage.LoadState()
}
func (m *Manager) saveState(state mc.State) error {
return m.storage.SaveState(state)
}
func (m *Manager) downloadArtifact(version connector.VersionInfo) error {
data, err := m.connector.DownloadVersion(version.URL)
if err != nil {
return err
}
digest := connector.NewSHA256Digest(data)
if !digest.Equal(version.Checksum) {
return gerr.WrapService(fmt.Errorf("downloaded client artifact checksum mismatch for version %s", version.Version))
}
path := ArtifactPath(version.Version, version.OS, version.Arch, version.Kind)
exists, _, err := m.storage.FileExists(path)
if err != nil {
return err
}
if exists {
storedData, err := m.storage.ReadFile(path)
if err != nil {
return err
}
if connector.NewSHA256Digest(storedData).Equal(version.Checksum) {
return nil
}
if err := m.storage.DeleteFile(path); err != nil {
return err
}
}
return m.storage.WriteFile(path, data)
}
func (m *Manager) cleanupArtifacts(state mc.State) error {
files, err := m.storage.ListFiles()
if err != nil {
return err
}
retain := make(map[string]struct{}, 2)
if state.ClientCurrentVersion != "" {
retain[ArtifactPath(state.ClientCurrentVersion, m.goos, m.goarch, m.kind)] = struct{}{}
}
if state.ClientNextVersion != nil {
retain[ArtifactPath(*state.ClientNextVersion, m.goos, m.goarch, m.kind)] = struct{}{}
}
prefix := filepath.ToSlash(ArtifactDir) + "/"
for _, file := range files {
slashed := filepath.ToSlash(file)
if !strings.HasPrefix(slashed, prefix) {
continue
}
if !strings.HasPrefix(filepath.Base(file), ArtifactPrefix+"-") {
continue
}
if _, ok := retain[file]; ok {
continue
}
if err := m.storage.DeleteFile(file); err != nil {
return err
}
}
return nil
}
-60
View File
@@ -1,60 +0,0 @@
package updater
import (
"testing"
"galaxy/connector"
gerr "galaxy/error"
"github.com/stretchr/testify/require"
)
func TestArtifactPathWindowsAddsExe(t *testing.T) {
t.Parallel()
got := ArtifactPath("1.2.3", "windows", "amd64", connector.ArtifactKindExecutable)
require.Equal(t, `ui\client-ui-1.2.3-windows-amd64-executable.exe`, got)
}
func TestLatestCompatibleVersionSelectsPlatformExecutable(t *testing.T) {
t.Parallel()
versions := []connector.VersionInfo{
{OS: "linux", Arch: "amd64", Kind: connector.ArtifactKindExecutable, Version: "1.0.0"},
{OS: "windows", Arch: "amd64", Kind: connector.ArtifactKindExecutable, Version: "1.2.0"},
{OS: "windows", Arch: "amd64", Kind: connector.ArtifactKindExecutable, Version: "1.3.0"},
{OS: "windows", Arch: "arm64", Kind: connector.ArtifactKindExecutable, Version: "9.9.9"},
}
got, ok, err := LatestCompatibleVersion(versions, "windows", "amd64", connector.ArtifactKindExecutable)
require.NoError(t, err)
require.True(t, ok)
require.Equal(t, "1.3.0", got.Version)
}
func TestLatestCompatibleVersionRejectsUnsupportedKinds(t *testing.T) {
t.Parallel()
versions := []connector.VersionInfo{
{OS: "windows", Arch: "amd64", Kind: "shared-library", Version: "1.0.0"},
}
_, ok, err := LatestCompatibleVersion(versions, "windows", "amd64", connector.ArtifactKindExecutable)
require.False(t, ok)
require.Error(t, err)
require.True(t, gerr.IsService(err))
}
func TestLatestCompatibleVersionRejectsAmbiguousVersions(t *testing.T) {
t.Parallel()
versions := []connector.VersionInfo{
{OS: "windows", Arch: "amd64", Kind: connector.ArtifactKindExecutable, Version: "1.0.0"},
{OS: "windows", Arch: "amd64", Kind: connector.ArtifactKindExecutable, Version: "1.0.0"},
}
_, ok, err := LatestCompatibleVersion(versions, "windows", "amd64", connector.ArtifactKindExecutable)
require.False(t, ok)
require.Error(t, err)
require.True(t, gerr.IsService(err))
}
-42
View File
@@ -1,42 +0,0 @@
package client
import (
"image/color"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/theme"
)
type rasterWidgetRender struct {
canvas *interactiveRaster
bg *canvas.Raster
onLayout func(fyne.Size)
}
func (r *rasterWidgetRender) Layout(size fyne.Size) {
r.bg.Resize(size)
r.canvas.raster.Resize(size)
if r.onLayout != nil {
r.onLayout(size)
}
}
func (r *rasterWidgetRender) MinSize() fyne.Size {
return r.MinSize()
}
func (r *rasterWidgetRender) Refresh() {
canvas.Refresh(r.canvas)
}
func (r *rasterWidgetRender) BackgroundColor() color.Color {
return theme.Color(theme.ColorNameBackground)
}
func (r *rasterWidgetRender) Objects() []fyne.CanvasObject {
return []fyne.CanvasObject{r.bg, r.canvas.raster}
}
func (r *rasterWidgetRender) Destroy() {
}
-629
View File
@@ -1,629 +0,0 @@
package calculator
import (
"errors"
"galaxy/calc"
"galaxy/client/widget/numeric"
"galaxy/util"
"slices"
"strconv"
"sync"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/lang"
"fyne.io/fyne/v2/widget"
)
type CalculatorOpt func(*Calculator)
type ShipClass struct {
Name string
Drive float64
Armament uint
Weapons float64
Shields float64
Cargo float64
}
type ShipClassFn func(string, float64, uint, float64, float64, float64)
type Calculator struct {
CanvasObject fyne.CanvasObject
playerDrivesTech float64
playerWeaponsTech float64
playerShieldsTech float64
playerCargoTech float64
shipDriveEntry *numeric.FloatEntry
shipWeaponsEntry *numeric.FloatEntry
shipArmamentEntry *numeric.IntEntry
shipShieldsEntry *numeric.FloatEntry
shipCargoEntry *numeric.FloatEntry
playerDrivesTechEntry *numeric.FloatEntry
playerWeaponsTechEntry *numeric.FloatEntry
playerShieldsTechEntry *numeric.FloatEntry
playerCargoTechEntry *numeric.FloatEntry
drivesTechOverride *widget.Check
weaponsTechOverride *widget.Check
shieldsTechOverride *widget.Check
cargoTechOverride *widget.Check
massEntry *numeric.FloatEntry
speedEntry *numeric.FloatEntry
attackEntry *numeric.FloatEntry
defenseEntry *numeric.FloatEntry
cargoLoadEntry *numeric.FloatEntry
planetMatEntry *numeric.FloatEntry
massOverride *widget.Check
speedOverride *widget.Check
attackOverride *widget.Check
defenseOverride *widget.Check
cargoLoadMaximize *widget.Check
planetMatOverride *widget.Check
planetLabel *widget.Label
planetMassProdLabel *widget.Label
planetShipsProdLabel *widget.Label
planetContainer fyne.CanvasObject
planetProdContainer fyne.CanvasObject
shipSelector *widget.SelectEntry
shipCreateButton *widget.Button
onCreateHandler ShipClassFn
loader ShipClassFn
knownClasses []ShipClass
validateMu sync.RWMutex
l, mat, res float64
Valid bool
}
func WithPlayerDrives(v float64) CalculatorOpt {
return func(c *Calculator) { c.playerDrivesTech = v }
}
func WithPlayerWeapons(v float64) CalculatorOpt {
return func(c *Calculator) { c.playerWeaponsTech = v }
}
func WithPlayerShields(v float64) CalculatorOpt {
return func(c *Calculator) { c.playerShieldsTech = v }
}
func WithPlayerCargo(v float64) CalculatorOpt {
return func(c *Calculator) { c.playerCargoTech = v }
}
func WithCreateHandler(f ShipClassFn) CalculatorOpt {
return func(c *Calculator) { c.onCreateHandler = f }
}
func NewCaclulator(opts ...CalculatorOpt) *Calculator {
c := &Calculator{}
c.shipCreateButton = widget.NewButton(lang.L("ship.action.create"), c.onCreateShipClassButton)
c.shipCreateButton.Disable()
c.loader = c.LoadShipClass
c.planetMatEntry = numeric.NewFloatEntry(10, c.onPlanetMatChange)
c.planetMatOverride = widget.NewCheck("", c.overridePlanetMat)
c.planetMatOverride.Disable()
c.planetLabel = widget.NewLabel("")
c.planetMassProdLabel = bareLabel("")
c.planetShipsProdLabel = bareLabel("")
c.planetProdContainer = container.NewHBox(
label(lang.L("planet.prod.mass")+":"),
fixedLabel(c.planetMassProdLabel, 80),
label(lang.L("planet.prod.ships")+":"),
fixedLabel(c.planetShipsProdLabel, 80),
)
c.planetProdContainer.Hide()
c.planetContainer = container.NewVBox(
widget.NewSeparator(),
container.NewHBox(c.planetLabel),
rowForItem(lang.L("planet.mat")+":", floatEntry(c.planetMatEntry, 100), c.planetMatOverride),
c.planetProdContainer,
)
c.planetContainer.Hide()
c.shipSelector = widget.NewSelectEntry(nil)
c.shipSelector.OnChanged = c.onShipSelectorChange
c.shipDriveEntry = numeric.NewFloatEntry(7, c.onShipDriveChange)
c.shipWeaponsEntry = numeric.NewFloatEntry(7, c.onShipWeaponsChange)
c.shipArmamentEntry = numeric.NewIntEntry(7, c.onShipArmamentChange)
c.shipShieldsEntry = numeric.NewFloatEntry(7, c.onShipShieldsChange)
c.shipCargoEntry = numeric.NewFloatEntry(7, c.onShipCargoChange)
c.playerDrivesTechEntry = numeric.NewFloatEntry(7, c.onDrivesTechChange)
c.playerWeaponsTechEntry = numeric.NewFloatEntry(7, c.onWeaponsTechChange)
c.playerShieldsTechEntry = numeric.NewFloatEntry(7, c.onShieldsTechChange)
c.playerCargoTechEntry = numeric.NewFloatEntry(7, c.onCargoTechChange)
c.massEntry = numeric.NewFloatEntry(7, c.onMassChange)
c.speedEntry = numeric.NewFloatEntry(7, c.onSpeedChange)
c.attackEntry = numeric.NewFloatEntry(7, c.onAttackChange)
c.defenseEntry = numeric.NewFloatEntry(7, c.onDefenseChange)
c.cargoLoadEntry = numeric.NewFloatEntry(7, c.onCargoLoadChange)
c.drivesTechOverride = widget.NewCheck("", c.overrideDrivesTech)
c.drivesTechOverride.Disable()
c.weaponsTechOverride = widget.NewCheck("", c.overrideWeaponsTech)
c.weaponsTechOverride.Disable()
c.shieldsTechOverride = widget.NewCheck("", c.overrideShieldsTech)
c.shieldsTechOverride.Disable()
c.cargoTechOverride = widget.NewCheck("", c.overrideCargoTech)
c.cargoTechOverride.Disable()
c.massOverride = widget.NewCheck("", c.overrideMass)
c.massOverride.Disable()
c.speedOverride = widget.NewCheck("", c.overrideSpeed)
c.speedOverride.Disable()
c.attackOverride = widget.NewCheck("", c.overrideAttack)
c.attackOverride.Disable()
c.defenseOverride = widget.NewCheck("", c.overrideDefense)
c.defenseOverride.Disable()
c.cargoLoadMaximize = widget.NewCheck(lang.L("label.max"), c.maximizeCargoLoad)
c.cargoLoadMaximize.SetChecked(true)
createShip := container.NewBorder(
nil, // top
nil, // bottom
nil, // left
c.shipCreateButton, // right
c.shipSelector, // center
)
c.CanvasObject = container.NewVBox(
container.NewPadded(createShip),
widget.NewSeparator(),
rowForTech(lang.L("tech.d")+":",
c.shipDriveEntry, floatEntry(c.playerDrivesTechEntry, 80), c.drivesTechOverride),
rowForWeapons(lang.L("tech.w")+":",
c.shipArmamentEntry, c.shipWeaponsEntry, floatEntry(c.playerWeaponsTechEntry, 80), c.weaponsTechOverride),
rowForTech(lang.L("tech.s")+":",
c.shipShieldsEntry, floatEntry(c.playerShieldsTechEntry, 80), c.shieldsTechOverride),
rowForTech(lang.L("tech.c")+":",
c.shipCargoEntry, floatEntry(c.playerCargoTechEntry, 80), c.cargoTechOverride),
widget.NewSeparator(),
rowForItem(lang.L("ship.load")+":",
floatEntry(c.cargoLoadEntry, 80), c.cargoLoadMaximize),
rowForItem(lang.L("ship.mass")+":",
floatEntry(c.massEntry, 80), c.massOverride),
rowForItem(lang.L("ship.speed")+":",
floatEntry(c.speedEntry, 80), c.speedOverride),
rowForItem(lang.L("ship.attack")+":",
floatEntry(c.attackEntry, 80), c.attackOverride),
rowForItem(lang.L("ship.defense")+":",
floatEntry(c.defenseEntry, 80), c.defenseOverride),
c.planetContainer,
)
c.Init(opts...)
return c
}
func (c *Calculator) Init(opts ...CalculatorOpt) {
for i := range opts {
opts[i](c)
}
c.playerDrivesTechEntry.SetOrigin(c.playerDrivesTech)
c.playerWeaponsTechEntry.SetOrigin(c.playerWeaponsTech)
c.playerShieldsTechEntry.SetOrigin(c.playerShieldsTech)
c.playerCargoTechEntry.SetOrigin(c.playerCargoTech)
c.CanvasObject.Show()
}
func (c *Calculator) Refresh() {
c.validate()
c.CanvasObject.Refresh()
}
func (c *Calculator) RegisterClasses(shipClass ...ShipClass) {
c.knownClasses = shipClass
names := make([]string, len(c.knownClasses))
for i := range c.knownClasses {
names[i] = c.knownClasses[i].Name
}
slices.Sort(names)
c.shipSelector = widget.NewSelectEntry(names)
c.shipSelector.OnChanged = c.onShipSelectorChange
}
func (c *Calculator) onCreateShipClassButton() {
if c.onCreateHandler == nil || !c.Valid {
return
}
}
func (c *Calculator) validate() {
fyne.Do(func() {
c.validateMu.Lock()
err := c.validateEntries()
c.Valid = err == nil
if err != nil {
} else {
}
c.shipClassNameValidate()
c.validateMu.Unlock()
})
}
func (c *Calculator) validateEntries() (err error) {
defer func() {
if err != nil {
c.cargoLoadEntry.Clear()
if !c.massOverride.Checked {
c.massEntry.Clear()
}
if !c.speedOverride.Checked {
c.speedEntry.Clear()
}
if !c.attackOverride.Checked {
c.attackEntry.Clear()
}
if !c.defenseOverride.Checked {
c.defenseEntry.Clear()
}
// c.planetProdContainer.Hide()
}
}()
drive, ok := c.shipDriveEntry.Value()
if !ok {
err = errors.New("Parameter Drive is not valid")
return
}
driveTech, ok := c.playerDrivesTechEntry.Value()
if !ok {
err = errors.New("Drive tech level is not valid")
return
}
armament, ok := c.shipArmamentEntry.Value()
if !ok {
err = errors.New("Parameter Armament is not valid")
return
}
weapons, ok := c.shipWeaponsEntry.Value()
if !ok {
err = errors.New("Parameter Weapons is not valid")
return
}
weaponsTech, ok := c.playerWeaponsTechEntry.Value()
if !ok {
err = errors.New("Weapons tech level is not valid")
return
}
shields, ok := c.shipShieldsEntry.Value()
if !ok {
err = errors.New("Parameter Shields is not valid")
return
}
shieldsTech, ok := c.playerShieldsTechEntry.Value()
if !ok {
err = errors.New("Shields tech level is not valid")
return
}
cargo, ok := c.shipCargoEntry.Value()
if !ok {
err = errors.New("Parameter Cargo is not valid")
return
}
cargoTech, ok := c.playerCargoTechEntry.Value()
if !ok {
err = errors.New("Cargo tech level is not valid")
return
}
err = calc.ValidateShipTypeValues(drive, armament, weapons, shields, cargo)
if err != nil {
return
}
var cargoLoad float64
if c.cargoLoadMaximize.Checked {
cargoLoad = calc.CargoCapacity(cargo, cargoTech)
c.cargoLoadEntry.SetOrigin(cargoLoad)
} else if cargoLoad, ok = c.cargoLoadEntry.Value(); !ok {
err = errors.New("Cargo load value is not valid")
return
}
emptyMass, ok := calc.EmptyMass(drive, weapons, uint(armament), shields, cargo)
if !ok {
err = errors.New("Unable to calculate empty mass (check armament and weapons)")
return
}
fullMass := calc.FullMass(emptyMass, cargoLoad)
speed := calc.Speed(calc.DriveEffective(drive, driveTech), fullMass)
effectiveAttack := calc.EffectiveAttack(weapons, weaponsTech)
effectiveDefense := calc.EffectiveDefence(shields, shieldsTech, fullMass)
c.massEntry.SetOrigin(emptyMass)
c.speedEntry.SetOrigin(speed)
c.attackEntry.SetOrigin(effectiveAttack)
c.defenseEntry.SetOrigin(effectiveDefense)
planetMat, ok := c.planetMatEntry.Value()
if !ok {
// c.planetProdContainer.Hide()
} else {
massProd := calc.PlanetProduceShipMass(c.l, planetMat, c.res)
c.planetMassProdLabel.SetText(strconv.FormatFloat(util.Fixed3(massProd), 'f', -1, 64))
ships := 0.
if emptyMass > 0 {
ships = massProd / emptyMass
}
c.planetShipsProdLabel.SetText(strconv.FormatFloat(util.Fixed3(ships), 'f', -1, 64))
c.planetProdContainer.Show()
}
return
}
func (c *Calculator) onOriginInputChange(cb *widget.Check, e *numeric.FloatEntry) {
if e == nil {
return
}
if cb != nil {
cb.Checked = e.Overriden()
if !cb.Checked {
cb.Disable()
} else {
cb.Enable()
}
}
c.onFloatEntryChange(e)
}
func (c *Calculator) onFloatEntryChange(e *numeric.FloatEntry) {
if e == nil {
return
}
e.Validate()
c.validate()
}
func (c *Calculator) onIntEntryChange(e *numeric.IntEntry) {
if e == nil {
return
}
e.Validate()
c.validate()
}
func (c *Calculator) overrideChecked(cb *widget.Check, e *numeric.FloatEntry) {
if cb == nil || e == nil {
return
}
if !cb.Checked {
e.Reset()
cb.Disable()
}
}
func (c *Calculator) onShipDriveChange(string) {
c.onFloatEntryChange(c.shipDriveEntry)
}
func (c *Calculator) onShipArmamentChange(string) {
defer c.onIntEntryChange(c.shipArmamentEntry)
if weapons, ok := c.shipWeaponsEntry.Value(); !ok || !c.shipWeaponsEntry.Valid {
return
} else if armament, ok := c.shipArmamentEntry.Value(); !ok || !c.shipArmamentEntry.Valid {
return
} else if armament > 0 && weapons == 0 {
c.shipWeaponsEntry.SetOrigin(1.0)
} else if armament == 0 && weapons > 0 {
c.shipWeaponsEntry.SetOrigin(0.0)
}
}
func (c *Calculator) onShipWeaponsChange(string) {
defer c.onFloatEntryChange(c.shipWeaponsEntry)
if weapons, ok := c.shipWeaponsEntry.Value(); !ok || !c.shipWeaponsEntry.Valid {
return
} else if armament, ok := c.shipArmamentEntry.Value(); !ok || !c.shipArmamentEntry.Valid {
return
} else if weapons > 0 && armament == 0 {
c.shipArmamentEntry.SetOrigin(1)
} else if weapons == 0 && armament > 0 {
c.shipArmamentEntry.SetOrigin(0)
}
}
func (c *Calculator) onShipShieldsChange(string) {
c.onFloatEntryChange(c.shipShieldsEntry)
}
func (c *Calculator) onShipCargoChange(string) {
c.onFloatEntryChange(c.shipCargoEntry)
}
func (c *Calculator) onDrivesTechChange(string) {
c.onOriginInputChange(c.drivesTechOverride, c.playerDrivesTechEntry)
}
func (c *Calculator) overrideDrivesTech(bool) {
c.overrideChecked(c.drivesTechOverride, c.playerDrivesTechEntry)
}
func (c *Calculator) onWeaponsTechChange(string) {
c.onOriginInputChange(c.weaponsTechOverride, c.playerWeaponsTechEntry)
}
func (c *Calculator) overrideWeaponsTech(bool) {
c.overrideChecked(c.weaponsTechOverride, c.playerWeaponsTechEntry)
}
func (c *Calculator) onShieldsTechChange(string) {
c.onOriginInputChange(c.shieldsTechOverride, c.playerShieldsTechEntry)
}
func (c *Calculator) overrideShieldsTech(bool) {
c.overrideChecked(c.shieldsTechOverride, c.playerShieldsTechEntry)
}
func (c *Calculator) onCargoTechChange(string) {
c.onOriginInputChange(c.cargoTechOverride, c.playerCargoTechEntry)
}
func (c *Calculator) overrideCargoTech(bool) {
c.overrideChecked(c.cargoTechOverride, c.playerCargoTechEntry)
}
func (c *Calculator) onCargoLoadChange(string) {
c.onFloatEntryChange(c.cargoLoadEntry)
}
func (c *Calculator) onMassChange(string) {
c.onOriginInputChange(c.massOverride, c.massEntry)
}
func (c *Calculator) overrideMass(bool) {
c.overrideChecked(c.massOverride, c.massEntry)
}
func (c *Calculator) onSpeedChange(string) {
c.onOriginInputChange(c.speedOverride, c.speedEntry)
}
func (c *Calculator) overrideSpeed(bool) {
c.overrideChecked(c.speedOverride, c.speedEntry)
}
func (c *Calculator) onAttackChange(string) {
c.onOriginInputChange(c.attackOverride, c.attackEntry)
}
func (c *Calculator) overrideAttack(bool) {
c.overrideChecked(c.attackOverride, c.attackEntry)
}
func (c *Calculator) onDefenseChange(string) {
c.onOriginInputChange(c.defenseOverride, c.defenseEntry)
}
func (c *Calculator) overrideDefense(bool) {
c.overrideChecked(c.defenseOverride, c.defenseEntry)
}
func (c *Calculator) maximizeCargoLoad(bool) {
c.validate()
}
func (c *Calculator) onPlanetMatChange(string) {
c.onOriginInputChange(c.planetMatOverride, c.planetMatEntry)
}
func (c *Calculator) overridePlanetMat(bool) {
c.overrideChecked(c.planetMatOverride, c.planetMatEntry)
}
func (c *Calculator) onShipSelectorChange(v string) {
i, ok := c.shipClassNameValidate()
if i < 0 || !ok || c.loader == nil {
return
}
c.loader(
c.knownClasses[i].Name,
c.knownClasses[i].Drive,
c.knownClasses[i].Armament,
c.knownClasses[i].Weapons,
c.knownClasses[i].Shields,
c.knownClasses[i].Cargo,
)
}
func (c *Calculator) shipClassNameValidate() (int, bool) {
var canCreateShip bool
defer func() {
if canCreateShip && c.Valid {
c.shipCreateButton.Enable()
} else {
c.shipCreateButton.Disable()
}
}()
name, canCreateShip := util.ValidateTypeName(c.shipSelector.Text)
if canCreateShip {
c.shipSelector.Text = name
}
i := slices.IndexFunc(c.knownClasses, func(v ShipClass) bool { return v.Name == name })
canCreateShip = canCreateShip && i < 0
return i, canCreateShip
}
func (c *Calculator) LoadShipClass(n string, D float64, A uint, W float64, S float64, C float64) {
c.shipDriveEntry.SetOrigin(D)
c.shipArmamentEntry.SetOrigin(int(A))
c.shipWeaponsEntry.SetOrigin(W)
c.shipShieldsEntry.SetOrigin(S)
c.shipCargoEntry.SetOrigin(C)
}
func rowForItem(l string, entry, override fyne.CanvasObject) fyne.CanvasObject {
i := []fyne.CanvasObject{label(l), entry}
if override != nil {
i = append(i, override)
}
return container.NewHBox(i...)
}
func rowForTech(l string, shipEntry, techEntry, btn fyne.CanvasObject) fyne.CanvasObject {
return container.NewHBox(
label(l),
floatEntry(shipEntry, 115),
widget.NewLabel("@"),
techEntry,
btn,
)
}
func rowForWeapons(l string, armamentEntry, weaponsEntry, techEntry, btn fyne.CanvasObject) fyne.CanvasObject {
return container.NewHBox(
label(l),
intEntry(armamentEntry, 35),
floatEntry(weaponsEntry, 75),
widget.NewLabel("@"),
techEntry,
btn,
)
}
func label(l string) fyne.CanvasObject {
return fixedLabel(bareLabel(l), 110)
}
func fixedLabel(w *widget.Label, width float32) fyne.CanvasObject {
s := container.NewHScroll(w)
s.SetMinSize(fyne.NewSize(width, 1))
return s
}
func bareLabel(l string) *widget.Label {
w := widget.NewLabelWithStyle(l, fyne.TextAlignTrailing, fyne.TextStyle{Monospace: true, Symbol: false})
w.Selectable = false
w.Truncation = fyne.TextTruncateOff
return w
}
func intEntry(content fyne.CanvasObject, width float32) fyne.CanvasObject {
s := container.NewHScroll(content)
s.SetMinSize(fyne.NewSize(width, 1))
return s
}
func floatEntry(content fyne.CanvasObject, width float32) fyne.CanvasObject {
s := container.NewHScroll(content)
s.SetMinSize(fyne.NewSize(width, 1))
return s
}
-16
View File
@@ -1,16 +0,0 @@
package calculator
import (
"fyne.io/fyne/v2/lang"
)
func (c *Calculator) UnloadPlanet() {
c.planetContainer.Hide()
}
func (c *Calculator) LoadPlanet(name string, number uint, L, Mat, Res float64) {
c.l, c.mat, c.res = L, Mat, Res
c.planetLabel.SetText(lang.L("planet.title", map[string]any{"Number": number, "Name": name}))
c.planetMatEntry.SetOrigin(Mat)
c.planetContainer.Show()
}
-217
View File
@@ -1,217 +0,0 @@
package numeric
import (
"galaxy/client/widget/validator"
"galaxy/util"
"strconv"
"strings"
"unicode/utf8"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/driver/mobile"
"fyne.io/fyne/v2/widget"
)
type FloatEntry struct {
widget.Entry
origin float64
MaxValue float64
maxSize uint
validator fyne.StringValidator
Valid bool
}
type IntEntry struct {
widget.Entry
origin uint
MaxValue uint
maxSize uint
validator fyne.StringValidator
Valid bool
}
func NewFloatEntry(maxSize uint, onChanged func(string)) *FloatEntry {
e := &FloatEntry{maxSize: maxSize, validator: validator.FloatEntryValidator}
e.ExtendBaseWidget(e)
e.Entry.Scroll = fyne.ScrollNone
e.Entry.TextStyle = fyne.TextStyle{Monospace: true}
// e.Validator = validator.FloatEntryValidator
// e.AlwaysShowValidationError = true
e.Entry.ActionItem = nil
e.SetOrigin(0)
e.Validate()
e.Entry.OnChanged = onChanged
return e
}
func NewIntEntry(maxSize uint, onChanged func(string)) *IntEntry {
e := &IntEntry{maxSize: maxSize, validator: validator.IntEntryValidator}
e.ExtendBaseWidget(e)
e.Entry.Scroll = fyne.ScrollNone
e.Entry.TextStyle = fyne.TextStyle{Monospace: true}
// e.Validator = validator.IntEntryValidator
// e.AlwaysShowValidationError = true
e.Entry.ActionItem = nil
e.SetOrigin(0)
e.Validate()
e.Entry.OnChanged = onChanged
return e
}
func (e *FloatEntry) CreateRenderer() fyne.WidgetRenderer {
r := e.Entry.CreateRenderer()
return r
}
func (e *FloatEntry) TypedRune(r rune) {
if !((r >= '0' && r <= '9') || r == '.') {
return
}
if !lengthBelowLimit(e.Entry.Text, e.maxSize) && e.Entry.SelectedText() == "" {
return
}
if r == '.' && strings.Contains(e.Entry.Text, ".") {
return
}
e.Entry.TypedRune(r)
}
func (e *FloatEntry) TypedShortcut(shortcut fyne.Shortcut) {
paste, ok := shortcut.(*fyne.ShortcutPaste)
if !ok {
e.Entry.TypedShortcut(shortcut)
return
}
content := paste.Clipboard.Content()
if _, err := strconv.ParseFloat(content, 64); err == nil {
e.Entry.TypedShortcut(shortcut)
}
}
func (e *FloatEntry) Keyboard() mobile.KeyboardType {
return mobile.NumberKeyboard
}
func (e *FloatEntry) SetOrigin(v float64) {
if v < 0 {
return
}
e.origin = v
e.Reset()
}
func (e *FloatEntry) Reset() {
e.SetValue(e.origin)
}
func (e *FloatEntry) Clear() {
onChanged := e.Entry.OnChanged
e.Entry.OnChanged = nil
e.Entry.SetText("")
e.Entry.OnChanged = onChanged
}
func (e *FloatEntry) SetValue(v float64) {
if v < 0 {
return
}
e.Entry.SetText(strconv.FormatFloat(util.Fixed3(v), 'f', -1, 64))
}
func (e *FloatEntry) Value() (float64, bool) {
if v, err := validator.ParseFloat(e.Entry.Text); err != nil {
return 0, false
} else {
return v, true
}
}
func (e *FloatEntry) Overriden() bool {
if v, ok := e.Value(); !ok {
return false
} else {
return util.Fixed3(v) != util.Fixed3(e.origin)
}
}
func (e *FloatEntry) Validate() {
if e.validator == nil {
return
}
err := e.validator(e.Entry.Text)
e.Valid = err == nil
}
func (e *IntEntry) TypedRune(r rune) {
if r >= '0' && r <= '9' {
if lengthBelowLimit(e.Entry.Text, e.maxSize) || e.Entry.SelectedText() != "" {
e.Entry.TypedRune(r)
}
}
}
func (e *IntEntry) TypedShortcut(shortcut fyne.Shortcut) {
paste, ok := shortcut.(*fyne.ShortcutPaste)
if !ok {
e.Entry.TypedShortcut(shortcut)
return
}
content := paste.Clipboard.Content()
if _, err := strconv.ParseInt(content, 10, 64); err == nil {
e.Entry.TypedShortcut(shortcut)
}
}
func (e *IntEntry) Keyboard() mobile.KeyboardType {
return mobile.NumberKeyboard
}
func (e *IntEntry) SetOrigin(v int) {
if v < 0 {
return
}
e.origin = uint(v)
e.Reset()
}
func (e *IntEntry) Reset() {
e.SetValue(int(e.origin))
}
func (e *IntEntry) SetValue(v int) {
if v < 0 {
return
}
e.Entry.SetText(strconv.Itoa(v))
}
func (e *IntEntry) Value() (int, bool) {
if v, err := validator.ParseInt(e.Entry.Text); err != nil {
return 0, false
} else {
return v, true
}
}
func (e *IntEntry) Overriden() bool {
if v, ok := e.Value(); !ok {
return false
} else {
return v != int(e.origin)
}
}
func (e *IntEntry) Validate() {
if e.validator == nil {
return
}
err := e.validator(e.Entry.Text)
e.Valid = err == nil
}
func lengthBelowLimit(s string, max uint) bool {
return utf8.RuneCountInString(s) < int(max)
}
-123
View File
@@ -1,123 +0,0 @@
package validator
import (
"errors"
"fmt"
"strconv"
"fyne.io/fyne/v2"
)
type floatValidator func(float64) error
var (
FloatEntryValidator = numericEntryValidator(
nonNegativeValidator,
minOrZeroValueValidator(1.),
)
IntEntryValidator = numericEntryValidator(
intValidator,
nonNegativeValidator,
minOrZeroValueValidator(1.),
)
)
func NewStackValidator(first fyne.StringValidator, rest ...fyne.StringValidator) fyne.StringValidator {
if first == nil {
panic("first validator cannot be nil")
}
return func(s string) error {
if err := first(s); err != nil {
return err
}
for i := range rest {
if err := rest[i](s); err != nil {
return err
}
}
return nil
}
}
func NewMutualValidator(other func() float64, valid func(float64) bool) fyne.StringValidator {
if other == nil {
panic("other value getter cannot be nil")
}
return func(s string) error {
myValue, err := ParseFloat(s)
if err != nil {
return err
}
if !valid(myValue) {
return errors.New("invalid value")
}
if !valid(other()) {
return errors.New("invalid other value")
}
return nil
}
}
func numericEntryValidator(other ...floatValidator) fyne.StringValidator {
return func(s string) error {
v, err := ParseFloat(s)
if err != nil {
return errors.New("not a float value")
}
for i := range other {
if err := other[i](v); err != nil {
return err
}
}
return nil
}
}
func nonNegativeValidator(v float64) error {
if v < 0 {
return errors.New("value must be greater of equal to zero")
}
return nil
}
func intValidator(v float64) error {
if float64(int(v)) != v {
return errors.New("value must be an integer")
}
return nil
}
func minOrZeroValueValidator(min float64) floatValidator {
return func(f float64) error {
if f > 0 && f < min {
return fmt.Errorf("value must be zero or >= %f", min)
}
return nil
}
}
func FloatValueValidator(s string) error {
if _, err := ParseFloat(s); err != nil {
return errors.New("not a float value")
}
return nil
}
func IntValueValidator(s string) error {
if _, err := ParseInt(s); err != nil {
return errors.New("not an integer value")
}
return nil
}
func ParseFloat(s string) (float64, error) {
return strconv.ParseFloat(s, 64)
}
func ParseInt(s string) (int, error) {
if v, err := strconv.ParseInt(s, 10, 64); err != nil {
return 0, err
} else {
return int(v), nil
}
}
-165
View File
@@ -1,165 +0,0 @@
# World rendering package
> **Deprecated.** This package belongs to the deprecated
> `galaxy/client` Fyne client. New code must not import it. The
> active map renderer lives in `ui/frontend/src/map/` (TypeScript
> + PixiJS), with its specification in `ui/docs/renderer.md`. The
> sources here remain for historical context only and are not the
> reference algorithm for the new renderer.
## Purpose
`world` is the client-side map model and renderer for a 2D world that normally
behaves like a torus. It owns:
- primitive storage (`Point`, `Line`, `Circle`)
- world-space indexing for render and hit-test queries
- theme and style resolution
- full-frame and incremental rendering onto an expanded canvas
- no-wrap helpers used by the UI when torus scrolling is disabled
The package does not own UI widgets, event loops, or camera policy beyond the
helpers exposed for zoom/clamp calculations.
## Symbol Map
- World creation and mutation: `NewWorld`, `AddPoint`, `AddLine`, `AddCircle`, `Remove`, `Reindex`
- Viewport/index lifecycle: `IndexOnViewportChange`, `SetCircleRadiusScaleFp`
- Rendering: `Render`, `RenderParams`, `RenderOptions`, `PrimitiveDrawer`, `GGDrawer`
- No-wrap camera helpers: `CorrectCameraZoom`, `ClampCameraNoWrapViewport`, `ClampRenderParamsNoWrap`, `PivotZoomCameraNoWrap`
- Hit testing: `HitTest`, `Hit`, `PrimitiveKind`
- Styling and themes: `Style`, `StyleOverride`, `StyleTable`, `StyleTheme`, `DefaultTheme`, `ThemeLight`, `ThemeDark`
## Coordinate Model
- World geometry is stored in fixed-point integers.
- `SCALE == 1000`, so `1.0` world units are represented as `1000`.
- Primitive coordinates, radii, world dimensions, and camera positions use `world-fixed` units.
- Viewport and canvas sizes use integer `canvas px`.
- Rectangles in world space and canvas space are treated as half-open intervals:
`[minX, maxX) x [minY, maxY)`.
- `RenderParams` describes the visible viewport, but rendering happens on the
expanded canvas:
- `canvasWidthPx = viewportWidthPx + 2*marginXPx`
- `canvasHeightPx = viewportHeightPx + 2*marginYPx`
- The camera always points to the center of the visible viewport, not the center
of the expanded canvas.
## Data Model
- `World` stores torus dimensions `W` and `H` in fixed-point units.
- `MapItem` is implemented by `Point`, `Line`, and `Circle`.
- `PrimitiveID` is allocated by `World` and may be reused after removal.
- Each primitive carries:
- geometry in fixed-point world coordinates
- `Priority` for deterministic draw order inside a tile
- resolved `StyleID`
- theme binding metadata (`Base`, `Override`, `Class`)
- optional per-primitive hit slop in pixels
- Themes resolve base styles per primitive kind, then optional class overrides,
then optional user `StyleOverride`.
- Explicit `StyleID` bypasses theme-relative recomputation across theme changes.
## Spatial Index Lifecycle
- Rendering and hit testing depend on the grid index stored in `World.grid`.
- `IndexOnViewportChange` must be called after viewport size or zoom changes.
- The grid cell size is derived from the current visible world span:
- start from roughly `visibleMin / 8`
- clamp into `[16*SCALE, 512*SCALE]`
- `AddPoint`, `AddLine`, `AddCircle`, `Remove`, `SetCircleRadiusScaleFp`, and
`Reindex` mark the index dirty and rebuild it automatically when the last
viewport/zoom state is known.
- Circle indexing uses the effective radius after `circleRadiusScaleFp` is applied.
- Line indexing uses the torus-shortest representation and indexes its wrapped
bounding boxes rather than exact rasterized coverage.
## Render Pipeline
`Render` follows this sequence:
1. Validate `RenderParams` and resolve background color/theme state.
2. Convert zoom to fixed-point and compute the expanded unwrapped world rect.
3. Split that rect into `WorldTile` segments:
- torus mode uses wrapped tiling
- no-wrap mode intersects against the bounded world once
4. Query the spatial grid per tile and deduplicate candidates per tile by `PrimitiveID`.
5. Build a `RenderPlan` containing:
- tile-to-canvas clip rectangles
- per-tile candidate lists
6. Draw background before primitives.
7. Draw primitives tile-by-tile in deterministic order:
- `Priority` ascending
- primitive kind as stable tie-breaker
- `PrimitiveID` ascending
8. For wrapped rendering:
- points and circles emit only the torus copies that intersect the current tile
- lines are split into torus-shortest canonical segments before projection
## Incremental Pan Rendering
- `Render` first tries incremental pan reuse through `ComputePanShiftPx` and
`PlanIncrementalPan`.
- If only camera pan changed and the shift stays inside the configured margins:
- existing pixels are moved with `PrimitiveDrawer.CopyShift`
- newly exposed strips become dirty rects
- dirty rects are cleared, background-redrawn, and clipped primitive redraw is applied
- If geometry changed in a way that breaks reuse, rendering falls back to full redraw.
- Theme changes, circle radius scale changes, and explicit `ForceFullRedrawNext`
reset incremental state.
## No-Wrap Behavior
When `RenderOptions.DisableWrapScroll == true`, the world is treated as a bounded
plane instead of a torus.
- `CorrectCameraZoom` prevents the visible viewport from becoming larger than the world.
- `ClampCameraNoWrapViewport` clamps the camera so the viewport remains inside the world.
- `ClampRenderParamsNoWrap` applies the same rule directly to `RenderParams`.
- `PivotZoomCameraNoWrap` keeps the world point under the cursor stable while zoom changes.
Margins are ignored by viewport clamp on purpose so panning remains usable even
when the expanded canvas extends beyond the world bounds.
## Hit Testing
- `HitTest` expects the grid to be built already.
- Cursor coordinates are passed in viewport pixels relative to the viewport top-left.
- The query path is:
1. convert cursor position into world-fixed coordinates
2. clamp or wrap based on no-wrap mode
3. query a conservative grid search box using default hit slop
4. run exact per-primitive hit checks
- Point hits use disc distance.
- Circle hits distinguish between filled circles and stroke-only rings.
- Line hits use the same torus-shortest segment decomposition as rendering.
- Final ranking is:
- `Priority` descending
- squared distance ascending
- primitive kind ascending
- `PrimitiveID` ascending
## UI Integration Checklist
Typical UI flow:
1. Create the world with `NewWorld`.
2. Add primitives and optional styles/themes.
3. Before each render, compute the current viewport size in pixels.
4. Call `CorrectCameraZoom` when UI zoom changes.
5. Call `IndexOnViewportChange` when viewport size or zoom changes.
6. If no-wrap mode is enabled, call `ClampRenderParamsNoWrap`.
7. Render into a `PrimitiveDrawer` with `Render`.
8. Reuse the same `RenderParams` snapshot for `HitTest`.
The `client` package in this repository follows exactly that pattern.
## Important Invariants and Limits
- `Render` and `HitTest` require the grid to be initialized; otherwise they return `errGridNotBuilt`.
- The package assumes single-goroutine access to hot render scratch buffers stored in `World`.
- `RenderScheduler` is only a coalescing example. It is not a license to call
`Render` on arbitrary background goroutines in real UI code.
- `PrimitiveDrawer` receives final canvas coordinates only; all torus math stays inside `world`.
- Background anchoring can be viewport-relative or world-relative, but dirty redraws
always use the same anchoring logic as full redraws.
-642
View File
@@ -1,642 +0,0 @@
package world
import (
"github.com/fogleman/gg"
"image"
"image/color"
"image/draw"
"reflect"
)
// PrimitiveDrawer is a low-level drawing backend used by the world renderer.
//
// The renderer is responsible for all torus logic, viewport/margin logic,
// coordinate projection, and primitive duplication. This interface only accepts
// final canvas pixel coordinates and exposes the minimum drawing operations
// needed to build and render paths.
//
// AddPoint, AddLine, and AddCircle append geometry to the current path.
// They do not render by themselves. The caller must finalize the path by
// calling Stroke or Fill.
//
// Save and Restore are intended for temporary local state changes such as
// clipping, colors, line width, or dash settings. After Restore, the outer
// drawing state must be visible again.
type PrimitiveDrawer interface {
// Save stores the current drawing state.
Save()
// Restore restores the most recently saved drawing state.
Restore()
// ResetClip clears the current clipping region completely.
ResetClip()
// ClipRect intersects the current clipping region with the given rectangle
// in canvas pixel coordinates.
ClipRect(x, y, w, h float64)
// SetStrokeColor sets the color used by Stroke.
SetStrokeColor(c color.Color)
// SetFillColor sets the color used by Fill.
SetFillColor(c color.Color)
// SetLineWidth sets the line width used by Stroke.
SetLineWidth(width float64)
// SetDash sets the dash pattern used by Stroke.
// Passing no values clears the current dash pattern.
SetDash(dashes ...float64)
// SetDashOffset sets the dash phase used by Stroke.
SetDashOffset(offset float64)
// AddPoint appends a point marker centered at (x, y) with radius r
// to the current path in canvas pixel coordinates.
AddPoint(x, y, r float64)
// AddLine appends a line segment to the current path in canvas pixel coordinates.
AddLine(x1, y1, x2, y2 float64)
// AddCircle appends a circle to the current path in canvas pixel coordinates.
AddCircle(cx, cy, r float64)
// Stroke renders the current path using the current stroke state.
Stroke()
// Fill renders the current path using the current fill state.
Fill()
// CopyShift shifts backing pixels by (dx,dy). Newly exposed areas become transparent/undefined;
// caller is expected to ClearRectTo() the dirty areas before drawing.
CopyShift(dx, dy int)
// Clear operations must NOT change clip state.
ClearAllTo(bg color.Color)
ClearRectTo(x, y, w, h int, bg color.Color)
DrawImage(img image.Image, x, y int)
DrawImageScaled(img image.Image, x, y, w, h int)
}
// ggClipRect stores one clip rectangle in canvas pixel coordinates.
// GGDrawer replays these rectangles on Restore because gg.Context Push/Pop
// do not restore clip masks the way this package expects.
type ggClipRect struct {
x, y float64
w, h float64
}
// GGDrawer is a PrimitiveDrawer implementation backed by gg.Context.
//
// It intentionally does not perform any world logic. It only forwards already
// projected canvas coordinates to gg while additionally maintaining a clip stack
// compatible with this package's Save/Restore contract.
type GGDrawer struct {
DC *gg.Context
clips []ggClipRect
clipStack [][]ggClipRect
// scratch is a reusable buffer for CopyShift to avoid allocations.
scratch *image.RGBA
bgCache bgTileCache
}
// Save stores the current gg state and the current logical clip stack.
func (d *GGDrawer) Save() {
d.DC.Push()
snapshot := append([]ggClipRect(nil), d.clips...)
d.clipStack = append(d.clipStack, snapshot)
}
// Restore restores the previous gg state and rebuilds the outer clip state.
//
// gg.Context.Pop restores most state from the stack, but its clip mask handling
// does not match this package's expected Save/Restore semantics. To preserve the
// contract, GGDrawer explicitly resets the clip and replays the previously saved
// clip rectangles after Pop.
func (d *GGDrawer) Restore() {
if len(d.clipStack) == 0 {
panic("GGDrawer: Restore without matching Save")
}
snapshot := d.clipStack[len(d.clipStack)-1]
d.clipStack = d.clipStack[:len(d.clipStack)-1]
d.DC.Pop()
d.clips = append([]ggClipRect(nil), snapshot...)
d.DC.ResetClip()
for _, clip := range d.clips {
d.DC.DrawRectangle(clip.x, clip.y, clip.w, clip.h)
d.DC.Clip()
}
}
// ResetClip clears the current clipping region and the logical clip stack
// for the active state frame.
func (d *GGDrawer) ResetClip() {
d.DC.ResetClip()
d.clips = nil
}
// ClipRect intersects the current clipping region with the given rectangle
// and records it so the clip can be reconstructed after Restore.
func (d *GGDrawer) ClipRect(x, y, w, h float64) {
d.DC.DrawRectangle(x, y, w, h)
d.DC.Clip()
d.clips = append(d.clips, ggClipRect{x: x, y: y, w: w, h: h})
}
// SetStrokeColor sets the stroke color by installing a solid stroke pattern.
func (d *GGDrawer) SetStrokeColor(c color.Color) {
d.DC.SetStrokeStyle(gg.NewSolidPattern(c))
}
// SetFillColor sets the fill color by installing a solid fill pattern.
func (d *GGDrawer) SetFillColor(c color.Color) {
d.DC.SetFillStyle(gg.NewSolidPattern(c))
}
// SetLineWidth sets the line width used for stroking.
func (d *GGDrawer) SetLineWidth(width float64) {
d.DC.SetLineWidth(width)
}
// SetDash sets the dash pattern used for stroking.
func (d *GGDrawer) SetDash(dashes ...float64) {
d.DC.SetDash(dashes...)
}
// SetDashOffset sets the dash phase used for stroking.
func (d *GGDrawer) SetDashOffset(offset float64) {
d.DC.SetDashOffset(offset)
}
// AddPoint appends a point marker to the current path.
func (d *GGDrawer) AddPoint(x, y, r float64) {
d.DC.DrawPoint(x, y, r)
}
// AddLine appends a line segment to the current path.
func (d *GGDrawer) AddLine(x1, y1, x2, y2 float64) {
d.DC.DrawLine(x1, y1, x2, y2)
}
// AddCircle appends a circle to the current path.
func (d *GGDrawer) AddCircle(cx, cy, r float64) {
d.DC.DrawCircle(cx, cy, r)
}
// Stroke renders the current path using the current stroke state.
func (d *GGDrawer) Stroke() {
d.DC.Stroke()
}
// Fill renders the current path using the current fill state.
func (d *GGDrawer) Fill() {
d.DC.Fill()
}
// CopyShift shifts the backing RGBA image by (dx, dy) pixels.
// It clears newly exposed areas to transparent.
func (d *GGDrawer) CopyShift(dx, dy int) {
if dx == 0 && dy == 0 {
return
}
img, ok := d.DC.Image().(*image.RGBA)
if !ok || img == nil {
panic("GGDrawer.CopyShift: backing image is not *image.RGBA")
}
b := img.Bounds()
w := b.Dx()
h := b.Dy()
if w <= 0 || h <= 0 {
return
}
adx := abs(dx)
ady := abs(dy)
if adx >= w || ady >= h {
// Everything shifts out of bounds => just clear.
for i := range img.Pix {
img.Pix[i] = 0
}
return
}
// Prepare scratch with the same bounds.
if d.scratch == nil || d.scratch.Bounds().Dx() != w || d.scratch.Bounds().Dy() != h {
d.scratch = image.NewRGBA(b)
} else {
// Clear scratch to transparent.
for i := range d.scratch.Pix {
d.scratch.Pix[i] = 0
}
}
// Compute source/destination rectangles.
dstX0 := 0
dstY0 := 0
srcX0 := 0
srcY0 := 0
if dx > 0 {
dstX0 = dx
} else {
srcX0 = -dx
}
if dy > 0 {
dstY0 = dy
} else {
srcY0 = -dy
}
copyW := w - max(dstX0, srcX0)
copyH := h - max(dstY0, srcY0)
if copyW <= 0 || copyH <= 0 {
for i := range img.Pix {
img.Pix[i] = 0
}
return
}
// Copy row-by-row (RGBA, 4 bytes per pixel).
for row := 0; row < copyH; row++ {
srcY := srcY0 + row
dstY := dstY0 + row
srcOff := srcY*img.Stride + srcX0*4
dstOff := dstY*d.scratch.Stride + dstX0*4
n := copyW * 4
copy(d.scratch.Pix[dstOff:dstOff+n], img.Pix[srcOff:srcOff+n])
}
// Swap buffers by copying scratch into img.
// (We keep img pointer stable for gg.Context.)
copy(img.Pix, d.scratch.Pix)
}
func (d *GGDrawer) ClearAllTo(bg color.Color) {
img, ok := d.DC.Image().(*image.RGBA)
if !ok || img == nil {
panic("GGDrawer.ClearAllTo: backing image is not *image.RGBA")
}
R, G, B, A := rgba8(bg)
// Prepare one full scanline once.
w := img.Bounds().Dx()
if w <= 0 {
return
}
line := make([]byte, w*4)
for i := 0; i < len(line); i += 4 {
line[i+0] = R
line[i+1] = G
line[i+2] = B
line[i+3] = A
}
// Copy scanline into each row (fast memmove).
h := img.Bounds().Dy()
for y := 0; y < h; y++ {
off := y * img.Stride
copy(img.Pix[off:off+w*4], line)
}
}
func (d *GGDrawer) ClearRectTo(x, y, w, h int, bg color.Color) {
if w <= 0 || h <= 0 {
return
}
img, ok := d.DC.Image().(*image.RGBA)
if !ok || img == nil {
panic("GGDrawer.ClearRectTo: backing image is not *image.RGBA")
}
b := img.Bounds()
x0 := max(x, b.Min.X)
y0 := max(y, b.Min.Y)
x1 := min(x+w, b.Max.X)
y1 := min(y+h, b.Max.Y)
if x0 >= x1 || y0 >= y1 {
return
}
R, G, B, A := rgba8(bg)
rowPx := x1 - x0
rowBytes := rowPx * 4
// Build one row once for this rect width.
line := make([]byte, rowBytes)
for i := 0; i < rowBytes; i += 4 {
line[i+0] = R
line[i+1] = G
line[i+2] = B
line[i+3] = A
}
for yy := y0; yy < y1; yy++ {
off := yy*img.Stride + x0*4
copy(img.Pix[off:off+rowBytes], line)
}
}
// rgba8 converts any color.Color into 8-bit RGBA components.
func rgba8(c color.Color) (R, G, B, A byte) {
r, g, b, a := c.RGBA()
return byte(r >> 8), byte(g >> 8), byte(b >> 8), byte(a >> 8)
}
func (g *GGDrawer) DrawImage(img image.Image, x, y int) {
g.DC.DrawImage(img, x, y)
}
func (g *GGDrawer) DrawImageScaled(img image.Image, x, y, w, h int) {
if w <= 0 || h <= 0 {
return
}
b := img.Bounds()
srcW := b.Dx()
srcH := b.Dy()
if srcW <= 0 || srcH <= 0 {
return
}
g.DC.Push()
// Translate to destination top-left.
g.DC.Translate(float64(x), float64(y))
// Scale so that the source bounds map to (w,h).
g.DC.Scale(float64(w)/float64(srcW), float64(h)/float64(srcH))
// Draw at origin in the scaled coordinate system.
g.DC.DrawImage(img, 0, 0)
g.DC.Pop()
}
// bgTileCacheKey identifies one scaled background-tile variant cached by GGDrawer.
type bgTileCacheKey struct {
imgPtr uintptr
scaleMode BackgroundScaleMode
canvasW int
canvasH int
srcW int
srcH int
}
// bgTileCache stores the most recently used scaled background tile.
type bgTileCache struct {
key bgTileCacheKey
valid bool
scaledTile *image.RGBA
tileW int
tileH int
}
// drawBackgroundFast renders the background directly into the RGBA backing
// image, bypassing gg path construction when the drawer supports it.
func (g *GGDrawer) drawBackgroundFast(w *World, params RenderParams, rect RectPx) bool {
th := w.Theme()
bgImg := th.BackgroundImage()
if bgImg == nil {
return false
}
dst, ok := g.DC.Image().(*image.RGBA)
if !ok || dst == nil {
return false
}
canvasW := params.CanvasWidthPx()
canvasH := params.CanvasHeightPx()
// Clamp rect to canvas.
if rect.W <= 0 || rect.H <= 0 {
return true
}
if rect.X < 0 {
rect.W += rect.X
rect.X = 0
}
if rect.Y < 0 {
rect.H += rect.Y
rect.Y = 0
}
if rect.X+rect.W > canvasW {
rect.W = canvasW - rect.X
}
if rect.Y+rect.H > canvasH {
rect.H = canvasH - rect.Y
}
if rect.W <= 0 || rect.H <= 0 {
return true
}
imgB := bgImg.Bounds()
srcW := imgB.Dx()
srcH := imgB.Dy()
if srcW <= 0 || srcH <= 0 {
return true
}
tileMode := th.BackgroundTileMode()
anchor := th.BackgroundAnchorMode()
scaleMode := th.BackgroundScaleMode()
// Compute scaled tile size in pixels (scale depends on canvas size).
tileW, tileH := backgroundScaledSize(srcW, srcH, canvasW, canvasH, scaleMode)
if tileW <= 0 || tileH <= 0 {
return true
}
// Prepare the tile image (possibly scaled) from cache.
tile := bgImg
if scaleMode != BackgroundScaleNone || tileW != srcW || tileH != srcH {
rgbaTile := g.getOrBuildScaledTile(bgImg, srcW, srcH, tileW, tileH, scaleMode, canvasW, canvasH)
if rgbaTile == nil {
// Fallback to slow path if we cannot scale (non-RGBA weirdness).
return false
}
tile = rgbaTile
}
offX, offY := w.backgroundAnchorOffsetPx(params, tileW, tileH, anchor)
switch tileMode {
case BackgroundTileNone:
// Draw single image centered in full canvas, then clipped by rect.
x := (canvasW-tileW)/2 + offX
y := (canvasH-tileH)/2 + offY
w.drawOneTileRGBA(dst, tile, rect, x, y)
case BackgroundTileRepeat:
originX := offX
originY := offY
startX := floorDiv(rect.X-originX, tileW)*tileW + originX
startY := floorDiv(rect.Y-originY, tileH)*tileH + originY
for yy := startY; yy < rect.Y+rect.H; yy += tileH {
for xx := startX; xx < rect.X+rect.W; xx += tileW {
w.drawOneTileRGBA(dst, tile, rect, xx, yy)
}
}
default:
// Treat unknown as none.
x := (canvasW-tileW)/2 + offX
y := (canvasH-tileH)/2 + offY
w.drawOneTileRGBA(dst, tile, rect, x, y)
}
return true
}
// getOrBuildScaledTile returns the cached scaled tile image for the current
// background configuration, rebuilding it when the cache key changes.
func (g *GGDrawer) getOrBuildScaledTile(img image.Image, srcW, srcH, dstW, dstH int, mode BackgroundScaleMode, canvasW, canvasH int) *image.RGBA {
// Identify image pointer (themes typically provide *image.RGBA).
ptr := imagePointer(img)
key := bgTileCacheKey{
imgPtr: ptr,
scaleMode: mode,
canvasW: canvasW,
canvasH: canvasH,
srcW: srcW,
srcH: srcH,
}
if g.bgCache.valid && g.bgCache.key == key && g.bgCache.scaledTile != nil &&
g.bgCache.tileW == dstW && g.bgCache.tileH == dstH {
return g.bgCache.scaledTile
}
// Scale only from *image.RGBA fast; otherwise, try a generic slow path.
var scaled *image.RGBA
if srcRGBA, ok := img.(*image.RGBA); ok {
scaled = scaleNearestRGBA(srcRGBA, dstW, dstH)
} else {
scaled = scaleNearestGeneric(img, dstW, dstH)
}
g.bgCache.key = key
g.bgCache.valid = true
g.bgCache.scaledTile = scaled
g.bgCache.tileW = dstW
g.bgCache.tileH = dstH
return scaled
}
// imagePointer returns a stable pointer identity for pointer-backed images.
// Non-pointer image values return 0, which disables cache reuse but remains correct.
func imagePointer(img image.Image) uintptr {
// Works well when img is a pointer type (e.g. *image.RGBA).
// If not pointer, Pointer() returns 0; cache will be less effective but still correct.
v := reflect.ValueOf(img)
if v.Kind() == reflect.Pointer || v.Kind() == reflect.UnsafePointer {
return v.Pointer()
}
return 0
}
// scaleNearestRGBA scales src -> dst with nearest-neighbor sampling.
// This is intended for background textures; performance > quality.
func scaleNearestRGBA(src *image.RGBA, dstW, dstH int) *image.RGBA {
if dstW <= 0 || dstH <= 0 {
return nil
}
sb := src.Bounds()
sw := sb.Dx()
sh := sb.Dy()
if sw <= 0 || sh <= 0 {
return nil
}
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
for y := 0; y < dstH; y++ {
sy := (y * sh) / dstH
srcOff := (sy+sb.Min.Y)*src.Stride + sb.Min.X*4
dstOff := y * dst.Stride
for x := 0; x < dstW; x++ {
sx := (x * sw) / dstW
si := srcOff + sx*4
di := dstOff + x*4
dst.Pix[di+0] = src.Pix[si+0]
dst.Pix[di+1] = src.Pix[si+1]
dst.Pix[di+2] = src.Pix[si+2]
dst.Pix[di+3] = src.Pix[si+3]
}
}
return dst
}
// scaleNearestGeneric scales an arbitrary image.Image with nearest-neighbor sampling.
func scaleNearestGeneric(src image.Image, dstW, dstH int) *image.RGBA {
if dstW <= 0 || dstH <= 0 {
return nil
}
sb := src.Bounds()
sw := sb.Dx()
sh := sb.Dy()
if sw <= 0 || sh <= 0 {
return nil
}
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
for y := 0; y < dstH; y++ {
sy := sb.Min.Y + (y*sh)/dstH
for x := 0; x < dstW; x++ {
sx := sb.Min.X + (x*sw)/dstW
dst.Set(x, y, src.At(sx, sy))
}
}
return dst
}
// drawOneTileRGBA draws tile at (x,y) into dst, but only the portion that intersects rect.
// Uses draw.Over (alpha compositing), assuming caller already cleared rect to background color.
func (w *World) drawOneTileRGBA(dst *image.RGBA, tile image.Image, rect RectPx, x, y int) {
tileB := tile.Bounds()
tw := tileB.Dx()
th := tileB.Dy()
if tw <= 0 || th <= 0 {
return
}
// Intersection of tile rect and target rect.
tx0 := x
ty0 := y
tx1 := x + tw
ty1 := y + th
rx0 := rect.X
ry0 := rect.Y
rx1 := rect.X + rect.W
ry1 := rect.Y + rect.H
ix0 := max(tx0, rx0)
iy0 := max(ty0, ry0)
ix1 := min(tx1, rx1)
iy1 := min(ty1, ry1)
if ix0 >= ix1 || iy0 >= iy1 {
return
}
dstR := image.Rect(ix0, iy0, ix1, iy1)
srcPt := image.Point{X: tileB.Min.X + (ix0 - tx0), Y: tileB.Min.Y + (iy0 - ty0)}
draw.Draw(dst, dstR, tile, srcPt, draw.Over)
}
-661
View File
@@ -1,661 +0,0 @@
package world
import (
"fmt"
"github.com/fogleman/gg"
"github.com/stretchr/testify/require"
"image"
"image/color"
"sync"
"testing"
)
func hasAnyNonTransparentPixel(img image.Image) bool {
b := img.Bounds()
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
_, _, _, a := img.At(x, y).RGBA()
if a != 0 {
return true
}
}
}
return false
}
func pixelHasAlpha(img image.Image, x, y int) bool {
_, _, _, a := img.At(x, y).RGBA()
return a != 0
}
// TestGGDrawerStrokeSequenceProducesPixels verifies gG Drawer Stroke Sequence Produces Pixels.
func TestGGDrawerStrokeSequenceProducesPixels(t *testing.T) {
t.Parallel()
dc := gg.NewContext(32, 32)
drawer := &GGDrawer{DC: dc}
drawer.SetStrokeColor(color.RGBA{R: 255, A: 255})
drawer.SetLineWidth(2)
drawer.SetDash(4, 2)
drawer.SetDashOffset(1)
drawer.AddLine(4, 16, 28, 16)
drawer.Stroke()
require.True(t, hasAnyNonTransparentPixel(dc.Image()))
}
// TestGGDrawerFillSequenceProducesPixels verifies gG Drawer Fill Sequence Produces Pixels.
func TestGGDrawerFillSequenceProducesPixels(t *testing.T) {
t.Parallel()
dc := gg.NewContext(32, 32)
drawer := &GGDrawer{DC: dc}
drawer.SetFillColor(color.RGBA{G: 255, A: 255})
drawer.AddCircle(16, 16, 6)
drawer.Fill()
require.True(t, pixelHasAlpha(dc.Image(), 16, 16))
}
// TestGGDrawerPointSequenceProducesPixels verifies gG Drawer Point Sequence Produces Pixels.
func TestGGDrawerPointSequenceProducesPixels(t *testing.T) {
t.Parallel()
dc := gg.NewContext(32, 32)
drawer := &GGDrawer{DC: dc}
drawer.SetFillColor(color.RGBA{B: 255, A: 255})
drawer.AddPoint(16, 16, 3)
drawer.Fill()
require.True(t, pixelHasAlpha(dc.Image(), 16, 16))
}
// TestGGDrawerClipRectLimitsDrawing verifies gG Drawer Clip Rect Limits Drawing.
func TestGGDrawerClipRectLimitsDrawing(t *testing.T) {
t.Parallel()
dc := gg.NewContext(32, 32)
drawer := &GGDrawer{DC: dc}
drawer.Save()
drawer.ClipRect(0, 0, 10, 32)
drawer.SetFillColor(color.RGBA{B: 255, A: 255})
drawer.AddCircle(15, 16, 10)
drawer.Fill()
drawer.Restore()
img := dc.Image()
require.True(t, pixelHasAlpha(img, 5, 16))
require.False(t, pixelHasAlpha(img, 15, 16))
}
// TestGGDrawerResetClipClearsClip verifies gG Drawer Reset Clip Clears Clip.
func TestGGDrawerResetClipClearsClip(t *testing.T) {
t.Parallel()
dc := gg.NewContext(32, 32)
drawer := &GGDrawer{DC: dc}
drawer.ClipRect(0, 0, 10, 32)
drawer.ResetClip()
drawer.SetFillColor(color.RGBA{R: 255, G: 255, A: 255})
drawer.AddCircle(15, 16, 10)
drawer.Fill()
require.True(t, pixelHasAlpha(dc.Image(), 15, 16))
}
// TestGGDrawerClearRectTo_FillsBackground verifies gG Drawer Clear Rect To Fills Background.
func TestGGDrawerClearRectTo_FillsBackground(t *testing.T) {
t.Parallel()
dc := gg.NewContext(10, 10)
dr := &GGDrawer{DC: dc}
// Draw something to ensure we overwrite non-background.
dr.SetFillColor(color.RGBA{R: 255, A: 255})
dr.AddCircle(5, 5, 5)
dr.Fill()
bg := color.RGBA{A: 255} // black
dr.ClearRectTo(1, 1, 2, 2, bg)
img := dc.Image()
r, g, b, a := img.At(1, 1).RGBA()
require.Equal(t, uint32(0), r)
require.Equal(t, uint32(0), g)
require.Equal(t, uint32(0), b)
require.Equal(t, uint32(0xffff), a)
// Pixel outside cleared rect should still have non-zero alpha.
_, _, _, a2 := img.At(5, 5).RGBA()
require.NotEqual(t, uint32(0), a2)
}
// TestGGDrawerSaveRestoreRestoresClipState verifies gG Drawer Save Restore Restores Clip State.
func TestGGDrawerSaveRestoreRestoresClipState(t *testing.T) {
t.Parallel()
dc := gg.NewContext(32, 32)
drawer := &GGDrawer{DC: dc}
drawer.Save()
drawer.ClipRect(0, 0, 10, 32)
drawer.Restore()
drawer.SetFillColor(color.RGBA{R: 255, A: 255})
drawer.AddCircle(15, 16, 10)
drawer.Fill()
require.True(t, pixelHasAlpha(dc.Image(), 15, 16))
}
// TestGGDrawerNestedSaveRestoreRestoresOuterClip verifies gG Drawer Nested Save Restore Restores Outer Clip.
func TestGGDrawerNestedSaveRestoreRestoresOuterClip(t *testing.T) {
t.Parallel()
dc := gg.NewContext(32, 32)
drawer := &GGDrawer{DC: dc}
drawer.ClipRect(0, 0, 20, 32)
drawer.Save()
drawer.ClipRect(0, 0, 10, 32)
drawer.Restore()
drawer.SetFillColor(color.RGBA{R: 255, G: 255, A: 255})
drawer.AddCircle(15, 16, 10)
drawer.Fill()
img := dc.Image()
require.True(t, pixelHasAlpha(img, 15, 16))
require.False(t, pixelHasAlpha(img, 25, 16))
}
// TestFakePrimitiveDrawerRecordsCommandsAndState verifies fake Primitive Drawer Records Commands And State.
func TestFakePrimitiveDrawerRecordsCommandsAndState(t *testing.T) {
t.Parallel()
d := &fakePrimitiveDrawer{}
d.Save()
d.ClipRect(1, 2, 30, 40)
d.SetStrokeColor(color.RGBA{R: 10, G: 20, B: 30, A: 255})
d.SetFillColor(color.RGBA{R: 40, G: 50, B: 60, A: 255})
d.SetLineWidth(3)
d.SetDash(5, 6)
d.SetDashOffset(7)
d.AddLine(10, 11, 12, 13)
d.Stroke()
d.Restore()
requireDrawerCommandNames(t, d,
"Save",
"ClipRect",
"SetStrokeColor",
"SetFillColor",
"SetLineWidth",
"SetDash",
"SetDashOffset",
"AddLine",
"Stroke",
"Restore",
)
cmd := requireDrawerSingleCommand(t, d, "AddLine")
requireCommandArgs(t, cmd, 10, 11, 12, 13)
requireCommandLineWidth(t, cmd, 3)
requireCommandDashes(t, cmd, 5, 6)
requireCommandDashOffset(t, cmd, 7)
requireCommandClipRects(t, cmd, fakeClipRect{X: 1, Y: 2, W: 30, H: 40})
require.Equal(t, color.RGBA{R: 10, G: 20, B: 30, A: 255}, cmd.StrokeColor)
require.Equal(t, color.RGBA{R: 40, G: 50, B: 60, A: 255}, cmd.FillColor)
}
// TestFakePrimitiveDrawerRestoreWithoutSavePanics verifies fake Primitive Drawer Restore Without Save Panics.
func TestFakePrimitiveDrawerRestoreWithoutSavePanics(t *testing.T) {
t.Parallel()
d := &fakePrimitiveDrawer{}
require.Panics(t, func() {
d.Restore()
})
}
// TestFakePrimitiveDrawerSaveRestoreRestoresState verifies fake Primitive Drawer Save Restore Restores State.
func TestFakePrimitiveDrawerSaveRestoreRestoresState(t *testing.T) {
t.Parallel()
d := &fakePrimitiveDrawer{}
d.SetLineWidth(1)
d.Save()
d.SetLineWidth(9)
d.ClipRect(1, 2, 3, 4)
d.Restore()
state := d.CurrentState()
require.Equal(t, 1.0, state.LineWidth)
require.Empty(t, state.Clips)
require.Equal(t, 0, d.SaveDepth())
}
// TestFakePrimitiveDrawerResetClipClearsOnlyClipState verifies fake Primitive Drawer Reset Clip Clears Only Clip State.
func TestFakePrimitiveDrawerResetClipClearsOnlyClipState(t *testing.T) {
t.Parallel()
d := &fakePrimitiveDrawer{}
d.SetLineWidth(4)
d.ClipRect(1, 2, 3, 4)
d.ResetClip()
state := d.CurrentState()
require.Equal(t, 4.0, state.LineWidth)
require.Empty(t, state.Clips)
}
// TestGGDrawerCopyShift_ShiftsPixels verifies gG Drawer Copy Shift Shifts Pixels.
func TestGGDrawerCopyShift_ShiftsPixels(t *testing.T) {
t.Parallel()
dc := gg.NewContext(10, 10)
drawer := &GGDrawer{DC: dc}
// Draw a single filled point at (1,1).
drawer.SetFillColor(color.RGBA{R: 255, A: 255})
drawer.AddPoint(1, 1, 1)
drawer.Fill()
// Shift image right by 2 and down by 3.
drawer.CopyShift(2, 3)
img := dc.Image()
// The old pixel near (1,1) should now be present near (3,4).
// We check alpha only to avoid depending on exact blending.
_, _, _, a := img.At(3, 4).RGBA()
require.NotEqual(t, uint32(0), a)
// A pixel in the newly exposed top-left area should be transparent.
_, _, _, a2 := img.At(0, 0).RGBA()
require.Equal(t, uint32(0), a2)
}
// TestGGDrawer_ClearRectTo_DoesNotAffectStrokeState verifies gG Drawer Clear Rect To Does Not Affect Stroke State.
func TestGGDrawer_ClearRectTo_DoesNotAffectStrokeState(t *testing.T) {
t.Parallel()
dc := gg.NewContext(40, 20)
d := &GGDrawer{DC: dc}
// Fill background to white.
d.ClearAllTo(color.RGBA{R: 255, G: 255, B: 255, A: 255})
// Configure stroke to red and draw first line.
d.SetStrokeColor(color.RGBA{R: 255, A: 255})
d.SetLineWidth(2)
d.AddLine(2, 5, 38, 5)
d.Stroke()
// Clear a rect in the middle with gray (must not affect stroke state).
d.ClearRectTo(10, 0, 20, 20, color.RGBA{R: 200, G: 200, B: 200, A: 255})
// Draw second line WITHOUT reapplying stroke style; it must still be red.
d.AddLine(2, 15, 38, 15)
d.Stroke()
img := dc.Image()
// Sample a pixel from the second line (y ~15). We expect red channel dominates.
r, g, b, a := img.At(20, 15).RGBA()
require.Greater(t, a, uint32(0), "pixel must not be fully transparent")
require.Greater(t, r, g, "expected red-ish pixel after ClearRectTo")
require.Greater(t, r, b, "expected red-ish pixel after ClearRectTo")
}
// fakeClipRect describes one clip rectangle in canvas pixel coordinates.
type fakeClipRect struct {
X, Y float64
W, H float64
}
// fakeDrawerState stores the active fake drawing state.
// The state is copied on Save and restored on Restore.
type fakeDrawerState struct {
StrokeColor color.RGBA
FillColor color.RGBA
LineWidth float64
Dashes []float64
DashOffset float64
Clips []fakeClipRect
}
// clone returns a deep copy of the state.
func (s fakeDrawerState) clone() fakeDrawerState {
out := s
out.Dashes = append([]float64(nil), s.Dashes...)
out.Clips = append([]fakeClipRect(nil), s.Clips...)
return out
}
// fakeDrawerCommand is one recorded drawer call together with a snapshot
// of the active fake drawing state at the moment of the call.
type fakeDrawerCommand struct {
Name string
Args []float64
StrokeColor color.RGBA
FillColor color.RGBA
LineWidth float64
Dashes []float64
DashOffset float64
Clips []fakeClipRect
}
// String returns a compact debug representation useful in assertion failures.
func (c fakeDrawerCommand) String() string {
return fmt.Sprintf(
"%s args=%v stroke=%v fill=%v lineWidth=%v dashes=%v dashOffset=%v clips=%v",
c.Name,
c.Args,
c.StrokeColor,
c.FillColor,
c.LineWidth,
c.Dashes,
c.DashOffset,
c.Clips,
)
}
// fakePrimitiveDrawer is a reusable PrimitiveDrawer test double.
// It records all calls and emulates stateful behavior, including nested
// Save/Restore and clip reset semantics.
type fakePrimitiveDrawer struct {
commands []fakeDrawerCommand
state fakeDrawerState
stack []fakeDrawerState
mu sync.Mutex
}
// Ensure fakePrimitiveDrawer implements PrimitiveDrawer.
var _ PrimitiveDrawer = (*fakePrimitiveDrawer)(nil)
// rgbaColor converts any color.Color into a comparable RGBA value.
func rgbaColor(c color.Color) color.RGBA {
if c == nil {
return color.RGBA{}
}
return color.RGBAModel.Convert(c).(color.RGBA)
}
// snapshotCommand records one command together with the current state snapshot.
func (d *fakePrimitiveDrawer) snapshotCommand(name string, args ...float64) {
cmd := fakeDrawerCommand{
Name: name,
Args: append([]float64(nil), args...),
StrokeColor: d.state.StrokeColor,
FillColor: d.state.FillColor,
LineWidth: d.state.LineWidth,
Dashes: append([]float64(nil), d.state.Dashes...),
DashOffset: d.state.DashOffset,
Clips: append([]fakeClipRect(nil), d.state.Clips...),
}
d.commands = append(d.commands, cmd)
}
// Save stores the current fake state.
func (d *fakePrimitiveDrawer) Save() {
d.stack = append(d.stack, d.state.clone())
d.snapshotCommand("Save")
}
// Restore restores the most recently saved fake state.
func (d *fakePrimitiveDrawer) Restore() {
if len(d.stack) == 0 {
panic("fakePrimitiveDrawer: Restore without matching Save")
}
d.state = d.stack[len(d.stack)-1]
d.stack = d.stack[:len(d.stack)-1]
d.snapshotCommand("Restore")
}
// ResetClip clears the current fake clip stack.
func (d *fakePrimitiveDrawer) ResetClip() {
d.state.Clips = nil
d.snapshotCommand("ResetClip")
}
// ClipRect appends one clip rectangle to the current fake state.
func (d *fakePrimitiveDrawer) ClipRect(x, y, w, h float64) {
d.state.Clips = append(d.state.Clips, fakeClipRect{X: x, Y: y, W: w, H: h})
d.snapshotCommand("ClipRect", x, y, w, h)
}
// SetStrokeColor sets the current fake stroke color.
func (d *fakePrimitiveDrawer) SetStrokeColor(c color.Color) {
d.state.StrokeColor = rgbaColor(c)
d.snapshotCommand("SetStrokeColor")
}
// SetFillColor sets the current fake fill color.
func (d *fakePrimitiveDrawer) SetFillColor(c color.Color) {
d.state.FillColor = rgbaColor(c)
d.snapshotCommand("SetFillColor")
}
// SetLineWidth sets the current fake line width.
func (d *fakePrimitiveDrawer) SetLineWidth(width float64) {
d.state.LineWidth = width
d.snapshotCommand("SetLineWidth", width)
}
// SetDash sets the current fake dash pattern.
func (d *fakePrimitiveDrawer) SetDash(dashes ...float64) {
d.state.Dashes = append([]float64(nil), dashes...)
d.snapshotCommand("SetDash", dashes...)
}
// SetDashOffset sets the current fake dash offset.
func (d *fakePrimitiveDrawer) SetDashOffset(offset float64) {
d.state.DashOffset = offset
d.snapshotCommand("SetDashOffset", offset)
}
// AddPoint records a point path append command.
func (d *fakePrimitiveDrawer) AddPoint(x, y, r float64) {
d.snapshotCommand("AddPoint", x, y, r)
}
// AddLine records a line path append command.
func (d *fakePrimitiveDrawer) AddLine(x1, y1, x2, y2 float64) {
d.snapshotCommand("AddLine", x1, y1, x2, y2)
}
// AddCircle records a circle path append command.
func (d *fakePrimitiveDrawer) AddCircle(cx, cy, r float64) {
d.snapshotCommand("AddCircle", cx, cy, r)
}
// Stroke records a stroke finalization command.
func (d *fakePrimitiveDrawer) Stroke() {
d.snapshotCommand("Stroke")
}
// Fill records a fill finalization command.
func (d *fakePrimitiveDrawer) Fill() {
d.snapshotCommand("Fill")
}
// Commands returns a defensive copy of the recorded command log.
func (d *fakePrimitiveDrawer) Commands() []fakeDrawerCommand {
out := make([]fakeDrawerCommand, len(d.commands))
copy(out, d.commands)
return out
}
// CommandNames returns only command names in call order.
func (d *fakePrimitiveDrawer) CommandNames() []string {
out := make([]string, 0, len(d.commands))
for _, cmd := range d.commands {
out = append(out, cmd.Name)
}
return out
}
// CommandsByName returns all commands with the given name.
func (d *fakePrimitiveDrawer) CommandsByName(name string) []fakeDrawerCommand {
var out []fakeDrawerCommand
for _, cmd := range d.commands {
if cmd.Name == name {
out = append(out, cmd)
}
}
return out
}
// LastCommand returns the last recorded command and whether it exists.
func (d *fakePrimitiveDrawer) LastCommand() (fakeDrawerCommand, bool) {
if len(d.commands) == 0 {
return fakeDrawerCommand{}, false
}
return d.commands[len(d.commands)-1], true
}
// CurrentState returns a defensive copy of the current fake state.
func (d *fakePrimitiveDrawer) CurrentState() fakeDrawerState {
return d.state.clone()
}
// SaveDepth returns the current Save/Restore nesting depth.
func (d *fakePrimitiveDrawer) SaveDepth() int {
return len(d.stack)
}
// ResetLog clears only the command log and keeps the current state intact.
func (d *fakePrimitiveDrawer) ResetLog() {
d.commands = nil
}
func (d *fakePrimitiveDrawer) CopyShift(dx, dy int) {
d.snapshotCommand("CopyShift", float64(dx), float64(dy))
}
func (d *fakePrimitiveDrawer) ClearAllTo(_ color.Color) {
// Store as a command; tests usually only care that it was called.
d.snapshotCommand("ClearAllTo")
}
func (d *fakePrimitiveDrawer) ClearRectTo(x, y, w, h int, _ color.Color) {
d.snapshotCommand("ClearRectTo", float64(x), float64(y), float64(w), float64(h))
}
func (d *fakePrimitiveDrawer) DrawImage(_ image.Image, x, y int) {
d.snapshotCommand("DrawImage", float64(x), float64(y))
}
func (d *fakePrimitiveDrawer) DrawImageScaled(_ image.Image, x, y, w, h int) {
d.snapshotCommand("DrawImageScaled", float64(x), float64(y), float64(w), float64(h))
}
func (d *fakePrimitiveDrawer) Reset() {
d.mu.Lock()
defer d.mu.Unlock()
d.commands = d.commands[:0]
}
// requireDrawerCommandNames asserts the exact command sequence recorded
// by fakePrimitiveDrawer.
func requireDrawerCommandNames(t *testing.T, d *fakePrimitiveDrawer, want ...string) {
t.Helper()
require.Equal(t, want, d.CommandNames())
}
// requireDrawerCommandCount asserts the number of recorded commands.
func requireDrawerCommandCount(t *testing.T, d *fakePrimitiveDrawer, want int) {
t.Helper()
require.Len(t, d.Commands(), want)
}
// requireDrawerCommandAt returns the command at the specified index.
func requireDrawerCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand {
t.Helper()
cmds := d.Commands()
require.GreaterOrEqual(t, index, 0)
require.Less(t, index, len(cmds))
return cmds[index]
}
// requireDrawerSingleCommand returns the only command with the given name.
func requireDrawerSingleCommand(t *testing.T, d *fakePrimitiveDrawer, name string) fakeDrawerCommand {
t.Helper()
cmds := d.CommandsByName(name)
require.Len(t, cmds, 1)
return cmds[0]
}
// requireCommandName asserts the command name.
func requireCommandName(t *testing.T, cmd fakeDrawerCommand, want string) {
t.Helper()
require.Equal(t, want, cmd.Name)
}
// requireCommandArgs asserts the exact float arguments.
func requireCommandArgs(t *testing.T, cmd fakeDrawerCommand, want ...float64) {
t.Helper()
require.Equal(t, want, cmd.Args)
}
// requireCommandArgsInDelta asserts the float arguments with tolerance.
func requireCommandArgsInDelta(t *testing.T, cmd fakeDrawerCommand, delta float64, want ...float64) {
t.Helper()
require.Len(t, cmd.Args, len(want))
for i := range want {
require.InDelta(t, want[i], cmd.Args[i], delta, "arg index %d", i)
}
}
// requireCommandClipRects asserts the clip stack snapshot attached to the command.
func requireCommandClipRects(t *testing.T, cmd fakeDrawerCommand, want ...fakeClipRect) {
t.Helper()
require.Equal(t, want, cmd.Clips)
}
// requireCommandLineWidth asserts the line width snapshot attached to the command.
func requireCommandLineWidth(t *testing.T, cmd fakeDrawerCommand, want float64) {
t.Helper()
require.Equal(t, want, cmd.LineWidth)
}
// requireCommandDashes asserts the dash snapshot attached to the command.
func requireCommandDashes(t *testing.T, cmd fakeDrawerCommand, want ...float64) {
t.Helper()
require.Equal(t, want, cmd.Dashes)
}
// requireCommandDashOffset asserts the dash offset snapshot attached to the command.
func requireCommandDashOffset(t *testing.T, cmd fakeDrawerCommand, want float64) {
t.Helper()
require.Equal(t, want, cmd.DashOffset)
}
-225
View File
@@ -1,225 +0,0 @@
package world
import (
"sort"
)
// PrimitiveKind identifies primitive types in hit-test results.
type PrimitiveKind uint8
const (
KindLine PrimitiveKind = iota
KindCircle
KindPoint
)
// Hit describes one primitive that matches a hit-test query.
type Hit struct {
ID PrimitiveID
Kind PrimitiveKind
Priority int
StyleID StyleID
// DistanceSq is squared distance in world-fixed units to the primitive geometry (best-effort).
// Used for tie-breaking (smaller is better).
DistanceSq u128
// Primitive world coordinates:
// - Point: X,Y set
// - Circle: X,Y,Radius set
// - Line: X1,Y1,X2,Y2 set
X, Y int
Radius int
X1, Y1 int
X2, Y2 int
}
// Default hit slop (in pixels) per primitive type.
const (
DefaultHitSlopLinePx = 6
DefaultHitSlopCirclePx = 6
DefaultHitSlopPointPx = 8
// If a circle's screen radius is below this threshold, treat it as point-like for hit testing.
CirclePointLikeMinRadiusPx = 3
)
// HitTest finds primitives under cursor (in viewport pixel coordinates) with hit slop.
// The caller provides a buffer `out`. The returned slice aliases `out` (no allocations).
//
// If cap(out) is too small, it returns only the best hits by ranking:
//
// Priority desc, Distance asc, Kind asc, ID asc.
//
// Notes:
// - cursorXPx/cursorYPx are relative to viewport top-left.
// - Works for wrap and no-wrap modes (based on params.Options.DisableWrapScroll).
func (w *World) HitTest(out []Hit, params *RenderParams, cursorXPx, cursorYPx int) ([]Hit, error) {
if err := params.Validate(); err != nil {
return nil, err
}
if w.grid == nil || w.rows == 0 || w.cols == 0 {
return nil, errGridNotBuilt
}
zoomFp, err := params.CameraZoomFp()
if err != nil {
return nil, err
}
allowWrap := true
if params.Options != nil && params.Options.DisableWrapScroll {
allowWrap = false
}
// Use clamped camera in no-wrap mode for consistency.
camX := params.CameraXWorldFp
camY := params.CameraYWorldFp
if !allowWrap {
camX, camY = ClampCameraNoWrapViewport(
camX, camY,
params.ViewportWidthPx, params.ViewportHeightPx,
zoomFp,
w.W, w.H,
)
}
// Convert cursor viewport px to world-fixed coordinate (unwrapped relative to camera).
worldPerPx := PixelSpanToWorldFixed(1, zoomFp)
offXPx := cursorXPx - params.ViewportWidthPx/2
offYPx := cursorYPx - params.ViewportHeightPx/2
cursorX := camX + offXPx*worldPerPx
cursorY := camY + offYPx*worldPerPx
if allowWrap {
cursorX = wrap(cursorX, w.W)
cursorY = wrap(cursorY, w.H)
} else {
// Clamp cursor into world bounds to avoid weird negative coords in margins.
cursorX = clamp(cursorX, 0, w.W-1)
cursorY = clamp(cursorY, 0, w.H-1)
}
// Compute a conservative search bbox around cursor using max possible slop (px->world).
// We use the maximum of default slops; per-object overrides are handled later.
maxSlopPx := max(DefaultHitSlopLinePx, max(DefaultHitSlopCirclePx, DefaultHitSlopPointPx))
maxSlopWorld := PixelSpanToWorldFixed(maxSlopPx, zoomFp)
minX := cursorX - maxSlopWorld
maxX := cursorX + maxSlopWorld + 1
minY := cursorY - maxSlopWorld
maxY := cursorY + maxSlopWorld + 1
var rects []Rect
if allowWrap {
rects = splitByWrap(w.W, w.H, minX, maxX, minY, maxY)
} else {
// Clamp to world.
minX = clamp(minX, 0, w.W)
maxX = clamp(maxX, 0, w.W)
minY = clamp(minY, 0, w.H)
maxY = clamp(maxY, 0, w.H)
if maxX <= minX || maxY <= minY {
return out[:0], nil
}
rects = []Rect{{minX: minX, maxX: maxX, minY: minY, maxY: maxY}}
}
// Gather candidates from grid cells, dedupe by ID.
cand := make(map[PrimitiveID]struct{}, 32)
for _, r := range rects {
colStart := w.worldToCellX(r.minX)
colEnd := w.worldToCellX(r.maxX - 1)
rowStart := w.worldToCellY(r.minY)
rowEnd := w.worldToCellY(r.maxY - 1)
for row := rowStart; row <= rowEnd; row++ {
for col := colStart; col <= colEnd; col++ {
cell := w.grid[row][col]
for _, it := range cell {
cand[it.ID()] = struct{}{}
}
}
}
}
// Use caller buffer as backing store; keep only best cap(out) hits.
out = out[:0]
limit := cap(out)
for id := range cand {
cur, ok := w.objects[id]
if !ok {
continue
}
h, ok := w.hitOne(cur, cursorX, cursorY, zoomFp, allowWrap)
if !ok {
continue
}
if limit == 0 {
// Caller provided zero-cap buffer; cannot store anything.
continue
}
if len(out) < limit {
out = append(out, h)
continue
}
// Replace the worst hit if the new one is better.
worstIdx := 0
for i := 1; i < len(out); i++ {
if hitLess(out[worstIdx], out[i]) {
worstIdx = i // out[i] is worse than out[worstIdx]
}
}
if hitLess(h, out[worstIdx]) {
out[worstIdx] = h
}
}
// Sort final hits by best-first order.
sort.Slice(out, func(i, j int) bool {
return hitLess(out[i], out[j])
})
return out, nil
}
// hitLess orders hits by:
// Priority desc, DistanceSq asc, Kind asc, ID asc.
func hitLess(a, b Hit) bool {
if a.Priority != b.Priority {
return a.Priority > b.Priority
}
if c := u128Cmp(a.DistanceSq, b.DistanceSq); c != 0 {
return c < 0
}
if a.Kind != b.Kind {
return a.Kind < b.Kind
}
return a.ID < b.ID
}
func (w *World) hitOne(it MapItem, cx, cy int, zoomFp int, allowWrap bool) (Hit, bool) {
switch v := it.(type) {
case Point:
return hitPoint(v, cx, cy, zoomFp, allowWrap, w.W, w.H)
case Circle:
style, ok := w.styles.Get(v.StyleID)
if !ok {
// Unknown style should not happen; treat as no-hit rather than panic.
return Hit{}, false
}
return hitCircle(v, circleRadiusEffFp(v.Radius, w.circleRadiusScaleFp), style, cx, cy, zoomFp, allowWrap, w.W, w.H)
case Line:
return hitLine(v, cx, cy, zoomFp, allowWrap, w.W, w.H)
default:
panic("HitTest: unknown map item type")
}
}
-336
View File
@@ -1,336 +0,0 @@
package world
import (
"github.com/stretchr/testify/require"
"image/color"
"testing"
)
// TestHitTest_ReturnsBestByPriorityAndAllHits verifies hit Test Returns Best By Priority And All Hits.
func TestHitTest_ReturnsBestByPriorityAndAllHits(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
// Build index once renderer state is initialized.
params := RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 100,
MarginXPx: 0,
MarginYPx: 0,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom)
// Add overlapping objects near center.
idLine, err := w.AddLine(4.5, 5.0, 5.5, 5.0, LineWithPriority(100))
require.NoError(t, err)
idCircle, err := w.AddCircle(5.0, 5.0, 1.0, CircleWithPriority(300))
require.NoError(t, err)
idPoint, err := w.AddPoint(5.0, 5.0, PointWithPriority(200))
require.NoError(t, err)
// Force index rebuild from last state (Add already does it, but keep explicit).
w.Reindex()
buf := make([]Hit, 0, 8)
hits, err := w.HitTest(buf, &params, 50, 50) // center of viewport
require.NoError(t, err)
// Should find all three, best first (priority desc).
require.Len(t, hits, 3)
require.Equal(t, idCircle, hits[0].ID)
require.Equal(t, idPoint, hits[1].ID)
require.Equal(t, idLine, hits[2].ID)
}
// TestHitTest_BufferTooSmall_KeepsBestHits verifies hit Test Buffer Too Small Keeps Best Hits.
func TestHitTest_BufferTooSmall_KeepsBestHits(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
params := RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 100,
MarginXPx: 0,
MarginYPx: 0,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom)
_, _ = w.AddLine(4.5, 5.0, 5.5, 5.0, LineWithPriority(100))
idCircle, _ := w.AddCircle(5.0, 5.0, 1.0, CircleWithPriority(300))
_, _ = w.AddPoint(5.0, 5.0, PointWithPriority(200))
w.Reindex()
// Only room for 1 hit => must keep the best (highest priority).
buf := make([]Hit, 0, 1)
hits, err := w.HitTest(buf, &params, 50, 50)
require.NoError(t, err)
require.Len(t, hits, 1)
require.Equal(t, idCircle, hits[0].ID)
}
// TestHitTest_NoWrap_ClampsCameraAndStillHits verifies hit Test No Wrap Clamps Camera And Still Hits.
func TestHitTest_NoWrap_ClampsCameraAndStillHits(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
params := RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 100,
MarginXPx: 25,
MarginYPx: 25,
CameraXWorldFp: -100000, // invalid camera, should be clamped
CameraYWorldFp: -100000,
CameraZoom: 1.0,
Options: &RenderOptions{DisableWrapScroll: true},
}
w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom)
_, err := w.AddPoint(0.0, 0.0, PointWithPriority(100))
require.NoError(t, err)
w.Reindex()
// Tap near top-left of viewport should still map to world and find the point.
buf := make([]Hit, 0, 8)
hits, err := w.HitTest(buf, &params, 0, 0)
require.NoError(t, err)
require.NotEmpty(t, hits)
}
// TestHitTest_CircleStrokeOnly_HitsNearRingNotCenter verifies hit Test Circle Stroke Only Hits Near Ring Not Center.
func TestHitTest_CircleStrokeOnly_HitsNearRingNotCenter(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
params := RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 100,
MarginXPx: 0,
MarginYPx: 0,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom)
// Stroke-only circle: FillColor alpha=0 => ring mode.
ov := StyleOverride{
FillColor: color.RGBA{A: 0},
StrokeColor: color.RGBA{A: 255},
}
strokeStyle := w.AddStyleCircle(ov)
_, err := w.AddCircle(5.0, 5.0, 2.0,
CircleWithStyleID(strokeStyle),
CircleWithPriority(100),
)
require.NoError(t, err)
w.Reindex()
buf := make([]Hit, 0, 8)
// Center must NOT hit.
hits, err := w.HitTest(buf, &params, 50, 50)
require.NoError(t, err)
require.Empty(t, hits)
// Near ring should hit. For small circles we use a minimum visible ring radius (3px).
// So tapping at +3px from center should be within ring+slop.
hits, err = w.HitTest(buf, &params, 50+3, 50)
require.NoError(t, err)
require.NotEmpty(t, hits)
require.Equal(t, KindCircle, hits[0].Kind)
}
// TestHitTest_CircleRadiusScale_AffectsHitArea verifies hit Test Circle Radius Scale Affects Hit Area.
func TestHitTest_CircleRadiusScale_AffectsHitArea(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.SetTheme(DefaultTheme{}) // filled circles by default in our defaults
w.IndexOnViewportChange(100, 100, 1.0)
// raw radius=2 units, centered at (5,5)
_, err := w.AddCircle(5, 5, 2)
require.NoError(t, err)
// scale=2 => eff radius=4
require.NoError(t, w.SetCircleRadiusScaleFp(2*SCALE))
w.Reindex()
params := RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 100,
MarginXPx: 0,
MarginYPx: 0,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
// Tap at +4 px from center should hit (eff radius 4).
buf := make([]Hit, 0, 8)
hits, err := w.HitTest(buf, &params, 50+4, 50)
require.NoError(t, err)
require.NotEmpty(t, hits)
require.Equal(t, KindCircle, hits[0].Kind)
// Tap at +5 should typically miss (depending on slop); enforce by setting small slop via options.
// We'll add a small-slope circle and test deterministically.
}
// TestHitTest_Circle_StrictThresholds_WithRadiusScale_Table verifies hit Test Circle Strict Thresholds With Radius Scale Table.
func TestHitTest_Circle_StrictThresholds_WithRadiusScale_Table(t *testing.T) {
t.Parallel()
type tc struct {
name string
fillVisible bool
rawRadius int // world units (not fixed); zoom=1 => 1px per unit
scaleFp int
hitSlopPx int
cursorDxPx int // offset from center in pixels along X axis
wantHit bool
wantKind PrimitiveKind
}
// Common settings: world 20x20, viewport 200x200, camera at center (10,10).
params := RenderParams{
ViewportWidthPx: 200,
ViewportHeightPx: 200,
MarginXPx: 0,
MarginYPx: 0,
CameraXWorldFp: 10 * SCALE,
CameraYWorldFp: 10 * SCALE,
CameraZoom: 1.0,
}
tests := []tc{
{
name: "filled: on boundary hits (R=4, S=1, dx=4)",
fillVisible: true,
rawRadius: 2,
scaleFp: 2 * SCALE, // eff radius = 4
hitSlopPx: 1,
cursorDxPx: 4,
wantHit: true,
wantKind: KindCircle,
},
{
name: "filled: outside beyond slop misses (R=4, S=1, dx=6)",
fillVisible: true,
rawRadius: 2,
scaleFp: 2 * SCALE,
hitSlopPx: 1,
cursorDxPx: 6, // 6 > R+S = 5
wantHit: false,
},
{
name: "filled: just inside slop hits (R=4, S=1, dx=5)",
fillVisible: true,
rawRadius: 2,
scaleFp: 2 * SCALE,
hitSlopPx: 1,
cursorDxPx: 5, // == R+S
wantHit: true,
wantKind: KindCircle,
},
{
name: "stroke-only: center must miss even if slop would cover",
fillVisible: false,
rawRadius: 2,
scaleFp: 2 * SCALE, // eff radius = 4
hitSlopPx: 10, // huge, would normally include center without our rule
cursorDxPx: 0,
wantHit: false,
},
{
name: "stroke-only: on ring hits (R=4, S=1, dx=4)",
fillVisible: false,
rawRadius: 2,
scaleFp: 2 * SCALE,
hitSlopPx: 1,
cursorDxPx: 4,
wantHit: true,
wantKind: KindCircle,
},
{
name: "stroke-only: inside ring beyond slop misses (R=4, S=1, dx=2)",
fillVisible: false,
rawRadius: 2,
scaleFp: 2 * SCALE,
hitSlopPx: 1,
cursorDxPx: 2, // 2 < R-S = 3
wantHit: false,
},
{
name: "stroke-only: outside ring beyond slop misses (R=4, S=1, dx=6)",
fillVisible: false,
rawRadius: 2,
scaleFp: 2 * SCALE,
hitSlopPx: 1,
cursorDxPx: 6, // 6 > R+S = 5
wantHit: false,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
w := NewWorld(20, 20)
w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom)
require.NoError(t, w.SetCircleRadiusScaleFp(tt.scaleFp))
// Build a stroke-only circle style if needed.
var opts []CircleOpt
opts = append(opts, CircleWithHitSlopPx(tt.hitSlopPx))
if !tt.fillVisible {
// Force fill alpha=0 => stroke-only for hit-test and rendering.
sw := 1.0
styleID := w.AddStyleCircle(StyleOverride{
FillColor: color.RGBA{A: 0},
StrokeColor: color.RGBA{A: 255},
StrokeWidthPx: &sw,
})
opts = append(opts, CircleWithStyleID(styleID))
}
_, err := w.AddCircle(10, 10, float64(tt.rawRadius), opts...)
require.NoError(t, err)
w.Reindex()
// Cursor at viewport center +/- dx along X. At zoom=1, 1px == 1 world unit.
cx := params.ViewportWidthPx/2 + tt.cursorDxPx
cy := params.ViewportHeightPx / 2
buf := make([]Hit, 0, 8)
hits, err := w.HitTest(buf, &params, cx, cy)
require.NoError(t, err)
if !tt.wantHit {
require.Empty(t, hits)
return
}
require.NotEmpty(t, hits)
require.Equal(t, tt.wantKind, hits[0].Kind)
})
}
}
File diff suppressed because it is too large Load Diff
-411
View File
@@ -1,411 +0,0 @@
package world
import (
"github.com/fogleman/gg"
"github.com/stretchr/testify/require"
"image"
"image/color"
"testing"
)
type benchBgTheme struct {
img image.Image
anchor BackgroundAnchorMode
tileMode BackgroundTileMode
scaleMode BackgroundScaleMode
}
func (t benchBgTheme) ID() string { return "benchbg" }
func (t benchBgTheme) Name() string { return "benchbg" }
func (t benchBgTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} }
func (t benchBgTheme) BackgroundImage() image.Image { return t.img }
func (t benchBgTheme) BackgroundTileMode() BackgroundTileMode { return t.tileMode }
func (t benchBgTheme) BackgroundScaleMode() BackgroundScaleMode { return t.scaleMode }
func (t benchBgTheme) BackgroundAnchorMode() BackgroundAnchorMode { return t.anchor }
func (t benchBgTheme) PointStyle() Style {
return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2}
}
func (t benchBgTheme) LineStyle() Style {
return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
}
func (t benchBgTheme) CircleStyle() Style {
return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
}
func (t benchBgTheme) PointClassOverride(PointClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
func (t benchBgTheme) LineClassOverride(LineClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
func (t benchBgTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
// BenchmarkRender_IncrementalPan_NoBackground benchmarks render Incremental Pan No Background.
func BenchmarkRender_IncrementalPan_NoBackground(b *testing.B) {
w := NewWorld(600, 600)
w.IndexOnViewportChange(1200, 800, 1.0)
// Some primitives to keep it realistic but not dominant.
for i := 0; i < 200; i++ {
_, _ = w.AddPoint(float64(i%600), float64((i*7)%600))
}
w.Reindex()
dc := gg.NewContext(1200, 800)
drawer := &GGDrawer{DC: dc}
params := RenderParams{
ViewportWidthPx: 1000,
ViewportHeightPx: 700,
MarginXPx: 250,
MarginYPx: 175,
CameraXWorldFp: 300 * SCALE,
CameraYWorldFp: 300 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
Incremental: &IncrementalPolicy{
AllowShiftOnly: false,
CoalesceUpdates: false,
MaxCatchUpAreaPx: 0,
RenderBudgetMs: 0,
},
},
}
// Initial render (commit state).
_ = w.Render(drawer, params)
b.ResetTimer()
for i := 0; i < b.N; i++ {
params.CameraXWorldFp += 1 * SCALE
_ = w.Render(drawer, params)
}
}
// BenchmarkRender_IncrementalPan_BackgroundRepeat_WorldAnchor_ScaleNone benchmarks render Incremental Pan Background Repeat World Anchor Scale None.
func BenchmarkRender_IncrementalPan_BackgroundRepeat_WorldAnchor_ScaleNone(b *testing.B) {
benchRenderBg(b, BackgroundAnchorWorld, BackgroundTileRepeat, BackgroundScaleNone)
}
// BenchmarkRender_IncrementalPan_BackgroundRepeat_WorldAnchor_ScaleFit benchmarks render Incremental Pan Background Repeat World Anchor Scale Fit.
func BenchmarkRender_IncrementalPan_BackgroundRepeat_WorldAnchor_ScaleFit(b *testing.B) {
benchRenderBg(b, BackgroundAnchorWorld, BackgroundTileRepeat, BackgroundScaleFit)
}
// BenchmarkRender_IncrementalPan_BackgroundRepeat_ViewportAnchor_ScaleNone benchmarks render Incremental Pan Background Repeat Viewport Anchor Scale None.
func BenchmarkRender_IncrementalPan_BackgroundRepeat_ViewportAnchor_ScaleNone(b *testing.B) {
benchRenderBg(b, BackgroundAnchorViewport, BackgroundTileRepeat, BackgroundScaleNone)
}
func benchRenderBg(b *testing.B, anchor BackgroundAnchorMode, tile BackgroundTileMode, scale BackgroundScaleMode) {
w := NewWorld(600, 600)
w.IndexOnViewportChange(1200, 800, 1.0)
for i := 0; i < 200; i++ {
_, _ = w.AddPoint(float64(i%600), float64((i*7)%600))
}
w.Reindex()
// Background tile (RGBA) — typical texture size.
bg := image.NewRGBA(image.Rect(0, 0, 96, 96))
// Make it semi-transparent so draw.Over has real work.
for y := 0; y < 96; y++ {
for x := 0; x < 96; x++ {
bg.SetRGBA(x, y, color.RGBA{R: 255, G: 255, B: 255, A: 18})
}
}
w.SetTheme(benchBgTheme{img: bg, anchor: anchor, tileMode: tile, scaleMode: scale})
dc := gg.NewContext(1200, 800)
drawer := &GGDrawer{DC: dc}
params := RenderParams{
ViewportWidthPx: 1000,
ViewportHeightPx: 700,
MarginXPx: 250,
MarginYPx: 175,
CameraXWorldFp: 300 * SCALE,
CameraYWorldFp: 300 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
Incremental: &IncrementalPolicy{
AllowShiftOnly: false,
CoalesceUpdates: false,
MaxCatchUpAreaPx: 0,
RenderBudgetMs: 0,
},
},
}
_ = w.Render(drawer, params)
b.ResetTimer()
for i := 0; i < b.N; i++ {
params.CameraXWorldFp += 1 * SCALE
_ = w.Render(drawer, params)
}
}
// BenchmarkDrawPlanSinglePass_Lines_GG benchmarks draw Plan Single Pass Lines GG.
func BenchmarkDrawPlanSinglePass_Lines_GG(b *testing.B) {
w := NewWorld(600, 600)
w.IndexOnViewportChange(1000, 700, 1.0)
// Make a lot of lines, including ones that likely wrap.
for i := 0; i < 4000; i++ {
x1 := float64(i % 600)
y1 := float64((i * 7) % 600)
x2 := float64((i*13 + 500) % 600) // shift to create various deltas
y2 := float64((i*17 + 300) % 600)
_, _ = w.AddLine(x1, y1, x2, y2)
}
w.Reindex()
params := RenderParams{
ViewportWidthPx: 1000,
ViewportHeightPx: 700,
MarginXPx: 250,
MarginYPx: 175,
CameraXWorldFp: 300 * SCALE,
CameraYWorldFp: 300 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
BackgroundColor: color.RGBA{A: 255},
},
}
plan, err := w.buildRenderPlan(params)
if err != nil {
b.Fatalf("build plan: %v", err)
}
dc := gg.NewContext(params.CanvasWidthPx(), params.CanvasHeightPx())
drawer := &GGDrawer{DC: dc}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
w.drawPlanSinglePass(drawer, plan, true, drawPlanSinglePassClipEnabled, false)
}
}
// BenchmarkDrawPlanSinglePass_Lines_Fake benchmarks draw Plan Single Pass Lines Fake.
func BenchmarkDrawPlanSinglePass_Lines_Fake(b *testing.B) {
w := NewWorld(600, 600)
w.IndexOnViewportChange(1000, 700, 1.0)
for i := 0; i < 4000; i++ {
x1 := float64(i % 600)
y1 := float64((i * 7) % 600)
x2 := float64((i*13 + 500) % 600)
y2 := float64((i*17 + 300) % 600)
_, _ = w.AddLine(x1, y1, x2, y2)
}
w.Reindex()
params := RenderParams{
ViewportWidthPx: 1000,
ViewportHeightPx: 700,
MarginXPx: 250,
MarginYPx: 175,
CameraXWorldFp: 300 * SCALE,
CameraYWorldFp: 300 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
BackgroundColor: color.RGBA{A: 255},
},
}
plan, err := w.buildRenderPlan(params)
if err != nil {
b.Fatalf("build plan: %v", err)
}
drawer := &fakePrimitiveDrawer{}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
// Reset command log so it doesn't grow forever and dominate allocations.
drawer.Reset()
w.drawPlanSinglePass(drawer, plan, true, drawPlanSinglePassClipEnabled, false)
}
}
// TestRender_IncrementalShift_UsesOuterClip_NotPerTileClips verifies render Incremental Shift Uses Outer Clip Not Per Tile Clips.
func TestRender_IncrementalShift_UsesOuterClip_NotPerTileClips(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.IndexOnViewportChange(100, 80, 1.0)
w.resetGrid(2 * SCALE)
_, _ = w.AddPoint(5, 5)
w.Reindex()
params := RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 80,
MarginXPx: 25,
MarginYPx: 20,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
Incremental: &IncrementalPolicy{AllowShiftOnly: false},
},
}
// First render initializes state.
d1 := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d1, params))
// Small pan.
params2 := params
params2.CameraXWorldFp += 1 * SCALE
d2 := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d2, params2))
// Expect very few ClipRect calls (dirty strips count), not per tile.
clipCmds := d2.CommandsByName("ClipRect")
require.NotEmpty(t, clipCmds)
require.LessOrEqual(t, len(clipCmds), 4)
}
// TestRender_BatchesConsecutiveLinesByStyleID verifies render Batches Consecutive Lines By Style ID.
func TestRender_BatchesConsecutiveLinesByStyleID(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.IndexOnViewportChange(100, 80, 1.0)
// Two lines with default style, same priority.
_, _ = w.AddLine(1, 1, 8, 1)
_, _ = w.AddLine(1, 2, 8, 2)
w.Reindex()
params := RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 80,
MarginXPx: 25,
MarginYPx: 20,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
d := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d, params))
// We expect at least two AddLine, but only 1 Stroke for that run in a tile.
adds := d.CommandsByName("AddLine")
strokes := d.CommandsByName("Stroke")
require.GreaterOrEqual(t, len(adds), 2)
require.GreaterOrEqual(t, len(strokes), 1)
// Stronger: within any consecutive group of AddLine commands, count strokes <= 1.
// (Keep it loose to avoid depending on tile partitioning.)
}
// BenchmarkDrawPlanSinglePass_DrawItemsReuse benchmarks draw Plan Single Pass Draw Items Reuse.
func BenchmarkDrawPlanSinglePass_DrawItemsReuse(b *testing.B) {
w := NewWorld(600, 600)
// Make grid + index available.
w.IndexOnViewportChange(1000, 700, 1.0)
// Add enough objects so tiles have candidates.
for i := range 2000 {
_, _ = w.AddPoint(float64(i%600), float64((i*7)%600))
}
for i := range 500 {
_, _ = w.AddCircle(float64((i*11)%600), float64((i*13)%600), 5.0)
}
w.Reindex()
params := RenderParams{
ViewportWidthPx: 1000,
ViewportHeightPx: 700,
MarginXPx: 250,
MarginYPx: 175,
CameraXWorldFp: 300 * SCALE,
CameraYWorldFp: 300 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
BackgroundColor: color.RGBA{A: 255},
},
}
plan, err := w.buildRenderPlan(params)
if err != nil {
b.Fatalf("build plan: %v", err)
}
dc := gg.NewContext(params.CanvasWidthPx(), params.CanvasHeightPx())
drawer := &GGDrawer{DC: dc}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
// We don't clear here; we only measure the draw loop overhead.
w.drawPlanSinglePass(drawer, plan, true, drawPlanSinglePassClipEnabled, false)
}
}
// BenchmarkBuildRenderPlanStageA_Candidates benchmarks build Render Plan Stage A Candidates.
func BenchmarkBuildRenderPlanStageA_Candidates(b *testing.B) {
w := NewWorld(600, 600)
// Make the index/grid available.
w.IndexOnViewportChange(1000, 700, 1.0)
// Populate with enough objects to create duplicates across cells.
// Circles and lines create bbox indexing (more duplicates).
for i := 0; i < 2000; i++ {
_, _ = w.AddPoint(float64(i%600), float64((i*7)%600))
}
for i := 0; i < 1200; i++ {
_, _ = w.AddCircle(float64((i*11)%600), float64((i*13)%600), 8.0)
}
for i := 0; i < 1200; i++ {
x1 := float64((i*3 + 10) % 600)
y1 := float64((i*5 + 20) % 600)
x2 := float64((i*7 + 400) % 600)
y2 := float64((i*11 + 300) % 600)
_, _ = w.AddLine(x1, y1, x2, y2)
}
w.Reindex()
params := RenderParams{
ViewportWidthPx: 1000,
ViewportHeightPx: 700,
MarginXPx: 250,
MarginYPx: 175,
CameraXWorldFp: 300 * SCALE,
CameraYWorldFp: 300 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
BackgroundColor: color.RGBA{A: 255},
},
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := w.buildRenderPlan(params)
if err != nil {
b.Fatalf("build plan: %v", err)
}
}
}
File diff suppressed because it is too large Load Diff
-695
View File
@@ -1,695 +0,0 @@
package world
import (
"image"
"image/color"
"sync"
)
// StyleID references a fully-materialized style stored in StyleTable.
type StyleID int
const (
// StyleIDInvalid means "no style". It should not be used for rendering.
StyleIDInvalid StyleID = 0
// Built-in default styles (stable IDs).
StyleIDDefaultLine StyleID = 1
StyleIDDefaultCircle StyleID = 2
StyleIDDefaultPoint StyleID = 3
)
// Default priorities (smaller draws earlier), step=100.
const (
DefaultPriorityLine = 100
DefaultPriorityCircle = 200
DefaultPriorityPoint = 300
)
var (
transparentColor color.Color = &color.RGBA{A: 0}
)
// TransparentFill returns a reusable fully transparent color value.
//
// It is intended for callers that want to explicitly disable fill while still
// setting a non-nil FillColor override.
func TransparentFill() color.Color { return transparentColor }
// Style is a fully resolved style used by the renderer.
// All fields are concrete values; no "optional" markers here.
// Optionality is handled by StyleOverride during style creation.
type Style struct {
// FillColor is used for Fill() operations (points/circles typically).
// If nil, the renderer may treat it as "do not fill" depending on primitive.
FillColor color.Color
// StrokeColor is used for Stroke() operations (lines typically).
// If nil, the renderer may treat it as "do not stroke" depending on primitive.
StrokeColor color.Color
// StrokeWidthPx is a screen-space stroke width in pixels.
StrokeWidthPx float64
// StrokeDashes is the dash pattern in pixels. nil/empty means "solid".
StrokeDashes []float64
// StrokeDashOffset is the dash phase in pixels.
StrokeDashOffset float64
// PointRadiusPx is a screen-space radius for Point markers.
PointRadiusPx float64
}
// StyleOverride describes partial modifications applied to a base Style.
// Fields set to nil mean "do not override".
type StyleOverride struct {
FillColor color.Color
StrokeColor color.Color
StrokeWidthPx *float64
StrokeDashes *[]float64
StrokeDashOffset *float64
PointRadiusPx *float64
}
// IsZero reports whether override does not specify any fields.
func (o StyleOverride) IsZero() bool {
return o.FillColor == nil &&
o.StrokeColor == nil &&
o.StrokeWidthPx == nil &&
o.StrokeDashes == nil &&
o.StrokeDashOffset == nil &&
o.PointRadiusPx == nil
}
// Apply applies override to base style and returns a new fully resolved style.
// It copies slices defensively to avoid aliasing.
func (o StyleOverride) Apply(base Style) Style {
out := base
if o.FillColor != nil {
out.FillColor = o.FillColor
}
if o.StrokeColor != nil {
out.StrokeColor = o.StrokeColor
}
if o.StrokeWidthPx != nil {
out.StrokeWidthPx = *o.StrokeWidthPx
}
if o.StrokeDashes != nil {
// Copy to avoid future mutation by caller.
src := *o.StrokeDashes
if src == nil {
out.StrokeDashes = nil
} else {
dst := make([]float64, len(src))
copy(dst, src)
out.StrokeDashes = dst
}
}
if o.StrokeDashOffset != nil {
out.StrokeDashOffset = *o.StrokeDashOffset
}
if o.PointRadiusPx != nil {
out.PointRadiusPx = *o.PointRadiusPx
}
return out
}
// StyleTable stores fully resolved styles and provides stable lookups by StyleID.
// It also holds three built-in defaults for Line/Circle/Point.
type StyleTable struct {
mu sync.RWMutex
nextID StyleID
styles map[StyleID]Style
}
// NewStyleTable creates a new style table with built-in default styles.
// The default values are intentionally simple and stable.
func NewStyleTable() *StyleTable {
t := &StyleTable{
nextID: StyleIDDefaultPoint + 1,
styles: make(map[StyleID]Style, 16),
}
// Defaults: conservative, deterministic.
// Colors: opaque black. (Callers can override.)
white := color.RGBA{R: 255, G: 255, B: 255, A: 255}
t.styles[StyleIDDefaultLine] = Style{
FillColor: nil,
StrokeColor: white,
StrokeWidthPx: 2.0,
StrokeDashes: nil,
StrokeDashOffset: 0,
PointRadiusPx: 0,
}
t.styles[StyleIDDefaultCircle] = Style{
FillColor: white,
StrokeColor: nil,
StrokeWidthPx: 0,
StrokeDashes: nil,
StrokeDashOffset: 0,
PointRadiusPx: 0,
}
t.styles[StyleIDDefaultPoint] = Style{
FillColor: white,
StrokeColor: nil,
StrokeWidthPx: 0,
StrokeDashes: nil,
StrokeDashOffset: 0,
PointRadiusPx: 2.0,
}
return t
}
// Get returns a style by id.
func (t *StyleTable) Get(id StyleID) (Style, bool) {
t.mu.RLock()
defer t.mu.RUnlock()
s, ok := t.styles[id]
if !ok {
return Style{}, false
}
// Defensive copy of slices.
if s.StrokeDashes != nil {
cp := make([]float64, len(s.StrokeDashes))
copy(cp, s.StrokeDashes)
s.StrokeDashes = cp
}
return s, true
}
// AddDerived creates a new style based on baseID with an override applied.
// It returns the new style ID.
func (t *StyleTable) AddDerived(baseID StyleID, override StyleOverride) StyleID {
t.mu.Lock()
defer t.mu.Unlock()
base, ok := t.styles[baseID]
if !ok {
panic("StyleTable.AddDerived: unknown base style ID")
}
derived := override.Apply(base)
id := t.nextID
t.nextID++
// Defensive copy of slices on store.
if derived.StrokeDashes != nil {
cp := make([]float64, len(derived.StrokeDashes))
copy(cp, derived.StrokeDashes)
derived.StrokeDashes = cp
}
t.styles[id] = derived
return id
}
// AddStyle stores a fully resolved style as a new StyleID.
// It defensively copies slice fields.
func (t *StyleTable) AddStyle(s Style) StyleID {
t.mu.Lock()
defer t.mu.Unlock()
id := t.nextID
t.nextID++
if s.StrokeDashes != nil {
cp := make([]float64, len(s.StrokeDashes))
copy(cp, s.StrokeDashes)
s.StrokeDashes = cp
}
t.styles[id] = s
return id
}
// Count returns the number of styles stored in the table.
// Intended for tests/diagnostics.
func (t *StyleTable) Count() int {
t.mu.RLock()
defer t.mu.RUnlock()
return len(t.styles)
}
// BackgroundTileMode defines how the background image is tiled.
type BackgroundTileMode uint8
const (
BackgroundTileNone BackgroundTileMode = iota
BackgroundTileRepeat
)
// BackgroundAnchorMode defines whether the background image scrolls with the world or stays fixed to viewport.
type BackgroundAnchorMode uint8
const (
BackgroundAnchorWorld BackgroundAnchorMode = iota
BackgroundAnchorViewport
)
// BackgroundScaleMode defines how the background image is scaled.
// (Step 1: defined for API completeness; used later when rendering background image.)
type BackgroundScaleMode uint8
const (
BackgroundScaleNone BackgroundScaleMode = iota
BackgroundScaleFit
BackgroundScaleFill
)
// StyleTheme describes a cohesive style set (theme) for rendering.
// Step 1: we store it in World and use it for background and default base styles.
// Step 2+: theme-relative overrides and background image drawing.
type StyleTheme interface {
ID() string
Name() string
BackgroundColor() color.Color
BackgroundImage() image.Image
BackgroundTileMode() BackgroundTileMode
BackgroundScaleMode() BackgroundScaleMode
BackgroundAnchorMode() BackgroundAnchorMode
PointStyle() Style
LineStyle() Style
CircleStyle() Style
// Class overrides (relative to base kind style).
// Return (override, true) when class is supported; (zero, false) means "no override".
PointClassOverride(class PointClassID) (StyleOverride, bool)
LineClassOverride(class LineClassID) (StyleOverride, bool)
CircleClassOverride(class CircleClassID) (StyleOverride, bool)
}
// DefaultTheme is a conservative theme matching built-in default styles.
type DefaultTheme struct{}
func (DefaultTheme) ID() string { return "default" }
func (DefaultTheme) Name() string { return "Default" }
func (DefaultTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} }
func (DefaultTheme) BackgroundImage() image.Image { return nil }
func (DefaultTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone }
func (DefaultTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone }
func (DefaultTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld }
func (DefaultTheme) PointStyle() Style {
s, _ := NewStyleTable().Get(StyleIDDefaultPoint)
return s
}
func (DefaultTheme) LineStyle() Style {
s, _ := NewStyleTable().Get(StyleIDDefaultLine)
return s
}
func (DefaultTheme) CircleStyle() Style {
s, _ := NewStyleTable().Get(StyleIDDefaultCircle)
return s
}
func (DefaultTheme) PointClassOverride(PointClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
func (DefaultTheme) LineClassOverride(LineClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
func (DefaultTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
// This file provides two sample themes for demos and UI integration:
// LightTheme uses only background color, while DarkTheme also carries a
// prebuilt tiled texture image.
var (
// ThemeLight is the shared light theme instance used by the client package.
ThemeLight = &LightTheme{}
// ThemeDark is the shared dark theme instance used by the client package.
ThemeDark = NewDarkTheme()
)
// -----------------------------
// Helpers
// -----------------------------
// cRGBA constructs an sRGB color from 8-bit RGBA channels.
func cRGBA(r, g, b, a uint8) color.Color { return color.RGBA{R: r, G: g, B: b, A: a} }
// -----------------------------
// Light Theme (color only)
// -----------------------------
// LightTheme is a soft high-contrast theme intended for bright backgrounds.
type LightTheme struct{}
func (LightTheme) ID() string { return "theme.light.v1" }
func (LightTheme) Name() string { return "Light (Soft)" }
func (LightTheme) BackgroundColor() color.Color { return cRGBA(244, 246, 248, 255) } // #F4F6F8
func (LightTheme) BackgroundImage() image.Image { return nil }
func (LightTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone }
func (LightTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone }
func (LightTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld }
// Base styles per primitive kind (full Style, not override).
func (LightTheme) PointStyle() Style {
return Style{
FillColor: cRGBA(32, 161, 145, 255), // soft teal
StrokeColor: nil,
StrokeWidthPx: 0,
PointRadiusPx: 3.0,
}
}
func (LightTheme) LineStyle() Style {
return Style{
FillColor: nil,
StrokeColor: cRGBA(70, 108, 196, 220), // soft blue
StrokeWidthPx: 2.0,
StrokeDashes: nil,
StrokeDashOffset: 0,
}
}
func (LightTheme) CircleStyle() Style {
return Style{
FillColor: cRGBA(133, 110, 201, 60), // soft purple with low alpha
StrokeColor: cRGBA(133, 110, 201, 200), // soft purple
StrokeWidthPx: 2.0,
}
}
// Point class overrides.
func (LightTheme) PointClassOverride(class PointClassID) (StyleOverride, bool) {
switch class {
case PointClassDefault:
return StyleOverride{}, false
case PointClassTrackUnknown:
// muted gray-blue
return StyleOverride{
FillColor: cRGBA(120, 135, 160, 230),
PointRadiusPx: new(3.0),
}, true
case PointClassTrackIncoming:
// soft green
return StyleOverride{
FillColor: cRGBA(76, 171, 107, 240),
PointRadiusPx: new(3.5),
}, true
case PointClassTrackOutgoing:
// soft orange
return StyleOverride{
FillColor: cRGBA(222, 142, 70, 240),
PointRadiusPx: new(3.5),
}, true
case PointClassUnidentifiedPlanet:
// soft orange
return StyleOverride{
FillColor: cRGBA(192, 192, 192, 255),
PointRadiusPx: new(2.5),
}, true
default:
return StyleOverride{}, false
}
}
func (LightTheme) LineClassOverride(class LineClassID) (StyleOverride, bool) {
switch class {
case LineClassDefault:
return StyleOverride{}, false
case LineClassTrackIncoming:
return StyleOverride{
StrokeColor: cRGBA(76, 171, 107, 220),
StrokeWidthPx: new(2.5),
}, true
case LineCLassTrackOutgoing:
return StyleOverride{
StrokeColor: cRGBA(222, 142, 70, 220),
StrokeWidthPx: new(2.5),
}, true
case LineClassMeasurement:
// dashed neutral line
d := []float64{6, 4}
return StyleOverride{
StrokeColor: cRGBA(100, 110, 125, 200),
StrokeWidthPx: new(1.8),
StrokeDashes: &d,
StrokeDashOffset: new(0.),
}, true
default:
return StyleOverride{}, false
}
}
func (LightTheme) CircleClassOverride(class CircleClassID) (StyleOverride, bool) {
switch class {
case CircleClassDefault:
return StyleOverride{}, false
case CircleClassLocalPlanet:
// blue
return StyleOverride{
FillColor: cRGBA(70, 108, 196, 45),
StrokeColor: cRGBA(70, 108, 196, 220),
StrokeWidthPx: new(2.2),
}, true
case CircleClassOthersPlanet:
// orange
return StyleOverride{
FillColor: cRGBA(222, 142, 70, 50),
StrokeColor: cRGBA(222, 142, 70, 220),
StrokeWidthPx: new(2.2),
}, true
case CircleClassFreePlanet:
// green
return StyleOverride{
FillColor: cRGBA(76, 171, 107, 45),
StrokeColor: cRGBA(76, 171, 107, 220),
StrokeWidthPx: new(2.2),
}, true
default:
return StyleOverride{}, false
}
}
// -----------------------------
// Dark Theme (color + tiled image)
// -----------------------------
// DarkTheme is a dark theme with an optional reusable background tile.
type DarkTheme struct {
bg image.Image
}
// NewDarkTheme constructs a DarkTheme with its immutable texture tile prepared.
func NewDarkTheme() *DarkTheme {
return &DarkTheme{bg: makeDarkBackgroundTile(96, 96)}
}
func (*DarkTheme) ID() string { return "theme.dark.v1" }
func (*DarkTheme) Name() string { return "Dark (Soft + Texture)" }
func (*DarkTheme) BackgroundColor() color.Color { return cRGBA(30, 32, 38, 255) } // #1E2026
func (t *DarkTheme) BackgroundImage() image.Image {
return nil
// This image is immutable after creation.
// return t.bg
}
func (*DarkTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileRepeat }
func (*DarkTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone }
func (*DarkTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorViewport }
// Base styles for dark theme.
func (*DarkTheme) PointStyle() Style {
return Style{
FillColor: cRGBA(120, 214, 198, 255),
StrokeColor: nil,
StrokeWidthPx: 0,
PointRadiusPx: 3.0,
}
}
func (*DarkTheme) LineStyle() Style {
return Style{
FillColor: nil,
StrokeColor: cRGBA(155, 175, 235, 255),
StrokeWidthPx: 2.0,
StrokeDashes: nil,
StrokeDashOffset: 0,
}
}
func (*DarkTheme) CircleStyle() Style {
return Style{
FillColor: nil, // cRGBA(186, 160, 255, 255),
StrokeColor: cRGBA(186, 160, 255, 255),
StrokeWidthPx: 2.0,
}
}
// Point class overrides.
func (*DarkTheme) PointClassOverride(class PointClassID) (StyleOverride, bool) {
switch class {
case PointClassDefault:
return StyleOverride{}, false
case PointClassTrackUnknown:
return StyleOverride{
FillColor: cRGBA(150, 160, 175, 255),
PointRadiusPx: new(3.0),
}, true
case PointClassTrackIncoming:
return StyleOverride{
FillColor: cRGBA(132, 219, 162, 255),
PointRadiusPx: new(3.5),
}, true
case PointClassTrackOutgoing:
return StyleOverride{
FillColor: cRGBA(245, 178, 120, 255),
PointRadiusPx: new(3.5),
}, true
case PointClassUnidentifiedPlanet:
return StyleOverride{
FillColor: cRGBA(192, 192, 192, 255),
PointRadiusPx: new(2.5),
}, true
default:
return StyleOverride{}, false
}
}
func (*DarkTheme) LineClassOverride(class LineClassID) (StyleOverride, bool) {
switch class {
case LineClassDefault:
return StyleOverride{}, false
case LineClassTrackIncoming:
return StyleOverride{
StrokeColor: cRGBA(132, 219, 162, 255),
StrokeWidthPx: new(2.5),
}, true
case LineCLassTrackOutgoing:
return StyleOverride{
StrokeColor: cRGBA(245, 178, 120, 255),
StrokeWidthPx: new(2.5),
}, true
case LineClassMeasurement:
d := []float64{6, 4}
return StyleOverride{
StrokeColor: cRGBA(170, 175, 190, 255),
StrokeWidthPx: new(1.8),
StrokeDashes: &d,
StrokeDashOffset: new(0.),
}, true
default:
return StyleOverride{}, false
}
}
func (*DarkTheme) CircleClassOverride(class CircleClassID) (StyleOverride, bool) {
switch class {
case CircleClassDefault:
return StyleOverride{}, false
case CircleClassLocalPlanet:
return StyleOverride{
FillColor: cRGBA(155, 175, 235, 255),
StrokeColor: cRGBA(155, 175, 235, 255),
StrokeWidthPx: new(2.2),
}, true
case CircleClassOthersPlanet:
return StyleOverride{
FillColor: cRGBA(245, 178, 120, 255),
StrokeColor: cRGBA(245, 178, 120, 255),
StrokeWidthPx: new(2.2),
}, true
case CircleClassFreePlanet:
return StyleOverride{
FillColor: cRGBA(132, 219, 162, 255),
StrokeColor: cRGBA(132, 219, 162, 255),
StrokeWidthPx: new(2.2),
}, true
default:
return StyleOverride{}, false
}
}
// makeDarkBackgroundTile creates a subtle, low-contrast texture tile.
// It is intentionally simple: a faint grid + a few diagonal accents.
// The tile is meant to be repeated.
func makeDarkBackgroundTile(w, h int) image.Image {
if w <= 0 || h <= 0 {
return nil
}
img := image.NewRGBA(image.Rect(0, 0, w, h))
// Base is transparent; background color is drawn separately.
// We draw subtle strokes with low alpha.
grid := color.RGBA{R: 255, G: 255, B: 255, A: 12} // very faint
diag := color.RGBA{R: 255, G: 255, B: 255, A: 18} // slightly stronger
dots := color.RGBA{R: 255, G: 255, B: 255, A: 10} // faint dots
// Grid spacing (pixels).
const step = 12
// Vertical grid lines.
for x := 0; x < w; x += step {
for y := 0; y < h; y++ {
img.SetRGBA(x, y, grid)
}
}
// Horizontal grid lines.
for y := 0; y < h; y += step {
for x := 0; x < w; x++ {
img.SetRGBA(x, y, grid)
}
}
// Diagonal accents (sparse).
for x := 0; x < w; x += step * 2 {
for i := 0; i < step && x+i < w && i < h; i++ {
img.SetRGBA(x+i, i, diag)
}
}
// Small dot pattern.
for y := step / 2; y < h; y += step {
for x := step / 2; x < w; x += step {
img.SetRGBA(x, y, dots)
}
}
return img
}
-569
View File
@@ -1,569 +0,0 @@
package world
import (
"github.com/stretchr/testify/require"
"image"
"image/color"
"testing"
)
// TestStyleOverrideApply_OverridesOnlyProvidedFields verifies style Override Apply Overrides Only Provided Fields.
func TestStyleOverrideApply_OverridesOnlyProvidedFields(t *testing.T) {
t.Parallel()
base := Style{
FillColor: color.RGBA{R: 1, A: 255},
StrokeColor: color.RGBA{G: 2, A: 255},
StrokeWidthPx: 1.0,
StrokeDashes: []float64{3, 1},
StrokeDashOffset: 0.5,
PointRadiusPx: 2.0,
}
newWidth := 5.0
newRadius := 7.0
override := StyleOverride{
StrokeWidthPx: &newWidth,
PointRadiusPx: &newRadius,
// Everything else is unset (nil) => must remain from base.
}
out := override.Apply(base)
require.Equal(t, base.FillColor, out.FillColor)
require.Equal(t, base.StrokeColor, out.StrokeColor)
require.Equal(t, 5.0, out.StrokeWidthPx)
require.Equal(t, base.StrokeDashes, out.StrokeDashes)
require.Equal(t, base.StrokeDashOffset, out.StrokeDashOffset)
require.Equal(t, 7.0, out.PointRadiusPx)
}
// TestStyleTable_DefaultsExistAndAreStable verifies style Table Defaults Exist And Are Stable.
func TestStyleTable_DefaultsExistAndAreStable(t *testing.T) {
t.Parallel()
tbl := NewStyleTable()
_, ok := tbl.Get(StyleIDDefaultLine)
require.True(t, ok)
_, ok = tbl.Get(StyleIDDefaultCircle)
require.True(t, ok)
_, ok = tbl.Get(StyleIDDefaultPoint)
require.True(t, ok)
}
// TestStyleTable_AddDerived_StoresResolvedStyleAndCopiesSlices verifies style Table Add Derived Stores Resolved Style And Copies Slices.
func TestStyleTable_AddDerived_StoresResolvedStyleAndCopiesSlices(t *testing.T) {
t.Parallel()
tbl := NewStyleTable()
dashes := []float64{10, 5}
override := StyleOverride{
StrokeDashes: &dashes,
}
id := tbl.AddDerived(StyleIDDefaultLine, override)
got, ok := tbl.Get(id)
require.True(t, ok)
require.Equal(t, []float64{10, 5}, got.StrokeDashes)
// Mutate caller slice; table must not change.
dashes[0] = 999
got2, ok := tbl.Get(id)
require.True(t, ok)
require.Equal(t, []float64{10, 5}, got2.StrokeDashes)
// Mutate returned slice; table must not change.
got2.StrokeDashes[0] = 123
got3, ok := tbl.Get(id)
require.True(t, ok)
require.Equal(t, []float64{10, 5}, got3.StrokeDashes)
}
// TestDefaultPriorities_AreOrderedAndStepped verifies default Priorities Are Ordered And Stepped.
func TestDefaultPriorities_AreOrderedAndStepped(t *testing.T) {
t.Parallel()
require.Equal(t, 100, DefaultPriorityLine)
require.Equal(t, 200, DefaultPriorityCircle)
require.Equal(t, 300, DefaultPriorityPoint)
require.Less(t, DefaultPriorityLine, DefaultPriorityCircle)
require.Less(t, DefaultPriorityCircle, DefaultPriorityPoint)
}
type cacheTheme struct{}
func (cacheTheme) ID() string { return "cache" }
func (cacheTheme) Name() string { return "cache" }
func (cacheTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} }
func (cacheTheme) BackgroundImage() image.Image { return nil }
func (cacheTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone }
func (cacheTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone }
func (cacheTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld }
func (cacheTheme) PointStyle() Style { return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2} }
func (cacheTheme) LineStyle() Style { return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} }
func (cacheTheme) CircleStyle() Style {
return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
}
func (cacheTheme) PointClassOverride(PointClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
func (cacheTheme) LineClassOverride(LineClassID) (StyleOverride, bool) { return StyleOverride{}, false }
func (cacheTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
type cacheTheme2 struct{ cacheTheme }
func (cacheTheme2) ID() string { return "cache2" }
func (cacheTheme2) Name() string { return "cache2" }
func (cacheTheme2) CircleStyle() Style {
return Style{FillColor: color.RGBA{B: 200, A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 3}
}
func (cacheTheme2) PointClassOverride(PointClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
func (cacheTheme2) LineClassOverride(LineClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
func (cacheTheme2) CircleClassOverride(CircleClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
// TestDerivedStyleCache_ReusesDerivedStylesAcrossObjectsAndThemes verifies derived Style Cache Reuses Derived Styles Across Objects And Themes.
func TestDerivedStyleCache_ReusesDerivedStylesAcrossObjectsAndThemes(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.SetTheme(cacheTheme{})
before := w.styles.Count()
white := color.RGBA{R: 255, G: 255, B: 255, A: 255}
ov := StyleOverride{StrokeColor: white}
id1, err := w.AddCircle(5, 5, 2, CircleWithStyleOverride(ov))
require.NoError(t, err)
id2, err := w.AddCircle(6, 5, 2, CircleWithStyleOverride(ov))
require.NoError(t, err)
c1 := w.objects[id1].(Circle)
c2 := w.objects[id2].(Circle)
require.Equal(t, c1.StyleID, c2.StyleID, "same override must reuse derived style ID")
afterAdd := w.styles.Count()
require.Equal(t, before+1, afterAdd, "only one derived style should be added for identical overrides")
// Change theme: derived cache is cleared and new base IDs are created; override must still be applied,
// and both objects should again share one derived style for the new base.
w.SetTheme(cacheTheme2{})
c1b := w.objects[id1].(Circle)
c2b := w.objects[id2].(Circle)
require.Equal(t, c1b.StyleID, c2b.StyleID)
afterTheme := w.styles.Count()
// Theme change creates 3 new theme default styles + 1 new derived for the override.
require.GreaterOrEqual(t, afterTheme, afterAdd+4)
}
type testTheme struct{}
func (testTheme) ID() string { return "t1" }
func (testTheme) Name() string { return "Theme1" }
func (testTheme) BackgroundColor() color.Color { return color.RGBA{R: 1, G: 2, B: 3, A: 255} }
func (testTheme) BackgroundImage() image.Image { return nil }
func (testTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileRepeat }
func (testTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone }
func (testTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld }
func (testTheme) PointClassOverride(PointClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
func (testTheme) LineClassOverride(LineClassID) (StyleOverride, bool) { return StyleOverride{}, false }
func (testTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
func (testTheme) PointStyle() Style {
return Style{
FillColor: color.RGBA{R: 9, A: 255},
StrokeColor: nil,
StrokeWidthPx: 0,
StrokeDashes: nil,
StrokeDashOffset: 0,
PointRadiusPx: 4,
}
}
func (testTheme) LineStyle() Style {
return Style{
FillColor: nil,
StrokeColor: color.RGBA{G: 9, A: 255},
StrokeWidthPx: 3,
StrokeDashes: []float64{2, 2},
StrokeDashOffset: 1,
PointRadiusPx: 0,
}
}
func (testTheme) CircleStyle() Style {
return Style{
FillColor: color.RGBA{B: 9, A: 255},
StrokeColor: color.RGBA{A: 255},
StrokeWidthPx: 2,
StrokeDashes: nil,
StrokeDashOffset: 0,
PointRadiusPx: 0,
}
}
// TestWorldSetTheme_MaterializesThemeDefaultStyles verifies world Set Theme Materializes Theme Default Styles.
func TestWorldSetTheme_MaterializesThemeDefaultStyles(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
// Built-ins should remain stable (1/2/3).
require.Equal(t, StyleIDDefaultLine, StyleID(1))
require.Equal(t, StyleIDDefaultCircle, StyleID(2))
require.Equal(t, StyleIDDefaultPoint, StyleID(3))
// Set a custom theme.
w.SetTheme(testTheme{})
// Theme defaults should NOT be built-in IDs anymore.
require.NotEqual(t, StyleIDDefaultLine, w.themeDefaultLineStyleID)
require.NotEqual(t, StyleIDDefaultCircle, w.themeDefaultCircleStyleID)
require.NotEqual(t, StyleIDDefaultPoint, w.themeDefaultPointStyleID)
ls, ok := w.styles.Get(w.themeDefaultLineStyleID)
require.True(t, ok)
require.Equal(t, 3.0, ls.StrokeWidthPx)
require.Equal(t, []float64{2, 2}, ls.StrokeDashes)
require.Equal(t, 1.0, ls.StrokeDashOffset)
cs, ok := w.styles.Get(w.themeDefaultCircleStyleID)
require.True(t, ok)
require.Equal(t, 2.0, cs.StrokeWidthPx)
ps, ok := w.styles.Get(w.themeDefaultPointStyleID)
require.True(t, ok)
require.Equal(t, 4.0, ps.PointRadiusPx)
}
// TestRender_UsesThemeBackgroundColor_WhenNoOptionOverride verifies render Uses Theme Background Color When No Option Override.
func TestRender_UsesThemeBackgroundColor_WhenNoOptionOverride(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.SetTheme(testTheme{})
// Minimal index.
w.resetGrid(2 * SCALE)
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 0,
MarginYPx: 0,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
d := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d, params))
// Should clear with theme background color via ClearAllTo(bg).
require.NotEmpty(t, d.CommandsByName("ClearAllTo"))
}
// TestRender_OptionsBackgroundColor_OverridesThemeBackgroundColor verifies render Options Background Color Overrides Theme Background Color.
func TestRender_OptionsBackgroundColor_OverridesThemeBackgroundColor(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.SetTheme(testTheme{})
w.resetGrid(2 * SCALE)
params := RenderParams{
ViewportWidthPx: 10,
ViewportHeightPx: 10,
MarginXPx: 0,
MarginYPx: 0,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
BackgroundColor: color.RGBA{R: 200, A: 255},
},
}
d := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d, params))
require.NotEmpty(t, d.CommandsByName("ClearAllTo"))
}
const (
testPointClassExtended PointClassID = 1
testCircleClassExtended CircleClassID = 1
)
type classThemeA struct{}
func (classThemeA) ID() string { return "classA" }
func (classThemeA) Name() string { return "classA" }
func (classThemeA) BackgroundColor() color.Color { return color.RGBA{A: 255} }
func (classThemeA) BackgroundImage() image.Image { return nil }
func (classThemeA) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone }
func (classThemeA) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone }
func (classThemeA) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld }
func (classThemeA) PointStyle() Style {
return Style{FillColor: color.RGBA{R: 10, A: 255}, PointRadiusPx: 2}
}
func (classThemeA) LineStyle() Style {
return Style{StrokeColor: color.RGBA{G: 10, A: 255}, StrokeWidthPx: 1}
}
func (classThemeA) CircleStyle() Style {
return Style{FillColor: color.RGBA{B: 10, A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
}
func (classThemeA) PointClassOverride(c PointClassID) (StyleOverride, bool) {
if c == testPointClassExtended {
r := 6.0
return StyleOverride{PointRadiusPx: &r}, true
}
return StyleOverride{}, false
}
func (classThemeA) LineClassOverride(LineClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
func (classThemeA) CircleClassOverride(c CircleClassID) (StyleOverride, bool) {
if c == testCircleClassExtended {
w := 3.0
return StyleOverride{StrokeWidthPx: &w}, true
}
return StyleOverride{}, false
}
type classThemeB struct{ classThemeA }
func (classThemeB) ID() string { return "classB" }
func (classThemeB) Name() string { return "classB" }
func (classThemeB) PointStyle() Style {
return Style{FillColor: color.RGBA{R: 99, A: 255}, PointRadiusPx: 3}
}
func (classThemeB) CircleStyle() Style {
return Style{FillColor: color.RGBA{B: 99, A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 2}
}
func (classThemeB) PointClassOverride(c PointClassID) (StyleOverride, bool) {
if c == testPointClassExtended {
r := 9.0
return StyleOverride{PointRadiusPx: &r}, true
}
return StyleOverride{}, false
}
// TestThemeClassOverride_AppliesAndUpdatesOnThemeChange verifies theme Class Override Applies And Updates On Theme Change.
func TestThemeClassOverride_AppliesAndUpdatesOnThemeChange(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.SetTheme(classThemeA{})
id, err := w.AddPoint(1, 1, PointWithClass(testPointClassExtended))
require.NoError(t, err)
p := w.objects[id].(Point)
s1, ok := w.styles.Get(p.StyleID)
require.True(t, ok)
require.Equal(t, 6.0, s1.PointRadiusPx)
w.SetTheme(classThemeB{})
p2 := w.objects[id].(Point)
s2, ok := w.styles.Get(p2.StyleID)
require.True(t, ok)
require.Equal(t, 9.0, s2.PointRadiusPx)
}
// TestThemeClassOverride_MergesWithUserOverride_UserWins verifies theme Class Override Merges With User Override User Wins.
func TestThemeClassOverride_MergesWithUserOverride_UserWins(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.SetTheme(classThemeA{})
// class would set point radius to 6, but user override sets it to 12.
rUser := 12.0
id, err := w.AddPoint(1, 1,
PointWithClass(testPointClassExtended),
PointWithStyleOverride(StyleOverride{PointRadiusPx: &rUser}),
)
require.NoError(t, err)
p := w.objects[id].(Point)
s1, ok := w.styles.Get(p.StyleID)
require.True(t, ok)
require.Equal(t, 12.0, s1.PointRadiusPx)
// After theme change, class would set to 9, but user override must still win.
w.SetTheme(classThemeB{})
p2 := w.objects[id].(Point)
s2, ok := w.styles.Get(p2.StyleID)
require.True(t, ok)
require.Equal(t, 12.0, s2.PointRadiusPx)
}
type themeA struct{}
func (themeA) ID() string { return "A" }
func (themeA) Name() string { return "A" }
func (themeA) BackgroundColor() color.Color { return color.RGBA{A: 255} }
func (themeA) BackgroundImage() image.Image { return nil }
func (themeA) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone }
func (themeA) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone }
func (themeA) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld }
func (themeA) PointStyle() Style {
return Style{FillColor: color.RGBA{R: 10, A: 255}, PointRadiusPx: 2}
}
func (themeA) LineStyle() Style {
return Style{StrokeColor: color.RGBA{G: 10, A: 255}, StrokeWidthPx: 1}
}
func (themeA) CircleStyle() Style {
return Style{FillColor: color.RGBA{B: 10, A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
}
func (themeA) PointClassOverride(PointClassID) (StyleOverride, bool) { return StyleOverride{}, false }
func (themeA) LineClassOverride(LineClassID) (StyleOverride, bool) { return StyleOverride{}, false }
func (themeA) CircleClassOverride(CircleClassID) (StyleOverride, bool) { return StyleOverride{}, false }
type themeB struct{}
func (themeB) ID() string { return "B" }
func (themeB) Name() string { return "B" }
func (themeB) BackgroundColor() color.Color { return color.RGBA{A: 255} }
func (themeB) BackgroundImage() image.Image { return nil }
func (themeB) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone }
func (themeB) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone }
func (themeB) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld }
func (themeB) PointStyle() Style {
return Style{FillColor: color.RGBA{R: 99, A: 255}, PointRadiusPx: 5}
}
func (themeB) LineStyle() Style {
return Style{StrokeColor: color.RGBA{G: 99, A: 255}, StrokeWidthPx: 3}
}
func (themeB) CircleStyle() Style {
return Style{FillColor: color.RGBA{B: 99, A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 4}
}
func (themeB) PointClassOverride(PointClassID) (StyleOverride, bool) { return StyleOverride{}, false }
func (themeB) LineClassOverride(LineClassID) (StyleOverride, bool) { return StyleOverride{}, false }
func (themeB) CircleClassOverride(CircleClassID) (StyleOverride, bool) { return StyleOverride{}, false }
// TestThemeChange_UpdatesThemeDefaultStyleObjects verifies theme Change Updates Theme Default Style Objects.
func TestThemeChange_UpdatesThemeDefaultStyleObjects(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.SetTheme(themeA{})
id, err := w.AddPoint(1, 1) // default => theme-managed
require.NoError(t, err)
p := w.objects[id].(Point)
styleBefore := p.StyleID
w.SetTheme(themeB{})
p2 := w.objects[id].(Point)
styleAfter := p2.StyleID
require.NotEqual(t, styleBefore, styleAfter)
s, ok := w.styles.Get(styleAfter)
require.True(t, ok)
// From themeB point style
require.Equal(t, 5.0, s.PointRadiusPx)
}
// TestThemeChange_UpdatesThemeRelativeOverride verifies theme Change Updates Theme Relative Override.
func TestThemeChange_UpdatesThemeRelativeOverride(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.SetTheme(themeA{})
white := color.RGBA{R: 255, G: 255, B: 255, A: 255}
ov := StyleOverride{StrokeColor: white}
id, err := w.AddCircle(5, 5, 2, CircleWithStyleOverride(ov))
require.NoError(t, err)
c1 := w.objects[id].(Circle)
s1, ok := w.styles.Get(c1.StyleID)
require.True(t, ok)
// Stroke overridden to white, fill from themeA (B=10).
require.Equal(t, uint32(0xffff), alphaOf(s1.StrokeColor))
require.Equal(t, u16FromU8(10), blueOf(s1.FillColor))
w.SetTheme(themeB{})
c2 := w.objects[id].(Circle)
s2, ok := w.styles.Get(c2.StyleID)
require.True(t, ok)
// Still white stroke, but fill should now come from themeB (B=99).
require.Equal(t, uint32(0xffff), alphaOf(s2.StrokeColor))
require.Equal(t, u16FromU8(99), blueOf(s2.FillColor))
}
// TestThemeChange_DoesNotAffectFixedStyleID verifies theme Change Does Not Affect Fixed Style ID.
func TestThemeChange_DoesNotAffectFixedStyleID(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.SetTheme(themeA{})
sw := 2.0
fixed := w.AddStyleCircle(StyleOverride{
FillColor: color.RGBA{A: 0},
StrokeColor: color.RGBA{R: 1, A: 255},
StrokeWidthPx: &sw,
})
id, err := w.AddCircle(5, 5, 2, CircleWithStyleID(fixed))
require.NoError(t, err)
c1 := w.objects[id].(Circle)
require.Equal(t, fixed, c1.StyleID)
w.SetTheme(themeB{})
c2 := w.objects[id].(Circle)
require.Equal(t, fixed, c2.StyleID)
}
func alphaOf(c color.Color) uint32 {
_, _, _, a := c.RGBA()
return a
}
func blueOf(c color.Color) uint32 {
_, _, b, _ := c.RGBA()
return b
}
// u16FromU8 converts an 8-bit channel value to the 16-bit value returned by color.Color.RGBA().
// The standard conversion is v * 257 (0x0101) so that 0xAB becomes 0xABAB.
func u16FromU8(v uint8) uint32 {
return uint32(v) * 257
}
-1432
View File
File diff suppressed because it is too large Load Diff
-969
View File
@@ -1,969 +0,0 @@
package world
import (
"github.com/stretchr/testify/require"
"math"
"testing"
)
// TestWrap verifies wrap.
func TestWrap(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value int
size int
want int
}{
{name: "zero", value: 0, size: 10, want: 0},
{name: "inside range", value: 7, size: 10, want: 7},
{name: "equal to size", value: 10, size: 10, want: 0},
{name: "greater than size", value: 27, size: 10, want: 7},
{name: "negative one", value: -1, size: 10, want: 9},
{name: "negative many", value: -23, size: 10, want: 7},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := wrap(tt.value, tt.size); got != tt.want {
t.Fatalf("wrap(%d, %d) = %d, want %d", tt.value, tt.size, got, tt.want)
}
})
}
}
// TestClamp verifies clamp.
func TestClamp(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value int
minValue int
maxValue int
want int
}{
{name: "below range", value: -5, minValue: 0, maxValue: 10, want: 0},
{name: "inside range", value: 5, minValue: 0, maxValue: 10, want: 5},
{name: "above range", value: 15, minValue: 0, maxValue: 10, want: 10},
{name: "equal min", value: 0, minValue: 0, maxValue: 10, want: 0},
{name: "equal max", value: 10, minValue: 0, maxValue: 10, want: 10},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := clamp(tt.value, tt.minValue, tt.maxValue); got != tt.want {
t.Fatalf("clamp(%d, %d, %d) = %d, want %d", tt.value, tt.minValue, tt.maxValue, got, tt.want)
}
})
}
}
// TestCeilDiv verifies ceil Div.
func TestCeilDiv(t *testing.T) {
t.Parallel()
tests := []struct {
a int
b int
want int
}{
{a: 1, b: 1, want: 1},
{a: 1, b: 2, want: 1},
{a: 2, b: 2, want: 1},
{a: 3, b: 2, want: 2},
{a: 10, b: 3, want: 4},
{a: 16, b: 8, want: 2},
{a: 17, b: 8, want: 3},
}
for _, tt := range tests {
if got := ceilDiv(tt.a, tt.b); got != tt.want {
t.Fatalf("ceilDiv(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
}
}
}
// TestFloorDiv verifies floor Div.
func TestFloorDiv(t *testing.T) {
t.Parallel()
require.Equal(t, 0, floorDiv(0, 10))
require.Equal(t, 0, floorDiv(1, 10))
require.Equal(t, 0, floorDiv(9, 10))
require.Equal(t, 1, floorDiv(10, 10))
require.Equal(t, 1, floorDiv(19, 10))
require.Equal(t, -1, floorDiv(-1, 10))
require.Equal(t, -1, floorDiv(-9, 10))
require.Equal(t, -1, floorDiv(-10, 10))
require.Equal(t, -2, floorDiv(-11, 10))
require.Panics(t, func() { _ = floorDiv(1, 0) })
require.Panics(t, func() { _ = floorDiv(1, -1) })
}
// TestFixedPoint verifies fixed Point.
func TestFixedPoint(t *testing.T) {
t.Parallel()
tests := []struct {
name string
v float64
want int
}{
{name: "zero", v: 0, want: 0},
{name: "integer", v: 3, want: 3000},
{name: "fraction", v: 1.234, want: 1234},
{name: "round down", v: 1.2344, want: 1234},
{name: "round up", v: 1.2345, want: 1235},
{name: "negative", v: -1.2345, want: -1235},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := fixedPoint(tt.v); got != tt.want {
t.Fatalf("fixedPoint(%f) = %d, want %d", tt.v, got, tt.want)
}
})
}
}
// TestAbs verifies abs.
func TestAbs(t *testing.T) {
t.Parallel()
tests := []struct {
v int
want int
}{
{v: 0, want: 0},
{v: 7, want: 7},
{v: -7, want: 7},
}
for _, tt := range tests {
if got := abs(tt.v); got != tt.want {
t.Fatalf("abs(%d) = %d, want %d", tt.v, got, tt.want)
}
}
}
// TestPixelSpanToWorldFixed verifies pixel Span To World Fixed.
func TestPixelSpanToWorldFixed(t *testing.T) {
t.Parallel()
tests := []struct {
name string
spanPx int
zoomFp int
want int
}{
{name: "1x zoom", spanPx: 100, zoomFp: SCALE, want: 100 * SCALE},
{name: "2x zoom", spanPx: 100, zoomFp: 2 * SCALE, want: 50 * SCALE},
{name: "half zoom", spanPx: 100, zoomFp: SCALE / 2, want: 200 * SCALE},
{name: "fractional result truncation", spanPx: 1, zoomFp: 3 * SCALE, want: 333},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := PixelSpanToWorldFixed(tt.spanPx, tt.zoomFp); got != tt.want {
t.Fatalf("PixelSpanToWorldFixed(%d, %d) = %d, want %d", tt.spanPx, tt.zoomFp, got, tt.want)
}
})
}
}
// TestWorldToCellPanicsOnInvalidGrid verifies world To Cell Panics On Invalid Grid.
func TestWorldToCellPanicsOnInvalidGrid(t *testing.T) {
t.Parallel()
tests := []struct {
name string
cells int
cellSize int
}{
{name: "zero cells", cells: 0, cellSize: 1},
{name: "negative cells", cells: -1, cellSize: 1},
{name: "zero cell size", cells: 1, cellSize: 0},
{name: "negative cell size", cells: 1, cellSize: -1},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
defer func() {
if recover() == nil {
t.Fatalf("worldToCell did not panic for cells=%d cellSize=%d", tt.cells, tt.cellSize)
}
}()
_ = worldToCell(0, 1000, tt.cells, tt.cellSize)
})
}
}
// TestShortestWrappedDelta verifies shortest Wrapped Delta.
func TestShortestWrappedDelta(t *testing.T) {
t.Parallel()
tests := []struct {
name string
from int
to int
size int
wantA int
wantB int
}{
{name: "no wrap forward", from: 1000, to: 3000, size: 10000, wantA: 1000, wantB: 3000},
{name: "no wrap backward", from: 3000, to: 1000, size: 10000, wantA: 3000, wantB: 1000},
{name: "wrap forward over half", from: 1000, to: 7000, size: 10000, wantA: 11000, wantB: 7000},
{name: "wrap backward over half", from: 7000, to: 1000, size: 10000, wantA: 7000, wantB: 11000},
{name: "tie positive half wraps", from: 1000, to: 6000, size: 10000, wantA: 11000, wantB: 6000},
{name: "tie negative half stays", from: 6000, to: 1000, size: 10000, wantA: 6000, wantB: 1000},
{name: "just below positive half does not wrap", from: 1000, to: 5999, size: 10000, wantA: 1000, wantB: 5999},
{name: "just beyond negative half wraps", from: 6001, to: 1000, size: 10000, wantA: 6001, wantB: 11000},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
gotA, gotB := shortestWrappedDelta(tt.from, tt.to, tt.size)
if gotA != tt.wantA || gotB != tt.wantB {
t.Fatalf("shortestWrappedDelta(%d, %d, %d) = (%d, %d), want (%d, %d)",
tt.from, tt.to, tt.size, gotA, gotB, tt.wantA, tt.wantB)
}
delta := gotB - gotA
half := tt.size / 2
if delta < -half || delta >= half {
t.Fatalf("normalized delta %d is outside [-%d, %d)", delta, half, half)
}
})
}
}
// TestCameraZoomToWorldFixed verifies camera Zoom To World Fixed.
func TestCameraZoomToWorldFixed(t *testing.T) {
t.Parallel()
tests := []struct {
name string
cameraZoom float64
want int
}{
{
name: "neutral zoom",
cameraZoom: 1.0,
want: SCALE,
},
{
name: "integer zoom",
cameraZoom: 2.0,
want: 2 * SCALE,
},
{
name: "fractional zoom",
cameraZoom: 1.25,
want: 1250,
},
{
name: "minimum configured zoom value shape",
cameraZoom: 0.25,
want: SCALE / 4,
},
{
name: "round down",
cameraZoom: 1.2344,
want: 1234,
},
{
name: "round up",
cameraZoom: 1.2345,
want: 1235,
},
{
name: "very small but still positive after rounding",
cameraZoom: 0.0006,
want: 1,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := CameraZoomToWorldFixed(tt.cameraZoom)
require.NoError(t, err)
require.Equal(t, tt.want, got)
})
}
}
// TestCameraZoomToWorldFixedReturnsError verifies camera Zoom To World Fixed Returns Error.
func TestCameraZoomToWorldFixedReturnsError(t *testing.T) {
t.Parallel()
tests := []struct {
name string
cameraZoom float64
}{
{
name: "zero",
cameraZoom: 0,
},
{
name: "negative",
cameraZoom: -1,
},
{
name: "nan",
cameraZoom: math.NaN(),
},
{
name: "positive infinity",
cameraZoom: math.Inf(1),
},
{
name: "negative infinity",
cameraZoom: math.Inf(-1),
},
{
name: "positive but rounds to zero",
cameraZoom: 0.0004,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := CameraZoomToWorldFixed(tt.cameraZoom)
require.ErrorIs(t, err, errInvalidCameraZoom)
require.Zero(t, got)
})
}
}
// TestMustCameraZoomToWorldFixed verifies must Camera Zoom To World Fixed.
func TestMustCameraZoomToWorldFixed(t *testing.T) {
t.Parallel()
require.Equal(t, 1250, mustCameraZoomToWorldFixed(1.25))
require.Panics(t, func() {
_ = mustCameraZoomToWorldFixed(0)
})
}
// TestWorldFixedToCameraZoom verifies world Fixed To Camera Zoom.
func TestWorldFixedToCameraZoom(t *testing.T) {
t.Parallel()
tests := []struct {
name string
zoomFp int
want float64
}{
{name: "zero", zoomFp: 0, want: 0},
{name: "neutral", zoomFp: SCALE, want: 1.0},
{name: "fractional", zoomFp: 1250, want: 1.25},
{name: "integer multiple", zoomFp: 3 * SCALE, want: 3.0},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := worldFixedToCameraZoom(tt.zoomFp)
require.Equal(t, tt.want, got)
})
}
}
// TestRequiredZoomToFitWorld verifies required Zoom To Fit World.
func TestRequiredZoomToFitWorld(t *testing.T) {
t.Parallel()
tests := []struct {
name string
viewportSpanPx int
worldSpanFp int
want int
}{
{
name: "zero viewport span",
viewportSpanPx: 0,
worldSpanFp: 10 * SCALE,
want: 0,
},
{
name: "exact neutral fit",
viewportSpanPx: 10,
worldSpanFp: 10 * SCALE,
want: SCALE,
},
{
name: "exact 2x fit",
viewportSpanPx: 20,
worldSpanFp: 10 * SCALE,
want: 2 * SCALE,
},
{
name: "fractional fit rounded up",
viewportSpanPx: 11,
worldSpanFp: 10 * SCALE,
want: 1100,
},
{
name: "small world requires larger zoom",
viewportSpanPx: 320,
worldSpanFp: 80 * SCALE,
want: 4 * SCALE,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := requiredZoomToFitWorld(tt.viewportSpanPx, tt.worldSpanFp)
require.Equal(t, tt.want, got)
})
}
}
// TestRequiredZoomToFitWorldPanics verifies required Zoom To Fit World Panics.
func TestRequiredZoomToFitWorldPanics(t *testing.T) {
t.Parallel()
tests := []struct {
name string
viewportSpanPx int
worldSpanFp int
}{
{
name: "negative viewport span",
viewportSpanPx: -1,
worldSpanFp: 10 * SCALE,
},
{
name: "zero world span",
viewportSpanPx: 10,
worldSpanFp: 0,
},
{
name: "negative world span",
viewportSpanPx: 10,
worldSpanFp: -1,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Panics(t, func() {
_ = requiredZoomToFitWorld(tt.viewportSpanPx, tt.worldSpanFp)
})
})
}
}
// TestCorrectCameraZoomFpReturnsCurrentWhenNoCorrectionNeeded verifies correct Camera Zoom Fp Returns Current When No Correction Needed.
func TestCorrectCameraZoomFpReturnsCurrentWhenNoCorrectionNeeded(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
2*SCALE,
40, 30,
100*SCALE, 100*SCALE,
MIN_ZOOM, MAX_ZOOM,
)
require.Equal(t, 2*SCALE, got)
}
// TestCorrectCameraZoomFpRaisesZoomToFitWorldWidth verifies correct Camera Zoom Fp Raises Zoom To Fit World Width.
func TestCorrectCameraZoomFpRaisesZoomToFitWorldWidth(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
SCALE,
120, 20,
100*SCALE, 100*SCALE,
0, 0,
)
require.Equal(t, 1200, got)
}
// TestCorrectCameraZoomFpRaisesZoomToFitWorldHeight verifies correct Camera Zoom Fp Raises Zoom To Fit World Height.
func TestCorrectCameraZoomFpRaisesZoomToFitWorldHeight(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
SCALE,
20, 150,
100*SCALE, 100*SCALE,
0, 0,
)
require.Equal(t, 1500, got)
}
// TestCorrectCameraZoomFpUsesMaxFitAcrossAxes verifies correct Camera Zoom Fp Uses Max Fit Across Axes.
func TestCorrectCameraZoomFpUsesMaxFitAcrossAxes(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
SCALE,
120, 150,
100*SCALE, 100*SCALE,
0, 0,
)
require.Equal(t, 1500, got)
}
// TestCorrectCameraZoomFpAppliesMinZoomWhenLargerThanCurrentAndFit verifies correct Camera Zoom Fp Applies Min Zoom When Larger Than Current And Fit.
func TestCorrectCameraZoomFpAppliesMinZoomWhenLargerThanCurrentAndFit(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
SCALE,
20, 20,
100*SCALE, 100*SCALE,
1500, 0,
)
require.Equal(t, 1500, got)
}
// TestCorrectCameraZoomFpAppliesMaxZoomWhenNoFitConflict verifies correct Camera Zoom Fp Applies Max Zoom When No Fit Conflict.
func TestCorrectCameraZoomFpAppliesMaxZoomWhenNoFitConflict(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
4*SCALE,
20, 20,
100*SCALE, 100*SCALE,
0, 3*SCALE,
)
require.Equal(t, 3*SCALE, got)
}
// TestCorrectCameraZoomFpIgnoresMaxZoomWhenFitNeedsMore verifies correct Camera Zoom Fp Ignores Max Zoom When Fit Needs More.
func TestCorrectCameraZoomFpIgnoresMaxZoomWhenFitNeedsMore(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
SCALE,
200, 20,
100*SCALE, 100*SCALE,
0, 1500,
)
require.Equal(t, 2*SCALE, got)
}
// TestCorrectCameraZoomFpAppliesMinThenMaxWhenBothValid verifies correct Camera Zoom Fp Applies Min Then Max When Both Valid.
func TestCorrectCameraZoomFpAppliesMinThenMaxWhenBothValid(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
SCALE,
20, 20,
100*SCALE, 100*SCALE,
1500, 1600,
)
require.Equal(t, 1500, got)
}
// TestCorrectCameraZoomFpCurrentAboveMaxGetsClamped verifies correct Camera Zoom Fp Current Above Max Gets Clamped.
func TestCorrectCameraZoomFpCurrentAboveMaxGetsClamped(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
5*SCALE,
20, 20,
100*SCALE, 100*SCALE,
0, 3*SCALE,
)
require.Equal(t, 3*SCALE, got)
}
// TestCorrectCameraZoomFpZeroViewportUsesOnlyBounds verifies correct Camera Zoom Fp Zero Viewport Uses Only Bounds.
func TestCorrectCameraZoomFpZeroViewportUsesOnlyBounds(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
SCALE,
0, 0,
100*SCALE, 100*SCALE,
1500, 0,
)
require.Equal(t, 1500, got)
}
// TestCorrectCameraZoomFpZeroBoundsAreIgnored verifies correct Camera Zoom Fp Zero Bounds Are Ignored.
func TestCorrectCameraZoomFpZeroBoundsAreIgnored(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
1250,
20, 20,
100*SCALE, 100*SCALE,
0, 0,
)
require.Equal(t, 1250, got)
}
// TestCorrectCameraZoomFpPanics verifies correct Camera Zoom Fp Panics.
func TestCorrectCameraZoomFpPanics(t *testing.T) {
t.Parallel()
tests := []struct {
name string
fn func()
}{
{
name: "non-positive current zoom",
fn: func() {
_ = correctCameraZoomFp(0, 10, 10, 100*SCALE, 100*SCALE, 0, 0)
},
},
{
name: "negative viewport width",
fn: func() {
_ = correctCameraZoomFp(SCALE, -1, 10, 100*SCALE, 100*SCALE, 0, 0)
},
},
{
name: "negative viewport height",
fn: func() {
_ = correctCameraZoomFp(SCALE, 10, -1, 100*SCALE, 100*SCALE, 0, 0)
},
},
{
name: "non-positive world width",
fn: func() {
_ = correctCameraZoomFp(SCALE, 10, 10, 0, 100*SCALE, 0, 0)
},
},
{
name: "non-positive world height",
fn: func() {
_ = correctCameraZoomFp(SCALE, 10, 10, 100*SCALE, 0, 0, 0)
},
},
{
name: "negative min zoom",
fn: func() {
_ = correctCameraZoomFp(SCALE, 10, 10, 100*SCALE, 100*SCALE, -1, 0)
},
},
{
name: "negative max zoom",
fn: func() {
_ = correctCameraZoomFp(SCALE, 10, 10, 100*SCALE, 100*SCALE, 0, -1)
},
},
{
name: "min greater than max",
fn: func() {
_ = correctCameraZoomFp(SCALE, 10, 10, 100*SCALE, 100*SCALE, 2000, 1500)
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Panics(t, tt.fn)
})
}
}
// TestWorldCorrectCameraZoomReturnsFloatValue verifies world Correct Camera Zoom Returns Float Value.
func TestWorldCorrectCameraZoomReturnsFloatValue(t *testing.T) {
t.Parallel()
w := NewWorld(100, 100)
got := w.CorrectCameraZoom(1.0, 120, 20)
require.Equal(t, 1.2, got)
}
// TestWorldCorrectCameraZoomAppliesDefaultBounds verifies world Correct Camera Zoom Applies Default Bounds.
func TestWorldCorrectCameraZoomAppliesDefaultBounds(t *testing.T) {
t.Parallel()
w := NewWorld(100, 100)
got := w.CorrectCameraZoom(100.0, 20, 20)
require.Equal(t, worldFixedToCameraZoom(MAX_ZOOM), got)
}
// TestWorldCorrectCameraZoomFitBeatsDefaultMaxBound verifies world Correct Camera Zoom Fit Beats Default Max Bound.
func TestWorldCorrectCameraZoomFitBeatsDefaultMaxBound(t *testing.T) {
t.Parallel()
w := NewWorld(1, 100)
got := w.CorrectCameraZoom(1.0, 40, 10)
require.Equal(t, 40.0, got)
}
// TestCorrectCameraZoomFp_DoesNotLowerZoomWhenViewportIsSmallerThanWorld verifies correct Camera Zoom Fp Does Not Lower Zoom When Viewport Is Smaller Than World.
func TestCorrectCameraZoomFp_DoesNotLowerZoomWhenViewportIsSmallerThanWorld(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
SCALE, // currentZoomFp = 1.0x
80, 80, // viewport px
100*SCALE, 100*SCALE, // world fp
0, 0,
)
// No anti-wrap needed, and we do not auto-fit by lowering zoom.
require.Equal(t, SCALE, got)
}
// TestCorrectCameraZoomFp_RaisesZoomToPreventWrapWhenViewportIsLarger verifies correct Camera Zoom Fp Raises Zoom To Prevent Wrap When Viewport Is Larger.
func TestCorrectCameraZoomFp_RaisesZoomToPreventWrapWhenViewportIsLarger(t *testing.T) {
t.Parallel()
// World width = 100 units, viewport width = 120 px, at zoom=1 visible span = 120 units => too large.
got := correctCameraZoomFp(
SCALE,
120, 20,
100*SCALE, 100*SCALE,
0, 0,
)
require.Equal(t, 1200, got) // 1.2x
}
// TestCorrectCameraZoomFp_AppliesMaxZoomWhenNoWrapConflict verifies correct Camera Zoom Fp Applies Max Zoom When No Wrap Conflict.
func TestCorrectCameraZoomFp_AppliesMaxZoomWhenNoWrapConflict(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
4*SCALE, // user wants 4x
20, 20,
100*SCALE, 100*SCALE,
0, 3*SCALE, // max 3x
)
require.Equal(t, 3*SCALE, got)
}
// TestCorrectCameraZoomFp_AntiWrapBeatsMaxZoom verifies correct Camera Zoom Fp Anti Wrap Beats Max Zoom.
func TestCorrectCameraZoomFp_AntiWrapBeatsMaxZoom(t *testing.T) {
t.Parallel()
// requiredFit = 2x, but max is 1.5x => must return 2x.
got := correctCameraZoomFp(
SCALE,
200, 20,
100*SCALE, 100*SCALE,
0, 1500,
)
require.Equal(t, 2*SCALE, got)
}
// TestCorrectCameraZoomFp_AppliesMinZoom verifies correct Camera Zoom Fp Applies Min Zoom.
func TestCorrectCameraZoomFp_AppliesMinZoom(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
800, // 0.8x
20, 20,
100*SCALE, 100*SCALE,
SCALE, 0, // min 1.0x
)
require.Equal(t, SCALE, got)
}
// TestCorrectCameraZoomFp_ZeroViewportUsesOnlyBounds verifies correct Camera Zoom Fp Zero Viewport Uses Only Bounds.
func TestCorrectCameraZoomFp_ZeroViewportUsesOnlyBounds(t *testing.T) {
t.Parallel()
got := correctCameraZoomFp(
SCALE,
0, 0,
100*SCALE, 100*SCALE,
1500, 0,
)
require.Equal(t, 1500, got)
}
// TestClampCameraNoWrapViewport_ClampsToKeepViewportInsideWorld verifies clamp Camera No Wrap Viewport Clamps To Keep Viewport Inside World.
func TestClampCameraNoWrapViewport_ClampsToKeepViewportInsideWorld(t *testing.T) {
t.Parallel()
worldW := 100 * SCALE
worldH := 100 * SCALE
zoomFp := SCALE // 1.0x
// viewport 40px => span 40 units => half 20.
viewportW, viewportH := 40, 40
// Too far left/up => clamp to minCam=20
cx, cy := ClampCameraNoWrapViewport(
0, 0,
viewportW, viewportH,
zoomFp,
worldW, worldH,
)
require.Equal(t, 20*SCALE, cx)
require.Equal(t, 20*SCALE, cy)
// Too far right/down => clamp to maxCam=world-half=80
cx, cy = ClampCameraNoWrapViewport(
99*SCALE, 99*SCALE,
viewportW, viewportH,
zoomFp,
worldW, worldH,
)
require.Equal(t, 80*SCALE, cx)
require.Equal(t, 80*SCALE, cy)
// Inside range => unchanged
cx, cy = ClampCameraNoWrapViewport(
50*SCALE, 60*SCALE,
viewportW, viewportH,
zoomFp,
worldW, worldH,
)
require.Equal(t, 50*SCALE, cx)
require.Equal(t, 60*SCALE, cy)
}
// TestClampCameraNoWrapViewport_WhenViewportLargerThanWorld_ForcesCenter verifies clamp Camera No Wrap Viewport When Viewport Larger Than World Forces Center.
func TestClampCameraNoWrapViewport_WhenViewportLargerThanWorld_ForcesCenter(t *testing.T) {
t.Parallel()
worldW := 50 * SCALE
worldH := 50 * SCALE
zoomFp := SCALE
// viewport 60px => span 60 units > world 50
viewportW, viewportH := 60, 60
cx, cy := ClampCameraNoWrapViewport(
0, 0,
viewportW, viewportH,
zoomFp,
worldW, worldH,
)
require.Equal(t, worldW/2, cx)
require.Equal(t, worldH/2, cy)
}
// TestWorldClampRenderParamsNoWrap_UsesViewportClamp verifies world Clamp Render Params No Wrap Uses Viewport Clamp.
func TestWorldClampRenderParamsNoWrap_UsesViewportClamp(t *testing.T) {
t.Parallel()
w := NewWorld(100, 100)
p := RenderParams{
ViewportWidthPx: 40,
ViewportHeightPx: 40,
MarginXPx: 10,
MarginYPx: 10,
CameraZoom: 1.0,
CameraXWorldFp: 0,
CameraYWorldFp: 0,
Options: &RenderOptions{DisableWrapScroll: true},
}
w.ClampRenderParamsNoWrap(&p)
// viewport half is 20, not 30 (margins ignored)
require.Equal(t, 20*SCALE, p.CameraXWorldFp)
require.Equal(t, 20*SCALE, p.CameraYWorldFp)
}
// TestPivotZoom_CursorAtCenter_KeepsCamera verifies pivot Zoom Cursor At Center Keeps Camera.
func TestPivotZoom_CursorAtCenter_KeepsCamera(t *testing.T) {
t.Parallel()
cx, cy := PivotZoomCameraNoWrap(
50*SCALE, 60*SCALE,
100, 80,
50, 40, // cursor at center
SCALE, 2*SCALE,
)
require.Equal(t, 50*SCALE, cx)
require.Equal(t, 60*SCALE, cy)
}
// TestPivotZoom_RightEdge_ZoomInMovesCameraRight verifies pivot Zoom Right Edge Zoom In Moves Camera Right.
func TestPivotZoom_RightEdge_ZoomInMovesCameraRight(t *testing.T) {
t.Parallel()
// viewport 100px, cursor at x=100 (right edge), center is 50 => offX=50px
// zoom 1->2 halves worldPerPx, so camera must move towards the cursor to keep the same world point.
cx0 := 50 * SCALE
cx, _ := PivotZoomCameraNoWrap(
cx0, 50*SCALE,
100, 100,
100, 50,
SCALE, 2*SCALE,
)
require.Greater(t, cx, cx0)
}
// TestPivotZoom_LeftEdge_ZoomInMovesCameraLeft verifies pivot Zoom Left Edge Zoom In Moves Camera Left.
func TestPivotZoom_LeftEdge_ZoomInMovesCameraLeft(t *testing.T) {
t.Parallel()
cx0 := 50 * SCALE
cx, _ := PivotZoomCameraNoWrap(
cx0, 50*SCALE,
100, 100,
0, 50,
SCALE, 2*SCALE,
)
require.Less(t, cx, cx0)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-1
View File
@@ -2,7 +2,6 @@ go 1.26.2
use (
./backend
./client
./game
./gateway
./integration
+4 -4
View File
@@ -18,6 +18,7 @@ github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/Buvy
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/containerd/typeurl/v2 v2.2.0/go.mod h1:8XOOxnyatxSWuG8OfsZXVnAF4iZfedjS/8UHSPJnX4g=
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/elastic/go-sysinfo v1.15.4/go.mod h1:ZBVXmqS368dOn/jvijV/zHLfakWTYHBZPk3G244lHrU=
github.com/elastic/go-windows v1.0.2/go.mod h1:bGcDpBzXgYSqM0Gx3DM4+UxFj300SZLixie9u9ixLM8=
@@ -25,6 +26,7 @@ github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY=
github.com/friendsofgo/errors v0.9.2/go.mod h1:yCvFW5AkDIL9qn7suHVLiI/gH228n7PC4Pn44IGoTOI=
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
@@ -41,6 +43,7 @@ github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwm
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A=
github.com/jackc/puddle v1.3.0 h1:eHK/5clGOatcjX3oWGBO/MpxpbHzSwud5EWTSCI+MX0=
@@ -53,8 +56,6 @@ github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lucor/goinfo v0.9.0/go.mod h1:L6m6tN5Rlova5Z83h1ZaKsMP1iiaoZ9vGTNzu5QKOD4=
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mcuadros/go-version v0.0.0-20190830083331-035f6764e8d2/go.mod h1:76rfSfYPWj01Z85hUf/ituArm797mNKcvINh1OlsZKo=
@@ -66,11 +67,11 @@ github.com/moby/sys/reexec v0.1.0/go.mod h1:EqjBg8F3X7iZe5pU6nRZnYCMUTXoxsjiIfHu
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/oasdiff/yaml3 v0.0.12/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/paulmach/orb v0.13.0/go.mod h1:6scRWINywA2Jf05dcjOfLfxrUIMECvTSG2MVbRLxu/k=
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
@@ -103,7 +104,6 @@ github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtX
github.com/ydb-platform/ydb-go-genproto v0.0.0-20260311095541-ebbf792c1180/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I=
github.com/ydb-platform/ydb-go-sdk/v3 v3.135.0/go.mod h1:VYUUkRJkKuQPkIpgtZJj6+58Fa2g8ccAqdmaaK6HP5k=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk=
+3 -3
View File
@@ -3,6 +3,7 @@ module galaxy/integration
go 1.26.1
require (
connectrpc.com/connect v1.19.2
galaxy/gateway v0.0.0-00010101000000-000000000000
galaxy/model v0.0.0-00010101000000-000000000000
galaxy/transcoder v0.0.0-00010101000000-000000000000
@@ -10,12 +11,11 @@ require (
github.com/moby/moby/api v1.54.2
github.com/testcontainers/testcontainers-go v0.42.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0
google.golang.org/grpc v1.80.0
golang.org/x/net v0.53.0
)
require (
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 // indirect
connectrpc.com/connect v1.19.2 // indirect
dario.cat/mergo v1.0.2 // indirect
galaxy/util v0.0.0-00010101000000-000000000000 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
@@ -65,10 +65,10 @@ require (
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+2
View File
@@ -1,3 +1,5 @@
module galaxy/storage
go 1.26.0
require github.com/google/uuid v1.6.0
+1
View File
@@ -0,0 +1 @@
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+5
View File
@@ -4,4 +4,9 @@ go 1.26.0
require galaxy/core v0.0.0
require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
)
replace galaxy/core => ../core
+2 -4
View File
@@ -1,7 +1,5 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=