388 lines
9.4 KiB
Go
388 lines
9.4 KiB
Go
package painter
|
|
|
|
import (
|
|
"bytes"
|
|
"image/color"
|
|
"image/draw"
|
|
"math"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/go-text/render"
|
|
"github.com/go-text/typesetting/di"
|
|
"github.com/go-text/typesetting/font"
|
|
"github.com/go-text/typesetting/fontscan"
|
|
"github.com/go-text/typesetting/language"
|
|
"github.com/go-text/typesetting/shaping"
|
|
"golang.org/x/image/math/fixed"
|
|
|
|
"fyne.io/fyne/v2"
|
|
"fyne.io/fyne/v2/internal/async"
|
|
"fyne.io/fyne/v2/internal/cache"
|
|
"fyne.io/fyne/v2/lang"
|
|
"fyne.io/fyne/v2/theme"
|
|
)
|
|
|
|
const (
|
|
// DefaultTabWidth is the default width in spaces
|
|
DefaultTabWidth = 4
|
|
|
|
fontTabSpaceSize = 10
|
|
)
|
|
|
|
var (
|
|
fm *fontscan.FontMap
|
|
fontScanLock sync.Mutex
|
|
loaded bool
|
|
)
|
|
|
|
func loadMap() {
|
|
loaded = true
|
|
|
|
fm = fontscan.NewFontMap(noopLogger{})
|
|
err := loadSystemFonts(fm)
|
|
if err != nil {
|
|
fm = nil // just don't fallback
|
|
}
|
|
}
|
|
|
|
func lookupLangFont(family string, aspect font.Aspect) *font.Face {
|
|
fontScanLock.Lock()
|
|
defer fontScanLock.Unlock()
|
|
|
|
if !loaded {
|
|
loadMap()
|
|
}
|
|
if fm == nil {
|
|
return nil
|
|
}
|
|
|
|
fm.SetQuery(fontscan.Query{Families: []string{family}, Aspect: aspect})
|
|
l, _ := fontscan.NewLangID(language.Language(lang.SystemLocale().LanguageString()))
|
|
return fm.ResolveFaceForLang(l)
|
|
}
|
|
|
|
func lookupRuneFont(r rune, family string, aspect font.Aspect) *font.Face {
|
|
fontScanLock.Lock()
|
|
defer fontScanLock.Unlock()
|
|
|
|
if !loaded {
|
|
loadMap()
|
|
}
|
|
if fm == nil {
|
|
return nil
|
|
}
|
|
|
|
fm.SetQuery(fontscan.Query{Families: []string{family}, Aspect: aspect})
|
|
return fm.ResolveFace(r)
|
|
}
|
|
|
|
func lookupFaces(theme, fallback, emoji fyne.Resource, family string, style fyne.TextStyle) (faces *dynamicFontMap) {
|
|
f1 := loadMeasureFont(theme)
|
|
if theme == fallback {
|
|
faces = &dynamicFontMap{family: family, faces: []*font.Face{f1}}
|
|
} else {
|
|
f2 := loadMeasureFont(fallback)
|
|
faces = &dynamicFontMap{family: family, faces: []*font.Face{f1, f2}}
|
|
}
|
|
|
|
aspect := font.Aspect{Style: font.StyleNormal}
|
|
if style.Italic {
|
|
aspect.Style = font.StyleItalic
|
|
}
|
|
if style.Bold {
|
|
aspect.Weight = font.WeightBold
|
|
}
|
|
|
|
if emoji != nil {
|
|
faces.addFace(loadMeasureFont(emoji))
|
|
}
|
|
|
|
local := lookupLangFont(family, aspect)
|
|
if local != nil {
|
|
faces.addFace(local)
|
|
}
|
|
|
|
return faces
|
|
}
|
|
|
|
// CachedFontFace returns a Font face held in memory. These are loaded from the current theme.
|
|
func CachedFontFace(style fyne.TextStyle, source fyne.Resource, o fyne.CanvasObject) *FontCacheItem {
|
|
if source != nil {
|
|
val, ok := fontCustomCache.Load(source)
|
|
if !ok {
|
|
face := loadMeasureFont(source)
|
|
if face == nil {
|
|
face = loadMeasureFont(theme.TextFont())
|
|
}
|
|
faces := &dynamicFontMap{family: source.Name(), faces: []*font.Face{face}}
|
|
|
|
val = &FontCacheItem{Fonts: faces}
|
|
fontCustomCache.Store(source, val)
|
|
}
|
|
return val
|
|
}
|
|
|
|
scope := ""
|
|
if o != nil { // for overridden themes get the cache key right
|
|
scope = cache.WidgetScopeID(o)
|
|
}
|
|
|
|
val, ok := fontCache.Load(cacheID{style: style, scope: scope})
|
|
if !ok {
|
|
var faces *dynamicFontMap
|
|
|
|
th := theme.CurrentForWidget(o)
|
|
font1 := th.Font(style)
|
|
|
|
emoji := theme.DefaultEmojiFont() // TODO only one emoji - maybe others too
|
|
switch {
|
|
case style.Monospace:
|
|
faces = lookupFaces(font1, theme.DefaultTextMonospaceFont(), emoji, fontscan.Monospace, style)
|
|
case style.Bold:
|
|
if style.Italic {
|
|
faces = lookupFaces(font1, theme.DefaultTextBoldItalicFont(), emoji, fontscan.SansSerif, style)
|
|
} else {
|
|
faces = lookupFaces(font1, theme.DefaultTextBoldFont(), emoji, fontscan.SansSerif, style)
|
|
}
|
|
case style.Italic:
|
|
faces = lookupFaces(font1, theme.DefaultTextItalicFont(), emoji, fontscan.SansSerif, style)
|
|
case style.Symbol:
|
|
th := theme.SymbolFont()
|
|
fallback := theme.DefaultSymbolFont()
|
|
f1 := loadMeasureFont(th)
|
|
|
|
if th == fallback {
|
|
faces = &dynamicFontMap{family: fontscan.SansSerif, faces: []*font.Face{f1}}
|
|
} else {
|
|
f2 := loadMeasureFont(fallback)
|
|
faces = &dynamicFontMap{family: fontscan.SansSerif, faces: []*font.Face{f1, f2}}
|
|
}
|
|
default:
|
|
faces = lookupFaces(font1, theme.DefaultTextFont(), emoji, fontscan.SansSerif, style)
|
|
}
|
|
|
|
val = &FontCacheItem{Fonts: faces}
|
|
fontCache.Store(cacheID{style: style, scope: scope}, val)
|
|
}
|
|
|
|
return val
|
|
}
|
|
|
|
// ClearFontCache is used to remove cached fonts in the case that we wish to re-load Font faces
|
|
func ClearFontCache() {
|
|
fontCache.Clear()
|
|
fontCustomCache.Clear()
|
|
}
|
|
|
|
// DrawString draws a string into an image.
|
|
func DrawString(dst draw.Image, s string, color color.Color, f shaping.Fontmap, fontSize, scale float32, style fyne.TextStyle) {
|
|
r := render.Renderer{
|
|
FontSize: fontSize,
|
|
PixScale: scale,
|
|
Color: color,
|
|
}
|
|
|
|
advance := float32(0)
|
|
y := math.MinInt
|
|
walkString(f, s, float32ToFixed266(fontSize), style, &advance, scale, func(run shaping.Output, x float32) {
|
|
if y == math.MinInt {
|
|
y = int(math.Ceil(float64(fixed266ToFloat32(run.LineBounds.Ascent) * r.PixScale)))
|
|
}
|
|
if len(run.Glyphs) == 1 {
|
|
if run.Glyphs[0].GlyphID == 0 {
|
|
r.DrawStringAt(string([]rune{0xfffd}), dst, int(x), y, f.ResolveFace(0xfffd))
|
|
return
|
|
}
|
|
}
|
|
|
|
r.DrawShapedRunAt(run, dst, int(x), y)
|
|
})
|
|
}
|
|
|
|
func loadMeasureFont(data fyne.Resource) *font.Face {
|
|
loaded, err := font.ParseTTF(bytes.NewReader(data.Content()))
|
|
if err != nil {
|
|
fyne.LogError("font load error", err)
|
|
return nil
|
|
}
|
|
|
|
return loaded
|
|
}
|
|
|
|
// MeasureString returns how far dot would advance by drawing s with f.
|
|
// Tabs are translated into a dot location change.
|
|
func MeasureString(f shaping.Fontmap, s string, textSize float32, style fyne.TextStyle) (size fyne.Size, advance float32) {
|
|
return walkString(f, s, float32ToFixed266(textSize), style, &advance, 1, func(shaping.Output, float32) {})
|
|
}
|
|
|
|
// RenderedTextSize looks up how big a string would be if drawn on screen.
|
|
// It also returns the distance from top to the text baseline.
|
|
func RenderedTextSize(text string, fontSize float32, style fyne.TextStyle, source fyne.Resource) (size fyne.Size, baseline float32) {
|
|
size, base := cache.GetFontMetrics(text, fontSize, style, source)
|
|
if base != 0 {
|
|
return size, base
|
|
}
|
|
|
|
size, base = measureText(text, fontSize, style, source)
|
|
cache.SetFontMetrics(text, fontSize, style, source, size, base)
|
|
return size, base
|
|
}
|
|
|
|
func fixed266ToFloat32(i fixed.Int26_6) float32 {
|
|
return float32(float64(i) / (1 << 6))
|
|
}
|
|
|
|
func float32ToFixed266(f float32) fixed.Int26_6 {
|
|
return fixed.Int26_6(float64(f) * (1 << 6))
|
|
}
|
|
|
|
func measureText(text string, fontSize float32, style fyne.TextStyle, source fyne.Resource) (fyne.Size, float32) {
|
|
face := CachedFontFace(style, source, nil)
|
|
return MeasureString(face.Fonts, text, fontSize, style)
|
|
}
|
|
|
|
func tabStop(spacew, x float32, tabWidth int) float32 {
|
|
if tabWidth <= 0 {
|
|
tabWidth = DefaultTabWidth
|
|
}
|
|
|
|
tabw := spacew * float32(tabWidth)
|
|
tabs, _ := math.Modf(float64((x + tabw) / tabw))
|
|
return tabw * float32(tabs)
|
|
}
|
|
|
|
func walkString(faces shaping.Fontmap, s string, textSize fixed.Int26_6, style fyne.TextStyle, advance *float32, scale float32,
|
|
cb func(run shaping.Output, x float32)) (size fyne.Size, base float32) {
|
|
s = strings.ReplaceAll(s, "\r", "")
|
|
|
|
runes := []rune(s)
|
|
in := shaping.Input{
|
|
Text: []rune{' '},
|
|
RunStart: 0,
|
|
RunEnd: 1,
|
|
Direction: di.DirectionLTR,
|
|
Face: faces.ResolveFace(' '),
|
|
Size: textSize,
|
|
}
|
|
shaper := &shaping.HarfbuzzShaper{}
|
|
segmenter := &shaping.Segmenter{}
|
|
out := shaper.Shape(in)
|
|
|
|
in.Text = runes
|
|
in.RunStart = 0
|
|
in.RunEnd = len(runes)
|
|
|
|
x := float32(0)
|
|
spacew := scale * fontTabSpaceSize
|
|
if style.Monospace {
|
|
spacew = scale * fixed266ToFloat32(out.Advance)
|
|
}
|
|
ins := segmenter.Split(in, faces)
|
|
for _, in := range ins {
|
|
inEnd := in.RunEnd
|
|
|
|
pending := false
|
|
for i, r := range in.Text[in.RunStart:in.RunEnd] {
|
|
if r == '\t' {
|
|
if pending {
|
|
in.RunEnd = i
|
|
x = shapeCallback(shaper, in, x, scale, cb)
|
|
}
|
|
x = tabStop(spacew, x, style.TabWidth)
|
|
|
|
in.RunStart = i + 1
|
|
in.RunEnd = inEnd
|
|
pending = false
|
|
} else {
|
|
pending = true
|
|
}
|
|
}
|
|
|
|
x = shapeCallback(shaper, in, x, scale, cb)
|
|
}
|
|
|
|
*advance = x
|
|
return fyne.NewSize(*advance, fixed266ToFloat32(out.LineBounds.LineThickness())),
|
|
fixed266ToFloat32(out.LineBounds.Ascent)
|
|
}
|
|
|
|
func shapeCallback(shaper shaping.Shaper, in shaping.Input, x, scale float32, cb func(shaping.Output, float32)) float32 {
|
|
out := shaper.Shape(in)
|
|
glyphs := out.Glyphs
|
|
start := 0
|
|
pending := false
|
|
adv := fixed.I(0)
|
|
for i, g := range out.Glyphs {
|
|
if g.GlyphID == 0 {
|
|
if pending {
|
|
out.Glyphs = glyphs[start:i]
|
|
cb(out, x)
|
|
x += fixed266ToFloat32(adv) * scale
|
|
adv = 0
|
|
}
|
|
|
|
out.Glyphs = glyphs[i : i+1]
|
|
cb(out, x)
|
|
x += fixed266ToFloat32(glyphs[i].XAdvance) * scale
|
|
adv = 0
|
|
|
|
start = i + 1
|
|
pending = false
|
|
} else {
|
|
pending = true
|
|
}
|
|
adv += g.XAdvance
|
|
}
|
|
|
|
if pending {
|
|
out.Glyphs = glyphs[start:]
|
|
cb(out, x)
|
|
x += fixed266ToFloat32(adv) * scale
|
|
adv = 0
|
|
}
|
|
return x + fixed266ToFloat32(adv)*scale
|
|
}
|
|
|
|
type FontCacheItem struct {
|
|
Fonts shaping.Fontmap
|
|
}
|
|
|
|
type cacheID struct {
|
|
style fyne.TextStyle
|
|
scope string
|
|
}
|
|
|
|
var fontCache async.Map[cacheID, *FontCacheItem]
|
|
var fontCustomCache async.Map[fyne.Resource, *FontCacheItem] // for custom resources
|
|
|
|
type noopLogger struct{}
|
|
|
|
func (n noopLogger) Printf(string, ...any) {}
|
|
|
|
type dynamicFontMap struct {
|
|
faces []*font.Face
|
|
family string
|
|
}
|
|
|
|
func (d *dynamicFontMap) ResolveFace(r rune) *font.Face {
|
|
|
|
for _, f := range d.faces {
|
|
if _, ok := f.NominalGlyph(r); ok {
|
|
return f
|
|
}
|
|
}
|
|
|
|
toAdd := lookupRuneFont(r, d.family, font.Aspect{})
|
|
if toAdd != nil {
|
|
d.addFace(toAdd)
|
|
return toAdd
|
|
}
|
|
|
|
return d.faces[0]
|
|
}
|
|
|
|
func (d *dynamicFontMap) addFace(f *font.Face) {
|
|
d.faces = append(d.faces, f)
|
|
}
|