diff --git a/go.mod b/go.mod index 160a6b3..b988b14 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/alecthomas/chroma/v2 v2.25.0 github.com/andybalholm/brotli v1.2.1 github.com/ayn2op/clipboard v0.0.0-20260308203959-c5ad7df3fc97 - github.com/ayn2op/tview v0.0.0-20260518065412-67dde5e23f28 + github.com/ayn2op/tview v0.0.0-20260527184746-2ff331312d36 github.com/deckarep/gosx-notifier v0.0.0-20180201035817-e127226297fb github.com/diamondburned/arikawa/v3 v3.6.1-0.20260518050745-b430932b3ee1 github.com/diamondburned/ningen/v3 v3.0.1-0.20260306213430-5a08d3a709b4 @@ -54,7 +54,7 @@ require ( github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect github.com/twmb/murmur3 v1.1.8 // indirect go4.org v0.0.0-20260112195520-a5071408f32f // indirect - golang.org/x/exp/shiny v0.0.0-20260508232706-74f9aab9d74a // indirect + golang.org/x/exp/shiny v0.0.0-20260527015227-08cc5374adb3 // indirect golang.org/x/image v0.41.0 // indirect golang.org/x/mobile v0.0.0-20260520154334-0e4426e1883d // indirect golang.org/x/sys v0.45.0 // indirect diff --git a/go.sum b/go.sum index 81b1c5c..8ce30b1 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,8 @@ github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eT github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/ayn2op/clipboard v0.0.0-20260308203959-c5ad7df3fc97 h1:WujETUV+v0DEJyZgjeLzQvihWyL80c0Tg4qf0dDo+Io= github.com/ayn2op/clipboard v0.0.0-20260308203959-c5ad7df3fc97/go.mod h1:3kFnpNCa3dF6WryzOMCDao7PfZ7DTCh+pievlfuwV80= -github.com/ayn2op/tview v0.0.0-20260518065412-67dde5e23f28 h1:p3kPeeYPtPW0hqiQObSIGb6KsFYy0nasc/AZnPMvUqs= -github.com/ayn2op/tview v0.0.0-20260518065412-67dde5e23f28/go.mod h1:qYiaybvIwn7ry4iXX5Q8T3NgZacKoytk5kTwJ1Jy8WY= +github.com/ayn2op/tview v0.0.0-20260527184746-2ff331312d36 h1:MC55SU7Y8JVLn5nCXmEvLkfec/+7eo3KtserKVlSbFI= +github.com/ayn2op/tview v0.0.0-20260527184746-2ff331312d36/go.mod h1:W0lZFznN+hDXma+CT27czLM8/zbNOda6vsfVgHI1Sew= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= @@ -117,8 +117,8 @@ go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/exp/shiny v0.0.0-20260508232706-74f9aab9d74a h1:rEXukQywIYF+MmY26FaDeMA3yOSPe4TAKjue5Maz3PM= -golang.org/x/exp/shiny v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:eQWld7Ucl0GVwFfFJYfSLmTbCJG2XEQrz+PGjltJgck= +golang.org/x/exp/shiny v0.0.0-20260527015227-08cc5374adb3 h1:UIWQrCxLb5XfsY23rY1N+HOiWQLccu3Sc7Brlvm9v08= +golang.org/x/exp/shiny v0.0.0-20260527015227-08cc5374adb3/go.mod h1:eQWld7Ucl0GVwFfFJYfSLmTbCJG2XEQrz+PGjltJgck= golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= golang.org/x/mobile v0.0.0-20260520154334-0e4426e1883d h1:pWrEKZvKeqE2xPrylgBjgCyJSpPPt3L2WG2DmA+Xccg= diff --git a/internal/config/config.go b/internal/config/config.go index f0f3af6..543a113 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -74,6 +74,12 @@ type ( Separator string `toml:"separator"` } + MessageInputConfig struct { + // MaxHeight caps how tall (in newline-separated rows) the input grows before it starts scrolling internally. + // Must be >= 1; values <= 0 fall back to the default. + MaxHeight int `toml:"max_height"` + } + SidebarMarkersConfig struct { Expanded string `toml:"expanded"` Collapsed string `toml:"collapsed"` @@ -107,14 +113,15 @@ type ( AutocompleteLimit uint8 `toml:"autocomplete_limit"` MessagesLimit uint8 `toml:"messages_limit"` - Markdown MarkdownConfig `toml:"markdown"` - Help HelpConfig `toml:"help"` - Picker PickerConfig `toml:"picker"` - Timestamps Timestamps `toml:"timestamps"` - DateSeparator DateSeparator `toml:"date_separator"` - Notifications Notifications `toml:"notifications"` - TypingIndicator TypingIndicator `toml:"typing_indicator"` - Sidebar SidebarConfig `toml:"sidebar"` + Markdown MarkdownConfig `toml:"markdown"` + Help HelpConfig `toml:"help"` + Picker PickerConfig `toml:"picker"` + Timestamps Timestamps `toml:"timestamps"` + DateSeparator DateSeparator `toml:"date_separator"` + Notifications Notifications `toml:"notifications"` + TypingIndicator TypingIndicator `toml:"typing_indicator"` + Sidebar SidebarConfig `toml:"sidebar"` + MessageInput MessageInputConfig `toml:"message_input"` Icons Icons `toml:"icons"` @@ -171,6 +178,10 @@ func applyDefaults(cfg *Config) { cfg.Status = discord.UnknownStatus } + if cfg.MessageInput.MaxHeight <= 0 { + cfg.MessageInput.MaxHeight = 10 + } + if cfg.DateSeparator.Format == "" { cfg.DateSeparator.Format = "January 2, 2006" } diff --git a/internal/config/config.toml b/internal/config/config.toml index 2f80e22..968dcfa 100644 --- a/internal/config/config.toml +++ b/internal/config/config.toml @@ -20,6 +20,11 @@ autocomplete_limit = 20 # The number of messages to fetch when a text-based channel is selected from guilds tree. The minimum and maximum value is 1 and 100, respectively. messages_limit = 50 +[message_input] +# Maximum height (in newline-separated rows) the message input grows to before it starts scrolling internally. +# Set to 1 for a fixed single-line input. +max_height = 10 + [markdown] # Whether to parse and render markdown in messages or not. enabled = true @@ -159,11 +164,12 @@ yank_url = "u" yank_id = "i" # Only while typing a message -# Alt+Enter: Insert a new line to the current text. [keybinds.message_input] -# paste from clipboard (supports both text and images) +# Paste from clipboard (supports both text and images) paste = "ctrl+v" send = "enter" +# Insert a new line at the cursor. +newline = "shift+enter" # Remove existing text or cancel reply. cancel = "esc" # Complete usernames when mentioning diff --git a/internal/config/keybinds.go b/internal/config/keybinds.go index a8b20dc..208ea3a 100644 --- a/internal/config/keybinds.go +++ b/internal/config/keybinds.go @@ -97,6 +97,7 @@ type MessagesListKeybinds struct { type MessageInputKeybinds struct { Paste Keybind `toml:"paste"` Send Keybind `toml:"send"` + Newline Keybind `toml:"newline"` Cancel Keybind `toml:"cancel"` TabComplete Keybind `toml:"tab_complete"` Undo Keybind `toml:"undo"` @@ -198,6 +199,7 @@ func defaultMessageInputKeybinds() MessageInputKeybinds { return MessageInputKeybinds{ Paste: desc("paste"), Send: desc("send"), + Newline: desc("nl"), Cancel: desc("cancel"), TabComplete: desc("complete"), Undo: desc("undo"), diff --git a/internal/ui/chat/message_input.go b/internal/ui/chat/message_input.go index 3f9ef3c..916af15 100644 --- a/internal/ui/chat/message_input.go +++ b/internal/ui/chat/message_input.go @@ -93,6 +93,46 @@ func newMessageInput(cfg *config.Config, chat *Model) *messageInput { return mi } +// forwardToTextArea passes a key event to the embedded TextArea then resizes for the new content. +// SetChangedFunc isn't used: it fires inside replace's defer, before truncateLines/findCursor, so it would observe stale state. +func (mi *messageInput) forwardToTextArea(ev *tcell.EventKey) tview.Cmd { + cmd := mi.TextArea.Update(ev) + mi.resizeForContent() + return cmd +} + +// resizeForContent fits the input to its newline count, capped at MessageInput.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 (mi *messageInput) resizeForContent() { + if mi.chat == nil || mi.chat.rightFlex == nil { + return + } + _, _, _, outerH := mi.Rect() + _, _, _, innerH := mi.InnerRect() + frame := outerH - innerH // border + title/footer + padding rows + _, _, _, parentH := mi.chat.rightFlex.InnerRect() + + visible := min( + strings.Count(mi.GetText(), "\n")+1, // newline-driven height + max(mi.cfg.MessageInput.MaxHeight, 1), // user-configured cap + max(parentH-frame-1, 1), // available room, reserving 1 row for messages + ) + mi.chat.rightFlex.ResizeItem(mi, 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. + mi.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 := mi.LineCount(0) + row, col := mi.GetOffset() + maxOff := max(total-visible, 0) // last valid rowOffset that still shows real content on the bottom row + if row > maxOff { + mi.SetOffset(maxOff, col) + } else if total <= visible && (row != 0 || col != 0) { + mi.SetOffset(0, 0) + } +} + func (mi *messageInput) reset() { mi.edit = false mi.sendMessageData = &api.SendMessageData{} @@ -101,6 +141,28 @@ func (mi *messageInput) reset() { mi.SetText("", true) } +// The following overrides wrap the embedded TextArea/Box methods to auto-resize when callers change text or chrome. + +func (mi *messageInput) SetText(text string, cursorAtTheEnd bool) *tview.TextArea { + defer mi.resizeForContent() + return mi.TextArea.SetText(text, cursorAtTheEnd) +} + +func (mi *messageInput) Replace(start, end int, text string) *tview.TextArea { + defer mi.resizeForContent() + return mi.TextArea.Replace(start, end, text) +} + +func (mi *messageInput) SetTitle(title string) *tview.Box { + defer mi.resizeForContent() + return mi.Box.SetTitle(title) +} + +func (mi *messageInput) SetFooter(footer string) *tview.Box { + defer mi.resizeForContent() + return mi.Box.SetFooter(footer) +} + func (mi *messageInput) stopTypingTimer() { mi.typingTimerMu.Lock() defer mi.typingTimerMu.Unlock() @@ -111,7 +173,6 @@ func (mi *messageInput) stopTypingTimer() { } func (mi *messageInput) Update(msg tview.Msg) tview.Cmd { - handler := mi.TextArea.Update switch msg := msg.(type) { case tabSuggestMsg: return mi.tabSuggest() @@ -134,7 +195,9 @@ func (mi *messageInput) Update(msg tview.Msg) tview.Cmd { case tview.KeyMsg: switch { case keybind.Matches(msg, mi.cfg.Keybinds.MessageInput.Paste.Keybind): - return tview.Sequence(mi.pasteImage(), handler(tcell.NewEventKey(tcell.KeyCtrlV, "", tcell.ModNone))) + return tview.Sequence(mi.pasteImage(), mi.forwardToTextArea(tcell.NewEventKey(tcell.KeyCtrlV, "", tcell.ModNone))) + case keybind.Matches(msg, mi.cfg.Keybinds.MessageInput.Newline.Keybind): + return mi.forwardToTextArea(tcell.NewEventKey(tcell.KeyEnter, "", tcell.ModNone)) case keybind.Matches(msg, mi.cfg.Keybinds.MessageInput.Send.Keybind): if mi.chat.GetVisible(mentionsListLayerName) { return mi.tabComplete() @@ -156,9 +219,9 @@ func (mi *messageInput) Update(msg tview.Msg) tview.Cmd { if mi.chat.GetVisible(mentionsListLayerName) { return mi.tabComplete() } - return handler(msg) + return mi.forwardToTextArea(msg) case keybind.Matches(msg, mi.cfg.Keybinds.MessageInput.Undo.Keybind): - return handler(tcell.NewEventKey(tcell.KeyCtrlZ, "", tcell.ModNone)) + return mi.forwardToTextArea(tcell.NewEventKey(tcell.KeyCtrlZ, "", tcell.ModNone)) } typingCmd := mi.sendTyping() @@ -175,11 +238,11 @@ func (mi *messageInput) Update(msg tview.Msg) tview.Cmd { } // Apply key edits first, then recompute autocomplete through Msg/Cmd. - return tview.Batch(typingCmd, tview.Sequence(handler(msg), mi.tabSuggest())) + return tview.Batch(typingCmd, tview.Sequence(mi.forwardToTextArea(msg), mi.tabSuggest())) } - return tview.Batch(typingCmd, handler(msg)) + return tview.Batch(typingCmd, mi.forwardToTextArea(msg)) } - return handler(msg) + return mi.TextArea.Update(msg) } type imagePastedMsg []byte @@ -768,7 +831,7 @@ func (mi *messageInput) ShortHelp() []keybind.Keybind { } cfg := mi.cfg.Keybinds.MessageInput - short := []keybind.Keybind{cfg.Send.Keybind, cfg.Cancel.Keybind, cfg.Paste.Keybind, cfg.OpenEditor.Keybind} + short := []keybind.Keybind{cfg.Send.Keybind, cfg.Newline.Keybind, cfg.Cancel.Keybind, cfg.Paste.Keybind, cfg.OpenEditor.Keybind} if mi.canAttachFiles() { short = append(short, cfg.OpenFilePicker.Keybind) } @@ -793,7 +856,7 @@ func (mi *messageInput) FullHelp() [][]keybind.Keybind { } return [][]keybind.Keybind{ - {cfg.Send.Keybind, cfg.Cancel.Keybind, cfg.TabComplete.Keybind, cfg.Undo.Keybind}, + {cfg.Send.Keybind, cfg.Newline.Keybind, cfg.Cancel.Keybind, cfg.TabComplete.Keybind, cfg.Undo.Keybind}, openEditor, } }