diff --git a/internal/ui/app.go b/internal/ui/app.go index 27dfeb3..4458d88 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -19,7 +19,6 @@ import ( "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" - "golang.design/x/clipboard" "github.com/gammons/slk/internal/cache" "github.com/gammons/slk/internal/config" "github.com/gammons/slk/internal/debuglog" @@ -44,6 +43,7 @@ import ( "github.com/gammons/slk/internal/ui/threadsview" "github.com/gammons/slk/internal/ui/workspace" "github.com/gammons/slk/internal/ui/workspacefinder" + "golang.design/x/clipboard" ) type Panel int @@ -349,7 +349,7 @@ type ( UserID string WorkspaceID string } - TypingExpiredMsg struct{} + TypingExpiredMsg struct{} PresenceChangeMsg struct { UserID string Presence string @@ -725,10 +725,10 @@ type App struct { keys KeyMap // Cached layout widths for mouse hit-testing - layoutRailWidth int - layoutSidebarEnd int // railWidth + sidebarWidth + sidebarBorder - layoutMsgEnd int // layoutSidebarEnd + msgWidth + msgBorder - layoutThreadEnd int // layoutMsgEnd + threadWidth + threadBorder + layoutRailWidth int + layoutSidebarEnd int // railWidth + sidebarWidth + sidebarBorder + layoutMsgEnd int // layoutSidebarEnd + msgWidth + msgBorder + layoutThreadEnd int // layoutMsgEnd + threadWidth + threadBorder // Cached pane content heights, used for page-up/down distance calculations. layoutMsgHeight int layoutSidebarHeight int @@ -774,11 +774,11 @@ type App struct { // always). channelSyncedAtReader func(channelID string) int64 olderMessagesFetcher OlderMessagesFetchFunc - messageSender MessageSendFunc - messageEditor MessageEditFunc - messageDeleter MessageDeleteFunc - messageMarkUnreader MarkUnreadFunc - uploader UploadFunc + messageSender MessageSendFunc + messageEditor MessageEditFunc + messageDeleter MessageDeleteFunc + messageMarkUnreader MarkUnreadFunc + uploader UploadFunc // clipboardAvailable is set at startup based on the result of // clipboard.Init(). When false, Ctrl+V smart-paste is a no-op. @@ -788,18 +788,18 @@ type App struct { // clipboard contents. Tests inject fakes via SetClipboardReader. clipboardRead clipboardReader - threadFetcher ThreadFetchFunc - threadCacheReader ThreadCacheReadFunc - threadMarker ThreadMarkFunc - threadReplySender ThreadReplySendFunc - channelJoiner JoinChannelFunc - threadsListFetcher ThreadsListFetchFunc + threadFetcher ThreadFetchFunc + threadCacheReader ThreadCacheReadFunc + threadMarker ThreadMarkFunc + threadReplySender ThreadReplySendFunc + channelJoiner JoinChannelFunc + threadsListFetcher ThreadsListFetchFunc // channelLastReadFetcher returns the parent channel's last_read_ts // so the thread panel can render a "── new ──" boundary. Optional — // when nil, the thread panel renders without an unread boundary. channelLastReadFetcher func(channelID string) string - threadsDirtyDebounce time.Duration - fetchingOlder bool + threadsDirtyDebounce time.Duration + fetchingOlder bool // Cached user-id -> display-name map (mirror of what SetUserNames // last received). Used by openSelectedThreadCmd to populate the @@ -1007,36 +1007,36 @@ func previewSpinnerTickCmd() tea.Cmd { func NewApp() *App { app := &App{ - workspaceRail: workspace.New(nil, 0), - sidebar: sidebar.New(nil), - messagepane: messages.New(nil, ""), - compose: compose.New(""), - statusbar: statusbar.New(), - channelFinder: channelfinder.New(), - workspaceFinder: workspacefinder.New(), - themeSwitcher: themeswitcher.New(), - presenceMenu: presencemenu.New(), - help: help.New(), - threadPanel: thread.New(), - threadCompose: compose.New("thread"), - threadsView: threadsview.New(nil, ""), - reactionPicker: reactionpicker.New(), - confirmPrompt: confirmprompt.New(), - mode: ModeNormal, - focusedPanel: PanelSidebar, - sidebarVisible: true, - view: ViewChannels, - keys: DefaultKeyMap(), - typingUsers: make(map[string]map[string]time.Time), - selfSentTSes: make(map[string]time.Time), + workspaceRail: workspace.New(nil, 0), + sidebar: sidebar.New(nil), + messagepane: messages.New(nil, ""), + compose: compose.New(""), + statusbar: statusbar.New(), + channelFinder: channelfinder.New(), + workspaceFinder: workspacefinder.New(), + themeSwitcher: themeswitcher.New(), + presenceMenu: presencemenu.New(), + help: help.New(), + threadPanel: thread.New(), + threadCompose: compose.New("thread"), + threadsView: threadsview.New(nil, ""), + reactionPicker: reactionpicker.New(), + confirmPrompt: confirmprompt.New(), + mode: ModeNormal, + focusedPanel: PanelSidebar, + sidebarVisible: true, + view: ViewChannels, + keys: DefaultKeyMap(), + typingUsers: make(map[string]map[string]time.Time), + selfSentTSes: make(map[string]time.Time), lastSelfSendByChannel: make(map[string]time.Time), - threadsDirtyDebounce: 150 * time.Millisecond, - userNames: map[string]string{}, - externalUsers: map[string]bool{}, - statusByTeam: map[string]workspaceStatus{}, - lastChannelByTeam: map[string]string{}, - navHistory: make(map[string]*navStack), - clipboardRead: defaultClipboardReader, + threadsDirtyDebounce: 150 * time.Millisecond, + userNames: map[string]string{}, + externalUsers: map[string]bool{}, + statusByTeam: map[string]workspaceStatus{}, + lastChannelByTeam: map[string]string{}, + navHistory: make(map[string]*navStack), + clipboardRead: defaultClipboardReader, } // Seed the picker with built-in emojis so the autocomplete works even // before the first workspace finishes loading customs. @@ -3177,9 +3177,15 @@ func (a *App) handleInsertMode(msg tea.KeyMsg) tea.Cmd { } } // Plain Enter sends; Shift+Enter (and Ctrl+J as a fallback for terminals - // that don't disambiguate modifiers) inserts a newline. - isSend := code == tea.KeyEnter && !mod.Contains(tea.ModShift) - isNewline := (code == tea.KeyEnter && mod.Contains(tea.ModShift)) || + // Plain Enter sends; modified Enter variants insert a newline. + keystroke := msg.Key().Keystroke() + stringForm := msg.String() + isModifiedEnter := stringForm == "shift+enter" || keystroke == "shift+enter" || stringForm == "shift+return" || keystroke == "shift+return" || stringForm == "alt+enter" || keystroke == "alt+enter" || stringForm == "alt+return" || keystroke == "alt+return" + textValue := target.Value() + isBackslashEnter := (code == tea.KeyEnter || code == tea.KeyReturn) && strings.HasSuffix(textValue, "\\") + isPlainEnter := (code == tea.KeyEnter || code == tea.KeyReturn) && !mod.Contains(tea.ModShift) && !mod.Contains(tea.ModAlt) + isSend := isPlainEnter && !isModifiedEnter && !isBackslashEnter + isNewline := ((code == tea.KeyEnter || code == tea.KeyReturn) && (mod.Contains(tea.ModShift) || mod.Contains(tea.ModAlt))) || isModifiedEnter || isBackslashEnter || (code == 'j' && mod == tea.ModCtrl) // Determine which compose box is active based on focused panel diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index 8037165..475aa8c 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -14,7 +14,6 @@ import ( "time" tea "charm.land/bubbletea/v2" - "golang.design/x/clipboard" "github.com/gammons/slk/internal/cache" imgpkg "github.com/gammons/slk/internal/image" "github.com/gammons/slk/internal/ui/compose" @@ -22,6 +21,7 @@ import ( "github.com/gammons/slk/internal/ui/sidebar" "github.com/gammons/slk/internal/ui/statusbar" "github.com/gammons/slk/internal/ui/styles" + "golang.design/x/clipboard" ) func TestAppFocusCycle(t *testing.T) { @@ -302,6 +302,69 @@ func TestHandleInsertMode_PlainEnterSends(t *testing.T) { } } +func TestHandleInsertMode_ShiftReturnInsertsNewline(t *testing.T) { + app := NewApp() + app.activeChannelID = "C1" + app.focusedPanel = PanelMessages + app.SetMode(ModeInsert) + app.compose.Focus() + app.compose.SetValue("hello") + + cmd := app.handleInsertMode(tea.KeyPressMsg{Code: tea.KeyReturn, Mod: tea.ModShift}) + if cmd != nil { + if msg := cmd(); msg != nil { + if _, ok := msg.(SendMessageMsg); ok { + t.Fatalf("Shift+Return should not send the message") + } + } + } + if !strings.Contains(app.compose.Value(), "\n") { + t.Fatalf("expected newline in compose value, got %q", app.compose.Value()) + } +} + +func TestHandleInsertMode_AltEnterInsertsNewline(t *testing.T) { + app := NewApp() + app.activeChannelID = "C1" + app.focusedPanel = PanelMessages + app.SetMode(ModeInsert) + app.compose.Focus() + app.compose.SetValue("hello") + + cmd := app.handleInsertMode(tea.KeyPressMsg{Code: tea.KeyEnter, Mod: tea.ModAlt}) + if cmd != nil { + if msg := cmd(); msg != nil { + if _, ok := msg.(SendMessageMsg); ok { + t.Fatalf("Alt+Enter should not send the message") + } + } + } + if !strings.Contains(app.compose.Value(), "\n") { + t.Fatalf("expected newline in compose value, got %q", app.compose.Value()) + } +} + +func TestHandleInsertMode_BackslashEnterInsertsNewline(t *testing.T) { + app := NewApp() + app.activeChannelID = "C1" + app.focusedPanel = PanelMessages + app.SetMode(ModeInsert) + app.compose.Focus() + app.compose.SetValue("hello\\") + + cmd := app.handleInsertMode(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd != nil { + if msg := cmd(); msg != nil { + if _, ok := msg.(SendMessageMsg); ok { + t.Fatalf("backslash+Enter should not send the message") + } + } + } + if !strings.Contains(app.compose.Value(), "\n") { + t.Fatalf("expected newline in compose value, got %q", app.compose.Value()) + } +} + // TestHandleInsertMode_PlainEnterReturnsToNormalMode locks in the // vim-style UX: hitting Enter to submit a channel message drops the // user back to ModeNormal instead of leaving them in insert mode.