feat(chat): auto-expanding message input with shift+enter newline (#799)
This commit is contained in:
parent
19907d7889
commit
318bf731ec
6 changed files with 107 additions and 25 deletions
4
go.mod
4
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
|
||||
|
|
|
|||
8
go.sum
8
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=
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue