From 9e841fcced9d8339986af7aeeba43a18a306ebfc Mon Sep 17 00:00:00 2001 From: grimsace Date: Mon, 30 Mar 2026 11:23:01 -0500 Subject: [PATCH] added requested add text feature --- text.go | 361 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ tools.go | 38 +++++- ui.go | 182 ++++++++++++++++++++++++++-- 3 files changed, 566 insertions(+), 15 deletions(-) create mode 100644 text.go diff --git a/text.go b/text.go new file mode 100644 index 0000000..4a38d72 --- /dev/null +++ b/text.go @@ -0,0 +1,361 @@ +package main + +import ( + "image" + "image/color" + "image/draw" + "math" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "sync" + + "fyne.io/fyne/v2" + "golang.org/x/image/font" + "golang.org/x/image/font/basicfont" + "golang.org/x/image/font/opentype" + "golang.org/x/image/math/fixed" +) + +const ( + minTextSizePercent = 0.5 + maxTextSizePercent = 12.0 + textSizePercentStep = 0.1 +) + +type SystemFont struct { + Name string + Path string +} + +type TextPlacementRequest struct { + Content string + FontName string + FontPath string + SizePercent float64 + ColorName string +} + +type PlacedText struct { + TextPlacementRequest + Position image.Point +} + +type PendingTextPlacement struct { + TextPlacementRequest + Position image.Point + Visible bool + Preview *image.RGBA +} + +type TextLayer struct { + Width int + Height int + Items []PlacedText + Image *image.RGBA + Mask *PixelMask +} + +var ( + systemFontsOnce sync.Once + cachedFonts []SystemFont + parsedFontMu sync.Mutex + parsedFontByPath = map[string]*opentype.Font{} +) + +func loadSystemFonts() []SystemFont { + systemFontsOnce.Do(func() { + seen := map[string]bool{} + for _, root := range fontSearchRoots() { + _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil || d == nil || d.IsDir() { + return nil + } + ext := strings.ToLower(filepath.Ext(path)) + if ext != ".ttf" && ext != ".otf" && ext != ".ttc" { + return nil + } + name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + key := strings.ToLower(name + "|" + path) + if seen[key] { + return nil + } + seen[key] = true + cachedFonts = append(cachedFonts, SystemFont{Name: name, Path: path}) + return nil + }) + } + sort.Slice(cachedFonts, func(i, j int) bool { + return strings.ToLower(cachedFonts[i].Name) < strings.ToLower(cachedFonts[j].Name) + }) + if len(cachedFonts) == 0 { + cachedFonts = []SystemFont{{Name: "Default", Path: ""}} + } + }) + out := make([]SystemFont, len(cachedFonts)) + copy(out, cachedFonts) + return out +} + +func fontSearchRoots() []string { + home, _ := os.UserHomeDir() + switch runtime.GOOS { + case "windows": + winDir := os.Getenv("WINDIR") + if winDir == "" { + winDir = `C:\\Windows` + } + return []string{filepath.Join(winDir, "Fonts")} + case "darwin": + roots := []string{"/System/Library/Fonts", "/Library/Fonts"} + if home != "" { + roots = append(roots, filepath.Join(home, "Library", "Fonts")) + } + return roots + default: + roots := []string{"/usr/share/fonts", "/usr/local/share/fonts"} + if home != "" { + roots = append(roots, filepath.Join(home, ".fonts"), filepath.Join(home, ".local", "share", "fonts")) + } + return roots + } +} + +func newTextLayer(width, height int) *TextLayer { + return &TextLayer{ + Width: width, + Height: height, + Image: image.NewRGBA(image.Rect(0, 0, width, height)), + Mask: NewPixelMask(width, height), + } +} + +func (l *TextLayer) Clear(width, height int) { + l.Width = width + l.Height = height + l.Items = nil + l.Image = image.NewRGBA(image.Rect(0, 0, width, height)) + l.Mask = NewPixelMask(width, height) +} + +func (l *TextLayer) Add(item PlacedText) error { + if l == nil { + return nil + } + l.Items = append(l.Items, item) + return l.rebuild() +} + +func (l *TextLayer) rebuild() error { + if l == nil { + return nil + } + l.Image = image.NewRGBA(image.Rect(0, 0, l.Width, l.Height)) + l.Mask = NewPixelMask(l.Width, l.Height) + for _, item := range l.Items { + if err := drawPlacedText(l.Image, l.Mask, item, 255, l.Width, l.Height); err != nil { + return err + } + } + return nil +} + +func composeCanvasWithText(base image.Image, layer *TextLayer, pending *PendingTextPlacement) image.Image { + if base == nil { + return nil + } + bounds := base.Bounds() + out := cloneToRGBA(base, bounds.Dx(), bounds.Dy()) + if layer != nil && layer.Image != nil { + draw.Draw(out, out.Bounds(), layer.Image, image.Point{}, draw.Over) + } + if pending != nil && pending.Visible && pending.Preview == nil && strings.TrimSpace(pending.Content) != "" { + _ = drawPlacedText(out, nil, PlacedText{TextPlacementRequest: pending.TextPlacementRequest, Position: pending.Position}, 128, bounds.Dx(), bounds.Dy()) + } + return out +} + +func renderTextSprite(req TextPlacementRequest, alpha uint8, width, height int) (*image.RGBA, error) { + if strings.TrimSpace(req.Content) == "" { + return image.NewRGBA(image.Rect(0, 0, 1, 1)), nil + } + face, err := loadFontFace(req.FontPath, textSizePixels(req.SizePercent, width, height)) + if err != nil { + return nil, err + } + metrics := face.Metrics() + spriteW := max(1, font.MeasureString(face, req.Content).Ceil()) + spriteH := max(1, (metrics.Ascent + metrics.Descent).Ceil()) + sprite := image.NewRGBA(image.Rect(0, 0, spriteW, spriteH)) + col := textColorByName(req.ColorName) + col.A = alpha + d := &font.Drawer{ + Dst: sprite, + Src: image.NewUniform(col), + Face: face, + Dot: fixed.P(0, metrics.Ascent.Ceil()), + } + d.DrawString(req.Content) + return sprite, nil +} + +func drawPlacedText(dst *image.RGBA, mask *PixelMask, item PlacedText, alpha uint8, width, height int) error { + if dst == nil || strings.TrimSpace(item.Content) == "" { + return nil + } + face, err := loadFontFace(item.FontPath, textSizePixels(item.SizePercent, width, height)) + if err != nil { + return err + } + + temp := image.NewRGBA(dst.Bounds()) + col := textColorByName(item.ColorName) + col.A = alpha + d := &font.Drawer{ + Dst: temp, + Src: image.NewUniform(col), + Face: face, + Dot: fixed.P(item.Position.X, item.Position.Y+face.Metrics().Ascent.Ceil()), + } + d.DrawString(item.Content) + draw.Draw(dst, dst.Bounds(), temp, image.Point{}, draw.Over) + + if mask != nil { + for y := 0; y < temp.Bounds().Dy(); y++ { + for x := 0; x < temp.Bounds().Dx(); x++ { + _, _, _, a := temp.At(x, y).RGBA() + if a != 0 { + mask.SetXY(x, y) + } + } + } + } + return nil +} + +func textSizePixels(sizePercent float64, width, height int) float64 { + sizePercent = clamp(sizePercent, minTextSizePercent, maxTextSizePercent) + avgDim := averageImageDimension(width, height) + if avgDim < 1 { + avgDim = 1 + } + sizePx := (sizePercent / 100.0) * avgDim + if sizePx < 1 { + sizePx = 1 + } + return sizePx +} + +func loadFontFace(path string, size float64) (font.Face, error) { + if size < 1 { + size = 1 + } + if path == "" { + return basicfont.Face7x13, nil + } + parsedFontMu.Lock() + parsed := parsedFontByPath[path] + parsedFontMu.Unlock() + if parsed == nil { + data, err := os.ReadFile(path) + if err != nil { + return basicfont.Face7x13, nil + } + fontFile, err := opentype.Parse(data) + if err != nil { + return basicfont.Face7x13, nil + } + parsedFontMu.Lock() + parsedFontByPath[path] = fontFile + parsed = fontFile + parsedFontMu.Unlock() + } + face, err := opentype.NewFace(parsed, &opentype.FaceOptions{Size: size, DPI: 72, Hinting: font.HintingFull}) + if err != nil { + return basicfont.Face7x13, nil + } + return face, nil +} + +func textColorByName(name string) color.RGBA { + switch strings.ToLower(name) { + case "white": + return color.RGBA{R: 255, G: 255, B: 255, A: 255} + case "grey", "gray": + return color.RGBA{R: 140, G: 140, B: 140, A: 255} + default: + return color.RGBA{R: 0, G: 0, B: 0, A: 255} + } +} + +func mapCanvasPositionToImagePoint(pos fyne.Position, widgetSize fyne.Size, img image.Image) (image.Point, bool) { + if img == nil { + return image.Point{}, false + } + offsetX, offsetY, drawW, drawH, ok := containedImageRect(widgetSize, img) + if !ok { + return image.Point{}, false + } + if pos.X < offsetX || pos.Y < offsetY || pos.X > offsetX+drawW || pos.Y > offsetY+drawH { + return image.Point{}, false + } + relX := (pos.X - offsetX) / drawW + relY := (pos.Y - offsetY) / drawH + bounds := img.Bounds() + x := int(math.Round(float64(relX * float32(bounds.Dx())))) + y := int(math.Round(float64(relY * float32(bounds.Dy())))) + if x < 0 { + x = 0 + } + if y < 0 { + y = 0 + } + if x >= bounds.Dx() { + x = bounds.Dx() - 1 + } + if y >= bounds.Dy() { + y = bounds.Dy() - 1 + } + return image.Point{X: x, Y: y}, true +} + +func mapImagePointToCanvasRect(point image.Point, sprite image.Image, widgetSize fyne.Size, base image.Image) (fyne.Position, fyne.Size, bool) { + offsetX, offsetY, drawW, drawH, ok := containedImageRect(widgetSize, base) + if !ok || sprite == nil { + return fyne.Position{}, fyne.Size{}, false + } + baseBounds := base.Bounds() + scaleX := drawW / float32(baseBounds.Dx()) + scaleY := drawH / float32(baseBounds.Dy()) + pos := fyne.NewPos(offsetX+float32(point.X)*scaleX, offsetY+float32(point.Y)*scaleY) + size := fyne.NewSize(float32(sprite.Bounds().Dx())*scaleX, float32(sprite.Bounds().Dy())*scaleY) + return pos, size, true +} + +func containedImageRect(widgetSize fyne.Size, img image.Image) (float32, float32, float32, float32, bool) { + if img == nil { + return 0, 0, 0, 0, false + } + bounds := img.Bounds() + imgW := float32(bounds.Dx()) + imgH := float32(bounds.Dy()) + if imgW <= 0 || imgH <= 0 || widgetSize.Width <= 0 || widgetSize.Height <= 0 { + return 0, 0, 0, 0, false + } + imgAspect := imgW / imgH + widgetAspect := widgetSize.Width / widgetSize.Height + drawW := widgetSize.Width + drawH := widgetSize.Height + offsetX := float32(0) + offsetY := float32(0) + if widgetAspect > imgAspect { + drawW = widgetSize.Height * imgAspect + offsetX = (widgetSize.Width - drawW) * 0.5 + } else { + drawH = widgetSize.Width / imgAspect + offsetY = (widgetSize.Height - drawH) * 0.5 + } + return offsetX, offsetY, drawW, drawH, true +} diff --git a/tools.go b/tools.go index 70a2bba..6451be6 100644 --- a/tools.go +++ b/tools.go @@ -11,6 +11,7 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/data/binding" + "fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/widget" ) @@ -241,12 +242,15 @@ func estimateWallTangent(mid image.Point, wallMask *PixelMask) (float64, float64 type tappableImage struct { widget.BaseWidget - image *fyne.Container - onTapped func() + image *fyne.Container + onTapped func(*fyne.PointEvent) + onSecondaryTap func(*fyne.PointEvent) + onHover func(fyne.Position) + onHoverEnd func() } // newTappableImage wraps a container so taps can trigger image interactions. -func newTappableImage(img *fyne.Container, tapped func()) *tappableImage { +func newTappableImage(img *fyne.Container, tapped func(*fyne.PointEvent)) *tappableImage { ti := &tappableImage{ image: img, onTapped: tapped, @@ -261,9 +265,33 @@ func (t *tappableImage) CreateRenderer() fyne.WidgetRenderer { } // Tapped invokes the handler and is used for click interactions. -func (t *tappableImage) Tapped(*fyne.PointEvent) { +func (t *tappableImage) Tapped(ev *fyne.PointEvent) { if t.onTapped != nil { - t.onTapped() + t.onTapped(ev) + } +} + +// TappedSecondary invokes the secondary handler and is used for right-click interactions. +func (t *tappableImage) TappedSecondary(ev *fyne.PointEvent) { + if t.onSecondaryTap != nil { + t.onSecondaryTap(ev) + } +} + +// MouseMoved forwards hover updates and is used for preview positioning. +func (t *tappableImage) MouseMoved(ev *desktop.MouseEvent) { + if t.onHover != nil { + t.onHover(ev.Position) + } +} + +// MouseIn is required by desktop.Hoverable. +func (t *tappableImage) MouseIn(*desktop.MouseEvent) {} + +// MouseOut clears hover state and is used when previews leave the canvas. +func (t *tappableImage) MouseOut() { + if t.onHoverEnd != nil { + t.onHoverEnd() } } diff --git a/ui.go b/ui.go index ab70556..2c51ddf 100644 --- a/ui.go +++ b/ui.go @@ -50,6 +50,11 @@ func main() { canvasImg := &canvas.Image{ FillMode: canvas.ImageFillContain, } + textPreviewImg := &canvas.Image{ + FillMode: canvas.ImageFillStretch, + } + textPreviewImg.Hide() + textPreviewLayer := container.NewWithoutLayout(textPreviewImg) heightmapImg := &canvas.Image{ FillMode: canvas.ImageFillContain, } @@ -69,6 +74,22 @@ func main() { var exitRoadMask *PixelMask var wallMask *PixelMask var turretMask *PixelMask + var canvasBaseImage image.Image + textLayer := newTextLayer(settings.Width, settings.Height) + var pendingText *PendingTextPlacement + refreshCanvasDisplay := func() { + if canvasBaseImage == nil { + canvasImg.Image = nil + } else { + canvasImg.Image = composeCanvasWithText(canvasBaseImage, textLayer, nil) + } + canvasImg.Refresh() + } + hideTextPreview := func() { + textPreviewImg.Image = nil + textPreviewImg.Hide() + textPreviewImg.Refresh() + } // Set up application configuration directory configDir, err := os.UserConfigDir() @@ -88,7 +109,7 @@ func main() { } closing = true - canvasSnapshot := canvasImg.Image + canvasSnapshot := canvasBaseImage heightmapSnapshot := heightmapImg.Image bumpmapSnapshot := bumpmapImg.Image @@ -127,7 +148,10 @@ func main() { defer canvasFile.Close() img, err := png.Decode(canvasFile) if err == nil { - canvasImg.Image = img + canvasBaseImage = img + textLayer.Clear(img.Bounds().Dx(), img.Bounds().Dy()) + hideTextPreview() + refreshCanvasDisplay() } } @@ -351,7 +375,11 @@ func main() { heightmapImg.Image = compositeImg bumpmapImg.Image = bumpMap - canvasImg.Image = finalImage + canvasBaseImage = finalImage + textLayer.Clear(settings.Width, settings.Height) + pendingText = nil + hideTextPreview() + refreshCanvasDisplay() } var generateBtn *widget.Button @@ -845,7 +873,7 @@ func main() { showSaveDialog(w, bumpmapImg.Image, settings) }) exportMasksBtn := widget.NewButton("Export Masks", func() { - showMasksSaveDialog(w, canvasImg.Image, heightmapImg.Image, bumpmapImg.Image, settings, lakes, riverMask, treeMask, roadMask, bridgeMask, wallMask, turretMask, buildingMask) + showMasksSaveDialog(w, canvasImg.Image, heightmapImg.Image, bumpmapImg.Image, settings, lakes, riverMask, treeMask, roadMask, bridgeMask, wallMask, turretMask, buildingMask, textLayer.Mask) }) exportSettingsBtn := widget.NewButton("Export Settings", func() { showSettingsExportDialog(w, settings) @@ -1144,8 +1172,11 @@ func main() { heightmapImg.Refresh() bumpmapImg.Image = bumpMap bumpmapImg.Refresh() - canvasImg.Image = finalImage - canvasImg.Refresh() + canvasBaseImage = finalImage + textLayer.Clear(settings.Width, settings.Height) + pendingText = nil + hideTextPreview() + refreshCanvasDisplay() if len(timedOutSteps) > 0 { errorLabel.SetText("Generation timed out after 1 minute for: " + strings.Join(timedOutSteps, ", ") + ". Partial results were used.") errorLabel.Show() @@ -1757,6 +1788,77 @@ func main() { removePresetBtn.Disable() refreshPresetOptions("No Preset") + textFonts := loadSystemFonts() + textFontNames := make([]string, 0, len(textFonts)) + fontPathByName := make(map[string]string, len(textFonts)) + for _, fontInfo := range textFonts { + textFontNames = append(textFontNames, fontInfo.Name) + fontPathByName[fontInfo.Name] = fontInfo.Path + } + selectedTextFont := textFontNames[0] + selectedTextColor := "black" + textSizeSlider := newNumericInputSliderWithStep(minTextSizePercent, maxTextSizePercent, 2.0, textSizePercentStep, "%.1f%%", "Font Size") + textFontLabel := widget.NewLabel("Font:") + textFontSelect := widget.NewSelect(textFontNames, func(s string) { + if s != "" { + selectedTextFont = s + } + }) + textFontSelect.SetSelected(selectedTextFont) + textColorLabel := widget.NewLabel("Font Color:") + textColorSelect := widget.NewSelect([]string{"white", "grey", "black"}, func(s string) { + if s != "" { + selectedTextColor = s + } + }) + textColorSelect.SetSelected(selectedTextColor) + textEntry := widget.NewEntry() + textEntry.SetPlaceHolder("Enter text to place on the map") + textInstructions := widget.NewLabel("Click Add to start placement. Move the mouse over the canvas image to preview the text at 50% opacity. Left click places it on the canvas. Right click cancels.") + textInstructions.Wrapping = fyne.TextWrapWord + addTextBtn := widget.NewButton("Add", func() { + if canvasBaseImage == nil { + textInstructions.SetText("Generate a canvas before placing text.") + return + } + content := strings.TrimSpace(textEntry.Text) + if content == "" { + textInstructions.SetText("Enter text before clicking Add.") + return + } + sizeVal, _ := textSizeSlider.value.Get() + pendingText = &PendingTextPlacement{ + TextPlacementRequest: TextPlacementRequest{ + Content: content, + FontName: selectedTextFont, + FontPath: fontPathByName[selectedTextFont], + SizePercent: sizeVal, + ColorName: selectedTextColor, + }, + Visible: false, + } + pendingText.Preview, _ = renderTextSprite(pendingText.TextPlacementRequest, 128, settings.Width, settings.Height) + textInstructions.SetText("Placement active on the canvas. Left click places the text. Right click cancels.") + hideTextPreview() + }) + clearTextBtn := widget.NewButton("Clear Text", func() { + textLayer.Clear(settings.Width, settings.Height) + pendingText = nil + textInstructions.SetText("All placed text has been cleared.") + hideTextPreview() + refreshCanvasDisplay() + }) + textTab := container.NewTabItem("Text", container.NewVBox( + textSizeSlider, + textFontLabel, + textFontSelect, + textColorLabel, + textColorSelect, + container.NewBorder(nil, nil, nil, addTextBtn, textEntry), + textInstructions, + clearTextBtn, + )) + buildingsTab := container.NewTabItem("Buildings", container.NewVBox( numBuildingsSlider, minBuildingSizeSlider, @@ -1797,6 +1899,7 @@ func main() { waterTab, roadsTab, fortificationsTab, + textTab, buildingsTab, ) @@ -1834,7 +1937,27 @@ func main() { right.Refresh() } - tappableCanvas = newTappableImage(container.NewMax(canvasImg), func() { + tappableCanvas = newTappableImage(container.NewMax(canvasImg, textPreviewLayer), func(ev *fyne.PointEvent) { + if pendingText != nil { + point, ok := mapCanvasPositionToImagePoint(ev.Position, tappableCanvas.Size(), canvasBaseImage) + if !ok { + return + } + pendingText.Position = point + pendingText.Visible = true + if err := textLayer.Add(PlacedText{ + TextPlacementRequest: pendingText.TextPlacementRequest, + Position: pendingText.Position, + }); err != nil { + textInstructions.SetText("Failed to place text with the selected font.") + return + } + pendingText = nil + textInstructions.SetText("Text placed on the canvas.") + hideTextPreview() + refreshCanvasDisplay() + return + } if settings.ImageViewState == 1 { settings.ImageViewState = 0 } else { @@ -1842,7 +1965,45 @@ func main() { } updateRightPanel() }) - tappableHeightmap = newTappableImage(container.NewMax(heightmapImg), func() { + tappableCanvas.onSecondaryTap = func(*fyne.PointEvent) { + if pendingText == nil { + return + } + pendingText = nil + textInstructions.SetText("Text placement cancelled.") + hideTextPreview() + refreshCanvasDisplay() + } + tappableCanvas.onHover = func(pos fyne.Position) { + if pendingText == nil || canvasBaseImage == nil { + return + } + point, ok := mapCanvasPositionToImagePoint(pos, tappableCanvas.Size(), canvasBaseImage) + pendingText.Visible = ok + if ok { + pendingText.Position = point + if pendingText.Preview != nil { + canvasPos, canvasSize, ok := mapImagePointToCanvasRect(point, pendingText.Preview, tappableCanvas.Size(), canvasBaseImage) + if ok { + textPreviewImg.Image = pendingText.Preview + textPreviewImg.Move(canvasPos) + textPreviewImg.Resize(canvasSize) + textPreviewImg.Show() + textPreviewImg.Refresh() + return + } + } + } + hideTextPreview() + } + tappableCanvas.onHoverEnd = func() { + if pendingText == nil { + return + } + pendingText.Visible = false + hideTextPreview() + } + tappableHeightmap = newTappableImage(container.NewMax(heightmapImg), func(*fyne.PointEvent) { if settings.ImageViewState == 2 { settings.ImageViewState = 0 } else { @@ -1850,7 +2011,7 @@ func main() { } updateRightPanel() }) - tappableBumpmap = newTappableImage(container.NewMax(bumpmapImg), func() { + tappableBumpmap = newTappableImage(container.NewMax(bumpmapImg), func(*fyne.PointEvent) { if settings.ImageViewState == 3 { settings.ImageViewState = 0 } else { @@ -1910,7 +2071,7 @@ func encodeImageToWriter(w io.Writer, img image.Image, format string) error { } // showMasksSaveDialog displays a dialog for saving the generated masks. -func showMasksSaveDialog(win fyne.Window, canvasImg, heightmapImg, bumpmapImg image.Image, settings *Settings, lakes [][]image.Point, riverMask, treeMask, roadMask, bridgeMask, wallMask, turretMask, buildingMask *PixelMask) { +func showMasksSaveDialog(win fyne.Window, canvasImg, heightmapImg, bumpmapImg image.Image, settings *Settings, lakes [][]image.Point, riverMask, treeMask, roadMask, bridgeMask, wallMask, turretMask, buildingMask, textMask *PixelMask) { // Create UI elements for the save dialog fileNameEntry := widget.NewEntry() fileNameEntry.SetPlaceHolder("masks_folder") @@ -1997,6 +2158,7 @@ func showMasksSaveDialog(win fyne.Window, canvasImg, heightmapImg, bumpmapImg im {name: "walls_mask." + imgFormat, make: func() image.Image { return maskToGray(wallMask) }}, {name: "turrets_mask." + imgFormat, make: func() image.Image { return maskToGray(turretMask) }}, {name: "buildings_mask." + imgFormat, make: func() image.Image { return maskToGray(buildingMask) }}, + {name: "text_mask." + imgFormat, make: func() image.Image { return maskToGray(textMask) }}, } // Save the images based on the selected packaging option