refactor: break up long inline expressions

This commit is contained in:
ayn2op 2026-10-16 18:52:15 -04:00
parent f9f53a0f55
commit f8141b8f8d
9 changed files with 98 additions and 33 deletions

View file

@ -234,8 +234,19 @@ func defaultUI() UIConfig {
Border: BorderUIConfig{Enabled: true, Padding: [4]int{0, 0, 1, 1}, NormalSet: round, ActiveSet: round}, Border: BorderUIConfig{Enabled: true, Padding: [4]int{0, 0, 1, 1}, NormalSet: round, ActiveSet: round},
GuildsTree: GuildsTreeUIConfig{TreeUIConfig{Graphics: true}, true}, GuildsTree: GuildsTreeUIConfig{TreeUIConfig{Graphics: true}, true},
MembersTree: TreeUIConfig{Graphics: true}, MembersTree: TreeUIConfig{Graphics: true},
ScrollBar: ScrollBarUIConfig{SymbolSet: SymbolSetWrapper{boxDrawing}, Visibility: ScrollBarVisibilityWrapper{viewport.ScrollBarVisibilityAutomatic}}, ScrollBar: ScrollBarUIConfig{
MessagesList: MessagesListUIConfig{Spacing: 1, ReplyIndicator: ">", ForwardedIndicator: "<", ImageProtocol: "auto", Attachments: attachments, Avatars: AvatarsUIConfig{Preview: true, Height: 2}, Grouping: GroupingUIConfig{Enabled: true, Interval: 10 * time.Minute}}, SymbolSet: SymbolSetWrapper{boxDrawing},
Visibility: ScrollBarVisibilityWrapper{viewport.ScrollBarVisibilityAutomatic},
},
MessagesList: MessagesListUIConfig{
Spacing: 1,
ReplyIndicator: ">",
ForwardedIndicator: "<",
ImageProtocol: "auto",
Attachments: attachments,
Avatars: AvatarsUIConfig{Preview: true, Height: 2},
Grouping: GroupingUIConfig{Enabled: true, Interval: 10 * time.Minute},
},
MentionsList: MentionsListUIConfig{MinWidth: 20}, MentionsList: MentionsListUIConfig{MinWidth: 20},
} }
} }

View file

