package client import ( "fmt" "image" "math" "sync" "galaxy/client/world" "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" ) type client struct { 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 } 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), } 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() } 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() } 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( widget.NewToolbarAction( theme.FolderIcon(), func() { e.loadWorld(mockWorld()) }), widget.NewToolbarSeparator(), widget.NewToolbarAction( theme.NavigateBackIcon(), func() {}), widget.NewToolbarAction( theme.NavigateNextIcon(), func() {}), ) tabs := container.NewAppTabs( container.NewTabItemWithIcon( "Map", theme.GridIcon(), mapCanvas), container.NewTabItemWithIcon( "Calculator", theme.ComputerIcon(), container.NewStack(widget.NewButton("Calc", func() {})), ), ) content := container.NewBorder( toolbar, // top nil, // bottom nil, // left nil, // right tabs, // center ) w.CenterOnScreen() w.SetContent(content) } func (e *client) loadWorld(w *world.World) { if e.world == nil { w.SetCircleRadiusScaleFp(world.SCALE / 4) 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) } else { if e.world.Theme().ID() == "theme.light.v1" { e.world.SetTheme(world.ThemeDark) } else { e.world.SetTheme(world.ThemeLight) } } e.RequestRefresh() }