package client import ( "fmt" "image" "image/color" "math" "sync" "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "github.com/iliadenisov/galaxy/client/world" ) type Editor interface { BuildUI(fyne.Window) } type editor struct { world *world.World drawer *world.GGDrawer raster *canvas.Raster canvasScale float32 win fyne.Window // Coalescer for latest-wins refresh scheduling. co *RasterCoalescer[world.RenderParams] pan *PanController // 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 hits []world.Hit } func (e *editor) CanvasScale() float32 { return e.canvasScale } func (e *editor) ForceFullRedraw() { e.world.ForceFullRedrawNext() } // здесь определяю, изменились ли границы raster, если да - обновляю размеры viewport, margin и корректирую zoom func (e *editor) updateSizes() { canvas := fyne.CurrentApp().Driver().CanvasForObject(e.raster) if canvas == nil { return } size := e.raster.Size() e.canvasScale = canvas.Scale() width := int(size.Width * e.canvasScale) height := int(size.Height * e.canvasScale) if width <= 0 || height <= 0 { 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) }() } 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) } } func (e *editor) onScrolled(s *fyne.ScrollEvent) { vw := e.wp.ViewportWidthPx vh := e.wp.ViewportHeightPx 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 } 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 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 { return } oldZoomFp, err := world.CameraZoomToWorldFixed(oldZoom) if err != nil { return } newZoomFp, err := world.CameraZoomToWorldFixed(newZoom) if err != nil { return } 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.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. e.world.ForceFullRedrawNext() e.co.Request(*e.wp) } 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) 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 *editor) 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) e.updateSizes() } else { if e.world.Theme().ID() == "theme.light.v1" { e.world.SetTheme(world.ThemeDark) } else { e.world.SetTheme(world.ThemeLight) } } 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) } }