@ -187,7 +187,16 @@ func (m Model) footer() string {
// isBound reports whether key is one of the composer's keybinds. // isBound reports whether key is one of the composer's keybinds.
func (m Model) isBound(key tview.KeyMsg) bool { func (m Model) isBound(key tview.KeyMsg) bool {
k := m.cfg.Keybinds.Composer k := m.cfg.Keybinds.Composer
if keybind.Matches(key, k.Paste.Keybind, k.Send.Keybind, k.OpenEditor.Keybind, k.OpenFilePicker.Keybind, k.Cancel.Keybind, k.TabComplete.Keybind, k.ToggleReplyMention.Keybind, k.Undo.Keybind) { if keybind.Matches(key,
k.Paste.Keybind,
k.Send.Keybind,
k.OpenEditor.Keybind,
k.OpenFilePicker.Keybind,
k.Cancel.Keybind,
k.TabComplete.Keybind,
k.ToggleReplyMention.Keybind,
k.Undo.Keybind,
) {
return true return true
} }
return keybind.Matches(key, k.EditLast.Keybind) && m.canEditLastMessage() return keybind.Matches(key, k.EditLast.Keybind) && m.canEditLastMessage()

View file

@ -223,7 +223,8 @@ func (m Model) createGuildNode(parent *tree.Node, guild discord.Guild) {
} }
func (m Model) createChannelNode(parent *tree.Node, channel discord.Channel) { func (m Model) createChannelNode(parent *tree.Node, channel discord.Channel) {
if channel.Type != discord.DirectMessage && channel.Type != discord.GroupDM && channel.Type != discord.GuildCategory && !m.state.HasPermissions(channel.ID, discord.PermissionViewChannel) { needsViewPermission := !slices.Contains([]discord.ChannelType{discord.DirectMessage, discord.GroupDM, discord.GuildCategory}, channel.Type)
if needsViewPermission && !m.state.HasPermissions(channel.ID, discord.PermissionViewChannel) {
return return
} }
@ -372,11 +373,14 @@ func (m Model) voiceChannel(node *tree.Node) *discord.Channel {
// joinable returns why the voice channel of node cannot be joined, or nil if it can be. // joinable returns why the voice channel of node cannot be joined, or nil if it can be.
func (m Model) joinable(node *tree.Node, channel discord.Channel) error { func (m Model) joinable(node *tree.Node, channel discord.Channel) error {
// The users in the channel are the children of its node.
atUserLimit := channel.VoiceUserLimit > 0 && len(node.Children()) >= int(channel.VoiceUserLimit)
switch { switch {
case !m.state.HasPermissions(channel.ID, discord.PermissionConnect): case !m.state.HasPermissions(channel.ID, discord.PermissionConnect):
return errors.New("you are not permitted to connect") return errors.New("you are not permitted to connect")
// Discord does not answer a request to join a full voice channel. // Discord does not answer a request to join a full voice channel, unless it is from someone who can move members.
case channel.VoiceUserLimit > 0 && len(node.Children()) >= int(channel.VoiceUserLimit) && !m.state.HasPermissions(channel.ID, discord.PermissionMoveMembers): case atUserLimit && !m.state.HasPermissions(channel.ID, discord.PermissionMoveMembers):
return errors.New("it is full") return errors.New("it is full")
} }

View file

@ -87,7 +87,19 @@ func (m *Model) SetChannel(channel *discord.Channel) tview.Cmd {
func (m Model) View(focused bool) tview.Widget { func (m Model) View(focused bool) tview.Widget {
onChange := func(current *tree.Node) tview.Msg { return Msg{current} } onChange := func(current *tree.Node) tview.Msg { return Msg{current} }
onSelect := func(n *tree.Node) tview.Msg { return SelectedMsg{Node: n} } onSelect := func(n *tree.Node) tview.Msg { return SelectedMsg{Node: n} }
return uitree.New(m.root, m.current, m.scrollState, m.cfg, m.cfg.Theme.MembersTree, m.cfg.UI.MembersTree.Graphics, m.cfg.Keybinds.MembersTree, focused, onChange, onSelect).Title("Members") return uitree.New(
m.root,
m.current,
m.scrollState,
m.cfg,
m.cfg.Theme.MembersTree,
m.cfg.UI.MembersTree.Graphics,
m.cfg.Keybinds.MembersTree,
focused,
onChange,
onSelect,
).
Title("Members")
} }
// setCurrentNode selects node and scrolls to it. // setCurrentNode selects node and scrolls to it.
@ -202,11 +214,11 @@ func (m Model) addRecipients(recipients []discord.User) {
if me, err := m.state.Cabinet.Me(); err == nil { if me, err := m.state.Cabinet.Me(); err == nil {
recipients = append(recipients, *me) recipients = append(recipients, *me)
} }
rank := func(user discord.User) int { return statusRank(m.status(discord.NullGuildID, user.ID)) }
name := func(user discord.User) string { return strings.ToLower(user.DisplayOrUsername()) }
// By status, online first, then by name.
slices.SortFunc(recipients, func(a, b discord.User) int { slices.SortFunc(recipients, func(a, b discord.User) int {
if c := cmp.Compare(statusRank(m.status(discord.NullGuildID, a.ID)), statusRank(m.status(discord.NullGuildID, b.ID))); c != 0 { return cmp.Or(cmp.Compare(rank(a), rank(b)), strings.Compare(name(a), name(b)))
return c
}
return strings.Compare(strings.ToLower(a.DisplayOrUsername()), strings.ToLower(b.DisplayOrUsername()))
}) })
group := m.addGroup(discord.NullGuildID, gateway.GuildMemberListGroup{ID: "members", Count: uint64(len(recipients))}) group := m.addGroup(discord.NullGuildID, gateway.GuildMemberListGroup{ID: "members", Count: uint64(len(recipients))})

View file

@ -216,12 +216,18 @@ func (m *Model) setMessages(messages []discord.Message) {
func (m *Model) AddMessage(message discord.Message) tview.Cmd { func (m *Model) AddMessage(message discord.Message) tview.Cmd {
// Appending keeps the items before it as they are, so only the separator of a new day is added rather than all rebuilt. // Appending keeps the items before it as they are, so only the separator of a new day is added rather than all rebuilt.
last := len(m.items) - 1 var previous *discord.Message
separator := m.cfg.DateSeparator.Enabled && (last < 0 || !sameLocalDate(m.items[last].message.Timestamp, message.Timestamp)) if last := len(m.items) - 1; last >= 0 {
previous = &m.items[last].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: true})
} }
m.items = append(m.items, messageItem{message: message, grouped: last >= 0 && !separator && m.grouped(m.items[last].message, message)})
grouped := !separator && m.shouldGroup(previous, message)
m.items = append(m.items, messageItem{message: message, grouped: grouped})
return m.loadPreviews() return m.loadPreviews()
} }
@ -388,10 +394,10 @@ func (m Model) renderMessage(message discord.Message, baseStyle tview.Style, wid
return text return text
} }
// grouped reports whether message is drawn under the header of previous, the message before it. // shouldGroup reports whether message is drawn under the header of previous, the message before it, which is nil for the first.
func (m Model) grouped(previous, message discord.Message) bool { func (m Model) shouldGroup(previous *discord.Message, message discord.Message) bool {
cfg := m.cfg.UI.MessagesList.Grouping cfg := m.cfg.UI.MessagesList.Grouping
return cfg.Enabled && return cfg.Enabled && previous != nil &&
// Replies and system messages start a group, as their header says more than who wrote them. // Replies and system messages start a group, as their header says more than who wrote them.
message.Type == discord.DefaultMessage && message.Type == discord.DefaultMessage &&
(previous.Type == discord.DefaultMessage || previous.Type == discord.InlinedReplyMessage) && (previous.Type == discord.DefaultMessage || previous.Type == discord.InlinedReplyMessage) &&
@ -404,17 +410,18 @@ func (m Model) grouped(previous, message discord.Message) bool {
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 {
continue continue
} }
separator := m.cfg.DateSeparator.Enabled && (len(items) == 0 || !sameLocalDate(previous.Timestamp, item.message.Timestamp))
separator := m.shouldSeparate(previous, item.message)
if separator { if separator {
items = append(items, messageItem{separator: true}) items = append(items, messageItem{separator: true})
} }
// 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 := len(items) > 0 && !separator && m.grouped(previous, item.message); grouped != item.grouped { if grouped := !separator && m.shouldGroup(previous, item.message); grouped != item.grouped {
item.grouped, item.text = grouped, nil item.grouped, item.text = grouped, nil
} }
if i == m.cursor { if i == m.cursor {
@ -424,13 +431,18 @@ func (m *Model) rebuildItems() {
target = len(items) target = len(items)
} }
items = append(items, item) items = append(items, item)
previous = item.message previous = &item.message
} }
m.items = items m.items = items
// The selected and target messages only move with the separators, so they are not scrolled to. // The selected and target messages only move with the separators, so they are not scrolled to.
m.cursor, m.target = cursor, target m.cursor, m.target = cursor, target
} }
// shouldSeparate reports whether a date separator goes before message, as it is the first (previous is nil) or starts a new day.
func (m Model) shouldSeparate(previous *discord.Message, message discord.Message) bool {
return m.cfg.DateSeparator.Enabled && (previous == nil || !sameLocalDate(previous.Timestamp, message.Timestamp))
}
func sameLocalDate(a discord.Timestamp, b discord.Timestamp) bool { func sameLocalDate(a discord.Timestamp, b discord.Timestamp) bool {
ta := a.Time().In(time.Local) ta := a.Time().In(time.Local)
tb := b.Time().In(time.Local) tb := b.Time().In(time.Local)

View file

@ -234,7 +234,7 @@ func TestModelDrawLayout(t *testing.T) {
} }
} }
func TestModelGrouped(t *testing.T) { func TestModelShouldGroup(t *testing.T) {
start := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC) start := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)
message := func(author string, after time.Duration, typ discord.MessageType) discord.Message { message := func(author string, after time.Duration, typ discord.MessageType) discord.Message {
return discord.Message{Type: typ, Author: discord.User{ID: 2, Username: author}, Timestamp: discord.NewTimestamp(start.Add(after)), Content: "hi"} return discord.Message{Type: typ, Author: discord.User{ID: 2, Username: author}, Timestamp: discord.NewTimestamp(start.Add(after)), Content: "hi"}
@ -254,7 +254,7 @@ func TestModelGrouped(t *testing.T) {
{"after a system message", message("a", 0, discord.ChannelPinnedMessage), message("a", time.Minute, discord.DefaultMessage), false}, {"after a system message", message("a", 0, discord.ChannelPinnedMessage), message("a", time.Minute, discord.DefaultMessage), false},
} { } {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if got := newTestModel(t, testConfig(t)).grouped(tt.previous, tt.message); got != tt.want { if got := newTestModel(t, testConfig(t)).shouldGroup(&tt.previous, tt.message); got != tt.want {
t.Fatalf("grouped = %v, want %v", got, tt.want) t.Fatalf("grouped = %v, want %v", got, tt.want)
} }
}) })
@ -263,7 +263,7 @@ func TestModelGrouped(t *testing.T) {
cfg := testConfig(t) cfg := testConfig(t)
cfg.Timestamps.Enabled = false cfg.Timestamps.Enabled = false
ml := newTestModel(t, cfg) ml := newTestModel(t, cfg)
if cfg.UI.MessagesList.Grouping.Enabled = false; ml.grouped(previous, previous) { if cfg.UI.MessagesList.Grouping.Enabled = false; ml.shouldGroup(&previous, previous) {
t.Fatal("grouped while disabled") t.Fatal("grouped while disabled")
} }
cfg.UI.MessagesList.Grouping.Enabled = true cfg.UI.MessagesList.Grouping.Enabled = true

View file

@ -58,7 +58,11 @@ func previewSources(message discord.Message) []previewSource {
// cols returns the preview's width in cells of cellWidth by cellHeight pixels: its own width at most, within the preview box. // cols returns the preview's width in cells of cellWidth by cellHeight pixels: its own width at most, within the preview box.
func (s previewSource) cols(cellWidth, cellHeight uint) int { func (s previewSource) cols(cellWidth, cellHeight uint) int {
return int(max(min((s.width+cellWidth-1)/cellWidth, previewWidth, s.width*previewHeight*cellHeight/(s.height*cellWidth)), 1)) // The columns its own width takes, rounded up, so it is not scaled up.
own := (s.width + cellWidth - 1) / cellWidth
// The columns at which its height fills the box, so a tall image is not cut off.
tall := s.width * previewHeight * cellHeight / (s.height * cellWidth)
return int(max(min(own, previewWidth, tall), 1))
} }
// previewCell returns the size of a cell in image pixels: the terminal's for kitty, and one pixel by two for half blocks. // previewCell returns the size of a cell in image pixels: the terminal's for kitty, and one pixel by two for half blocks.

View file

@ -368,9 +368,13 @@ func (p paneWidget) Draw(screen tview.Screen, area tview.Rectangle) {
func (p paneWidget) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg { func (p paneWidget) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg {
out := p.child.Handle(msg, area) out := p.child.Handle(msg, area)
// Mouse input the pane leaves, but for a press, goes on to the other panes, for a slider dragged out of one to follow the pointer. // Mouse input the pane leaves, but for a press, goes on to the other panes, for a slider dragged out of one to follow the pointer.
if mouse, ok := msg.(tview.MouseMsg); ok && area.Contains(mouse.Position()) && (out != msg || mouse.Action == tview.MouseLeftDown) { if mouse, ok := msg.(tview.MouseMsg); ok && area.Contains(mouse.Position()) {
return paneMsg{pane: p.pane, msg: out, focus: mouse.Action == tview.MouseLeftDown} pressed := mouse.Action == tview.MouseLeftDown
if out != msg || pressed {
return paneMsg{pane: p.pane, msg: out, focus: pressed}
} }
}
// Keys and paste reach only the focused pane, so what it makes of them goes to that pane through route. // Keys and paste reach only the focused pane, so what it makes of them goes to that pane through route.
return out return out
} }

View file

@ -20,7 +20,16 @@ type ScrollMsg struct {
} }
// New returns a boxed tree of the nodes under root in a viewport. // New returns a boxed tree of the nodes under root in a viewport.
func New(root, current *tree.Node, scrollState viewport.ScrollState, cfg *config.Config, theme config.TreeThemeConfig, graphics bool, kbs config.TreeKeybinds, focused bool, onChange, onSelect func(*tree.Node) tview.Msg) box.Widget { func New(
root, current *tree.Node,
scrollState viewport.ScrollState,
cfg *config.Config,
theme config.TreeThemeConfig,
graphics bool,
kbs config.TreeKeybinds,
focused bool,
onChange, onSelect func(*tree.Node) tview.Msg,
) box.Widget {
set := cfg.UI.Border.NormalSet.BorderSet set := cfg.UI.Border.NormalSet.BorderSet
if focused { if focused {
set = cfg.UI.Border.ActiveSet.BorderSet set = cfg.UI.Border.ActiveSet.BorderSet