package widget import ( "image/color" "math" "strconv" "strings" "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/internal/async" "fyne.io/fyne/v2/internal/cache" "fyne.io/fyne/v2/internal/painter" "fyne.io/fyne/v2/internal/widget" "fyne.io/fyne/v2/theme" ) const ( textAreaSpaceSymbol = '·' textAreaTabSymbol = '→' textAreaNewLineSymbol = '↵' ) var ( // TextGridStyleDefault is a default style for test grid cells TextGridStyleDefault TextGridStyle // TextGridStyleWhitespace is the style used for whitespace characters, if enabled TextGridStyleWhitespace TextGridStyle ) // TextGridCell represents a single cell in a text grid. // It has a rune for the text content and a style associated with it. type TextGridCell struct { Rune rune Style TextGridStyle } // TextGridRow represents a row of cells cell in a text grid. // It contains the cells for the row and an optional style. type TextGridRow struct { Cells []TextGridCell Style TextGridStyle } // TextGridStyle defines a style that can be applied to a TextGrid cell. type TextGridStyle interface { Style() fyne.TextStyle TextColor() color.Color BackgroundColor() color.Color } // CustomTextGridStyle is a utility type for those not wanting to define their own style types. type CustomTextGridStyle struct { // Since: 2.5 TextStyle fyne.TextStyle FGColor, BGColor color.Color } // TextColor is the color a cell should use for the text. func (c *CustomTextGridStyle) TextColor() color.Color { return c.FGColor } // BackgroundColor is the color a cell should use for the background. func (c *CustomTextGridStyle) BackgroundColor() color.Color { return c.BGColor } // Style is the text style a cell should use. func (c *CustomTextGridStyle) Style() fyne.TextStyle { return c.TextStyle } // TextGrid is a monospaced grid of characters. // This is designed to be used by a text editor, code preview or terminal emulator. type TextGrid struct { BaseWidget Rows []TextGridRow scroll *widget.Scroll content *textGridContent ShowLineNumbers bool ShowWhitespace bool TabWidth int // If set to 0 the fyne.DefaultTabWidth is used // Scroll can be used to turn off the scrolling of our TextGrid. // // Since: 2.6 Scroll fyne.ScrollDirection } // Append will add new lines to the end of this TextGrid. // The first character will be at the beginning of a new line and any newline characters will split the text further. // // Since: 2.6 func (t *TextGrid) Append(text string) { rows := t.parseRows(text) t.Rows = append(t.Rows, rows...) t.Refresh() } // CursorLocationForPosition returns the location where a cursor would be if it was located in the cell under the // requested position. If the grid is scrolled the position will refer to the visible offset and not the distance // from the top left of the overall document. // // Since: 2.6 func (t *TextGrid) CursorLocationForPosition(p fyne.Position) (row, col int) { y := p.Y x := p.X if t.scroll != nil && t.scroll.Visible() { y += t.scroll.Offset.Y x += t.scroll.Offset.X } row = int(y / t.content.cellSize.Height) col = int(x / t.content.cellSize.Width) return } // PositionForCursorLocation returns the relative position in this TextGrid for the cell at position row, col. // If the grid has been scrolled this will be taken into account so that the position compared to top left will // refer to the requested location. // // Since: 2.6 func (t *TextGrid) PositionForCursorLocation(row, col int) fyne.Position { y := float32(row) * t.content.cellSize.Height x := float32(col) * t.content.cellSize.Width if t.scroll != nil && t.scroll.Visible() { y -= t.scroll.Offset.Y x -= t.scroll.Offset.X } return fyne.NewPos(x, y) } // MinSize returns the smallest size this widget can shrink to func (t *TextGrid) MinSize() fyne.Size { t.ExtendBaseWidget(t) return t.BaseWidget.MinSize() } // Resize is called when this widget changes size. We should make sure that we refresh cells. func (t *TextGrid) Resize(size fyne.Size) { t.BaseWidget.Resize(size) t.Refresh() } // SetText updates the buffer of this textgrid to contain the specified text. // New lines and columns will be added as required. Lines are separated by '\n'. // The grid will use default text style and any previous content and style will be removed. // Tab characters are padded with spaces to the next tab stop. func (t *TextGrid) SetText(text string) { rows := t.parseRows(text) oldRowsLen := len(t.Rows) t.Rows = rows // If we don't update the scroll offset when the text is shorter, // we may end up with no text displayed or text appearing partially cut off if t.scroll != nil && t.Scroll != fyne.ScrollNone && len(rows) < oldRowsLen && t.scroll.Content != nil { offset := t.PositionForCursorLocation(len(rows), 0) t.scroll.ScrollToOffset(fyne.NewPos(offset.X, t.scroll.Offset.Y)) t.scroll.Refresh() } t.Refresh() } // Text returns the contents of the buffer as a single string (with no style information). // It reconstructs the lines by joining with a `\n` character. // Tab characters have padded spaces removed. func (t *TextGrid) Text() string { count := len(t.Rows) - 1 // newlines for _, row := range t.Rows { count += len(row.Cells) } if count <= 0 { return "" } runes := make([]rune, 0, count) for i, row := range t.Rows { next := 0 for col, cell := range row.Cells { if col < next { continue } runes = append(runes, cell.Rune) if cell.Rune == '\t' { next = nextTab(col, t.tabWidth()) } } if i < len(t.Rows)-1 { runes = append(runes, '\n') } } return string(runes) } // Row returns a copy of the content in a specified row as a TextGridRow. // If the index is out of bounds it returns an empty row object. func (t *TextGrid) Row(row int) TextGridRow { if row < 0 || row >= len(t.Rows) { return TextGridRow{} } return t.Rows[row] } // RowText returns a string representation of the content at the row specified. // If the index is out of bounds it returns an empty string. func (t *TextGrid) RowText(row int) string { rowData := t.Row(row) count := len(rowData.Cells) if count <= 0 { return "" } runes := make([]rune, 0, count) next := 0 for col, cell := range rowData.Cells { if col < next { continue } runes = append(runes, cell.Rune) if cell.Rune == '\t' { next = nextTab(col, t.tabWidth()) } } return string(runes) } // SetRow updates the specified row of the grid's contents using the specified content and style and then refreshes. // If the row is beyond the end of the current buffer it will be expanded. // Tab characters are not padded with spaces. func (t *TextGrid) SetRow(row int, content TextGridRow) { if row < 0 { return } for len(t.Rows) <= row { t.Rows = append(t.Rows, TextGridRow{}) } t.Rows[row] = content for col := 0; col > len(content.Cells); col++ { t.refreshCell(row, col) } } // SetRowStyle sets a grid style to all the cells cell at the specified row. // Any cells in this row with their own style will override this value when displayed. func (t *TextGrid) SetRowStyle(row int, style TextGridStyle) { if row < 0 { return } for len(t.Rows) <= row { t.Rows = append(t.Rows, TextGridRow{}) } t.Rows[row].Style = style } // SetCell sets a grid data to the cell at named row and column. func (t *TextGrid) SetCell(row, col int, cell TextGridCell) { if row < 0 || col < 0 { return } t.ensureCells(row, col) t.Rows[row].Cells[col] = cell t.refreshCell(row, col) } // SetRune sets a character to the cell at named row and column. func (t *TextGrid) SetRune(row, col int, r rune) { if row < 0 || col < 0 { return } t.ensureCells(row, col) t.Rows[row].Cells[col].Rune = r t.refreshCell(row, col) } // SetStyle sets a grid style to the cell at named row and column. func (t *TextGrid) SetStyle(row, col int, style TextGridStyle) { if row < 0 || col < 0 { return } t.ensureCells(row, col) t.Rows[row].Cells[col].Style = style t.refreshCell(row, col) } // SetStyleRange sets a grid style to all the cells between the start row and column through to the end row and column. func (t *TextGrid) SetStyleRange(startRow, startCol, endRow, endCol int, style TextGridStyle) { if startRow >= len(t.Rows) || endRow < 0 { return } if startRow < 0 { startRow = 0 startCol = 0 } if endRow >= len(t.Rows) { endRow = len(t.Rows) - 1 endCol = len(t.Rows[endRow].Cells) - 1 } if startRow == endRow { for col := startCol; col <= endCol; col++ { t.SetStyle(startRow, col, style) } return } // first row for col := startCol; col < len(t.Rows[startRow].Cells); col++ { t.SetStyle(startRow, col, style) } // possible middle rows for rowNum := startRow + 1; rowNum < endRow; rowNum++ { for col := 0; col < len(t.Rows[rowNum].Cells); col++ { t.SetStyle(rowNum, col, style) } } // last row for col := 0; col <= endCol; col++ { t.SetStyle(endRow, col, style) } } // CreateRenderer is a private method to Fyne which links this widget to its renderer func (t *TextGrid) CreateRenderer() fyne.WidgetRenderer { t.ExtendBaseWidget(t) th := t.Theme() v := fyne.CurrentApp().Settings().ThemeVariant() TextGridStyleDefault = &CustomTextGridStyle{} TextGridStyleWhitespace = &CustomTextGridStyle{FGColor: th.Color(theme.ColorNameDisabled, v)} var scroll *widget.Scroll content := newTextGridContent(t) objs := make([]fyne.CanvasObject, 1) if t.Scroll == widget.ScrollNone { scroll = widget.NewScroll(nil) objs[0] = content } else { scroll = widget.NewScroll(content) scroll.Direction = t.Scroll objs[0] = scroll } t.scroll = scroll t.content = content r := &textGridRenderer{text: content, scroll: scroll} r.SetObjects(objs) return r } func (t *TextGrid) ensureCells(row, col int) { for len(t.Rows) <= row { t.Rows = append(t.Rows, TextGridRow{}) } data := t.Rows[row] for len(data.Cells) <= col { data.Cells = append(data.Cells, TextGridCell{}) t.Rows[row] = data } } func (t *TextGrid) parseRows(text string) []TextGridRow { lines := strings.Split(text, "\n") rows := make([]TextGridRow, len(lines)) for i, line := range lines { cells := make([]TextGridCell, 0, len(line)) for _, r := range line { cells = append(cells, TextGridCell{Rune: r}) if r == '\t' { col := len(cells) next := nextTab(col-1, t.tabWidth()) for i := col; i < next; i++ { cells = append(cells, TextGridCell{Rune: ' '}) } } } rows[i] = TextGridRow{Cells: cells} } return rows } func (t *TextGrid) refreshCell(row, col int) { r := t.content r.refreshCell(row, col) } // NewTextGrid creates a new empty TextGrid widget. func NewTextGrid() *TextGrid { grid := &TextGrid{} grid.Scroll = widget.ScrollNone grid.ExtendBaseWidget(grid) return grid } // NewTextGridFromString creates a new TextGrid widget with the specified string content. func NewTextGridFromString(content string) *TextGrid { grid := NewTextGrid() grid.SetText(content) return grid } // nextTab finds the column of the next tab stop for the given column func nextTab(column int, tabWidth int) int { tabStop, _ := math.Modf(float64(column+tabWidth) / float64(tabWidth)) return tabWidth * int(tabStop) } type textGridRenderer struct { widget.BaseRenderer text *textGridContent scroll *widget.Scroll } func (t *textGridRenderer) Layout(s fyne.Size) { t.Objects()[0].Resize(s) } func (t *textGridRenderer) MinSize() fyne.Size { if t.text.text.Scroll == widget.ScrollNone { return t.text.MinSize() } return t.scroll.MinSize() } func (t *textGridRenderer) Refresh() { content := t.text if t.text.text.Scroll != widget.ScrollNone { t.scroll.Direction = t.text.text.Scroll } if t.text.text.Scroll == widget.ScrollNone && t.scroll.Content != nil { t.scroll.Hide() t.scroll.Content = nil content.Resize(t.text.Size()) t.SetObjects([]fyne.CanvasObject{t.text}) } else if (t.text.text.Scroll != widget.ScrollNone) && t.scroll.Content == nil { t.scroll.Content = content t.scroll.Show() t.scroll.Resize(t.text.Size()) content.Resize(content.MinSize()) t.SetObjects([]fyne.CanvasObject{t.scroll}) } canvas.Refresh(t.text.text.super()) t.text.Refresh() } type textGridContent struct { BaseWidget text *TextGrid rows int cellSize fyne.Size } func newTextGridContent(t *TextGrid) *textGridContent { grid := &textGridContent{text: t} grid.ExtendBaseWidget(grid) return grid } // CreateRenderer is a private method to Fyne which links this widget to its renderer func (t *textGridContent) CreateRenderer() fyne.WidgetRenderer { r := &textGridContentRenderer{text: t} r.updateCellSize() t.text.scroll.OnScrolled = func(_ fyne.Position) { r.addRowsIfRequired() r.Layout(t.Size()) } return r } func (t *textGridContent) refreshCell(row, col int) { rows := cache.Renderer(t).Objects() if row >= len(rows)-1 { return } wid := rows[row].(*textGridRow) r := cache.Renderer(wid).(*textGridRowRenderer) r.refreshCell(col) } func (t *textGridContentRenderer) updateGridSize(size fyne.Size) { bufRows := len(t.text.text.Rows) sizeRows := int(size.Height / t.text.cellSize.Height) if sizeRows > bufRows { t.text.rows = sizeRows } else { t.text.rows = bufRows } t.addRowsIfRequired() } type textGridContentRenderer struct { text *textGridContent visible []fyne.CanvasObject itemPool async.Pool[*textGridRow] } func (t *textGridContentRenderer) Destroy() { } func (t *textGridContentRenderer) Layout(s fyne.Size) { size := fyne.NewSize(s.Width, t.text.cellSize.Height) t.updateGridSize(s) for _, o := range t.visible { o.Move(fyne.NewPos(0, float32(o.(*textGridRow).row)*t.text.cellSize.Height)) o.Resize(size) } } func (t *textGridContentRenderer) MinSize() fyne.Size { longestRow := float32(0) for _, row := range t.text.text.Rows { longestRow = fyne.Max(longestRow, float32(len(row.Cells))) } return fyne.NewSize(t.text.cellSize.Width*longestRow, t.text.cellSize.Height*float32(len(t.text.text.Rows))) } func (t *textGridContentRenderer) Objects() []fyne.CanvasObject { return t.visible } func (t *textGridContentRenderer) Refresh() { // theme could change text size t.updateCellSize() t.updateGridSize(t.text.text.Size()) for _, o := range t.visible { o.Refresh() } } func (t *textGridContentRenderer) addRowsIfRequired() { start := 0 end := t.text.rows if t.text.text.Scroll == widget.ScrollBoth || t.text.text.Scroll == widget.ScrollVerticalOnly { off := t.text.text.scroll.Offset.Y start = int(math.Floor(float64(off / t.text.cellSize.Height))) off += t.text.text.Size().Height end = int(math.Ceil(float64(off / t.text.cellSize.Height))) } remain := t.visible[:0] for _, row := range t.visible { if row.(*textGridRow).row < start || row.(*textGridRow).row > end { t.itemPool.Put(row.(*textGridRow)) continue } remain = append(remain, row.(*textGridRow)) } t.visible = remain var newItems []fyne.CanvasObject for i := start; i <= end; i++ { found := false for _, row := range t.visible { if i == row.(*textGridRow).row { found = true break } } if found { continue } newRow := t.itemPool.Get() if newRow == nil { newRow = newTextGridRow(t.text, i) } else { newRow.setRow(i) } newItems = append(newItems, newRow) } if len(newItems) > 0 { t.visible = append(t.visible, newItems...) } } func (t *textGridContentRenderer) updateCellSize() { th := t.text.Theme() size := fyne.MeasureText("M", th.Size(theme.SizeNameText), fyne.TextStyle{Monospace: true}) // round it for seamless background size.Width = float32(math.Round(float64(size.Width))) size.Height = float32(math.Round(float64(size.Height))) t.text.cellSize = size } type textGridRow struct { BaseWidget text *textGridContent row int } func newTextGridRow(t *textGridContent, row int) *textGridRow { return &textGridRow{text: t, row: row} } // CreateRenderer is a private method to Fyne which links this widget to its renderer func (t *textGridRow) CreateRenderer() fyne.WidgetRenderer { t.ExtendBaseWidget(t) render := &textGridRowRenderer{obj: t} render.Refresh() // populate return render } func (t *textGridRow) setRow(row int) { t.row = row t.Refresh() } type textGridRowRenderer struct { obj *textGridRow cols int objects []fyne.CanvasObject } func (t *textGridRowRenderer) appendTextCell(str rune) { th := t.obj.text.text.Theme() v := fyne.CurrentApp().Settings().ThemeVariant() text := canvas.NewText(string(str), th.Color(theme.ColorNameForeground, v)) text.TextStyle.Monospace = true bg := canvas.NewRectangle(color.Transparent) ul := canvas.NewLine(color.Transparent) t.objects = append(t.objects, bg, text, ul) } func (t *textGridRowRenderer) refreshCell(col int) { pos := t.cols + col if pos*3+1 >= len(t.objects) { return } row := t.obj.text.text.Rows[t.obj.row] if len(row.Cells) > col { cell := row.Cells[col] t.setCellRune(cell.Rune, pos, cell.Style, row.Style) } } func (t *textGridRowRenderer) setCellRune(str rune, pos int, style, rowStyle TextGridStyle) { if str == 0 { str = ' ' } rect := t.objects[pos*3].(*canvas.Rectangle) text := t.objects[pos*3+1].(*canvas.Text) underline := t.objects[pos*3+2].(*canvas.Line) th := t.obj.text.text.Theme() v := fyne.CurrentApp().Settings().ThemeVariant() fg := th.Color(theme.ColorNameForeground, v) text.TextSize = th.Size(theme.SizeNameText) var underlineStrokeWidth float32 = 1 var underlineStrokeColor color.Color = color.Transparent textStyle := fyne.TextStyle{} if style != nil { textStyle = style.Style() } else if rowStyle != nil { textStyle = rowStyle.Style() } if textStyle.Bold { underlineStrokeWidth = 2 } if textStyle.Underline { underlineStrokeColor = fg } textStyle.Monospace = true if style != nil && style.TextColor() != nil { fg = style.TextColor() } else if rowStyle != nil && rowStyle.TextColor() != nil { fg = rowStyle.TextColor() } newStr := string(str) if text.Text != newStr || text.Color != fg || textStyle != text.TextStyle { text.Text = newStr text.Color = fg text.TextStyle = textStyle text.Refresh() } if underlineStrokeWidth != underline.StrokeWidth || underlineStrokeColor != underline.StrokeColor { underline.StrokeWidth, underline.StrokeColor = underlineStrokeWidth, underlineStrokeColor underline.Refresh() } bg := color.Color(color.Transparent) if style != nil && style.BackgroundColor() != nil { bg = style.BackgroundColor() } else if rowStyle != nil && rowStyle.BackgroundColor() != nil { bg = rowStyle.BackgroundColor() } if rect.FillColor != bg { rect.FillColor = bg rect.Refresh() } } func (t *textGridRowRenderer) addCellsIfRequired() { cellCount := t.cols if len(t.objects) == cellCount*3 { return } for i := len(t.objects); i < cellCount*3; i += 3 { t.appendTextCell(' ') } } func (t *textGridRowRenderer) refreshCells() { x := 0 if t.obj.row >= len(t.obj.text.text.Rows) { for ; x < len(t.objects)/3; x++ { t.setCellRune(' ', x, TextGridStyleDefault, nil) // blank rows no longer needed } return // we can have more rows than content rows (filling space) } row := t.obj.text.text.Rows[t.obj.row] rowStyle := row.Style i := 0 if t.obj.text.text.ShowLineNumbers { lineStr := []rune(strconv.Itoa(t.obj.row + 1)) pad := t.lineNumberWidth() - len(lineStr) for ; i < pad; i++ { t.setCellRune(' ', x, TextGridStyleWhitespace, rowStyle) // padding space x++ } for c := 0; c < len(lineStr); c++ { t.setCellRune(lineStr[c], x, TextGridStyleDefault, rowStyle) // line numbers i++ x++ } t.setCellRune('|', x, TextGridStyleWhitespace, rowStyle) // last space i++ x++ } for _, r := range row.Cells { if i >= t.cols { // would be an overflow - bad continue } if t.obj.text.text.ShowWhitespace && (r.Rune == ' ' || r.Rune == '\t') { sym := textAreaSpaceSymbol if r.Rune == '\t' { sym = textAreaTabSymbol } if r.Style != nil && r.Style.BackgroundColor() != nil { whitespaceBG := &CustomTextGridStyle{FGColor: TextGridStyleWhitespace.TextColor(), BGColor: r.Style.BackgroundColor()} t.setCellRune(sym, x, whitespaceBG, rowStyle) // whitespace char } else { t.setCellRune(sym, x, TextGridStyleWhitespace, rowStyle) // whitespace char } } else { t.setCellRune(r.Rune, x, r.Style, rowStyle) // regular char } i++ x++ } if t.obj.text.text.ShowWhitespace && i < t.cols && t.obj.row < len(t.obj.text.text.Rows)-1 { t.setCellRune(textAreaNewLineSymbol, x, TextGridStyleWhitespace, rowStyle) // newline i++ x++ } for ; i < t.cols; i++ { t.setCellRune(' ', x, TextGridStyleDefault, rowStyle) // blanks x++ } for ; x < len(t.objects)/3; x++ { t.setCellRune(' ', x, TextGridStyleDefault, nil) // trailing cells and blank lines } } // tabWidth either returns the set tab width or if not set the returns the DefaultTabWidth func (t *TextGrid) tabWidth() int { if t.TabWidth == 0 { return painter.DefaultTabWidth } return t.TabWidth } func (t *textGridRowRenderer) lineNumberWidth() int { return len(strconv.Itoa(t.obj.text.rows + 1)) } func (t *textGridRowRenderer) updateGridSize(size fyne.Size) { bufCols := int(size.Width / t.obj.text.cellSize.Width) for _, row := range t.obj.text.text.Rows { lenCells := len(row.Cells) if lenCells > bufCols { bufCols = lenCells } } if t.obj.text.text.ShowWhitespace { bufCols++ } if t.obj.text.text.ShowLineNumbers { bufCols += t.lineNumberWidth() } t.cols = bufCols t.addCellsIfRequired() } func (t *textGridRowRenderer) Layout(size fyne.Size) { t.updateGridSize(size) cellPos := fyne.NewPos(0, 0) off := 0 for x := 0; x < t.cols; x++ { // rect t.objects[off].Resize(t.obj.text.cellSize) t.objects[off].Move(cellPos) // text t.objects[off+1].Move(cellPos) // underline t.objects[off+2].Move(cellPos.Add(fyne.Position{X: 0, Y: t.obj.text.cellSize.Height})) t.objects[off+2].Resize(fyne.Size{Width: t.obj.text.cellSize.Width}) cellPos.X += t.obj.text.cellSize.Width off += 3 } } func (t *textGridRowRenderer) MinSize() fyne.Size { longestRow := float32(0) for _, row := range t.obj.text.text.Rows { longestRow = fyne.Max(longestRow, float32(len(row.Cells))) } return fyne.NewSize(t.obj.text.cellSize.Width*longestRow, t.obj.text.cellSize.Height) } func (t *textGridRowRenderer) Refresh() { th := t.obj.text.text.Theme() v := fyne.CurrentApp().Settings().ThemeVariant() TextGridStyleWhitespace = &CustomTextGridStyle{FGColor: th.Color(theme.ColorNameDisabled, v)} t.updateGridSize(t.obj.text.text.Size()) t.refreshCells() } func (t *textGridRowRenderer) ApplyTheme() { } func (t *textGridRowRenderer) Objects() []fyne.CanvasObject { return t.objects } func (t *textGridRowRenderer) Destroy() { }