chore: remove deprecated client
This commit is contained in:
+1
-1
@@ -7,6 +7,7 @@ require (
|
|||||||
galaxy/model v0.0.0
|
galaxy/model v0.0.0
|
||||||
galaxy/postgres v0.0.0
|
galaxy/postgres v0.0.0
|
||||||
galaxy/util v0.0.0-00010101000000-000000000000
|
galaxy/util v0.0.0-00010101000000-000000000000
|
||||||
|
github.com/abadojack/whatlanggo v1.0.1
|
||||||
github.com/disciplinedware/go-confusables v0.1.1
|
github.com/disciplinedware/go-confusables v0.1.1
|
||||||
github.com/getkin/kin-openapi v0.135.0
|
github.com/getkin/kin-openapi v0.135.0
|
||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
@@ -36,7 +37,6 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/abadojack/whatlanggo v1.0.1 // indirect
|
|
||||||
github.com/oschwald/geoip2-golang/v2 v2.1.0 // indirect
|
github.com/oschwald/geoip2-golang/v2 v2.1.0 // indirect
|
||||||
github.com/oschwald/maxminddb-golang/v2 v2.1.1 // indirect
|
github.com/oschwald/maxminddb-golang/v2 v2.1.1 // indirect
|
||||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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"
|
|
||||||
)
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package client
|
|
||||||
|
|
||||||
import "embed"
|
|
||||||
|
|
||||||
//go:embed resource/lang
|
|
||||||
var Translations embed.FS
|
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
@@ -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=
|
|
||||||
@@ -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, ¶ms, 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()
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 ®istry{
|
|
||||||
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) {
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -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() {
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, 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
@@ -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
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -2,7 +2,6 @@ go 1.26.2
|
|||||||
|
|
||||||
use (
|
use (
|
||||||
./backend
|
./backend
|
||||||
./client
|
|
||||||
./game
|
./game
|
||||||
./gateway
|
./gateway
|
||||||
./integration
|
./integration
|
||||||
|
|||||||
+4
-4
@@ -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/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/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/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/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-sysinfo v1.15.4/go.mod h1:ZBVXmqS368dOn/jvijV/zHLfakWTYHBZPk3G244lHrU=
|
||||||
github.com/elastic/go-windows v1.0.2/go.mod h1:bGcDpBzXgYSqM0Gx3DM4+UxFj300SZLixie9u9ixLM8=
|
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/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/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/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/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY=
|
||||||
github.com/friendsofgo/errors v0.9.2/go.mod h1:yCvFW5AkDIL9qn7suHVLiI/gH228n7PC4Pn44IGoTOI=
|
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=
|
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/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.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/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/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
|
||||||
github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A=
|
github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A=
|
||||||
github.com/jackc/puddle v1.3.0 h1:eHK/5clGOatcjX3oWGBO/MpxpbHzSwud5EWTSCI+MX0=
|
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/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/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/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/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/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=
|
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/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/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/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/paulmach/orb v0.13.0/go.mod h1:6scRWINywA2Jf05dcjOfLfxrUIMECvTSG2MVbRLxu/k=
|
||||||
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
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/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/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/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_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
|
||||||
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
|
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-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/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/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=
|
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/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=
|
go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk=
|
||||||
|
|||||||
+3
-3
@@ -3,6 +3,7 @@ module galaxy/integration
|
|||||||
go 1.26.1
|
go 1.26.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
connectrpc.com/connect v1.19.2
|
||||||
galaxy/gateway v0.0.0-00010101000000-000000000000
|
galaxy/gateway v0.0.0-00010101000000-000000000000
|
||||||
galaxy/model v0.0.0-00010101000000-000000000000
|
galaxy/model v0.0.0-00010101000000-000000000000
|
||||||
galaxy/transcoder 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/moby/moby/api v1.54.2
|
||||||
github.com/testcontainers/testcontainers-go v0.42.0
|
github.com/testcontainers/testcontainers-go v0.42.0
|
||||||
github.com/testcontainers/testcontainers-go/modules/postgres 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 (
|
require (
|
||||||
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 // indirect
|
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
|
dario.cat/mergo v1.0.2 // indirect
|
||||||
galaxy/util v0.0.0-00010101000000-000000000000 // indirect
|
galaxy/util v0.0.0-00010101000000-000000000000 // indirect
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // 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/metric v1.43.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||||
golang.org/x/crypto v0.50.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/sys v0.43.0 // indirect
|
||||||
golang.org/x/text v0.36.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/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
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
module galaxy/storage
|
module galaxy/storage
|
||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
|
require github.com/google/uuid v1.6.0
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
@@ -4,4 +4,9 @@ go 1.26.0
|
|||||||
|
|
||||||
require galaxy/core v0.0.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
|
replace galaxy/core => ../core
|
||||||
|
|||||||
+2
-4
@@ -1,7 +1,5 @@
|
|||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
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/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
Reference in New Issue
Block a user