package client import ( "image" "fyne.io/fyne/v2" // adjust import path to your world package // "your/module/world" "github.com/fogleman/gg" "github.com/iliadenisov/galaxy/client/world" ) /* 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) */ // FyneExecutor posts functions onto the Fyne UI thread. type FyneExecutor struct{} 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 { 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 *editor) UpdateParams(fn func(p *world.RenderParams)) { e.mu.Lock() fn(e.wp) p := e.wp // snapshot 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 *editor) 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 *editor) draw(wPx, hPx int) image.Image { // Snapshot latest params and render once. // e.mu.RLock() // p := e.wp // e.mu.RUnlock() // Request() already scheduled refreshes; Draw() actually renders for this callback. // We bypass co.Draw(w,h) because we need to pass our snapshot to coalescer in a controlled way. // The simplest pattern: keep coalescer as the sole driver: call co.Draw(w,h) here. // But then coalescer uses its internal latest. So make sure we always call co.Request on updates. // // In normal operation you can just: return e.co.Draw(wPx,hPx) // and never use p above. We'll do that to keep a single source of truth. // _ = p 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 { // 1) Viewport sizes come from raster 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 } // 3) Ensure GG backing canvas is sized for expanded canvas. canvasW := p.CanvasWidthPx() canvasH := p.CanvasHeightPx() e.ensureDrawerCanvas(canvasW, canvasH) // 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 // 5) 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)) } 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 *editor) 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 // 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) { 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 } // 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) }