text: migrate to tview text package

This commit is contained in:
ayn2op 2026-08-30 20:06:45 -04:00
parent 811c5e4418
commit 94eaf25286
9 changed files with 77 additions and 140 deletions

2
go.mod
View file

@ -15,7 +15,7 @@ require (
github.com/andybalholm/brotli v1.2.3
github.com/ayn2op/arikawa/v3 v3.0.0-20260827004545-b90631eb8a21
github.com/ayn2op/ningen/v3 v3.0.1-0.20260827010304-ee3a9ce6c2e4
github.com/ayn2op/tview v0.0.0-20260829040318-2c40c4a50e56
github.com/ayn2op/tview v0.0.0-20260831000059-9f61521d46b4
github.com/bogdanfinn/fhttp v0.6.8
github.com/bogdanfinn/tls-client v1.15.1
github.com/gdamore/tcell/v3 v3.4.2

4
go.sum
View file

@ -14,8 +14,8 @@ github.com/ayn2op/arikawa/v3 v3.0.0-20260827004545-b90631eb8a21 h1:+6W+QtSmIV8Dr
github.com/ayn2op/arikawa/v3 v3.0.0-20260827004545-b90631eb8a21/go.mod h1:C7NGoHHt0MQH/plbA9ly4YUQwG0v+n0XdgXmdEPoRpU=
github.com/ayn2op/ningen/v3 v3.0.1-0.20260827010304-ee3a9ce6c2e4 h1:Jl3hKkjANCCGPHTyrxLqy+FVdvdftGY41E5f4/1YrRA=
github.com/ayn2op/ningen/v3 v3.0.1-0.20260827010304-ee3a9ce6c2e4/go.mod h1:p+rsTJpVomHc8gyyLY0/Wwzj9qsPKoXgwCg2yIlG04A=
github.com/ayn2op/tview v0.0.0-20260829040318-2c40c4a50e56 h1:0kerJdw1SWXBafEtkRAZbEdPYxWtq+c0N1HnLTcwqsE=
github.com/ayn2op/tview v0.0.0-20260829040318-2c40c4a50e56/go.mod h1:iFzkW7DBltCaeLMR9SvXVUAw/zgKIbgE8JaUU8W8crk=
github.com/ayn2op/tview v0.0.0-20260831000059-9f61521d46b4 h1:mYUivDN9S69PScIxEkQm5ujKuIdhUT522N3FP/FJcMY=
github.com/ayn2op/tview v0.0.0-20260831000059-9f61521d46b4/go.mod h1:iFzkW7DBltCaeLMR9SvXVUAw/zgKIbgE8JaUU8W8crk=
github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q=
github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM=
github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic=

View file

@ -10,6 +10,7 @@ import (
"github.com/ayn2op/discordo/internal/config"
"github.com/ayn2op/ningen/v3/discordmd"
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/text"
"github.com/gdamore/tcell/v3"
"github.com/yuin/goldmark/ast"
)
@ -27,11 +28,11 @@ func NewRenderer(cfg *config.Config) *Renderer {
return &Renderer{cfg: cfg}
}
func (r *Renderer) RenderLines(source []byte, node ast.Node, base tcell.Style) []tview.Line {
func (r *Renderer) RenderText(source []byte, node ast.Node, base tcell.Style) text.Text {
r.listIx = nil
r.listNested = 0
builder := tview.NewLineBuilder()
builder := new(text.Builder)
styleStack := []tcell.Style{base}
linkDepth := 0
@ -144,7 +145,7 @@ func (r *Renderer) RenderLines(source []byte, node ast.Node, base tcell.Style) [
return builder.Finish()
}
func (r *Renderer) renderFencedCodeBlock(builder *tview.LineBuilder, source []byte, node *ast.FencedCodeBlock, base tcell.Style) {
func (r *Renderer) renderFencedCodeBlock(builder *text.Builder, source []byte, node *ast.FencedCodeBlock, base tcell.Style) {
var code strings.Builder
lines := node.Lines()
for i := range lines.Len() {

View file

@ -25,9 +25,9 @@ func TestRendererMasksSpoilers(t *testing.T) {
{true, "before [spoiler] after"},
{false, "before secret after"},
} {
lines := NewRenderer(&config.Config{Markdown: config.MarkdownConfig{MaskSpoilers: test.mask}}).RenderLines(source, node, tcell.StyleDefault)
rendered := NewRenderer(&config.Config{Markdown: config.MarkdownConfig{MaskSpoilers: test.mask}}).RenderText(source, node, tcell.StyleDefault)
var got strings.Builder
for _, line := range lines {
for _, line := range rendered {
for _, segment := range line {
got.WriteString(segment.Text)
}

View file

@ -29,6 +29,7 @@ import (
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/help"
"github.com/ayn2op/tview/keybind"
"github.com/ayn2op/tview/text"
"github.com/gdamore/tcell/v3"
"github.com/ncruces/zenity"
"github.com/sahilm/fuzzy"
@ -75,7 +76,7 @@ func newComposer(cfg *config.Config, chat *Model) *composer {
}
ui.ConfigureBox(c.Box, &cfg.Theme)
c.
SetPlaceholder(tview.NewLine(tview.NewSegment("Select a channel to start chatting", tcell.StyleDefault.Dim(true)))).
SetPlaceholder(text.NewLine(text.NewSegment("Select a channel to start chatting", tcell.StyleDefault.Dim(true)))).
SetClipboard(
func(s string) {
if _, err := clipboard.Write(context.Background(), clipboard.FmtText, []byte(s)); err != nil {

View file

@ -5,6 +5,7 @@ import (
"github.com/ayn2op/discordo/internal/ui"
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/list"
"github.com/ayn2op/tview/text"
"github.com/gdamore/tcell/v3"
"github.com/rivo/uniseg"
)
@ -73,13 +74,13 @@ func (m *Model) Rebuild() {
}
item := m.items[index]
style := item.Style
line := tview.NewLine(tview.NewSegment(item.DisplayText, style))
line := text.NewLine(text.NewSegment(item.DisplayText, style))
return tview.NewTextView().
SetScrollable(false).
SetWrap(false).
SetWordWrap(false).
SetTextStyle(style).
SetLines([]tview.Line{line})
SetContent(text.Text{line})
})
if len(m.items) == 0 {

View file

@ -31,13 +31,14 @@ import (
"github.com/ayn2op/tview/help"
"github.com/ayn2op/tview/keybind"
"github.com/ayn2op/tview/list"
"github.com/ayn2op/tview/text"
"github.com/gdamore/tcell/v3"
"github.com/gdamore/tcell/v3/color"
"github.com/rivo/uniseg"
"github.com/skratchdot/open-golang/open"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
goldtext "github.com/yuin/goldmark/text"
"golang.design/x/clipboard"
)
@ -174,29 +175,29 @@ func (ml *messagesList) buildItem(index int) list.Item {
item = tview.NewTextView().
SetWrap(true).
SetWordWrap(true).
SetLines(ml.renderMessage(message, ml.cfg.Theme.MessagesList.MessageStyle.Style))
SetContent(ml.renderMessage(message, ml.cfg.Theme.MessagesList.MessageStyle.Style))
ml.itemByID[message.ID] = item
}
return item
}
func (ml *messagesList) renderMessage(message discord.Message, baseStyle tcell.Style) []tview.Line {
builder := tview.NewLineBuilder()
func (ml *messagesList) renderMessage(message discord.Message, baseStyle tcell.Style) text.Text {
builder := new(text.Builder)
ml.writeMessage(builder, message, baseStyle)
return builder.Finish()
}
func (ml *messagesList) buildSeparatorItem(ts discord.Timestamp) *tview.TextView {
builder := tview.NewLineBuilder()
builder := new(text.Builder)
ml.drawDateSeparator(builder, ts, ml.cfg.Theme.MessagesList.MessageStyle.Style)
return tview.NewTextView().
SetScrollable(false).
SetWrap(false).
SetWordWrap(false).
SetLines(builder.Finish())
SetContent(builder.Finish())
}
func (ml *messagesList) drawDateSeparator(builder *tview.LineBuilder, ts discord.Timestamp, baseStyle tcell.Style) {
func (ml *messagesList) drawDateSeparator(builder *text.Builder, ts discord.Timestamp, baseStyle tcell.Style) {
date := ts.Time().In(time.Local).Format(ml.cfg.DateSeparator.Format)
label := " " + date + " "
fillChar := ml.cfg.DateSeparator.Character
@ -304,7 +305,7 @@ func (ml *messagesList) nearestMessageRowIndex(rowIndex int) int {
return -1
}
func (ml *messagesList) writeMessage(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
func (ml *messagesList) writeMessage(builder *text.Builder, message discord.Message, baseStyle tcell.Style) {
if ml.cfg.HideBlockedUsers {
isBlocked := ml.chat.state.UserIsBlocked(message.Author.ID)
if isBlocked {
@ -335,7 +336,7 @@ func (ml *messagesList) writeMessage(builder *tview.LineBuilder, message discord
ml.drawReactions(builder, message.Reactions, baseStyle)
}
func (ml *messagesList) drawReactions(builder *tview.LineBuilder, reactions []discord.Reaction, baseStyle tcell.Style) {
func (ml *messagesList) drawReactions(builder *text.Builder, reactions []discord.Reaction, baseStyle tcell.Style) {
if len(reactions) == 0 {
return
}
@ -362,12 +363,12 @@ func (ml *messagesList) formatTimestamp(ts discord.Timestamp) string {
return ts.Time().In(time.Local).Format(ml.cfg.Timestamps.Format)
}
func (ml *messagesList) drawTimestamps(builder *tview.LineBuilder, ts discord.Timestamp, baseStyle tcell.Style) {
func (ml *messagesList) drawTimestamps(builder *text.Builder, ts discord.Timestamp, baseStyle tcell.Style) {
dimStyle := baseStyle.Dim(true)
builder.Write(ml.formatTimestamp(ts)+" ", dimStyle)
}
func (ml *messagesList) drawAuthor(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
func (ml *messagesList) drawAuthor(builder *text.Builder, message discord.Message, baseStyle tcell.Style) {
name := message.Author.DisplayOrUsername()
foreground := tcell.ColorDefault
@ -407,9 +408,9 @@ func (ml *messagesList) memberForMessage(message discord.Message) *discord.Membe
// together with the source bytes it indexes into, so callers can reuse them
// instead of re-parsing the same content (see drawEmbeds). root is nil when
// markdown rendering is disabled.
func (ml *messagesList) drawContent(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) (ast.Node, []byte) {
lines, root, source := ml.renderContentLines(message, baseStyle)
if ml.cfg.Markdown.Enabled && builder.HasCurrentLine() {
func (ml *messagesList) drawContent(builder *text.Builder, message discord.Message, baseStyle tcell.Style) (ast.Node, []byte) {
content, root, source := ml.renderContent(message, baseStyle)
if ml.cfg.Markdown.Enabled && !builder.LineEmpty() {
startsWithCodeBlock := false
if root != nil {
if first := root.FirstChild(); first != nil {
@ -420,37 +421,37 @@ func (ml *messagesList) drawContent(builder *tview.LineBuilder, message discord.
if startsWithCodeBlock {
// Keep code blocks visually separate from "timestamp + author".
builder.NewLine()
for len(lines) > 0 && len(lines[0]) == 0 {
lines = lines[1:]
for len(content) > 0 && len(content[0]) == 0 {
content = content[1:]
}
} else {
for len(lines) > 1 && len(lines[0]) == 0 {
lines = lines[1:]
for len(content) > 1 && len(content[0]) == 0 {
content = content[1:]
}
}
}
builder.AppendLines(lines)
builder.WriteText(content)
return root, source
}
func (ml *messagesList) renderContentLines(message discord.Message, baseStyle tcell.Style) ([]tview.Line, ast.Node, []byte) {
return ml.renderContentLinesWithMarkdown(message, baseStyle, false)
func (ml *messagesList) renderContent(message discord.Message, baseStyle tcell.Style) (text.Text, ast.Node, []byte) {
return ml.renderContentWithMarkdown(message, baseStyle, false)
}
func (ml *messagesList) renderContentLinesWithMarkdown(message discord.Message, baseStyle tcell.Style, forceMarkdown bool) ([]tview.Line, ast.Node, []byte) {
func (ml *messagesList) renderContentWithMarkdown(message discord.Message, baseStyle tcell.Style, forceMarkdown bool) (text.Text, ast.Node, []byte) {
// Keep one rendering path for both normal messages and embed fragments so we preserve mention/link parsing behavior consistently across both.
if forceMarkdown || ml.cfg.Markdown.Enabled {
c := []byte(message.Content)
root := discordmd.ParseWithMessage(c, *ml.chat.state.Cabinet, &message, false)
return ml.renderer.RenderLines(c, root, baseStyle), root, c
return ml.renderer.RenderText(c, root, baseStyle), root, c
}
b := tview.NewLineBuilder()
b := new(text.Builder)
b.Write(message.Content, baseStyle)
return b.Finish(), nil, nil
}
func (ml *messagesList) drawSnapshotContent(builder *tview.LineBuilder, parent discord.Message, snapshot discord.MessageSnapshotMessage, baseStyle tcell.Style) {
func (ml *messagesList) drawSnapshotContent(builder *text.Builder, parent discord.Message, snapshot discord.MessageSnapshotMessage, baseStyle tcell.Style) {
// Convert discord.MessageSnapshotMessage to discord.Message with common fields.
message := discord.Message{
Type: snapshot.Type,
@ -470,7 +471,7 @@ func (ml *messagesList) drawSnapshotContent(builder *tview.LineBuilder, parent d
ml.drawContent(builder, message, baseStyle)
}
func (ml *messagesList) drawDefaultMessage(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
func (ml *messagesList) drawDefaultMessage(builder *text.Builder, message discord.Message, baseStyle tcell.Style) {
if ml.cfg.Timestamps.Enabled {
ml.drawTimestamps(builder, message.Timestamp, baseStyle)
}
@ -498,7 +499,7 @@ func (ml *messagesList) drawDefaultMessage(builder *tview.LineBuilder, message d
}
}
func (ml *messagesList) drawEmbeds(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style, contentRoot ast.Node, contentSource []byte) {
func (ml *messagesList) drawEmbeds(builder *text.Builder, message discord.Message, baseStyle tcell.Style, contentRoot ast.Node, contentSource []byte) {
if len(message.Embeds) == 0 {
return
}
@ -531,12 +532,12 @@ func (ml *messagesList) drawEmbeds(builder *tview.LineBuilder, message discord.M
continue
}
embedContentLines := make([]tview.Line, 0, len(lines)*2)
embedText := make(text.Text, 0, len(lines)*2)
barStyle := defaultBarStyle
if embed.Color != discord.NullColor && embed.Color != 0 {
barStyle = barStyle.Foreground(tcell.NewHexColor(int32(embed.Color)))
}
prefix := tview.NewSegment(prefixText, barStyle)
prefix := text.NewSegment(prefixText, barStyle)
builder.NewLine()
for _, line := range lines {
if strings.TrimSpace(line.Text) == "" {
@ -546,98 +547,29 @@ func (ml *messagesList) drawEmbeds(builder *tview.LineBuilder, message discord.M
msg.Content = line.Text
lineStyle := lineStyles[line.Kind]
// Embed descriptions are always markdown-rendered to match Discord's rich embed semantics, even when message markdown is globally disabled.
rendered, _, _ := ml.renderContentLinesWithMarkdown(msg, lineStyle, line.Kind == embedLineDescription)
rendered, _, _ := ml.renderContentWithMarkdown(msg, lineStyle, line.Kind == embedLineDescription)
for _, renderedLine := range rendered {
if line.URL != "" {
renderedLine = lineWithURL(renderedLine, line.URL)
}
// Prefix must be applied after wrapping so every visual line keeps the embed bar marker ("▎"), not only the first logical line.
for _, wrapped := range wrapStyledLine(renderedLine, wrapWidth) {
prefixed := make(tview.Line, 0, len(wrapped)+1)
for _, wrapped := range text.Wrap(renderedLine, wrapWidth) {
prefixed := make(text.Line, 0, len(wrapped)+1)
prefixed = append(prefixed, prefix)
prefixed = append(prefixed, wrapped...)
embedContentLines = append(embedContentLines, prefixed)
embedText = append(embedText, prefixed)
}
}
}
if len(embedContentLines) > 0 {
builder.AppendLines(embedContentLines)
if len(embedText) > 0 {
builder.WriteText(embedText)
}
}
}
func wrapStyledLine(line tview.Line, width int) []tview.Line {
if width <= 0 {
return []tview.Line{line}
}
if len(line) == 0 {
return []tview.Line{line}
}
lines := make([]tview.Line, 0, 2)
current := make(tview.Line, 0, len(line))
currentWidth := 0
pushSegment := func(text string, style tcell.Style) {
if text == "" {
return
}
if n := len(current); n > 0 && current[n-1].Style == style {
current[n-1].Text += text
return
}
current = append(current, tview.Segment{Text: text, Style: style})
}
flush := func() {
lineCopy := make(tview.Line, len(current))
copy(lineCopy, current)
lines = append(lines, lineCopy)
current = current[:0]
currentWidth = 0
}
for _, segment := range line {
state := -1
rest := segment.Text
for len(rest) > 0 {
cluster, nextRest, boundaries, nextState := uniseg.StepString(rest, state)
state = nextState
rest = nextRest
if cluster == "" {
continue
}
// Use grapheme width (not rune count) so wrapping stays correct with wide glyphs, emoji, and combining characters.
clusterWidth := graphemeClusterWidth(boundaries)
if currentWidth > 0 && currentWidth+clusterWidth > width {
flush()
}
pushSegment(cluster, segment.Style)
currentWidth += clusterWidth
if currentWidth >= width {
flush()
}
}
}
if len(current) > 0 {
flush()
}
if len(lines) == 0 {
return []tview.Line{{}}
}
return lines
}
func graphemeClusterWidth(boundaries int) int {
return boundaries >> uniseg.ShiftWidth
}
func lineWithURL(line tview.Line, rawURL string) tview.Line {
out := make(tview.Line, len(line))
func lineWithURL(line text.Line, rawURL string) text.Line {
out := make(text.Line, len(line))
for i, segment := range line {
out[i] = segment
out[i].Style = out[i].Style.Url(rawURL)
@ -801,7 +733,7 @@ func isMarkdownEscapable(c byte) bool {
}
}
func (ml *messagesList) drawForwardedMessage(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
func (ml *messagesList) drawForwardedMessage(builder *text.Builder, message discord.Message, baseStyle tcell.Style) {
dimStyle := baseStyle.Dim(true)
ml.drawTimestamps(builder, message.Timestamp, baseStyle)
ml.drawAuthor(builder, message, baseStyle)
@ -810,7 +742,7 @@ func (ml *messagesList) drawForwardedMessage(builder *tview.LineBuilder, message
builder.Write(" ("+ml.formatTimestamp(message.MessageSnapshots[0].Message.Timestamp)+") ", dimStyle)
}
func (ml *messagesList) drawReplyMessage(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
func (ml *messagesList) drawReplyMessage(builder *text.Builder, message discord.Message, baseStyle tcell.Style) {
dimStyle := baseStyle.Dim(true)
// indicator
builder.Write(ml.cfg.Theme.MessagesList.ReplyIndicator+" ", dimStyle)
@ -828,7 +760,7 @@ func (ml *messagesList) drawReplyMessage(builder *tview.LineBuilder, message dis
ml.drawDefaultMessage(builder, message, baseStyle)
}
func (ml *messagesList) drawPinnedMessage(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
func (ml *messagesList) drawPinnedMessage(builder *text.Builder, message discord.Message, baseStyle tcell.Style) {
builder.Write(message.Author.DisplayOrUsername(), baseStyle)
builder.Write(" pinned a message.", baseStyle)
}
@ -1090,7 +1022,7 @@ func extractURLs(content string) []string {
node := parser.NewParser(
parser.WithBlockParsers(discordmd.BlockParsers()...),
parser.WithInlineParsers(discordmd.InlineParserWithLink()...),
).Parse(text.NewReader(src))
).Parse(goldtext.NewReader(src))
return urlsFromAST(node, src)
}

View file

@ -26,6 +26,7 @@ import (
"github.com/ayn2op/tview/flex"
"github.com/ayn2op/tview/keybind"
"github.com/ayn2op/tview/layers"
"github.com/ayn2op/tview/text"
"github.com/gdamore/tcell/v3"
)
@ -341,13 +342,13 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd {
hasNoPerm := !isDM && !m.state.HasPermissions(msg.Channel.ID, discord.PermissionSendMessages)
m.composer.SetDisabled(hasNoPerm)
text := "Message..."
placeholder := "Message..."
if hasNoPerm {
text = "You do not have permission to send messages in this channel."
placeholder = "You do not have permission to send messages in this channel."
} else if m.cfg.AutoFocus {
m.focusComposer()
}
m.composer.SetPlaceholder(tview.NewLine(tview.NewSegment(text, tcell.StyleDefault.Dim(true))))
m.composer.SetPlaceholder(text.NewLine(text.NewSegment(placeholder, tcell.StyleDefault.Dim(true))))
if msg.Channel.GuildID.IsValid() {
return m.messagesList.requestGuildMembers(msg.Channel.GuildID, msg.Messages)
}

View file

@ -7,6 +7,7 @@ import (
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/tabs"
"github.com/ayn2op/tview/text"
"github.com/gdamore/tcell/v3"
"github.com/gorilla/websocket"
"github.com/skip2/go-qrcode"
@ -137,28 +138,28 @@ func (m *Model) setStatus(status string) {
}
func (m *Model) render() {
var text strings.Builder
var out strings.Builder
if m.qrCode != nil {
bitmap := m.qrCode.Bitmap()
for y := 0; y < len(bitmap); y += 2 {
for x := range bitmap[y] {
top := bitmap[y][x]
bottom := y+1 < len(bitmap) && bitmap[y+1][x]
text.WriteRune(halfBlock(top, bottom))
out.WriteRune(halfBlock(top, bottom))
}
text.WriteByte('\n')
out.WriteByte('\n')
}
}
if m.status != "" {
if text.Len() > 0 {
text.WriteByte('\n')
if out.Len() > 0 {
out.WriteByte('\n')
}
text.WriteString(m.status)
out.WriteString(m.status)
}
builder := tview.NewLineBuilder()
builder.Write(text.String(), tcell.StyleDefault)
m.SetLines(m.centerLines(builder.Finish()))
builder := new(text.Builder)
builder.Write(out.String(), tcell.StyleDefault)
m.SetContent(m.centerText(builder.Finish()))
m.TextView.SetRect(m.Rect())
}
@ -166,23 +167,23 @@ func (m *Model) View(screen tcell.Screen) {
m.TextView.View(screen)
}
func (m *Model) centerLines(lines []tview.Line) []tview.Line {
func (m *Model) centerText(content text.Text) text.Text {
_, _, _, height := m.InnerRect()
if height == 0 {
height = 40
}
padding := (height - len(lines)) / 2
padding := (height - len(content)) / 2
if padding < 0 {
padding = 0
} else if padding < 1 && height > len(lines) {
} else if padding < 1 && height > len(content) {
padding = 1
}
if padding == 0 {
return lines
return content
}
centered := make([]tview.Line, 0, padding+len(lines))
centered = append(centered, make([]tview.Line, padding)...)
centered = append(centered, lines...)
centered := make(text.Text, 0, padding+len(content))
centered = append(centered, make(text.Text, padding)...)
centered = append(centered, content...)
return centered
}