package client import ( "fmt" "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)) } // 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. 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 } } 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) 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 { // 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 *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) }