Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 57 additions & 51 deletions internal/ui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -349,7 +349,7 @@ type (
UserID string
WorkspaceID string
}
TypingExpiredMsg struct{}
TypingExpiredMsg struct{}
PresenceChangeMsg struct {
UserID string
Presence string
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
65 changes: 64 additions & 1 deletion internal/ui/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ 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"
"github.com/gammons/slk/internal/ui/messages"
"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) {
Expand Down Expand Up @@ -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.
Expand Down