From bb2bb899de4063fca086ebbc381e16152a7ab39f Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 9 Mar 2026 13:26:17 +0200 Subject: [PATCH] client refactor --- client/canvas.go | 7 +- client/client.go | 15 +- client/{coalesce_test.go => client_test.go} | 37 +++ client/cmd/ui/main.go | 2 +- client/coalesce.go | 32 -- client/editor.go | 345 ++++++++------------ client/mock.go | 48 +++ client/ui.go | 118 +++++-- client/ui_drag.go | 14 +- client/ui_drag_test.go | 16 +- 10 files changed, 339 insertions(+), 295 deletions(-) rename client/{coalesce_test.go => client_test.go} (82%) create mode 100644 client/mock.go diff --git a/client/canvas.go b/client/canvas.go index cc67351..4d07841 100644 --- a/client/canvas.go +++ b/client/canvas.go @@ -11,7 +11,7 @@ import ( type interactiveRaster struct { widget.BaseWidget - edit *editor + edit *client min fyne.Size raster *canvas.Raster onLayout func(fyne.Size) @@ -49,7 +49,9 @@ func (r *interactiveRaster) Tapped(ev *fyne.PointEvent) { // TappedSecondary is a right-click event func (r *interactiveRaster) TappedSecondary(*fyne.PointEvent) {} -func newInteractiveRaster(edit *editor, raster *canvas.Raster, +func newInteractiveRaster( + edit *client, + raster *canvas.Raster, onLayout func(fyne.Size), onScrolled func(*fyne.ScrollEvent), onDragged func(*fyne.DragEvent), @@ -57,7 +59,6 @@ func newInteractiveRaster(edit *editor, raster *canvas.Raster, onTapped func(*fyne.PointEvent), ) *interactiveRaster { r := &interactiveRaster{ - // raster: canvas.NewRaster(edit.draw), raster: raster, edit: edit, onLayout: onLayout, diff --git a/client/client.go b/client/client.go index a7b0bf2..8f3e403 100644 --- a/client/client.go +++ b/client/client.go @@ -5,21 +5,20 @@ import ( "fyne.io/fyne/v2/app" ) -type client struct { +type ui struct { app fyne.App window fyne.Window } -func NewClient() *client { - c := &client{} +func NewUI() *ui { + c := &ui{} c.app = app.New() - c.window = c.app.NewWindow("Galaxy+") - editor := NewEditor() - editor.BuildUI(c.window) - + c.window = c.app.NewWindow("Galaxy Plus") + client := NewClient() + client.BuildUI(c.window) return c } -func (c *client) Run() { +func (c *ui) Run() { c.window.ShowAndRun() } diff --git a/client/coalesce_test.go b/client/client_test.go similarity index 82% rename from client/coalesce_test.go rename to client/client_test.go index c876f12..77c1df1 100644 --- a/client/coalesce_test.go +++ b/client/client_test.go @@ -188,3 +188,40 @@ func TestCopyViewportRGBA_CopiesROIAndIsIndependentFromSource(t *testing.T) { 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) +} diff --git a/client/cmd/ui/main.go b/client/cmd/ui/main.go index a0da06d..dbda6b9 100644 --- a/client/cmd/ui/main.go +++ b/client/cmd/ui/main.go @@ -3,6 +3,6 @@ package main import "github.com/iliadenisov/galaxy/client" func main() { - c := client.NewClient() + c := client.NewUI() c.Run() } diff --git a/client/coalesce.go b/client/coalesce.go index 5e4abf0..5a7f8a4 100644 --- a/client/coalesce.go +++ b/client/coalesce.go @@ -3,38 +3,6 @@ package client /* Fyne-friendly latest-wins coalescing for canvas.NewRaster(draw func(w,h int) image.Image). -How to use (integration sketch): - - type editor struct { - w *world.World - drawer world.PrimitiveDrawer // wraps gg.Context over a backing *image.RGBA - raster *canvas.Raster - - co *client.RasterCoalescer[world.RenderParams] - } - - func (e *editor) initCoalescer() { - exec := client.FyneMainThreadExecutor{} // uses fyne.CurrentApp().Driver().RunOnMain - e.co = client.NewRasterCoalescer(exec, e.raster, func(wPx, hPx int, p world.RenderParams) image.Image { - // your existing draw pipeline: - // 1) ensure viewport/margins inside p are consistent with wPx/hPx if needed - // 2) call e.w.Render(e.drawer, p) - // 3) get image from gg.Context, crop margins, return - _ = e.w.Render(e.drawer, p) - return e.drawerImageCropped() // your code - }) - } - - // Call from input handlers (pan/zoom/etc). Can be from any goroutine. - func (e *editor) RefreshUI(p world.RenderParams) { - e.co.Request(p) // schedules raster.Refresh() on UI thread - } - - // Raster draw callback: - func (e *editor) draw(wPx, hPx int) image.Image { - return e.co.Draw(wPx, hPx) - } - Key property: - draw() renders at most once per invocation (never loops). - if new requests arrived while drawing, we schedule exactly one extra Refresh. diff --git a/client/editor.go b/client/editor.go index aefada2..64b6b84 100644 --- a/client/editor.go +++ b/client/editor.go @@ -3,7 +3,6 @@ package client import ( "fmt" "image" - "image/color" "math" "sync" @@ -15,36 +14,42 @@ import ( "github.com/iliadenisov/galaxy/client/world" ) -type Editor interface { - BuildUI(fyne.Window) -} +type client struct { + world *world.World + drawer *world.GGDrawer + raster *canvas.Raster + co *RasterCoalescer[world.RenderParams] + pan *PanController -type editor struct { - world *world.World - drawer *world.GGDrawer - raster *canvas.Raster + // 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 - win fyne.Window + // 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 - // Coalescer for latest-wins refresh scheduling. - co *RasterCoalescer[world.RenderParams] + // 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 - pan *PanController + // Indexing / backing-canvas caches (owned by client because it depends on UI geometry) + lastIndexedViewportW int + lastIndexedViewportH int + lastIndexedZoomFp int - // Protected render params state. Stored as value to avoid aliasing issues. - mu sync.RWMutex - wp *world.RenderParams - - // Last viewport size we indexed the world for. - lastViewportW int - lastViewportH int - - // Optional: you can keep the last expanded canvas size to avoid reallocations. lastCanvasW int lastCanvasH int - // Reusable viewport buffer to avoid per-frame allocations. viewportImg *image.RGBA viewportW int viewportH int @@ -52,99 +57,160 @@ type editor struct { hits []world.Hit } -func (e *editor) CanvasScale() float32 { return e.canvasScale } +func NewClient() *client { + e := &client{ + world: nil, + wp: &world.RenderParams{ + CameraZoom: 1.0, + Options: &world.RenderOptions{DisableWrapScroll: false}, + }, + lastCanvasScale: 1.0, + hits: make([]world.Hit, 5), + } -func (e *editor) ForceFullRedraw() { + 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) + }, + ) + + e.RequestRefresh() + return e +} + +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() } -// здесь определяю, изменились ли границы raster, если да - обновляю размеры viewport, margin и корректирую zoom -func (e *editor) updateSizes() { - canvas := fyne.CurrentApp().Driver().CanvasForObject(e.raster) - if canvas == nil { +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 } - size := e.raster.Size() - e.canvasScale = canvas.Scale() + sz := e.raster.Size() // logical (Fyne units) + scale := canvasObj.Scale() - width := int(size.Width * e.canvasScale) - height := int(size.Height * e.canvasScale) + e.metaMu.Lock() + e.lastRasterLogicW = sz.Width + e.lastRasterLogicH = sz.Height + e.lastCanvasScale = scale + e.metaMu.Unlock() - if width <= 0 || height <= 0 { + e.RequestRefresh() +} + +func (e *client) onDragged(ev *fyne.DragEvent) { + e.pan.Dragged(ev) +} + +func (e *client) onDradEnd() { + e.pan.DragEnd() +} + +func (e *client) onTapped(ev *fyne.PointEvent) { + if e.world == nil || ev == nil { return } - if width != e.wp.ViewportWidthPx || height != e.wp.ViewportHeightPx { - e.wp.ViewportWidthPx = width - e.wp.ViewportHeightPx = height - e.wp.MarginXPx = e.wp.ViewportWidthPx / 4 - e.wp.MarginYPx = e.wp.ViewportHeightPx / 4 - defer func() { e.co.Request(*e.wp) }() + xPx, yPx, ok := e.eventPosToPixel(ev.Position.X, ev.Position.Y) + if !ok { + return } - if e.world != nil { - e.wp.CameraZoom = e.world.CorrectCameraZoom(e.wp.CameraZoom, e.wp.ViewportWidthPx, e.wp.ViewportHeightPx) - e.world.IndexOnViewportChange(e.wp.ViewportWidthPx, e.wp.ViewportHeightPx, e.wp.CameraZoom) - e.world.ClampRenderParamsNoWrap(e.wp) + + params := e.getLastRenderedParams() + hits, err := e.world.HitTest(e.hits, ¶ms, xPx, yPx) + if err != nil { + // In UI you probably don't want panic; keep your existing handling. + panic(err) + } + + m := func(v int) float64 { return float64(v) / float64(world.SCALE) } + + for _, hit := range hits { + 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) } } -func (e *editor) onScrolled(s *fyne.ScrollEvent) { - vw := e.wp.ViewportWidthPx - vh := e.wp.ViewportHeightPx +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 } - // Cursor position in viewport pixels (Fyne units -> px by multiplying). - cxPx := int(float32(s.Position.X) * e.canvasScale) - cyPx := int(float32(s.Position.Y) * e.canvasScale) - - if cxPx < 0 { - cxPx = 0 - } else if cxPx > vw { - cxPx = vw - } - if cyPx < 0 { - cyPx = 0 - } else if cyPx > vh { - cyPx = vh + cxPx, cyPx, ok := e.eventPosToPixel(s.Position.X, s.Position.Y) + if !ok { + return } + e.mu.Lock() oldZoom := e.wp.CameraZoom - // Exponential zoom: - // - Each "notch" multiplies zoom by a fixed factor. - // - Using DY directly as exponent gives smooth trackpad behavior too. - // - // Tune base: - // base=1.10 => ~10% per wheel step (if DY≈1 per step) - // base=1.05 => ~5% per step - // - // In user settings, better store on percents userZoomLevelPercent = (0,100]: base = 1 + (userZoomLevelPercent) / 10 + // Exponential zoom factor; tune later. const base = 1.005 - - // Negative DY => zoom out, positive DY => zoom in (depending on platform settings). - // If you want inverted direction, negate float64(s.Scrolled.DY). delta := float64(s.Scrolled.DY) - newZoom := oldZoom * math.Pow(base, delta) - // Clamp/correct (min/max + prevent wrap if needed). 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, @@ -155,56 +221,15 @@ func (e *editor) onScrolled(s *fyne.ScrollEvent) { e.wp.CameraZoom = newZoom e.wp.CameraXWorldFp = newCamX e.wp.CameraYWorldFp = newCamY + e.mu.Unlock() - e.world.IndexOnViewportChange(vw, vh, e.wp.CameraZoom) - - // No-wrap clamp to avoid "detaching" from borders. - e.world.ClampRenderParamsNoWrap(e.wp) - - // Zoom changes are best done as full redraw. + // Any zoom change should rebuild index and force full redraw. e.world.ForceFullRedrawNext() - - e.co.Request(*e.wp) + e.RequestRefresh() } -func (e *editor) onDragged(ev *fyne.DragEvent) { - e.pan.Dragged(ev) -} - -func (e *editor) onDradEnd() { - e.pan.DragEnd() -} - -func (e *editor) onTapped(ev *fyne.PointEvent) { - if e.world == nil { - return - } - hits, err := e.world.HitTest(e.hits, e.wp, int(ev.Position.X*e.canvasScale), int(ev.Position.Y*e.canvasScale)) - if err != nil { - panic(err) - } - m := func(v int) float64 { - return float64(v) / float64(world.SCALE) - } - var coord string - for _, hit := range hits { - 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) - } -} - -func (e *editor) onMapLayout(s fyne.Size) { - e.updateSizes() -} - -func (e *editor) BuildUI(w fyne.Window) { - e.win = w - - mapCanvas := newInteractiveRaster(e, e.raster, e.onMapLayout, e.onScrolled, e.onDragged, e.onDradEnd, e.onTapped) +func (e *client) BuildUI(w fyne.Window) { + mapCanvas := newInteractiveRaster(e, e.raster, e.onRasterWidgetLayout, e.onScrolled, e.onDragged, e.onDradEnd, e.onTapped) mapCanvas.SetMinSize(fyne.NewSize(292, 292)) toolbar := widget.NewToolbar( @@ -244,7 +269,7 @@ func (e *editor) BuildUI(w fyne.Window) { w.SetContent(content) } -func (e *editor) loadWorld(w *world.World) { +func (e *client) loadWorld(w *world.World) { if e.world == nil { w.SetCircleRadiusScaleFp(world.SCALE / 4) e.world = w @@ -252,7 +277,6 @@ func (e *editor) loadWorld(w *world.World) { e.wp.CameraXWorldFp = w.W / 2 e.wp.CameraYWorldFp = w.H / 2 e.world.SetTheme(world.ThemeDark) - e.updateSizes() } else { if e.world.Theme().ID() == "theme.light.v1" { e.world.SetTheme(world.ThemeDark) @@ -262,82 +286,3 @@ func (e *editor) loadWorld(w *world.World) { } e.RequestRefresh() } - -func NewEditor() *editor { - e := &editor{ - world: nil, - wp: &world.RenderParams{ - CameraZoom: 1.0, - Options: &world.RenderOptions{DisableWrapScroll: false}, - }, - canvasScale: 1.0, - hits: make([]world.Hit, 5), - } - - // Create a drawer with some initial context; real size will be adjusted on first draw. - e.drawer = &world.GGDrawer{DC: nil} - - // Create raster; its draw callback delegates to coalescer. - e.raster = canvas.NewRaster(func(wPx, hPx int) image.Image { - return e.draw(wPx, hPx) - }) - - e.pan = NewPanController(e) - - // Wire coalescer: it schedules raster.Refresh() on UI thread and renders once per draw call. - exec := FyneExecutor{} - e.co = NewRasterCoalescer( - exec, - e.raster, // Refresher - func(wPx, hPx int, p world.RenderParams) image.Image { - // This runs on UI thread (inside draw). It must return an image. - return e.renderRasterImage(wPx, hPx, p) - }, - ) - - // Kick initial draw. - e.RequestRefresh() - - return e -} - -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) - } -} diff --git a/client/mock.go b/client/mock.go new file mode 100644 index 0000000..8bffe5a --- /dev/null +++ b/client/mock.go @@ -0,0 +1,48 @@ +package client + +import ( + "image/color" + + "github.com/iliadenisov/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) + } +} diff --git a/client/ui.go b/client/ui.go index 7447e54..bd88806 100644 --- a/client/ui.go +++ b/client/ui.go @@ -2,6 +2,7 @@ package client import ( "image" + "math" "fyne.io/fyne/v2" "github.com/fogleman/gg" @@ -39,13 +40,8 @@ func (FyneExecutor) Post(fn func()) { fyne.Do(fn) } -// Widget returns the fyne CanvasObject to add into your UI. -func (e *editor) Widget() fyne.CanvasObject { - return e.raster -} - // GetParams returns a copy of current render params for external reads. -func (e *editor) GetParams() world.RenderParams { +func (e *client) GetParams() world.RenderParams { e.mu.RLock() defer e.mu.RUnlock() return *e.wp @@ -53,70 +49,83 @@ func (e *editor) GetParams() world.RenderParams { // 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 *editor) UpdateParams(fn func(p *world.RenderParams)) { +func (e *client) UpdateParams(fn func(p *world.RenderParams)) { e.mu.Lock() fn(e.wp) - - // IMPORTANT: clamp camera if no-wrap - e.world.ClampRenderParamsNoWrap(e.wp) - - p := e.wp // snapshot + p := *e.wp e.mu.Unlock() - e.co.Request(*p) + 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 *editor) RequestRefresh() { +func (e *client) RequestRefresh() { e.mu.RLock() - p := e.wp + p := *e.wp e.mu.RUnlock() - e.co.Request(*p) + 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 *editor) draw(wPx, hPx int) image.Image { +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 *editor) renderRasterImage(viewportW, viewportH int, p world.RenderParams) image.Image { +func (e *client) renderRasterImage(viewportW, viewportH int, p world.RenderParams) image.Image { if e.world == nil { - return blankImage + return image.NewRGBA(image.Rect(0, 0, 0, 0)) } - // 1) Viewport sizes come from raster draw callback. + + // 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 - // 2) Ensure indexing is up-to-date when viewport size changed. - if viewportW != e.lastViewportW || viewportH != e.lastViewportH { - e.world.IndexOnViewportChange(viewportW, viewportH, p.CameraZoom) - e.lastViewportW = viewportW - e.lastViewportH = viewportH + // Correct zoom for viewport/world constraints, and clamp camera for no-wrap. + p.CameraZoom = e.world.CorrectCameraZoom(p.CameraZoom, viewportW, viewportH) + + // 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 + } } - // 3) Ensure GG backing canvas is sized for expanded canvas. + e.world.ClampRenderParamsNoWrap(&p) + + // Ensure backing expanded canvas (gg context) is sized properly. canvasW := p.CanvasWidthPx() canvasH := p.CanvasHeightPx() e.ensureDrawerCanvas(canvasW, canvasH) - // Savety clamp - e.world.ClampRenderParamsNoWrap(&p) + // Render into expanded canvas backing. + _ = e.world.Render(e.drawer, p) // TODO: handle error - // 4) Render into expanded canvas backing (full or incremental is decided inside world.Render). - _ = e.world.Render(e.drawer, p) // handle error in your real code + // Save snapshot of params actually used for this render (for HitTest consistency). + e.lastRenderedMu.Lock() + e.lastRenderedParams = p + e.lastRenderedMu.Unlock() - // 5) Copy viewport ROI into reusable viewport buffer and return it. + // 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 { - // Should not happen if GGDrawer is backed by RGBA. return image.NewRGBA(image.Rect(0, 0, viewportW, viewportH)) } @@ -125,7 +134,7 @@ func (e *editor) renderRasterImage(viewportW, viewportH int, p world.RenderParam } // ensureDrawerCanvas ensures drawer has a gg.Context sized to canvasW x canvasH. -func (e *editor) ensureDrawerCanvas(canvasW, canvasH int) { +func (e *client) ensureDrawerCanvas(canvasW, canvasH int) { if e.drawer.DC != nil && e.lastCanvasW == canvasW && e.lastCanvasH == canvasH { return } @@ -133,11 +142,9 @@ func (e *editor) ensureDrawerCanvas(canvasW, canvasH int) { e.drawer.DC = NewGGContextRGBA(canvasW, canvasH) e.lastCanvasW = canvasW e.lastCanvasH = canvasH - // e.wp.CameraXWorldFp = e.wp.ViewportWidthPx / 2 * world.SCALE - // e.wp.CameraYWorldFp = e.wp.ViewportHeightPx / 2 * world.SCALE } -func (e *editor) ensureViewportBuffer(w, h int) { +func (e *client) ensureViewportBuffer(w, h int) { if e.viewportImg != nil && e.viewportW == w && e.viewportH == h { return } @@ -146,6 +153,45 @@ func (e *editor) ensureViewportBuffer(w, h int) { 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 +} + // 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. diff --git a/client/ui_drag.go b/client/ui_drag.go index 6c90f5b..3bc420f 100644 --- a/client/ui_drag.go +++ b/client/ui_drag.go @@ -9,10 +9,10 @@ import ( ) /* -Editor pan integration for Fyne Draggable: +Client pan integration for Fyne Draggable: - DragEvent provides absolute coordinates in "Fyne units". -- Editor knows canvasScale (Fyne units per pixel) and converts to pixels. +- Client knows canvasScale (Fyne units per pixel) and converts to pixels. - We keep last drag position and compute dx/dy ourselves. - We update camera center in world-fixed (CameraXWorldFp/YWorldFp). @@ -21,9 +21,9 @@ Sign convention (map follows pointer): - Drag down (dyPx > 0): move world content down => move camera up => CameraYWorldFp -= dyWorldFp */ -// draggableEditor is the minimal interface we need from your editor implementation. -// If your Editor already has these methods/fields, you can fold the code directly into it. -type draggableEditor interface { +// 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() float32 @@ -39,7 +39,7 @@ type draggableEditor interface { // PanController holds per-drag transient state. type PanController struct { - ed draggableEditor + ed draggableClient dragging bool lastFx float32 // last absolute position in Fyne units @@ -50,7 +50,7 @@ type PanController struct { remPxY float32 } -func NewPanController(ed draggableEditor) *PanController { +func NewPanController(ed draggableClient) *PanController { return &PanController{ed: ed} } diff --git a/client/ui_drag_test.go b/client/ui_drag_test.go index 9ce1141..92956d2 100644 --- a/client/ui_drag_test.go +++ b/client/ui_drag_test.go @@ -11,7 +11,7 @@ import ( "github.com/iliadenisov/galaxy/client/world" ) -type fakeEditor struct { +type fakeClient struct { scale float32 p world.RenderParams @@ -20,21 +20,21 @@ type fakeEditor struct { refresh int } -func (e *fakeEditor) CanvasScale() float32 { return e.scale } +func (e *fakeClient) CanvasScale() float32 { return e.scale } -func (e *fakeEditor) UpdateParams(fn func(p *world.RenderParams)) { +func (e *fakeClient) UpdateParams(fn func(p *world.RenderParams)) { fn(&e.p) e.updates++ } -func (e *fakeEditor) RequestRefresh() { e.refresh++ } +func (e *fakeClient) RequestRefresh() { e.refresh++ } -func (e *fakeEditor) ForceFullRedraw() { e.forced = true } +func (e *fakeClient) ForceFullRedraw() { e.forced = true } func TestPanController_DraggedUpdatesCameraByDeltaPx(t *testing.T) { t.Parallel() - fe := &fakeEditor{ + fe := &fakeClient{ scale: 1.0, // 1 fyne unit == 1 px for the test p: world.RenderParams{ CameraZoom: 1.0, @@ -60,7 +60,7 @@ func TestPanController_DraggedUpdatesCameraByDeltaPx(t *testing.T) { func TestPanController_DraggedUsesCanvasScaleByMultiplying(t *testing.T) { t.Parallel() - fe := &fakeEditor{ + fe := &fakeClient{ scale: 2.0, // 2 px per fyne unit p: world.RenderParams{ CameraZoom: 1.0, @@ -82,7 +82,7 @@ func TestPanController_DraggedUsesCanvasScaleByMultiplying(t *testing.T) { func TestPanController_DragEndForcesFullRedrawAndRefresh(t *testing.T) { t.Parallel() - fe := &fakeEditor{ + fe := &fakeClient{ scale: 1.0, p: world.RenderParams{ CameraZoom: 1.0,