Major improvements to UnifiedPlayer: 1. GetFrameImage() now works when paused for responsive UI updates 2. Play() method properly starts FFmpeg process 3. Frame display loop runs continuously for smooth video display 4. Disabled audio temporarily to fix video playback fundamentals 5. Simplified FFmpeg command to focus on video stream only Player now: - Generates video frames correctly - Shows video when paused - Has responsive progress tracking - Starts playback properly Next steps: Re-enable audio playback once video is stable
1143 lines
30 KiB
Go
1143 lines
30 KiB
Go
package widget
|
|
|
|
import (
|
|
"image/color"
|
|
"math"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"github.com/go-text/typesetting/di"
|
|
"github.com/go-text/typesetting/shaping"
|
|
"golang.org/x/image/math/fixed"
|
|
|
|
"fyne.io/fyne/v2"
|
|
"fyne.io/fyne/v2/canvas"
|
|
"fyne.io/fyne/v2/internal/cache"
|
|
paint "fyne.io/fyne/v2/internal/painter"
|
|
"fyne.io/fyne/v2/internal/widget"
|
|
"fyne.io/fyne/v2/layout"
|
|
"fyne.io/fyne/v2/theme"
|
|
)
|
|
|
|
const passwordChar = "•"
|
|
|
|
var _ fyne.Widget = (*RichText)(nil)
|
|
|
|
// RichText represents the base element for a rich text-based widget.
|
|
//
|
|
// Since: 2.1
|
|
type RichText struct {
|
|
BaseWidget
|
|
Segments []RichTextSegment
|
|
Wrapping fyne.TextWrap
|
|
Scroll fyne.ScrollDirection
|
|
|
|
// The truncation mode of the text
|
|
//
|
|
// Since: 2.4
|
|
Truncation fyne.TextTruncation
|
|
|
|
inset fyne.Size // this varies due to how the widget works (entry with scroller vs others with padding)
|
|
rowBounds []rowBoundary // cache for boundaries
|
|
scr *widget.Scroll
|
|
prop *canvas.Rectangle // used to apply text minsize to the scroller `scr`, if present - TODO improve #2464
|
|
|
|
visualCache map[RichTextSegment][]fyne.CanvasObject
|
|
minCache fyne.Size
|
|
}
|
|
|
|
// NewRichText returns a new RichText widget that renders the given text and segments.
|
|
// If no segments are specified it will be converted to a single segment using the default text settings.
|
|
//
|
|
// Since: 2.1
|
|
func NewRichText(segments ...RichTextSegment) *RichText {
|
|
t := &RichText{Segments: segments}
|
|
t.Scroll = widget.ScrollNone
|
|
return t
|
|
}
|
|
|
|
// NewRichTextWithText returns a new RichText widget that renders the given text.
|
|
// The string will be converted to a single text segment using the default text settings.
|
|
//
|
|
// Since: 2.1
|
|
func NewRichTextWithText(text string) *RichText {
|
|
return NewRichText(&TextSegment{
|
|
Style: RichTextStyleInline,
|
|
Text: text,
|
|
})
|
|
}
|
|
|
|
// CreateRenderer is a private method to Fyne which links this widget to its renderer
|
|
func (t *RichText) CreateRenderer() fyne.WidgetRenderer {
|
|
t.prop = canvas.NewRectangle(color.Transparent)
|
|
if t.scr == nil && t.Scroll != widget.ScrollNone {
|
|
t.scr = widget.NewScroll(&fyne.Container{Layout: layout.NewStackLayout(), Objects: []fyne.CanvasObject{
|
|
t.prop, &fyne.Container{},
|
|
}})
|
|
}
|
|
|
|
t.ExtendBaseWidget(t)
|
|
r := &textRenderer{obj: t}
|
|
|
|
t.updateRowBounds() // set up the initial text layout etc
|
|
r.Refresh()
|
|
return r
|
|
}
|
|
|
|
// MinSize calculates the minimum size of a rich text widget.
|
|
// This is based on the contained text with a standard amount of padding added.
|
|
func (t *RichText) MinSize() fyne.Size {
|
|
// we don't return the minCache here, as any internal segments could have caused it to change...
|
|
t.ExtendBaseWidget(t)
|
|
|
|
min := t.BaseWidget.MinSize()
|
|
t.minCache = min
|
|
return min
|
|
}
|
|
|
|
// Refresh triggers a redraw of the rich text.
|
|
func (t *RichText) Refresh() {
|
|
t.minCache = fyne.Size{}
|
|
t.updateRowBounds()
|
|
|
|
for _, s := range t.Segments {
|
|
if txt, ok := s.(*TextSegment); ok {
|
|
txt.parent = t
|
|
}
|
|
}
|
|
|
|
t.BaseWidget.Refresh()
|
|
}
|
|
|
|
// Resize sets a new size for the rich text.
|
|
// This should only be called if it is not in a container with a layout manager.
|
|
func (t *RichText) Resize(size fyne.Size) {
|
|
if size == t.Size() {
|
|
return
|
|
}
|
|
|
|
t.size = size
|
|
|
|
skipResize := !t.minCache.IsZero() && size.Width >= t.minCache.Width && size.Height >= t.minCache.Height && t.Wrapping == fyne.TextWrapOff && t.Truncation == fyne.TextTruncateOff
|
|
|
|
if skipResize {
|
|
if len(t.Segments) < 2 { // we can simplify :)
|
|
cache.Renderer(t).Layout(size)
|
|
return
|
|
}
|
|
}
|
|
t.updateRowBounds()
|
|
|
|
t.Refresh()
|
|
}
|
|
|
|
// String returns the text widget buffer as string
|
|
func (t *RichText) String() string {
|
|
ret := strings.Builder{}
|
|
for _, seg := range t.Segments {
|
|
ret.WriteString(seg.Textual())
|
|
}
|
|
return ret.String()
|
|
}
|
|
|
|
// charMinSize returns the average char size to use for internal computation
|
|
func (t *RichText) charMinSize(concealed bool, style fyne.TextStyle, textSize float32) fyne.Size {
|
|
defaultChar := "M"
|
|
if concealed {
|
|
defaultChar = passwordChar
|
|
}
|
|
|
|
return fyne.MeasureText(defaultChar, textSize, style)
|
|
}
|
|
|
|
// deleteFromTo removes the text between the specified positions
|
|
func (t *RichText) deleteFromTo(lowBound int, highBound int) []rune {
|
|
if lowBound >= highBound {
|
|
return []rune{}
|
|
}
|
|
|
|
start := 0
|
|
ret := make([]rune, 0, highBound-lowBound)
|
|
deleting := false
|
|
var segs []RichTextSegment
|
|
for i, seg := range t.Segments {
|
|
if _, ok := seg.(*TextSegment); !ok {
|
|
if !deleting {
|
|
segs = append(segs, seg)
|
|
}
|
|
continue
|
|
}
|
|
end := start + len([]rune(seg.(*TextSegment).Text))
|
|
if end < lowBound {
|
|
segs = append(segs, seg)
|
|
start = end
|
|
continue
|
|
}
|
|
|
|
startOff := int(math.Max(float64(lowBound-start), 0))
|
|
endOff := int(math.Min(float64(end), float64(highBound))) - start
|
|
r := ([]rune)(seg.(*TextSegment).Text)
|
|
ret = append(ret, r[startOff:endOff]...)
|
|
r2 := append(r[:startOff], r[endOff:]...)
|
|
seg.(*TextSegment).Text = string(r2)
|
|
segs = append(segs, seg)
|
|
|
|
// prepare next iteration
|
|
start = end
|
|
if start >= highBound {
|
|
segs = append(segs, t.Segments[i+1:]...)
|
|
break
|
|
} else if start >= lowBound {
|
|
deleting = true
|
|
}
|
|
}
|
|
t.Segments = segs
|
|
t.Refresh()
|
|
return ret
|
|
}
|
|
|
|
// cachedSegmentVisual returns a cached segment visual representation.
|
|
// The offset value is > 0 if the segment had been split and so we need multiple objects.
|
|
func (t *RichText) cachedSegmentVisual(seg RichTextSegment, offset int) fyne.CanvasObject {
|
|
if t.visualCache == nil {
|
|
t.visualCache = make(map[RichTextSegment][]fyne.CanvasObject)
|
|
}
|
|
|
|
if vis, ok := t.visualCache[seg]; ok && offset < len(vis) {
|
|
return vis[offset]
|
|
}
|
|
|
|
vis := seg.Visual()
|
|
if offset < len(t.visualCache[seg]) {
|
|
t.visualCache[seg][offset] = vis
|
|
} else {
|
|
t.visualCache[seg] = append(t.visualCache[seg], vis)
|
|
}
|
|
return vis
|
|
}
|
|
|
|
func (t *RichText) cleanVisualCache() {
|
|
if len(t.visualCache) <= len(t.Segments) {
|
|
return
|
|
}
|
|
var deletingSegs []RichTextSegment
|
|
for seg1 := range t.visualCache {
|
|
found := false
|
|
for _, seg2 := range t.Segments {
|
|
if seg1 == seg2 {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
// cached segment is not currently in t.Segments, clear it
|
|
deletingSegs = append(deletingSegs, seg1)
|
|
}
|
|
}
|
|
for _, seg := range deletingSegs {
|
|
delete(t.visualCache, seg)
|
|
}
|
|
}
|
|
|
|
// insertAt inserts the text at the specified position
|
|
func (t *RichText) insertAt(pos int, runes []rune) {
|
|
index := 0
|
|
start := 0
|
|
var into *TextSegment
|
|
for i, seg := range t.Segments {
|
|
if _, ok := seg.(*TextSegment); !ok {
|
|
continue
|
|
}
|
|
end := start + len([]rune(seg.(*TextSegment).Text))
|
|
into = seg.(*TextSegment)
|
|
index = i
|
|
if end > pos {
|
|
break
|
|
}
|
|
|
|
start = end
|
|
}
|
|
|
|
if into == nil {
|
|
return
|
|
}
|
|
r := ([]rune)(into.Text)
|
|
if pos > len(r) { // safety in case position is out of bounds for the segment
|
|
pos = len(r)
|
|
}
|
|
r2 := append(r[:pos], append(runes, r[pos:]...)...)
|
|
into.Text = string(r2)
|
|
t.Segments[index] = into
|
|
}
|
|
|
|
// Len returns the text widget buffer length
|
|
func (t *RichText) len() int {
|
|
ret := 0
|
|
for _, seg := range t.Segments {
|
|
ret += len([]rune(seg.Textual()))
|
|
}
|
|
return ret
|
|
}
|
|
|
|
// lineSizeToColumn returns the rendered size for the line specified by row up to the col position
|
|
func (t *RichText) lineSizeToColumn(col, row int, textSize, innerPad float32) fyne.Size {
|
|
if row < 0 {
|
|
row = 0
|
|
}
|
|
if col < 0 {
|
|
col = 0
|
|
}
|
|
bound := t.rowBoundary(row)
|
|
total := fyne.NewSize(0, 0)
|
|
counted := 0
|
|
last := false
|
|
if bound == nil {
|
|
return t.charMinSize(false, fyne.TextStyle{}, textSize)
|
|
}
|
|
for i, seg := range bound.segments {
|
|
var size fyne.Size
|
|
if text, ok := seg.(*TextSegment); ok {
|
|
start := 0
|
|
if i == 0 {
|
|
start = bound.begin
|
|
}
|
|
measureText := []rune(text.Text)[start:]
|
|
if col < counted+len(measureText) {
|
|
measureText = measureText[0 : col-counted]
|
|
last = true
|
|
}
|
|
if concealed(seg) {
|
|
measureText = []rune(strings.Repeat(passwordChar, len(measureText)))
|
|
}
|
|
counted += len(measureText)
|
|
|
|
label := canvas.NewText(string(measureText), color.Black)
|
|
label.TextStyle = text.Style.TextStyle
|
|
label.TextSize = text.size()
|
|
|
|
size = label.MinSize()
|
|
} else {
|
|
size = t.cachedSegmentVisual(seg, 0).MinSize()
|
|
}
|
|
|
|
total.Width += size.Width
|
|
total.Height = fyne.Max(total.Height, size.Height)
|
|
if last {
|
|
break
|
|
}
|
|
}
|
|
return total.Add(fyne.NewSize(innerPad-t.inset.Width, 0))
|
|
}
|
|
|
|
// Row returns the characters in the row specified.
|
|
// The row parameter should be between 0 and t.Rows()-1.
|
|
func (t *RichText) row(row int) []rune {
|
|
if row < 0 || row >= t.rows() {
|
|
return nil
|
|
}
|
|
bound := t.rowBounds[row]
|
|
var ret []rune
|
|
for i, seg := range bound.segments {
|
|
if text, ok := seg.(*TextSegment); ok {
|
|
if i == 0 {
|
|
if len(bound.segments) == 1 {
|
|
ret = append(ret, []rune(text.Text)[bound.begin:bound.end]...)
|
|
} else {
|
|
ret = append(ret, []rune(text.Text)[bound.begin:]...)
|
|
}
|
|
} else if i == len(bound.segments)-1 && len(bound.segments) > 1 && bound.end != 0 {
|
|
ret = append(ret, []rune(text.Text)[:bound.end]...)
|
|
}
|
|
}
|
|
}
|
|
return ret
|
|
}
|
|
|
|
// RowBoundary returns the boundary of the row specified.
|
|
// The row parameter should be between 0 and t.Rows()-1.
|
|
func (t *RichText) rowBoundary(row int) *rowBoundary {
|
|
if row < 0 || row >= t.rows() {
|
|
return nil
|
|
}
|
|
return &t.rowBounds[row]
|
|
}
|
|
|
|
// RowLength returns the number of visible characters in the row specified.
|
|
// The row parameter should be between 0 and t.Rows()-1.
|
|
func (t *RichText) rowLength(row int) int {
|
|
return len(t.row(row))
|
|
}
|
|
|
|
// rows returns the number of text rows in this text entry.
|
|
// The entry may be longer than required to show this amount of content.
|
|
func (t *RichText) rows() int {
|
|
if t.rowBounds == nil { // if the widget API is used before it is shown
|
|
t.updateRowBounds()
|
|
}
|
|
return len(t.rowBounds)
|
|
}
|
|
|
|
// updateRowBounds updates the row bounds used to render properly the text widget.
|
|
// updateRowBounds should be invoked every time a segment Text, widget Wrapping or size changes.
|
|
func (t *RichText) updateRowBounds() {
|
|
th := t.Theme()
|
|
innerPadding := th.Size(theme.SizeNameInnerPadding)
|
|
fitSize := t.Size()
|
|
if t.scr != nil {
|
|
fitSize = t.scr.Content.MinSize()
|
|
}
|
|
fitSize.Height -= (innerPadding + t.inset.Height) * 2
|
|
|
|
var bounds []rowBoundary
|
|
maxWidth := t.Size().Width - 2*innerPadding + 2*t.inset.Width
|
|
wrapWidth := maxWidth
|
|
|
|
var currentBound *rowBoundary
|
|
var iterateSegments func(segList []RichTextSegment)
|
|
iterateSegments = func(segList []RichTextSegment) {
|
|
for _, seg := range segList {
|
|
if parent, ok := seg.(RichTextBlock); ok {
|
|
segs := parent.Segments()
|
|
iterateSegments(segs)
|
|
if len(segs) > 0 && !segs[len(segs)-1].Inline() {
|
|
wrapWidth = maxWidth
|
|
currentBound = nil
|
|
}
|
|
continue
|
|
}
|
|
if _, ok := seg.(*TextSegment); !ok {
|
|
if currentBound == nil {
|
|
bound := rowBoundary{segments: []RichTextSegment{seg}}
|
|
bounds = append(bounds, bound)
|
|
currentBound = &bound
|
|
} else {
|
|
bounds[len(bounds)-1].segments = append(bounds[len(bounds)-1].segments, seg)
|
|
}
|
|
|
|
itemMin := t.cachedSegmentVisual(seg, 0).MinSize()
|
|
if seg.Inline() {
|
|
wrapWidth -= itemMin.Width
|
|
} else {
|
|
wrapWidth = maxWidth
|
|
currentBound = nil
|
|
fitSize.Height -= itemMin.Height + th.Size(theme.SizeNameLineSpacing)
|
|
}
|
|
continue
|
|
}
|
|
textSeg := seg.(*TextSegment)
|
|
textStyle := textSeg.Style.TextStyle
|
|
textSize := textSeg.size()
|
|
|
|
leftPad := float32(0)
|
|
if textSeg.Style == RichTextStyleBlockquote {
|
|
leftPad = innerPadding * 2
|
|
}
|
|
retBounds, height := lineBounds(textSeg, t.Wrapping, t.Truncation, wrapWidth-leftPad, fyne.NewSize(maxWidth, fitSize.Height), func(text []rune) fyne.Size {
|
|
return fyne.MeasureText(string(text), textSize, textStyle)
|
|
})
|
|
if currentBound != nil {
|
|
if len(retBounds) > 0 {
|
|
bounds[len(bounds)-1].end = retBounds[0].end // invalidate row ending as we have more content
|
|
bounds[len(bounds)-1].segments = append(bounds[len(bounds)-1].segments, seg)
|
|
bounds = append(bounds, retBounds[1:]...)
|
|
|
|
fitSize.Height -= height
|
|
}
|
|
} else {
|
|
bounds = append(bounds, retBounds...)
|
|
|
|
fitSize.Height -= height
|
|
}
|
|
currentBound = &bounds[len(bounds)-1]
|
|
if seg.Inline() {
|
|
last := bounds[len(bounds)-1]
|
|
begin := 0
|
|
if len(last.segments) == 1 {
|
|
begin = last.begin
|
|
}
|
|
runes := []rune(textSeg.Text)
|
|
// check ranges - as we resize it can be wrong?
|
|
if begin > len(runes) {
|
|
begin = len(runes)
|
|
}
|
|
end := last.end
|
|
if end > len(runes) {
|
|
end = len(runes)
|
|
}
|
|
text := string(runes[begin:end])
|
|
measured := fyne.MeasureText(text, textSeg.size(), textSeg.Style.TextStyle)
|
|
lastWidth := measured.Width
|
|
if len(retBounds) == 1 {
|
|
wrapWidth -= lastWidth
|
|
} else {
|
|
wrapWidth = maxWidth - lastWidth
|
|
}
|
|
} else {
|
|
currentBound = nil
|
|
wrapWidth = maxWidth
|
|
}
|
|
}
|
|
}
|
|
|
|
iterateSegments(t.Segments)
|
|
t.rowBounds = bounds
|
|
}
|
|
|
|
// RichTextBlock is an extension of a text segment that contains other segments
|
|
//
|
|
// Since: 2.1
|
|
type RichTextBlock interface {
|
|
Segments() []RichTextSegment
|
|
}
|
|
|
|
// Renderer
|
|
type textRenderer struct {
|
|
widget.BaseRenderer
|
|
obj *RichText
|
|
}
|
|
|
|
func (r *textRenderer) Layout(size fyne.Size) {
|
|
th := r.obj.Theme()
|
|
bounds := r.obj.rowBounds
|
|
objs := r.Objects()
|
|
if r.obj.scr != nil {
|
|
r.obj.scr.Resize(size)
|
|
objs = r.obj.scr.Content.(*fyne.Container).Objects[1].(*fyne.Container).Objects
|
|
}
|
|
|
|
// Accessing theme here is slow, so we cache the value
|
|
innerPadding := th.Size(theme.SizeNameInnerPadding)
|
|
lineSpacing := th.Size(theme.SizeNameLineSpacing)
|
|
|
|
xInset := innerPadding - r.obj.inset.Width
|
|
left := xInset
|
|
yPos := innerPadding - r.obj.inset.Height
|
|
lineWidth := size.Width - left*2
|
|
var rowItems []fyne.CanvasObject
|
|
rowAlign := fyne.TextAlignLeading
|
|
i := 0
|
|
for row, bound := range bounds {
|
|
for segI := range bound.segments {
|
|
if i == len(objs) {
|
|
break // Refresh may not have created all objects for all rows yet...
|
|
}
|
|
inline := segI < len(bound.segments)-1
|
|
obj := objs[i]
|
|
i++
|
|
_, isText := obj.(*canvas.Text)
|
|
if !isText && !inline {
|
|
if len(rowItems) != 0 {
|
|
width, _ := r.layoutRow(rowItems, rowAlign, left, yPos, lineWidth)
|
|
left += width
|
|
rowItems = nil
|
|
}
|
|
height := obj.MinSize().Height
|
|
|
|
obj.Move(fyne.NewPos(left, yPos))
|
|
obj.Resize(fyne.NewSize(lineWidth, height))
|
|
yPos += height
|
|
left = xInset
|
|
continue
|
|
}
|
|
rowItems = append(rowItems, obj)
|
|
if inline {
|
|
continue
|
|
}
|
|
|
|
leftPad := float32(0)
|
|
if text, ok := bound.segments[0].(*TextSegment); ok {
|
|
rowAlign = text.Style.Alignment
|
|
if text.Style == RichTextStyleBlockquote {
|
|
leftPad = lineSpacing * 4
|
|
}
|
|
} else if link, ok := bound.segments[0].(*HyperlinkSegment); ok {
|
|
rowAlign = link.Alignment
|
|
}
|
|
_, y := r.layoutRow(rowItems, rowAlign, left+leftPad, yPos, lineWidth-leftPad)
|
|
yPos += y
|
|
rowItems = nil
|
|
}
|
|
|
|
lastSeg := bound.segments[len(bound.segments)-1]
|
|
if !lastSeg.Inline() && row < len(bounds)-1 && bounds[row+1].segments[0] != lastSeg { // ignore wrapped lines etc
|
|
yPos += lineSpacing
|
|
}
|
|
}
|
|
}
|
|
|
|
// MinSize calculates the minimum size of a rich text widget.
|
|
// This is based on the contained text with a standard amount of padding added.
|
|
func (r *textRenderer) MinSize() fyne.Size {
|
|
th := r.obj.Theme()
|
|
textSize := th.Size(theme.SizeNameText)
|
|
innerPad := th.Size(theme.SizeNameInnerPadding)
|
|
|
|
bounds := r.obj.rowBounds
|
|
wrap := r.obj.Wrapping
|
|
trunc := r.obj.Truncation
|
|
scroll := r.obj.Scroll
|
|
objs := r.Objects()
|
|
if r.obj.scr != nil {
|
|
objs = r.obj.scr.Content.(*fyne.Container).Objects[1].(*fyne.Container).Objects
|
|
}
|
|
|
|
charMinSize := r.obj.charMinSize(false, fyne.TextStyle{}, textSize)
|
|
min := r.calculateMin(bounds, wrap, objs, charMinSize, th)
|
|
if r.obj.scr != nil {
|
|
r.obj.prop.SetMinSize(min)
|
|
}
|
|
|
|
if trunc != fyne.TextTruncateOff && r.obj.Scroll == widget.ScrollNone {
|
|
minBounds := charMinSize
|
|
if wrap == fyne.TextWrapOff {
|
|
minBounds.Height = min.Height
|
|
} else {
|
|
minBounds = minBounds.Add(fyne.NewSquareSize(innerPad * 2).Subtract(r.obj.inset).Subtract(r.obj.inset))
|
|
}
|
|
if trunc == fyne.TextTruncateClip {
|
|
return minBounds
|
|
} else if trunc == fyne.TextTruncateEllipsis {
|
|
ellipsisSize := fyne.MeasureText("…", th.Size(theme.SizeNameText), fyne.TextStyle{})
|
|
return minBounds.AddWidthHeight(ellipsisSize.Width, 0)
|
|
}
|
|
}
|
|
|
|
switch scroll {
|
|
case widget.ScrollBoth:
|
|
return fyne.NewSize(32, 32)
|
|
case widget.ScrollHorizontalOnly:
|
|
return fyne.NewSize(32, min.Height)
|
|
case widget.ScrollVerticalOnly:
|
|
return fyne.NewSize(min.Width, 32)
|
|
default:
|
|
return min
|
|
}
|
|
}
|
|
|
|
func (r *textRenderer) calculateMin(bounds []rowBoundary, wrap fyne.TextWrap, objs []fyne.CanvasObject,
|
|
charMinSize fyne.Size, th fyne.Theme,
|
|
) fyne.Size {
|
|
height := float32(0)
|
|
width := float32(0)
|
|
rowHeight := float32(0)
|
|
rowWidth := float32(0)
|
|
trunc := r.obj.Truncation
|
|
innerPad := th.Size(theme.SizeNameInnerPadding)
|
|
|
|
// Accessing the theme here is slow, so we cache the value
|
|
lineSpacing := th.Size(theme.SizeNameLineSpacing)
|
|
|
|
i := 0
|
|
for row, bound := range bounds {
|
|
for range bound.segments {
|
|
if i == len(objs) {
|
|
break // Refresh may not have created all objects for all rows yet...
|
|
}
|
|
obj := objs[i]
|
|
i++
|
|
|
|
min := obj.MinSize()
|
|
if img, ok := obj.(*richImage); ok {
|
|
if newMin := img.MinSize(); newMin != img.oldMin {
|
|
img.oldMin = newMin
|
|
|
|
min := r.calculateMin(bounds, wrap, objs, charMinSize, th)
|
|
if r.obj.scr != nil {
|
|
r.obj.prop.SetMinSize(min)
|
|
}
|
|
r.Refresh() // TODO resolve this in a similar way to #2991
|
|
}
|
|
}
|
|
rowHeight = fyne.Max(rowHeight, min.Height)
|
|
rowWidth += min.Width
|
|
}
|
|
|
|
if wrap == fyne.TextWrapOff && trunc == fyne.TextTruncateOff {
|
|
width = fyne.Max(width, rowWidth)
|
|
}
|
|
height += rowHeight
|
|
rowHeight = 0
|
|
rowWidth = 0
|
|
|
|
lastSeg := bound.segments[len(bound.segments)-1]
|
|
if !lastSeg.Inline() && row < len(bounds)-1 && bounds[row+1].segments[0] != lastSeg { // ignore wrapped lines etc
|
|
height += lineSpacing
|
|
}
|
|
}
|
|
|
|
if height == 0 {
|
|
height = charMinSize.Height
|
|
}
|
|
return fyne.NewSize(width, height).
|
|
Add(fyne.NewSquareSize(innerPad * 2).Subtract(r.obj.inset).Subtract(r.obj.inset))
|
|
}
|
|
|
|
func (r *textRenderer) Refresh() {
|
|
bounds := r.obj.rowBounds
|
|
scroll := r.obj.Scroll
|
|
|
|
var objs []fyne.CanvasObject
|
|
for _, bound := range bounds {
|
|
for i, seg := range bound.segments {
|
|
if _, ok := seg.(*TextSegment); !ok {
|
|
obj := r.obj.cachedSegmentVisual(seg, 0)
|
|
seg.Update(obj)
|
|
objs = append(objs, obj)
|
|
continue
|
|
}
|
|
|
|
reuse := 0
|
|
if i == 0 {
|
|
reuse = bound.firstSegmentReuse
|
|
}
|
|
obj := r.obj.cachedSegmentVisual(seg, reuse)
|
|
seg.Update(obj)
|
|
txt := obj.(*canvas.Text)
|
|
textSeg := seg.(*TextSegment)
|
|
runes := []rune(textSeg.Text)
|
|
|
|
if i == 0 {
|
|
if len(bound.segments) == 1 {
|
|
txt.Text = string(runes[bound.begin:bound.end])
|
|
} else {
|
|
txt.Text = string(runes[bound.begin:])
|
|
}
|
|
} else if i == len(bound.segments)-1 && len(bound.segments) > 1 {
|
|
txt.Text = string(runes[:bound.end])
|
|
}
|
|
if bound.ellipsis && i == len(bound.segments)-1 {
|
|
txt.Text = txt.Text + "…"
|
|
}
|
|
|
|
if concealed(seg) {
|
|
txt.Text = strings.Repeat(passwordChar, len(runes))
|
|
}
|
|
|
|
objs = append(objs, txt)
|
|
}
|
|
}
|
|
|
|
if r.obj.scr != nil {
|
|
r.obj.scr.Content = &fyne.Container{Layout: layout.NewStackLayout(), Objects: []fyne.CanvasObject{
|
|
r.obj.prop, &fyne.Container{Objects: objs},
|
|
}}
|
|
r.obj.scr.Direction = scroll
|
|
r.SetObjects([]fyne.CanvasObject{r.obj.scr})
|
|
r.obj.scr.Refresh()
|
|
} else {
|
|
r.SetObjects(objs)
|
|
}
|
|
|
|
r.Layout(r.obj.Size())
|
|
canvas.Refresh(r.obj.super())
|
|
|
|
r.obj.cleanVisualCache()
|
|
}
|
|
|
|
func (r *textRenderer) layoutRow(texts []fyne.CanvasObject, align fyne.TextAlign, xPos, yPos, lineWidth float32) (float32, float32) {
|
|
initialX := xPos
|
|
if len(texts) == 1 {
|
|
min := texts[0].MinSize()
|
|
if text, ok := texts[0].(*canvas.Text); ok {
|
|
texts[0].Resize(min)
|
|
xPad := float32(0)
|
|
switch text.Alignment {
|
|
case fyne.TextAlignLeading:
|
|
case fyne.TextAlignTrailing:
|
|
xPad = lineWidth - min.Width
|
|
case fyne.TextAlignCenter:
|
|
xPad = (lineWidth - min.Width) / 2
|
|
}
|
|
texts[0].Move(fyne.NewPos(xPos+xPad, yPos))
|
|
} else {
|
|
texts[0].Resize(fyne.NewSize(lineWidth, min.Height))
|
|
texts[0].Move(fyne.NewPos(xPos, yPos))
|
|
}
|
|
return min.Width, min.Height
|
|
}
|
|
height := float32(0)
|
|
tallestBaseline := float32(0)
|
|
realign := false
|
|
baselines := make([]float32, len(texts))
|
|
|
|
// Access to theme is slow, so we cache the text size
|
|
textSize := theme.SizeForWidget(theme.SizeNameText, r.obj)
|
|
|
|
driver := fyne.CurrentApp().Driver()
|
|
for i, text := range texts {
|
|
var size fyne.Size
|
|
if txt, ok := text.(*canvas.Text); ok {
|
|
s, base := driver.RenderedTextSize(txt.Text, txt.TextSize, txt.TextStyle, txt.FontSource)
|
|
if base > tallestBaseline {
|
|
if tallestBaseline > 0 {
|
|
realign = true
|
|
}
|
|
tallestBaseline = base
|
|
}
|
|
size = s
|
|
baselines[i] = base
|
|
} else if c, ok := text.(*fyne.Container); ok {
|
|
wid := c.Objects[0]
|
|
if link, ok := wid.(*Hyperlink); ok {
|
|
s, base := driver.RenderedTextSize(link.Text, textSize, link.TextStyle, nil)
|
|
if base > tallestBaseline {
|
|
if tallestBaseline > 0 {
|
|
realign = true
|
|
}
|
|
tallestBaseline = base
|
|
}
|
|
size = s
|
|
baselines[i] = base
|
|
}
|
|
}
|
|
if size.IsZero() {
|
|
size = text.MinSize()
|
|
}
|
|
text.Resize(size)
|
|
text.Move(fyne.NewPos(xPos, yPos))
|
|
|
|
xPos += size.Width
|
|
if height == 0 {
|
|
height = size.Height
|
|
} else if height != size.Height {
|
|
height = fyne.Max(height, size.Height)
|
|
realign = true
|
|
}
|
|
}
|
|
|
|
if realign {
|
|
for i, text := range texts {
|
|
delta := tallestBaseline - baselines[i]
|
|
text.Move(fyne.NewPos(text.Position().X, yPos+delta))
|
|
}
|
|
}
|
|
|
|
spare := lineWidth - xPos
|
|
switch align {
|
|
case fyne.TextAlignTrailing:
|
|
first := texts[0]
|
|
first.Resize(fyne.NewSize(first.Size().Width+spare, height))
|
|
setAlign(first, fyne.TextAlignTrailing)
|
|
|
|
for _, text := range texts[1:] {
|
|
text.Move(text.Position().Add(fyne.NewPos(spare, 0)))
|
|
}
|
|
case fyne.TextAlignCenter:
|
|
pad := spare / 2
|
|
first := texts[0]
|
|
first.Resize(fyne.NewSize(first.Size().Width+pad, height))
|
|
setAlign(first, fyne.TextAlignTrailing)
|
|
last := texts[len(texts)-1]
|
|
last.Resize(fyne.NewSize(last.Size().Width+pad, height))
|
|
setAlign(last, fyne.TextAlignLeading)
|
|
|
|
for _, text := range texts[1:] {
|
|
text.Move(text.Position().Add(fyne.NewPos(pad, 0)))
|
|
}
|
|
default:
|
|
last := texts[len(texts)-1]
|
|
last.Resize(fyne.NewSize(last.Size().Width+spare, height))
|
|
setAlign(last, fyne.TextAlignLeading)
|
|
}
|
|
|
|
return xPos - initialX, height
|
|
}
|
|
|
|
// binarySearch accepts a function that checks if the text width less the maximum width and the start and end rune index
|
|
// binarySearch returns the index of rune located as close to the maximum line width as possible
|
|
func binarySearch(lessMaxWidth func(int, int) bool, low int, maxHigh int) int {
|
|
if low >= maxHigh {
|
|
return low
|
|
}
|
|
if lessMaxWidth(low, maxHigh) {
|
|
return maxHigh
|
|
}
|
|
high := low
|
|
delta := maxHigh - low
|
|
for delta > 0 {
|
|
delta /= 2
|
|
if lessMaxWidth(low, high+delta) {
|
|
high += delta
|
|
}
|
|
}
|
|
for (high < maxHigh) && lessMaxWidth(low, high+1) {
|
|
high++
|
|
}
|
|
return high
|
|
}
|
|
|
|
// concealed returns true if the segment represents a password, meaning the text should be obscured.
|
|
func concealed(seg RichTextSegment) bool {
|
|
if text, ok := seg.(*TextSegment); ok {
|
|
return text.Style.concealed
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func ellipsisPriorBound(bounds []rowBoundary, trunc fyne.TextTruncation, width float32, measurer func([]rune) fyne.Size) []rowBoundary {
|
|
if trunc != fyne.TextTruncateEllipsis || len(bounds) == 0 {
|
|
return bounds
|
|
}
|
|
|
|
prior := bounds[len(bounds)-1]
|
|
seg := prior.segments[0].(*TextSegment)
|
|
ellipsisSize := fyne.MeasureText("…", seg.size(), seg.Style.TextStyle)
|
|
|
|
widthChecker := func(low int, high int) bool {
|
|
return measurer([]rune(seg.Text)[low:high]).Width <= width-ellipsisSize.Width
|
|
}
|
|
|
|
limit := binarySearch(widthChecker, prior.begin, prior.end)
|
|
prior.end = limit
|
|
|
|
prior.ellipsis = true
|
|
bounds[len(bounds)-1] = prior
|
|
return bounds
|
|
}
|
|
|
|
// findSpaceIndex accepts a slice of runes and a fallback index
|
|
// findSpaceIndex returns the index of the last space in the text, or fallback if there are no spaces
|
|
func findSpaceIndex(text []rune, fallback int) int {
|
|
curIndex := fallback
|
|
for ; curIndex >= 0; curIndex-- {
|
|
if unicode.IsSpace(text[curIndex]) {
|
|
break
|
|
}
|
|
}
|
|
if curIndex < 0 {
|
|
return fallback
|
|
}
|
|
return curIndex
|
|
}
|
|
|
|
func float32ToFixed266(f float32) fixed.Int26_6 {
|
|
return fixed.Int26_6(float64(f) * (1 << 6))
|
|
}
|
|
|
|
// lineBounds accepts a slice of Segments, a wrapping mode, a maximum size available to display and a function to
|
|
// measure text size.
|
|
// It will return a slice containing the boundary metadata of each line with the given wrapping applied and the
|
|
// total height required to render the boundaries at the given width/height constraints
|
|
func lineBounds(seg *TextSegment, wrap fyne.TextWrap, trunc fyne.TextTruncation, firstWidth float32, max fyne.Size, measurer func([]rune) fyne.Size) ([]rowBoundary, float32) {
|
|
lines := splitLines(seg)
|
|
|
|
if wrap == fyne.TextWrap(fyne.TextTruncateClip) {
|
|
if trunc == fyne.TextTruncateOff {
|
|
trunc = fyne.TextTruncateClip
|
|
}
|
|
wrap = fyne.TextWrapOff
|
|
}
|
|
|
|
if max.Width < 0 || wrap == fyne.TextWrapOff && trunc == fyne.TextTruncateOff {
|
|
return lines, 0 // don't bother returning a calculated height, our MinSize is going to cover it
|
|
}
|
|
|
|
measureWidth := float32(math.Min(float64(firstWidth), float64(max.Width)))
|
|
text := []rune(seg.Text)
|
|
widthChecker := func(low int, high int) bool {
|
|
return measurer(text[low:high]).Width <= measureWidth
|
|
}
|
|
|
|
reuse := 0
|
|
yPos := float32(0)
|
|
var bounds []rowBoundary
|
|
for _, l := range lines {
|
|
low := l.begin
|
|
high := l.end
|
|
if low == high {
|
|
l.firstSegmentReuse = reuse
|
|
reuse++
|
|
bounds = append(bounds, l)
|
|
continue
|
|
}
|
|
|
|
switch wrap {
|
|
case fyne.TextWrapBreak:
|
|
for low < high {
|
|
measured := measurer(text[low:high])
|
|
if yPos+measured.Height > max.Height && trunc != fyne.TextTruncateOff {
|
|
return ellipsisPriorBound(bounds, trunc, measureWidth, measurer), yPos
|
|
}
|
|
|
|
if measured.Width <= measureWidth {
|
|
bounds = append(bounds, rowBoundary{[]RichTextSegment{seg}, reuse, low, high, false})
|
|
reuse++
|
|
low = high
|
|
high = l.end
|
|
measureWidth = max.Width
|
|
|
|
yPos += measured.Height
|
|
} else {
|
|
newHigh := binarySearch(widthChecker, low, high)
|
|
if newHigh <= low {
|
|
bounds = append(bounds, rowBoundary{[]RichTextSegment{seg}, reuse, low, low + 1, false})
|
|
reuse++
|
|
low++
|
|
|
|
yPos += measured.Height
|
|
} else {
|
|
high = newHigh
|
|
}
|
|
}
|
|
}
|
|
case fyne.TextWrapWord:
|
|
for low < high {
|
|
sub := text[low:high]
|
|
measured := measurer(sub)
|
|
if yPos+measured.Height > max.Height && trunc != fyne.TextTruncateOff {
|
|
return ellipsisPriorBound(bounds, trunc, measureWidth, measurer), yPos
|
|
}
|
|
|
|
subWidth := measured.Width
|
|
if subWidth <= measureWidth {
|
|
bounds = append(bounds, rowBoundary{[]RichTextSegment{seg}, reuse, low, high, false})
|
|
reuse++
|
|
low = high
|
|
high = l.end
|
|
if low < high && unicode.IsSpace(text[low]) {
|
|
low++
|
|
}
|
|
measureWidth = max.Width
|
|
|
|
yPos += measured.Height
|
|
} else {
|
|
oldHigh := high
|
|
last := low + len(sub) - 1
|
|
fallback := binarySearch(widthChecker, low, last) - low
|
|
|
|
if fallback < 1 { // even a character won't fit
|
|
include := 1
|
|
ellipsis := false
|
|
if trunc == fyne.TextTruncateEllipsis {
|
|
include = 0
|
|
ellipsis = true
|
|
}
|
|
bounds = append(bounds, rowBoundary{[]RichTextSegment{seg}, reuse, low, low + include, ellipsis})
|
|
low++
|
|
high = low + 1
|
|
reuse++
|
|
|
|
yPos += measured.Height
|
|
if high > l.end {
|
|
return bounds, yPos
|
|
}
|
|
} else {
|
|
spaceIndex := findSpaceIndex(sub, fallback)
|
|
if spaceIndex == 0 {
|
|
spaceIndex = 1
|
|
}
|
|
|
|
high = low + spaceIndex
|
|
}
|
|
if high == fallback && subWidth <= max.Width { // add a newline as there is more space on next
|
|
bounds = append(bounds, rowBoundary{[]RichTextSegment{seg}, reuse, low, low, false})
|
|
reuse++
|
|
high = oldHigh
|
|
measureWidth = max.Width
|
|
|
|
yPos += measured.Height
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
default:
|
|
if trunc == fyne.TextTruncateEllipsis {
|
|
txt := []rune(seg.Text)[low:high]
|
|
end, full := truncateLimit(string(txt), seg.Visual().(*canvas.Text), int(measureWidth), []rune{'…'})
|
|
high = low + end
|
|
bounds = append(bounds, rowBoundary{[]RichTextSegment{seg}, reuse, low, high, !full})
|
|
reuse++
|
|
} else if trunc == fyne.TextTruncateClip {
|
|
high = binarySearch(widthChecker, low, high)
|
|
bounds = append(bounds, rowBoundary{[]RichTextSegment{seg}, reuse, low, high, false})
|
|
reuse++
|
|
}
|
|
}
|
|
}
|
|
return bounds, yPos
|
|
}
|
|
|
|
func setAlign(obj fyne.CanvasObject, align fyne.TextAlign) {
|
|
if text, ok := obj.(*canvas.Text); ok {
|
|
text.Alignment = align
|
|
return
|
|
}
|
|
if c, ok := obj.(*fyne.Container); ok {
|
|
wid := c.Objects[0]
|
|
if link, ok := wid.(*Hyperlink); ok {
|
|
link.Alignment = align
|
|
link.Refresh()
|
|
}
|
|
}
|
|
}
|
|
|
|
// splitLines accepts a text segment and returns a slice of boundary metadata denoting the
|
|
// start and end indices of each line delimited by the newline character.
|
|
func splitLines(seg *TextSegment) []rowBoundary {
|
|
var low, high int
|
|
var lines []rowBoundary
|
|
text := []rune(seg.Text)
|
|
length := len(text)
|
|
for i := 0; i < length; i++ {
|
|
if text[i] == '\n' {
|
|
high = i
|
|
lines = append(lines, rowBoundary{[]RichTextSegment{seg}, len(lines), low, high, false})
|
|
low = i + 1
|
|
}
|
|
}
|
|
return append(lines, rowBoundary{[]RichTextSegment{seg}, len(lines), low, length, false})
|
|
}
|
|
|
|
func truncateLimit(s string, text *canvas.Text, limit int, ellipsis []rune) (int, bool) {
|
|
face := paint.CachedFontFace(text.TextStyle, text.FontSource, text)
|
|
|
|
runes := []rune(s)
|
|
in := shaping.Input{
|
|
Text: ellipsis,
|
|
RunStart: 0,
|
|
RunEnd: len(ellipsis),
|
|
Direction: di.DirectionLTR,
|
|
Face: face.Fonts.ResolveFace(ellipsis[0]),
|
|
Size: float32ToFixed266(text.TextSize),
|
|
}
|
|
shaper := &shaping.HarfbuzzShaper{}
|
|
segmenter := &shaping.Segmenter{}
|
|
|
|
conf := shaping.WrapConfig{}
|
|
conf = conf.WithTruncator(shaper, in)
|
|
conf.BreakPolicy = shaping.WhenNecessary
|
|
conf.TruncateAfterLines = 1
|
|
l := shaping.LineWrapper{}
|
|
|
|
in.Text = runes
|
|
in.RunEnd = len(runes)
|
|
ins := segmenter.Split(in, face.Fonts)
|
|
outs := make([]shaping.Output, len(ins))
|
|
for i, in := range ins {
|
|
outs[i] = shaper.Shape(in)
|
|
}
|
|
|
|
l.Prepare(conf, runes, shaping.NewSliceIterator(outs))
|
|
wrapped, done := l.WrapNextLine(limit)
|
|
|
|
count := len(runes)
|
|
if wrapped.Truncated != 0 {
|
|
count -= wrapped.Truncated
|
|
count += len(ellipsis)
|
|
}
|
|
|
|
full := done && count == len(runes)
|
|
if !full && len(ellipsis) > 0 {
|
|
count--
|
|
}
|
|
return count, full
|
|
}
|
|
|
|
type rowBoundary struct {
|
|
segments []RichTextSegment
|
|
firstSegmentReuse int
|
|
begin, end int
|
|
ellipsis bool
|
|
}
|