refactor(messageslist): draw embeds and the date separator as boxes

This commit is contained in:
ayn2op 2026-10-18 16:25:03 -04:00
parent 68ee346501
commit b46624796d
5 changed files with 105 additions and 97 deletions

View file

@ -8,22 +8,28 @@ import (
"github.com/ayn2op/discordo/internal/config" "github.com/ayn2op/discordo/internal/config"
"github.com/ayn2op/discordo/internal/markdown" "github.com/ayn2op/discordo/internal/markdown"
"github.com/ayn2op/tview" "github.com/ayn2op/tview"
"github.com/ayn2op/tview/box"
"github.com/ayn2op/tview/layout"
"github.com/ayn2op/tview/text" "github.com/ayn2op/tview/text"
"github.com/gdamore/tcell/v3" "github.com/gdamore/tcell/v3"
"github.com/rockorager/go-uucode"
"github.com/yuin/goldmark/v2/ast" "github.com/yuin/goldmark/v2/ast"
) )
// embedPrefix is drawn before each line of an embed, in the embed's color. const (
const embedPrefix = " ▎ " // embedBar is drawn down the left of an embed, in the embed's color.
embedBar = "▎"
// embedPrefixWidth is how many cells embedPrefix takes. // embedIndent is the cells before the bar, and embedBarGap those between the bar and the text.
var embedPrefixWidth = uucode.StringWidth(embedPrefix) embedIndent, embedBarGap = 2, 1
)
// embedViews returns each embed of message as its text with a bar down its left, or nil if the message does not show its embeds: one without any, by a blocked user, or that is not drawn with its own content, as a forward.
// The views capture the model and the message, so they are made once with the text of the message rather than in buildItem.
// root is the parsed content of message, or nil to parse it here. // root is the parsed content of message, or nil to parse it here.
func (m Model) drawEmbeds(builder *markdown.Builder, message discord.Message, baseStyle tview.Style, width int, root ast.Node) { func (m Model) embedViews(message discord.Message, root ast.Node) []tview.Widget {
if len(message.Embeds) == 0 { forward := message.Reference != nil && message.Reference.Type == discord.MessageReferenceTypeForward
return ownContent := message.Type == discord.DefaultMessage && !forward || message.Type == discord.InlinedReplyMessage
if len(message.Embeds) == 0 || m.blocked(message.Author) || !ownContent {
return nil
} }
// Embed URLs are deduplicated against links already shown in the message body. // Embed URLs are deduplicated against links already shown in the message body.
@ -33,47 +39,52 @@ func (m Model) drawEmbeds(builder *markdown.Builder, message discord.Message, ba
seenURLs[u] = struct{}{} seenURLs[u] = struct{}{}
} }
baseStyle := m.cfg.Theme.MessagesList.MessageStyle.Style
lineStyles := embedLineStyles(baseStyle, m.cfg.Theme.MessagesList.Embeds) lineStyles := embedLineStyles(baseStyle, m.cfg.Theme.MessagesList.Embeds)
defaultPrefixStyle := baseStyle.Dim(true) var views []tview.Widget
// Wrap against the list's width, so the message is rendered again when it changes.
wrapWidth := max(width-embedPrefixWidth, 1)
for _, embed := range message.Embeds { for _, embed := range message.Embeds {
lines := embedLines(embed, seenURLs) lines := embedLines(embed, seenURLs)
if len(lines) == 0 { if len(lines) == 0 {
continue continue
} }
prefixStyle := defaultPrefixStyle barStyle := baseStyle.Dim(true)
if embed.Color != discord.NullColor && embed.Color != 0 { if embed.Color != discord.NullColor && embed.Color != 0 {
prefixStyle = prefixStyle.Foreground(tcell.NewHexColor(int32(embed.Color))) barStyle = barStyle.Foreground(tcell.NewHexColor(int32(embed.Color)))
} }
prefix := text.Span{Text: embedPrefix, Style: prefixStyle}
builder.NewLine()
for _, line := range lines {
if strings.TrimSpace(line.text) == "" {
continue
}
msg := message
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 := m.renderContent(msg, nil, lineStyle, line.kind == embedLineDescription)
if line.url != "" {
rendered = spansWithURL(rendered, line.url)
}
// The prefix is added after wrapping, so every visual line has it, not only the first. content := &messageView{render: func(int) []text.Span { return m.renderEmbed(message, lines, lineStyles) }}
for _, wrapped := range text.Lines(rendered, wrapWidth, text.WrappingGlyph) { bar := box.New(content).
if !builder.LineEmpty() { Borders(tview.BordersLeft).
builder.NewLine() BorderSet(tview.BorderSet{Left: embedBar}).
} BorderStyle(barStyle).
Padding(0, 0, embedBarGap, 0).
builder.WriteSpans(prefix) Height(layout.Shrink)
builder.WriteSpans(wrapped...) views = append(views, box.New(bar).Padding(0, 0, embedIndent, 0).Height(layout.Shrink))
}
}
} }
return views
}
// renderEmbed returns lines of an embed of message, each on its own line in the style of its kind.
func (m Model) renderEmbed(message discord.Message, lines []embedLine, lineStyles [embedLineKinds]tview.Style) []text.Span {
builder := new(markdown.Builder)
for _, line := range lines {
if !builder.LineEmpty() {
builder.NewLine()
}
msg := message
msg.Content = line.text
// Embed descriptions are always markdown-rendered to match Discord's rich embed semantics, even when message markdown is globally disabled.
rendered := m.renderContent(msg, nil, lineStyles[line.kind], line.kind == embedLineDescription)
if line.url != "" {
rendered = spansWithURL(rendered, line.url)
}
builder.WriteSpans(rendered...)
}
return builder.Finish()
} }
func spansWithURL(spans []text.Span, rawURL string) []text.Span { func spansWithURL(spans []text.Span, rawURL string) []text.Span {

View file

@ -5,7 +5,6 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"unicode/utf8"
"github.com/ayn2op/arikawa/v3/discord" "github.com/ayn2op/arikawa/v3/discord"
md "github.com/ayn2op/arikawa/v3/markdown" md "github.com/ayn2op/arikawa/v3/markdown"
@ -25,7 +24,7 @@ import (
// root is the parsed content of message, or nil to parse it when it is rendered. // root is the parsed content of message, or nil to parse it when it is rendered.
func (m Model) messageViews(message discord.Message, root ast.Node, grouped bool) (content, reply *messageView) { func (m Model) messageViews(message discord.Message, root ast.Node, grouped bool) (content, reply *messageView) {
style := m.cfg.Theme.MessagesList.MessageStyle.Style style := m.cfg.Theme.MessagesList.MessageStyle.Style
content = &messageView{render: func(width int) []text.Span { return m.renderMessage(message, root, style, width, grouped) }} content = &messageView{render: func(int) []text.Span { return m.renderMessage(message, root, style, grouped) }}
reply = &messageView{render: func(int) []text.Span { reply = &messageView{render: func(int) []text.Span {
builder := new(markdown.Builder) builder := new(markdown.Builder)
m.drawReply(builder, message, style) m.drawReply(builder, message, style)
@ -70,35 +69,9 @@ func (v *messageView) Draw(screen tview.Screen, area tview.Rectangle) {
func (v *messageView) Handle(msg tview.Msg, _ tview.Rectangle) tview.Msg { return msg } func (v *messageView) Handle(msg tview.Msg, _ tview.Rectangle) tview.Msg { return msg }
// dateSeparator is a line with the date in the middle, filled to the width it is drawn at. func (m Model) renderMessage(message discord.Message, root ast.Node, baseStyle tview.Style, grouped bool) []text.Span {
type dateSeparator struct {
date, fill string
style tview.Style
}
// Size returns Fill and a height of one row.
func (dateSeparator) Size() (width, height layout.Length) { return layout.Fill, layout.Fixed(1) }
// Layout returns a height of one row.
func (dateSeparator) Layout(limits layout.Limits) layout.Size {
return layout.Atomic(limits, layout.Fill, layout.Fixed(1))
}
func (d dateSeparator) Draw(screen tview.Screen, area tview.Rectangle) {
line := d.date
label := " " + d.date + " "
if labelWidth := utf8.RuneCountInString(label); area.Width > labelWidth {
fill := area.Width - labelWidth
line = strings.Repeat(d.fill, fill/2) + label + strings.Repeat(d.fill, fill-fill/2)
}
tview.Print(screen, line, area.X, area.Y, area.Width, tview.HorizontalAlignmentLeft, d.style)
}
func (dateSeparator) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg { return msg }
func (m Model) renderMessage(message discord.Message, root ast.Node, baseStyle tview.Style, width int, grouped bool) []text.Span {
builder := new(markdown.Builder) builder := new(markdown.Builder)
m.writeMessage(builder, message, root, baseStyle, width, grouped) m.writeMessage(builder, message, root, baseStyle, grouped)
// Without a header or content, the message starts with the line break that attachments and embeds start below. // Without a header or content, the message starts with the line break that attachments and embeds start below.
return trimLeadingLineBreaks(builder.Finish()) return trimLeadingLineBreaks(builder.Finish())
} }
@ -114,7 +87,7 @@ func trimLeadingLineBreaks(spans []text.Span) []text.Span {
return spans return spans
} }
func (m Model) writeMessage(builder *markdown.Builder, message discord.Message, root ast.Node, baseStyle tview.Style, width int, grouped bool) { func (m Model) writeMessage(builder *markdown.Builder, message discord.Message, root ast.Node, baseStyle tview.Style, grouped bool) {
if m.blocked(message.Author) { if m.blocked(message.Author) {
builder.Write("Blocked message", baseStyle.Foreground(color.Red).Bold(true)) builder.Write("Blocked message", baseStyle.Foreground(color.Red).Bold(true))
return return
@ -125,12 +98,12 @@ func (m Model) writeMessage(builder *markdown.Builder, message discord.Message,
if message.Reference != nil && message.Reference.Type == discord.MessageReferenceTypeForward { if message.Reference != nil && message.Reference.Type == discord.MessageReferenceTypeForward {
m.drawForwardedMessage(builder, message, baseStyle, grouped) m.drawForwardedMessage(builder, message, baseStyle, grouped)
} else { } else {
m.drawDefaultMessage(builder, message, root, baseStyle, width, grouped) m.drawDefaultMessage(builder, message, root, baseStyle, grouped)
} }
case discord.GuildMemberJoinMessage: case discord.GuildMemberJoinMessage:
m.drawLayout(builder, message, baseStyle, false, func() { builder.Write("joined the server.", baseStyle) }) m.drawLayout(builder, message, baseStyle, false, func() { builder.Write("joined the server.", baseStyle) })
case discord.InlinedReplyMessage: case discord.InlinedReplyMessage:
m.drawDefaultMessage(builder, message, root, baseStyle, width, false) m.drawDefaultMessage(builder, message, root, baseStyle, false)
case discord.ChannelPinnedMessage: case discord.ChannelPinnedMessage:
drawPinnedMessage(builder, message, baseStyle) drawPinnedMessage(builder, message, baseStyle)
default: default:
@ -338,14 +311,13 @@ func (m Model) drawSnapshotContent(builder *markdown.Builder, parent discord.Mes
m.drawContent(builder, message, nil, baseStyle) m.drawContent(builder, message, nil, baseStyle)
} }
func (m Model) drawDefaultMessage(builder *markdown.Builder, message discord.Message, root ast.Node, baseStyle tview.Style, width int, grouped bool) { func (m Model) drawDefaultMessage(builder *markdown.Builder, message discord.Message, root ast.Node, baseStyle tview.Style, grouped bool) {
content := func() { m.drawContent(builder, message, root, baseStyle) } content := func() { m.drawContent(builder, message, root, baseStyle) }
if message.Content == "" { if message.Content == "" {
content = nil content = nil
} }
m.drawLayout(builder, message, baseStyle, grouped, content) m.drawLayout(builder, message, baseStyle, grouped, content)
m.drawEmbeds(builder, message, baseStyle, width, root)
attachmentStyle := tview.MergeStyle(baseStyle, m.cfg.Theme.MessagesList.AttachmentStyle.Style) attachmentStyle := tview.MergeStyle(baseStyle, m.cfg.Theme.MessagesList.AttachmentStyle.Style)
// A voice message is shown as its player. // A voice message is shown as its player.

View file

@ -11,6 +11,7 @@ import (
"github.com/ayn2op/discordo/internal/voice" "github.com/ayn2op/discordo/internal/voice"
"github.com/ayn2op/ningen/v3" "github.com/ayn2op/ningen/v3"
"github.com/ayn2op/tview" "github.com/ayn2op/tview"
"github.com/ayn2op/tview/box"
"github.com/ayn2op/tview/column" "github.com/ayn2op/tview/column"
tviewimage "github.com/ayn2op/tview/image" tviewimage "github.com/ayn2op/tview/image"
"github.com/ayn2op/tview/keybind" "github.com/ayn2op/tview/keybind"
@ -19,6 +20,7 @@ import (
"github.com/ayn2op/tview/row" "github.com/ayn2op/tview/row"
"github.com/ayn2op/tview/scrollbar" "github.com/ayn2op/tview/scrollbar"
"github.com/ayn2op/tview/spacer" "github.com/ayn2op/tview/spacer"
"github.com/ayn2op/tview/text"
"github.com/ayn2op/tview/viewport" "github.com/ayn2op/tview/viewport"
"github.com/yuin/goldmark/v2/ast" "github.com/yuin/goldmark/v2/ast"
) )
@ -27,8 +29,10 @@ type messageItem struct {
message discord.Message message discord.Message
// text and reply are the message and what it replies to as wrapped, or nil until they are shown. // text and reply are the message and what it replies to as wrapped, or nil until they are shown.
text, reply *messageView text, reply *messageView
// A separator shows the date of the message after it. // embeds are the embeds of the message, made with text.
separator bool embeds []tview.Widget
// separator is the line with the date of the message after it, or nil for a message.
separator tview.Widget
// grouped reports whether the message is drawn without a header, under that of the message before it. // grouped reports whether the message is drawn without a header, under that of the message before it.
grouped bool grouped bool
@ -181,7 +185,7 @@ func (m Model) indexOf(channelID discord.ChannelID, id discord.MessageID) int {
if m.channel == nil || m.channel.ID != channelID { if m.channel == nil || m.channel.ID != channelID {
return -1 return -1
} }
return slices.IndexFunc(m.items, func(item messageItem) bool { return !item.separator && item.message.ID == id }) return slices.IndexFunc(m.items, func(item messageItem) bool { return item.separator == nil && item.message.ID == id })
} }
func (m *Model) reset() { func (m *Model) reset() {
@ -209,7 +213,7 @@ func (m *Model) AddMessage(message discord.Message) tview.Cmd {
separator := m.shouldSeparate(previous, message) separator := m.shouldSeparate(previous, message)
if separator { if separator {
m.items = append(m.items, messageItem{separator: true}) m.items = append(m.items, messageItem{separator: m.separatorView(message)})
} }
grouped := !separator && m.shouldGroup(previous, message) grouped := !separator && m.shouldGroup(previous, message)
@ -218,7 +222,7 @@ func (m *Model) AddMessage(message discord.Message) tview.Cmd {
} }
func (m *Model) setMessage(index int, message discord.Message) { func (m *Model) setMessage(index int, message discord.Message) {
if index < 0 || index >= len(m.items) || m.items[index].separator { if index < 0 || index >= len(m.items) || m.items[index].separator != nil {
return return
} }
@ -227,7 +231,7 @@ func (m *Model) setMessage(index int, message discord.Message) {
} }
func (m *Model) deleteMessage(index int) { func (m *Model) deleteMessage(index int) {
if index < 0 || index >= len(m.items) || m.items[index].separator { if index < 0 || index >= len(m.items) || m.items[index].separator != nil {
return return
} }
@ -255,17 +259,22 @@ func (m Model) buildItem(index int) tview.Widget {
return nil return nil
} }
item := &m.items[index] item := &m.items[index]
if item.separator { if item.separator != nil {
date := m.items[index+1].message.Timestamp.Time().In(time.Local).Format(m.cfg.DateSeparator.Format) return item.separator
return dateSeparator{date: date, fill: m.cfg.DateSeparator.Character, style: m.cfg.Theme.MessagesList.MessageStyle.Dim(true)}
} }
message := &item.message message := &item.message
if item.text == nil { if item.text == nil {
item.text, item.reply = m.messageViews(*message, m.contentRoot(item), item.grouped) root := m.contentRoot(item)
item.text, item.reply = m.messageViews(*message, root, item.grouped)
item.embeds = m.embedViews(*message, root)
} }
// Attachments are named in the text, before the embeds.
body := column.New(item.text).Height(layout.Shrink) body := column.New(item.text).Height(layout.Shrink)
for _, embed := range item.embeds {
body = body.Push(embed)
}
if isVoiceMessage(*message) { if isVoiceMessage(*message) {
body = body.Push(m.voiceMessageView(message.Attachments[0])) body = body.Push(m.voiceMessageView(message.Attachments[0]))
} }
@ -310,19 +319,35 @@ func (m Model) shouldGroup(previous *discord.Message, message discord.Message) b
message.Timestamp.Time().Sub(previous.Timestamp.Time()) <= cfg.Interval message.Timestamp.Time().Sub(previous.Timestamp.Time()) <= cfg.Interval
} }
// separatorDatePadding is on either side of the date on its separator line.
const separatorDatePadding = " "
// separatorView returns the line with the date of message in the middle of it. It is made once, when the separator is added, as the list builds every item each time it is laid out.
func (m Model) separatorView(message discord.Message) tview.Widget {
date := message.Timestamp.Time().In(time.Local).Format(m.cfg.DateSeparator.Format)
style := m.cfg.Theme.MessagesList.MessageStyle.Dim(true)
return box.New(spacer.New()).
Borders(tview.BordersTop).
BorderSet(tview.BorderSet{Top: m.cfg.DateSeparator.Character}).
BorderStyle(style).
// The separator is kept, so its date is laid out once.
Title(text.New(separatorDatePadding + date + separatorDatePadding).Style(style).Wrapping(text.WrappingNone).Paragraph(new(text.Paragraph))).
Height(layout.Fixed(1))
}
// rebuildItems replaces date separators and groups while retaining message views and selection. // rebuildItems replaces date separators and groups while retaining message views and selection.
func (m *Model) rebuildItems() { func (m *Model) rebuildItems() {
items := make([]messageItem, 0, len(m.items)) items := make([]messageItem, 0, len(m.items))
cursor, target := -1, -1 cursor, target := -1, -1
var previous *discord.Message var previous *discord.Message
for i, item := range m.items { for i, item := range m.items {
if item.separator { if item.separator != nil {
continue continue
} }
separator := m.shouldSeparate(previous, item.message) separator := m.shouldSeparate(previous, item.message)
if separator { if separator {
items = append(items, messageItem{separator: true}) items = append(items, messageItem{separator: m.separatorView(item.message)})
} }
// A message that joins or leaves a group is drawn again, with or without its header. // A message that joins or leaves a group is drawn again, with or without its header.
if grouped := !separator && m.shouldGroup(previous, item.message); grouped != item.grouped { if grouped := !separator && m.shouldGroup(previous, item.message); grouped != item.grouped {
@ -356,7 +381,7 @@ func sameLocalDate(a discord.Timestamp, b discord.Timestamp) bool {
// messageIndex finds the next message in direction (-1 or 1), excluding start. // messageIndex finds the next message in direction (-1 or 1), excluding start.
func (m Model) messageIndex(start, direction int) int { func (m Model) messageIndex(start, direction int) int {
for i := start + direction; i >= 0 && i < len(m.items); i += direction { for i := start + direction; i >= 0 && i < len(m.items); i += direction {
if !m.items[i].separator { if m.items[i].separator == nil {
return i return i
} }
} }
@ -364,7 +389,7 @@ func (m Model) messageIndex(start, direction int) int {
} }
func (m *Model) onRowCursorChanged(index int) { func (m *Model) onRowCursorChanged(index int) {
if index < 0 || index >= len(m.items) || !m.items[index].separator { if index < 0 || index >= len(m.items) || m.items[index].separator == nil {
return return
} }
target := m.messageIndex(index, -1) target := m.messageIndex(index, -1)
@ -376,7 +401,7 @@ func (m *Model) onRowCursorChanged(index int) {
func (m Model) selectedMessage() (*discord.Message, bool) { func (m Model) selectedMessage() (*discord.Message, bool) {
cursor := m.cursor cursor := m.cursor
if cursor < 0 || cursor >= len(m.items) || m.items[cursor].separator { if cursor < 0 || cursor >= len(m.items) || m.items[cursor].separator != nil {
return nil, false return nil, false
} }
@ -479,7 +504,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
return m, cmd return m, cmd
} }
index := slices.IndexFunc(m.items, func(item messageItem) bool { return !item.separator && item.message.ID == msg.messageID }) index := slices.IndexFunc(m.items, func(item messageItem) bool { return item.separator == nil && item.message.ID == msg.messageID })
if index < 0 || m.items[index].previews == nil { if index < 0 || m.items[index].previews == nil {
return m, nil return m, nil
} }
@ -584,7 +609,7 @@ func (m *Model) selectReply() {
if ref := messages[cursor].message.ReferencedMessage; ref != nil { if ref := messages[cursor].message.ReferencedMessage; ref != nil {
refIdx := slices.IndexFunc(messages, func(m messageItem) bool { refIdx := slices.IndexFunc(messages, func(m messageItem) bool {
return !m.separator && m.message.ID == ref.ID return m.separator == nil && m.message.ID == ref.ID
}) })
if refIdx != -1 { if refIdx != -1 {
m.setCursor(refIdx) m.setCursor(refIdx)
@ -600,7 +625,7 @@ func (m *Model) SelectLastOwn() (discord.Message, bool) {
} }
for i, item := range slices.Backward(m.items) { for i, item := range slices.Backward(m.items) {
message := item.message message := item.message
if item.separator || message.ChannelID != channel.ID || !ui.IsMe(m.state, message.Author.ID) || if item.separator != nil || message.ChannelID != channel.ID || !ui.IsMe(m.state, message.Author.ID) ||
!message.ID.IsValid() || message.Content == "" || len(message.MessageSnapshots) > 0 || !message.ID.IsValid() || message.Content == "" || len(message.MessageSnapshots) > 0 ||
(message.Type != discord.DefaultMessage && message.Type != discord.InlinedReplyMessage) { (message.Type != discord.DefaultMessage && message.Type != discord.InlinedReplyMessage) {
continue continue

View file

@ -113,8 +113,8 @@ func TestModelRebuildItems(t *testing.T) {
t.Helper() t.Helper()
var got []discord.MessageID var got []discord.MessageID
for i, item := range ml.items { for i, item := range ml.items {
if item.separator { if item.separator != nil {
if !tt.separators || i+1 == len(ml.items) || ml.items[i+1].separator { if !tt.separators || i+1 == len(ml.items) || ml.items[i+1].separator != nil {
t.Fatalf("item %d: orphaned separator", i) t.Fatalf("item %d: orphaned separator", i)
} }
} else { } else {
@ -223,7 +223,7 @@ func TestModelDrawLayout(t *testing.T) {
cfg.UI.MessagesList.Compact = compact cfg.UI.MessagesList.Compact = compact
var got strings.Builder var got strings.Builder
for _, span := range ml.renderMessage(message, nil, tview.Style{}, 80, false) { for _, span := range ml.renderMessage(message, nil, tview.Style{}, false) {
got.WriteString(span.Text) got.WriteString(span.Text)
} }
@ -272,7 +272,7 @@ func TestModelShouldGroup(t *testing.T) {
if !ml.items[newest].grouped || ml.items[ml.messageIndex(-1, 1)].grouped { if !ml.items[newest].grouped || ml.items[ml.messageIndex(-1, 1)].grouped {
t.Fatal("only the first message has a header") t.Fatal("only the first message has a header")
} }
if got := ml.renderMessage(ml.items[newest].message, nil, tview.Style{}, 80, true); len(got) != 1 || got[0].Text != "hi" { if got := ml.renderMessage(ml.items[newest].message, nil, tview.Style{}, true); len(got) != 1 || got[0].Text != "hi" {
t.Fatalf("grouped message = %v, want only its content", got) t.Fatalf("grouped message = %v, want only its content", got)
} }
second := ml.messageIndex(newest, -1) second := ml.messageIndex(newest, -1)

View file

@ -85,7 +85,7 @@ func (m Model) loadPreviews() tview.Cmd {
var cmds []tview.Cmd var cmds []tview.Cmd
for i := range m.items { for i := range m.items {
item := &m.items[i] item := &m.items[i]
if item.separator { if item.separator != nil {
continue continue
} }