diff --git a/client/ui.go b/client/ui.go index d5daa1e..d432b9d 100644 --- a/client/ui.go +++ b/client/ui.go @@ -82,6 +82,10 @@ func (e *client) renderRasterImage(viewportW, viewportH int, p world.RenderParam 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 @@ -95,7 +99,17 @@ func (e *client) renderRasterImage(viewportW, viewportH int, p world.RenderParam p.MarginYPx = viewportH / 4 // Correct zoom for viewport/world constraints, and clamp camera for no-wrap. - p.CameraZoom = e.world.CorrectCameraZoom(p.CameraZoom, viewportW, viewportH) + 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() diff --git a/client/ui_drag.go b/client/ui_drag.go index f613687..3b809ec 100644 --- a/client/ui_drag.go +++ b/client/ui_drag.go @@ -11,9 +11,8 @@ import ( /* Client pan integration for Fyne Draggable: -- DragEvent provides absolute coordinates in "Fyne units". -- Client knows canvasScale (Fyne units per pixel) and converts to pixels. -- We keep last drag position and compute dx/dy ourselves. +- 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): @@ -24,7 +23,7 @@ Sign convention (map follows pointer): // 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 the fyne-units-per-pixel scale factor. + // CanvasScale returns pixels per Fyne logical unit. CanvasScale() float32 // UpdateParams applies a mutation and schedules refresh through your coalescer. diff --git a/client/ui_drag_test.go b/client/ui_test.go similarity index 52% rename from client/ui_drag_test.go rename to client/ui_test.go index 954178c..1f02e17 100644 --- a/client/ui_drag_test.go +++ b/client/ui_test.go @@ -1,6 +1,7 @@ package client import ( + "image" "testing" "fyne.io/fyne/v2" @@ -107,3 +108,94 @@ 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) +}