refactor(ui): adopt message-driven focus

This commit is contained in:
ayn2op 2026-08-09 23:23:37 -04:00
parent be5aebdd14
commit f00a66835c
13 changed files with 137 additions and 159 deletions

View file

@ -64,7 +64,5 @@ func Run() error {
screen.EnablePaste()
tview.Styles = tview.Theme{}
app := tview.NewApplication(tview.WithScreen(screen))
app.SetRoot(root.NewModel(cfg, app))
return app.Run()
return tview.NewApplication(root.NewModel(cfg), tview.WithScreen(screen)).Run()
}

4
go.mod
View file

@ -10,7 +10,7 @@ require (
github.com/andybalholm/brotli v1.2.2
github.com/ayn2op/arikawa/v3 v3.0.0-20260708075022-9cc760fe507e
github.com/ayn2op/ningen/v3 v3.0.1-0.20260708075321-0ba1ffc70bd6
github.com/ayn2op/tview v0.0.0-20260808011018-71a6ffd9ad39
github.com/ayn2op/tview v0.0.0-20260810031733-e2c6c70cbb87
github.com/bogdanfinn/fhttp v0.6.8
github.com/bogdanfinn/tls-client v1.15.1
github.com/gdamore/tcell/v3 v3.4.1
@ -39,7 +39,7 @@ require (
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/dchest/jsmin v1.0.0 // indirect
github.com/dlclark/regexp2/v2 v2.5.2 // indirect
github.com/dlclark/regexp2/v2 v2.6.0 // indirect
github.com/ebitengine/purego v0.10.2 // indirect
github.com/gdamore/encoding v1.0.1 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect

8
go.sum
View file

@ -14,8 +14,8 @@ github.com/ayn2op/arikawa/v3 v3.0.0-20260708075022-9cc760fe507e h1:rsMifRXwxkbha
github.com/ayn2op/arikawa/v3 v3.0.0-20260708075022-9cc760fe507e/go.mod h1:gqklpHK7WHYTzpd1ZY6Ho/p9GURBghzBCzRiBYpaUP8=
github.com/ayn2op/ningen/v3 v3.0.1-0.20260708075321-0ba1ffc70bd6 h1:/khHm6G+GWwXpmwA8bJoaXoUhubqMEUG6spjZsnklIw=
github.com/ayn2op/ningen/v3 v3.0.1-0.20260708075321-0ba1ffc70bd6/go.mod h1:vqLIvETLkjc2vIgQzVEBqpGNCrgNe7YJqS9TUAJsXRI=
github.com/ayn2op/tview v0.0.0-20260808011018-71a6ffd9ad39 h1:SOzvhkwbIQtlIkRUUFTBKdnECPuFjUBqptO8Pe7aP9Q=
github.com/ayn2op/tview v0.0.0-20260808011018-71a6ffd9ad39/go.mod h1:bJZdl/FfspPLEW3nuX6ubt7YbmDoSj7f2IoWH37w1NE=
github.com/ayn2op/tview v0.0.0-20260810031733-e2c6c70cbb87 h1:GsOC8upE5wja+ncj6M3M0oIIvoRVkRyd3LztSa/YchE=
github.com/ayn2op/tview v0.0.0-20260810031733-e2c6c70cbb87/go.mod h1:GQq8n9VWVgRcEGjVaPBu53kXhPeNC13LCIcq0btKLqQ=
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=
@ -40,8 +40,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dchest/jsmin v1.0.0 h1:Y2hWXmGZiRxtl+VcTksyucgTlYxnhPzTozCwx9gy9zI=
github.com/dchest/jsmin v1.0.0/go.mod h1:AVBIund7Mr7lKXT70hKT2YgL3XEXUaUk5iw9DZ8b0Uc=
github.com/dlclark/regexp2/v2 v2.5.2 h1:HAsucWRhsqcDzl6Ua9aR8JwYOTzrZyPrF0/FNxJVAI0=
github.com/dlclark/regexp2/v2 v2.5.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dlclark/regexp2/v2 v2.6.0 h1:KugbSrpXcRpziHIcqqFEXwKwi09LbPkTzvLRItf2mo8=
github.com/dlclark/regexp2/v2 v2.6.0/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=

View file

@ -62,6 +62,7 @@ type composer struct {
}
type tabSuggestMsg struct{}
type editorMsg string
var _ help.KeyMap = (*composer)(nil)
@ -92,35 +93,26 @@ func (c *composer) forwardToTextArea(ev *tcell.EventKey) tview.Cmd {
return cmd
}
// resizeForContent fits the input to its newline count, capped at Composer.MaxHeight and at the parent flex's height (leaving at least 1 row for the messages list), then pins the scroll offset to the new visible window.
// Safe to call after every edit; ResizeItem and SetOffset are both idempotent.
func (c *composer) resizeForContent() {
if c.chat == nil || c.chat.rightFlex == nil {
return
}
_, _, _, outerH := c.Rect()
_, _, _, innerH := c.InnerRect()
frame := outerH - innerH // border + title/footer + padding rows
frame := outerH - innerH
_, _, _, parentH := c.chat.rightFlex.InnerRect()
visible := min(
strings.Count(c.Text(), "\n")+1, // newline-driven height
max(c.cfg.Composer.MaxHeight, 1), // user-configured cap
max(parentH-frame-1, 1), // available room, reserving 1 row for messages
strings.Count(c.Text(), "\n")+1,
max(c.cfg.Composer.MaxHeight, 1),
max(parentH-frame-1, 1),
)
c.chat.rightFlex.ResizeItem(c, visible+frame, 1) // outer height = inner content + frame
// Sync the textarea's inner height so the next keystroke's cursor clamping uses the new size.
c.chat.rightFlex.ResizeItem(c, visible+frame, 1)
c.SetVisibleSize(0, visible)
// Clamp scroll: when content overflows, pin the bottom row to the end of the text so backspacing the last newline doesn't leave a blank trailing row; once everything fits, reset to (0,0).
total := c.LineCount(0)
row, col := c.Offset()
maxOff := max(total-visible, 0) // last valid rowOffset that still shows real content on the bottom row
if row > maxOff {
c.SetOffset(maxOff, col)
} else if total <= visible && (row != 0 || col != 0) {
if total <= visible {
c.SetOffset(0, 0)
} else if offset := total - visible; row > offset {
c.SetOffset(offset, col)
}
}
@ -132,8 +124,6 @@ func (c *composer) reset() {
c.SetText("", true)
}
// The following overrides wrap the embedded TextArea/Box methods to auto-resize when callers change text or chrome.
func (c *composer) SetText(text string, cursorAtTheEnd bool) *tview.TextArea {
defer c.resizeForContent()
return c.TextArea.SetText(text, cursorAtTheEnd)
@ -164,7 +154,10 @@ func (c *composer) stopTypingTimer() {
}
func (c *composer) Update(msg tview.Msg) tview.Cmd {
ui.UpdateBoxFocus(c.Box, &c.cfg.Theme, msg)
switch msg := msg.(type) {
case tview.FocusMsg:
return tview.Sequence(c.TextArea.Update(msg), focused(c))
case tabSuggestMsg:
return c.tabSuggest()
case imagePastedMsg:
@ -182,6 +175,9 @@ func (c *composer) Update(msg tview.Msg) tview.Cmd {
c.attach(file.Name, file.Reader)
}
return nil
case editorMsg:
c.SetText(string(msg), true)
return nil
case tview.KeyMsg:
switch {
@ -195,9 +191,7 @@ func (c *composer) Update(msg tview.Msg) tview.Cmd {
}
return c.send()
case keybind.Matches(msg, c.cfg.Keybinds.Composer.OpenEditor.Keybind):
cmd := c.stopTabCompletion()
c.editor()
return cmd
return tview.Sequence(c.stopTabCompletion(), c.editor())
case keybind.Matches(msg, c.cfg.Keybinds.Composer.OpenFilePicker.Keybind):
return tview.Sequence(c.stopTabCompletion(), c.pickFiles())
case keybind.Matches(msg, c.cfg.Keybinds.Composer.Cancel.Keybind):
@ -228,7 +222,6 @@ func (c *composer) Update(msg tview.Msg) tview.Cmd {
}
}
// Apply key edits first, then recompute autocomplete through Msg/Cmd.
return tview.Batch(typingCmd, tview.Sequence(c.forwardToTextArea(msg), c.tabSuggest()))
}
return tview.Batch(typingCmd, c.forwardToTextArea(msg))
@ -757,7 +750,6 @@ func (c *composer) addMentionUser(user *discord.User) {
}
func (c *composer) removeMentionsList() {
// Make sure that the layer is visible before hiding it to avoid a refocus in the parent.
if c.chat.GetVisible(mentionsListLayerName) {
c.chat.HideLayer(mentionsListLayerName)
}
@ -772,46 +764,44 @@ func (c *composer) stopTabCompletion() tview.Cmd {
return nil
}
func (c *composer) editor() {
file, err := os.CreateTemp("", tmpFilePattern)
if err != nil {
slog.Error("failed to create tmp file", "err", err)
return
}
defer file.Close()
defer os.Remove(file.Name())
file.WriteString(c.Text())
func (c *composer) editor() tview.Cmd {
if c.cfg.Editor == "" {
slog.Warn("Attempt to open file with editor, but no editor is set")
return
}
cmd := c.cfg.CreateEditorCommand(file.Name())
if cmd == nil {
return
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
c.chat.app.Suspend(func() {
err := cmd.Run()
if err != nil {
slog.Error("failed to run command", "args", cmd.Args, "err", err)
return
return func() tview.Msg {
slog.Warn("Attempt to open file with editor, but no editor is set")
return nil
}
})
msg, err := os.ReadFile(file.Name())
if err != nil {
slog.Error("failed to read tmp file", "name", file.Name(), "err", err)
return
}
text := c.Text()
cfg := c.cfg
return tview.Suspend(func() tview.Msg {
file, err := os.CreateTemp("", tmpFilePattern)
if err != nil {
slog.Error("failed to create tmp file", "err", err)
return nil
}
name := file.Name()
defer os.Remove(name)
_, _ = file.WriteString(text)
_ = file.Close()
c.SetText(strings.TrimSpace(string(msg)), true)
cmd := cfg.CreateEditorCommand(name)
if cmd == nil {
return nil
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
slog.Error("failed to run command", "args", cmd.Args, "err", err)
return nil
}
msg, err := os.ReadFile(name)
if err != nil {
slog.Error("failed to read tmp file", "name", name, "err", err)
return nil
}
return editorMsg(strings.TrimSpace(string(msg)))
})
}
func (c *composer) attach(name string, reader io.Reader) {

View file

@ -336,7 +336,10 @@ func (gt *guildsTree) collapseParentNode(node *tree.Node) {
}
func (gt *guildsTree) Update(msg tview.Msg) tview.Cmd {
ui.UpdateBoxFocus(gt.Box, &gt.cfg.Theme, msg)
switch msg := msg.(type) {
case tview.FocusMsg:
return tview.Sequence(gt.Model.Update(msg), focused(gt))
case tree.SelectedMsg:
return gt.onSelected(msg.Node)
case tview.KeyMsg:

View file

@ -33,7 +33,7 @@ func (m *Model) activeKeyMap() help.KeyMap {
return m.messagesList.attachmentsPicker
}
switch m.app.Focused() {
switch m.focused {
case m.guildsTree:
return m.guildsTree
case m.messagesList:
@ -64,7 +64,7 @@ func (m *Model) baseFullHelp() [][]keybind.Keybind {
func (m *Model) focusHelp() []keybind.Keybind {
kbs := m.cfg.Keybinds
focused := m.app.Focused()
focused := m.focused
focusKbs := make([]keybind.Keybind, 0, 3)
if focused != m.guildsTree {

View file

@ -847,7 +847,10 @@ func (ml *messagesList) selectedMessage() (*discord.Message, bool) {
}
func (ml *messagesList) Update(msg tview.Msg) tview.Cmd {
ui.UpdateBoxFocus(ml.Box, &ml.cfg.Theme, msg)
switch msg := msg.(type) {
case tview.FocusMsg:
return tview.Sequence(ml.Model.Update(msg), focused(ml))
case tview.KeyMsg:
switch {
case keybind.Matches(msg, ml.cfg.Keybinds.MessagesList.Cancel.Keybind):

View file

@ -51,6 +51,7 @@ type Model struct {
messagesList *messagesList
composer *composer
channelsPicker *channelspicker.Model
focused tview.Model
selectedChannel *discord.Channel
selectedChannelMu sync.RWMutex
@ -61,11 +62,10 @@ type Model struct {
typersMu sync.RWMutex
typers map[discord.UserID]*time.Timer
app *tview.Application
cfg *config.Config
}
func NewModel(app *tview.Application, cfg *config.Config, token string) *Model {
func NewModel(cfg *config.Config, token string) *Model {
m := &Model{
Layers: layers.New(),
@ -74,7 +74,6 @@ func NewModel(app *tview.Application, cfg *config.Config, token string) *Model {
typers: make(map[discord.UserID]*time.Timer),
app: app,
cfg: cfg,
}
@ -147,15 +146,14 @@ func (m *Model) buildLayout() {
)
}
func (m *Model) togglePicker() {
func (m *Model) togglePicker() tview.Cmd {
if m.HasLayer(channelsPickerLayerName) {
m.closePicker()
} else {
m.openPicker()
return m.closePicker()
}
return m.openPicker()
}
func (m *Model) openPicker() {
func (m *Model) openPicker() tview.Cmd {
m.AddLayer(
ui.Centered(m.channelsPicker, m.cfg.Picker.Width, m.cfg.Picker.Height),
layers.WithName(channelsPickerLayerName),
@ -164,11 +162,13 @@ func (m *Model) openPicker() {
layers.WithOverlay(),
).SendToFront(channelsPickerLayerName)
m.channelsPicker.RefreshChannels(m.state)
return tview.SetFocus(m.channelsPicker)
}
func (m *Model) closePicker() {
func (m *Model) closePicker() tview.Cmd {
m.RemoveLayer(channelsPickerLayerName)
m.channelsPicker.Refresh()
return tview.SetFocus(m.mainFlex)
}
func (m *Model) closeAttachmentsPicker() tview.Cmd {
@ -191,15 +191,14 @@ func (m *Model) navigateToChannel(channelID discord.ChannelID) tview.Cmd {
m.guildsTree.expandPathToNode(node)
m.guildsTree.SetCurrentNode(node)
m.closePicker()
focus := m.closePicker()
if channel.Type != discord.GuildCategory {
return m.guildsTree.onSelected(node)
return tview.Sequence(focus, m.guildsTree.onSelected(node))
}
return nil
return focus
}
func (m *Model) toggleGuildsTree() tview.Cmd {
// The guilds tree is visible if the number of items is two.
if m.mainFlex.GetItemCount() == 2 {
m.mainFlex.RemoveItem(m.guildsTree)
if m.guildsTree.HasFocus() {
@ -213,7 +212,6 @@ func (m *Model) toggleGuildsTree() tview.Cmd {
}
func (m *Model) focusGuildsTree() tview.Cmd {
// The guilds tree is not hidden if the number of items is two.
if m.mainFlex.GetItemCount() == 2 {
return tview.SetFocus(m.guildsTree)
}
@ -227,38 +225,32 @@ func (m *Model) focusComposer() tview.Cmd {
return nil
}
func (m *Model) focusMessagesList() tview.Cmd {
return tview.SetFocus(m.messagesList)
}
func (m *Model) focusPrevious() tview.Cmd {
switch m.app.Focused() {
switch m.focused {
case m.guildsTree:
if cmd := m.focusComposer(); cmd != nil {
return cmd
}
return m.focusMessagesList()
return tview.SetFocus(m.messagesList)
case m.messagesList:
// Fallback when guilds/input are unavailable.
if cmd := m.focusGuildsTree(); cmd != nil {
return cmd
}
if cmd := m.focusComposer(); cmd != nil {
return cmd
}
return m.focusMessagesList()
return tview.SetFocus(m.messagesList)
case m.composer:
return m.focusMessagesList()
return tview.SetFocus(m.messagesList)
}
return nil
}
func (m *Model) focusNext() tview.Cmd {
switch m.app.Focused() {
switch m.focused {
case m.guildsTree:
return m.focusMessagesList()
return tview.SetFocus(m.messagesList)
case m.messagesList:
// Fallback when input/guilds are unavailable.
if cmd := m.focusComposer(); cmd != nil {
return cmd
}
@ -269,13 +261,16 @@ func (m *Model) focusNext() tview.Cmd {
if cmd := m.focusGuildsTree(); cmd != nil {
return cmd
}
return m.focusMessagesList()
return tview.SetFocus(m.messagesList)
}
return nil
}
func (m *Model) Update(msg tview.Msg) tview.Cmd {
switch msg := msg.(type) {
case FocusedMsg:
m.focused = msg.Model
return nil
case tview.InitMsg:
return tview.Batch(m.openState(), m.listen())
case gateway.Event:
@ -341,7 +336,7 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd {
m.composer.SetDisabled(hasNoPerm)
text := "Message..."
focusCmd := tview.Cmd(nil)
var focusCmd tview.Cmd
if hasNoPerm {
text = "You do not have permission to send messages in this channel."
@ -358,8 +353,7 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd {
case channelspicker.SelectedMsg:
return m.navigateToChannel(msg.ChannelID)
case channelspicker.CancelMsg:
m.closePicker()
return nil
return m.closePicker()
case attachmentspicker.SelectedMsg:
return tview.Sequence(msg.Open, m.closeAttachmentsPicker())
case attachmentspicker.CancelMsg:
@ -373,7 +367,7 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd {
return m.focusGuildsTree()
case keybind.Matches(msg, m.cfg.Keybinds.FocusMessagesList.Keybind):
m.composer.removeMentionsList()
return m.focusMessagesList()
return tview.SetFocus(m.messagesList)
case keybind.Matches(msg, m.cfg.Keybinds.FocusComposer.Keybind):
return m.focusComposer()
@ -385,15 +379,12 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd {
case keybind.Matches(msg, m.cfg.Keybinds.ToggleGuildsTree.Keybind):
return m.toggleGuildsTree()
case keybind.Matches(msg, m.cfg.Keybinds.ToggleChannelsPicker.Keybind):
m.togglePicker()
return nil
return m.togglePicker()
case keybind.Matches(msg, m.cfg.Keybinds.Logout.Keybind):
return tview.Sequence(m.closeState(), m.logout())
}
case tabSuggestMsg:
// Member search completes in a command goroutine; resume suggestion
// generation on the update loop to keep UI mutations serialized.
return m.composer.Update(msg)
}
return m.Layers.Update(msg)

View file

@ -57,3 +57,7 @@ func (m *Model) logout() tview.Cmd {
}
type QuitMsg struct{}
type FocusedMsg struct{ Model tview.Model }
func focused(model tview.Model) tview.Cmd { return func() tview.Msg { return FocusedMsg{model} } }

View file

@ -29,7 +29,6 @@ const (
type Model struct {
*layers.Layers
app *tview.Application
rootFlex *flex.Model // inner + help
inner tview.Model
help *help.Model
@ -37,13 +36,13 @@ type Model struct {
modalRequest *ui.ModalMsg
modalDialog *modal.Model
modalPreviousFocus tview.Model
focused tview.Model
cfg *config.Config
}
func NewModel(cfg *config.Config, app *tview.Application) *Model {
func NewModel(cfg *config.Config) *Model {
m := &Model{
Layers: layers.New(),
app: app,
rootFlex: flex.NewModel(),
help: help.NewModel(),
@ -75,7 +74,7 @@ func (m *Model) showLogin() tview.Cmd {
}
func (m *Model) showChat(token string) tview.Cmd {
m.inner = chat.NewModel(m.app, m.cfg, token)
m.inner = chat.NewModel(m.cfg, token)
m.buildLayout()
return tview.Batch(m.inner.Update(tview.InitMsg{}), tview.SetFocus(m))
}
@ -85,6 +84,7 @@ func (m *Model) buildLayout() {
m.modalRequest = nil
m.modalDialog = nil
m.modalPreviousFocus = nil
m.focused = nil
m.rootFlex.Clear()
if m.inner != nil {
m.rootFlex.AddItem(m.inner, 0, 1, true)
@ -98,6 +98,8 @@ var _ tview.Model = (*Model)(nil)
func (m *Model) Update(msg tview.Msg) tview.Cmd {
switch msg := msg.(type) {
case chat.FocusedMsg:
m.focused = msg.Model
case tview.InitMsg:
var cmd tview.Cmd
if token := os.Getenv(tokenEnvVarKey); token != "" {
@ -146,8 +148,7 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd {
m.updateHelpHeight()
return nil
case keybind.Matches(msg, m.cfg.Keybinds.Suspend.Keybind):
m.suspend()
return nil
return suspend()
case keybind.Matches(msg, m.cfg.Keybinds.Quit.Keybind):
var innerCmd tview.Cmd
if m.inner != nil {
@ -188,14 +189,17 @@ func (m *Model) showModal(request ui.ModalMsg) tview.Cmd {
m.modalRequest = &request
m.modalDialog = dialog
m.modalPreviousFocus = m.app.Focused()
m.modalPreviousFocus = m.focused
if m.modalPreviousFocus == nil {
m.modalPreviousFocus = m.inner
}
m.AddLayer(dialog,
layers.WithName(modalLayerName),
layers.WithResize(true),
layers.WithVisible(true),
layers.WithOverlay(),
)
return nil
return tview.SetFocus(dialog)
}
func (m *Model) finishModal(done modal.DoneMsg) tview.Cmd {
@ -214,7 +218,7 @@ func (m *Model) finishModal(done modal.DoneMsg) tview.Cmd {
}
m.RemoveLayer(modalLayerName)
focus := tview.Cmd(nil)
var focus tview.Cmd
if m.modalPreviousFocus != nil {
focus = tview.SetFocus(m.modalPreviousFocus)
}

View file

@ -2,4 +2,6 @@
package root
func (m *Model) suspend() {}
import "github.com/ayn2op/tview"
func suspend() tview.Cmd { return nil }

View file

@ -6,15 +6,18 @@ import (
"os"
"os/signal"
"syscall"
"github.com/ayn2op/tview"
)
func (m *Model) suspend() {
m.app.Suspend(func() {
func suspend() tview.Cmd {
return tview.Suspend(func() tview.Msg {
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGCONT)
defer signal.Stop(c)
_ = syscall.Kill(0, syscall.SIGTSTP)
<-c
return nil
})
}

View file

@ -16,57 +16,37 @@ import (
// ConfigureBox configures the provided box according to the provided theme.
func ConfigureBox(box *tview.Box, cfg *config.Theme) {
border := cfg.Border
normalBorderStyle, activeBorderStyle := border.NormalStyle.Style, border.ActiveStyle.Style
normalBorderSet, activeBorderSet := border.NormalSet.BorderSet, border.ActiveSet.BorderSet
title := cfg.Title
normalTitleStyle, activeTitleStyle := title.NormalStyle.Style, title.ActiveStyle.Style
footer := cfg.Footer
normalFooterStyle, activeFooterStyle := footer.NormalStyle.Style, footer.ActiveStyle.Style
padding := border.Padding
padding := cfg.Border.Padding
UpdateBoxFocus(box, cfg, tview.BlurMsg{})
box.
SetBorderStyle(normalBorderStyle).
SetBorderSet(normalBorderSet).
SetBorderPadding(padding[0], padding[1], padding[2], padding[3]).
SetTitleStyle(normalTitleStyle).
SetTitleAlignment(title.Alignment.Alignment).
SetFooterStyle(normalFooterStyle).
SetFooterAlignment(footer.Alignment.Alignment).
SetBlurFunc(func() {
box.
SetBorderStyle(normalBorderStyle).
SetBorderSet(normalBorderSet)
box.SetTitleStyle(normalTitleStyle).SetFooterStyle(normalFooterStyle)
}).
SetFocusFunc(func() {
box.
SetBorderStyle(activeBorderStyle).
SetBorderSet(activeBorderSet)
box.SetTitleStyle(activeTitleStyle).SetFooterStyle(activeFooterStyle)
})
SetTitleAlignment(cfg.Title.Alignment.Alignment).
SetFooterAlignment(cfg.Footer.Alignment.Alignment)
if border.Enabled {
if cfg.Border.Enabled {
box.SetBorders(tview.BordersAll)
}
}
func UpdateBoxFocus(box *tview.Box, cfg *config.Theme, msg tview.Msg) {
switch msg.(type) {
case tview.FocusMsg:
box.SetBorderStyle(cfg.Border.ActiveStyle.Style).
SetBorderSet(cfg.Border.ActiveSet.BorderSet).
SetTitleStyle(cfg.Title.ActiveStyle.Style).
SetFooterStyle(cfg.Footer.ActiveStyle.Style)
case tview.BlurMsg:
box.SetBorderStyle(cfg.Border.NormalStyle.Style).
SetBorderSet(cfg.Border.NormalSet.BorderSet).
SetTitleStyle(cfg.Title.NormalStyle.Style).
SetFooterStyle(cfg.Footer.NormalStyle.Style)
}
}
func ConfigurePicker(model *picker.Model, cfg *config.Config, title string) {
model.Box = tview.NewBox()
ConfigureBox(model.Box, &cfg.Theme)
// When a child of the parent flex is focused, the parent layout itself is not reported as focused.
// Instead, the focused child (picker) is considered focused.
// Therefore, we manually set the active border style on the picker to ensure it displays the correct focused appearance.
model.
SetBlurFunc(nil).
SetFocusFunc(nil).
SetBorderSet(cfg.Theme.Border.ActiveSet.BorderSet).
SetBorderStyle(cfg.Theme.Border.ActiveStyle.Style).
SetTitleStyle(cfg.Theme.Title.ActiveStyle.Style).
SetFooterStyle(cfg.Theme.Footer.ActiveStyle.Style)
UpdateBoxFocus(model.Box, &cfg.Theme, tview.FocusMsg{})
model.SetTitle(title)
model.SetScrollBarVisibility(cfg.Theme.ScrollBar.Visibility.ScrollBarVisibility)