From cc8e04777077196ac7eafaa73f0f6d3a0b18d4a0 Mon Sep 17 00:00:00 2001 From: Gavin Jeong Date: Fri, 22 May 2026 19:32:49 +0900 Subject: [PATCH 1/3] feat: add slash command picker and merge worktree input fixes Show available slash commands when typing / in compose, execute them through Slack's command endpoint, and merge the pending worktree input improvements for tmux keyboard reporting and clipboard paste handling. --- cmd/slk/main.go | 92 ++++++++--- internal/slack/client.go | 109 +++++++++++++ internal/slack/client_test.go | 52 ++++++ internal/ui/app.go | 238 +++++++++++++++++++++------- internal/ui/app_test.go | 103 +++++++++++- internal/ui/clipboard_darwin.go | 9 ++ internal/ui/clipboard_darwin_cgo.go | 91 +++++++++++ internal/ui/clipboard_default.go | 9 ++ internal/ui/compose/model.go | 143 +++++++++++++++++ internal/ui/compose/model_test.go | 50 ++++++ internal/ui/slashpicker/model.go | 144 +++++++++++++++++ 11 files changed, 964 insertions(+), 76 deletions(-) create mode 100644 internal/ui/clipboard_darwin.go create mode 100644 internal/ui/clipboard_darwin_cgo.go create mode 100644 internal/ui/clipboard_default.go create mode 100644 internal/ui/slashpicker/model.go diff --git a/cmd/slk/main.go b/cmd/slk/main.go index a2eebf29..056192c8 100644 --- a/cmd/slk/main.go +++ b/cmd/slk/main.go @@ -37,6 +37,7 @@ import ( "github.com/gammons/slk/internal/ui/presencemenu" "github.com/gammons/slk/internal/ui/reactionpicker" "github.com/gammons/slk/internal/ui/sidebar" + "github.com/gammons/slk/internal/ui/slashpicker" "github.com/gammons/slk/internal/ui/statusbar" "github.com/gammons/slk/internal/ui/styles" "github.com/gammons/slk/internal/ui/themeswitcher" @@ -93,10 +94,10 @@ func (a sectionsProviderAdapter) OrderedSlackSections() []sidebar.SectionMeta { // WorkspaceContext holds all state for a single connected workspace. type WorkspaceContext struct { - Client *slackclient.Client - ConnMgr *slackclient.ConnectionManager - RTMHandler *rtmEventHandler - UserNames map[string]string + Client *slackclient.Client + ConnMgr *slackclient.ConnectionManager + RTMHandler *rtmEventHandler + UserNames map[string]string // AvatarURLs maps userID -> avatar image URL. Populated from the // local users cache at connect time (synchronous, before any // goroutines spin up) and refreshed from the background @@ -120,7 +121,7 @@ type WorkspaceContext struct { // background users.list fetch and any on-demand resolveUser calls. // Used during channel construction to bucket app DMs into a separate // "Apps" sidebar section. - BotUserIDs map[string]bool + BotUserIDs map[string]bool // SectionStore holds the user's Slack-native sidebar sections for // this workspace. Nil when use_slack_sections is disabled, the // REST bootstrap failed, or this workspace hasn't connected yet. @@ -149,7 +150,7 @@ type WorkspaceContext struct { // after a failed one. The UI uses it to decide whether to draw // the "Threads list unavailable" banner. SubscriptionsAvailable bool - Channels []sidebar.ChannelItem + Channels []sidebar.ChannelItem // FinderItems is the merged list shown in the Ctrl+T finder. Initially // contains only joined channels; the BrowseableChannelsLoadedMsg pipeline // extends it with non-joined public channels in the background. @@ -159,6 +160,7 @@ type WorkspaceContext struct { UserID string UnresolvedDMs []UnresolvedDM CustomEmoji map[string]string // emoji name -> URL or "alias:target" + SlashCommands []slashpicker.Command // Self presence and DND state for this workspace. Populated on connect // and updated by manual_presence_change / dnd_updated WS events plus // optimistic writes from the presence menu. @@ -1011,6 +1013,20 @@ func run() error { } }) + app.SetSlashCommandRunner(func(channelID, text string) tea.Msg { + wctx := router.Active() + if wctx == nil { + return ui.ToastMsg{Text: "Command failed: no active workspace"} + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := wctx.Client.ExecuteSlashCommand(ctx, channelID, text); err != nil { + log.Printf("Warning: failed to execute slash command: %v", err) + return ui.ToastMsg{Text: "Command failed: " + err.Error()} + } + return ui.ToastMsg{Text: "Command sent"} + }) + app.SetMessageEditor(func(channelID, ts, text string) tea.Msg { wctx := router.Active() if wctx == nil { @@ -1313,6 +1329,7 @@ func run() error { ExternalUsers: external, UserID: wctx.UserID, CustomEmoji: wctx.CustomEmoji, + SlashCommands: wctx.SlashCommands, SectionsProvider: sectionsProviderAdapter{store: wctx.SectionStore}, } }) @@ -1457,6 +1474,7 @@ func run() error { ExternalUsers: external, UserID: wctx.UserID, CustomEmoji: wctx.CustomEmoji, // empty at this point; filled by the goroutine below + SlashCommands: wctx.SlashCommands, SectionsProvider: sectionsProviderAdapter{store: wctx.SectionStore}, InitialActive: isInitial, }) @@ -1485,19 +1503,19 @@ func run() error { // Resolve unknown DM user names in background if len(wctx.UnresolvedDMs) > 0 { go func() { - for _, dm := range wctx.UnresolvedDMs { - resolved, isBot := resolveUser(wctx.Client, dm.UserID, wctx.UserNames, db, avatarCache) - if isBot { - wctx.BotUserIDs[dm.UserID] = true - } - if resolved != dm.UserID { - p.Send(ui.DMNameResolvedMsg{ - ChannelID: dm.ChannelID, - DisplayName: resolved, - IsBot: isBot, - }) + for _, dm := range wctx.UnresolvedDMs { + resolved, isBot := resolveUser(wctx.Client, dm.UserID, wctx.UserNames, db, avatarCache) + if isBot { + wctx.BotUserIDs[dm.UserID] = true + } + if resolved != dm.UserID { + p.Send(ui.DMNameResolvedMsg{ + ChannelID: dm.ChannelID, + DisplayName: resolved, + IsBot: isBot, + }) + } } - } }() } }(ot.Token) @@ -1532,6 +1550,38 @@ func run() error { return err } +func buildSlashPickerCommands(commands []slackclient.SlashCommand) []slashpicker.Command { + out := make([]slashpicker.Command, 0, len(commands)) + for _, cmd := range commands { + if cmd.Command == "" { + continue + } + out = append(out, slashpicker.Command{ + Name: cmd.Command, + Description: cmd.Description, + UsageHint: cmd.UsageHint, + }) + } + return out +} + +func defaultSlashPickerCommands() []slashpicker.Command { + return []slashpicker.Command{ + {Name: "/away", Description: "Set yourself away"}, + {Name: "/active", Description: "Set yourself active"}, + {Name: "/dnd", Description: "Manage do not disturb", UsageHint: "[minutes]"}, + {Name: "/invite", Description: "Invite people to this channel", UsageHint: "@user"}, + {Name: "/leave", Description: "Leave the current channel"}, + {Name: "/me", Description: "Post an emote message", UsageHint: "action"}, + {Name: "/msg", Description: "Open a direct message", UsageHint: "@user message"}, + {Name: "/remind", Description: "Create a reminder", UsageHint: "who what when"}, + {Name: "/shrug", Description: "Append a shrug", UsageHint: "message"}, + {Name: "/status", Description: "Set your status", UsageHint: "text"}, + {Name: "/topic", Description: "Set the channel topic", UsageHint: "text"}, + {Name: "/who", Description: "See who is in the channel"}, + } +} + func connectWorkspace(ctx context.Context, token slackclient.Token, db *cache.DB, cfg config.Config, avatarCache *avatar.Cache, p *tea.Program) (*WorkspaceContext, error) { client := slackclient.NewClient(token.AccessToken, token.Cookie) if err := client.Connect(ctx); err != nil { @@ -1548,6 +1598,7 @@ func connectWorkspace(ctx context.Context, token slackclient.Token, db *cache.DB UserNamesByHandle: make(map[string]string), BotUserIDs: make(map[string]bool), CustomEmoji: make(map[string]string), + SlashCommands: defaultSlashPickerCommands(), LastVisitedByChannel: make(map[string]int64), } wctx.SubscriptionsAvailable = true @@ -1632,6 +1683,11 @@ func connectWorkspace(ctx context.Context, token slackclient.Token, db *cache.DB } else { wctx.LastVisitedByChannel = visits } + if commands, err := client.ListSlashCommands(ctx); err == nil { + if pickerCommands := buildSlashPickerCommands(commands); len(pickerCommands) > 0 { + wctx.SlashCommands = pickerCommands + } + } // Initialize Slack-native section store if enabled. Bootstrap is // best-effort: failure is logged, the field stays nil, and the diff --git a/internal/slack/client.go b/internal/slack/client.go index d1704788..d59afd7e 100644 --- a/internal/slack/client.go +++ b/internal/slack/client.go @@ -525,6 +525,12 @@ type HistorySinceResult struct { Capped bool } +type SlashCommand struct { + Command string + Description string + UsageHint string +} + // GetHistorySince fetches all messages newer than `oldest` for the // given channel, paginating through next_cursor up to a hard ceiling // of maxTotal messages. Slack returns messages newest-first per page; @@ -639,6 +645,109 @@ func (c *Client) SendMessage(ctx context.Context, channelID, text string) (strin return ts, mr, nil } +func (c *Client) ExecuteSlashCommand(ctx context.Context, channelID, text string) error { + command, args, ok := splitSlashCommand(text) + if channelID == "" { + return fmt.Errorf("executing slash command: missing channel") + } + if !ok { + return fmt.Errorf("executing slash command: invalid command") + } + + body, err := c.postForm(ctx, "chat.command", url.Values{ + "channel": {channelID}, + "command": {command}, + "text": {args}, + }) + if err != nil { + return fmt.Errorf("executing slash command: %w", err) + } + + var resp struct { + OK bool `json:"ok"` + Error string `json:"error"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return fmt.Errorf("parsing chat.command response: %w (body=%q)", err, truncateForLog(body)) + } + if !resp.OK { + if resp.Error == "" { + resp.Error = "unknown_error" + } + return fmt.Errorf("chat.command: %s", resp.Error) + } + return nil +} + +func splitSlashCommand(text string) (command, args string, ok bool) { + trimmed := strings.TrimSpace(text) + if !strings.HasPrefix(trimmed, "/") || len(trimmed) == 1 { + return "", "", false + } + for i, r := range trimmed { + if i == 0 { + continue + } + if r == ' ' || r == '\t' || r == '\n' { + return trimmed[:i], strings.TrimSpace(trimmed[i:]), true + } + } + return trimmed, "", true +} + +func (c *Client) ListSlashCommands(ctx context.Context) ([]SlashCommand, error) { + body, err := c.postForm(ctx, "commands.list", nil) + if err != nil { + return nil, fmt.Errorf("listing slash commands: %w", err) + } + + var resp struct { + OK bool `json:"ok"` + Error string `json:"error"` + Commands []struct { + Command string `json:"command"` + Name string `json:"name"` + Description string `json:"description"` + UsageHint string `json:"usage_hint"` + ArgHint string `json:"arg_hint"` + Hint string `json:"hint"` + } `json:"commands"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("parsing commands.list response: %w (body=%q)", err, truncateForLog(body)) + } + if !resp.OK { + if resp.Error == "" { + resp.Error = "unknown_error" + } + return nil, fmt.Errorf("commands.list: %s", resp.Error) + } + + commands := make([]SlashCommand, 0, len(resp.Commands)) + for _, cmd := range resp.Commands { + name := cmd.Command + if name == "" { + name = cmd.Name + } + if name == "" { + continue + } + hint := cmd.UsageHint + if hint == "" { + hint = cmd.ArgHint + } + if hint == "" { + hint = cmd.Hint + } + commands = append(commands, SlashCommand{ + Command: name, + Description: cmd.Description, + UsageHint: hint, + }) + } + return commands, nil +} + // UploadFile uploads a single file to a channel (and optional thread) // using Slack's V2 external-upload flow. The slack-go library's // UploadFileContext (named for the underlying file.upload.v2 API) diff --git a/internal/slack/client_test.go b/internal/slack/client_test.go index e8167783..5fa9f4bb 100644 --- a/internal/slack/client_test.go +++ b/internal/slack/client_test.go @@ -130,6 +130,58 @@ func TestSendMessage_EmptyTextSendsNoBlocks(t *testing.T) { } } +func TestExecuteSlashCommand_PostsChatCommandForm(t *testing.T) { + var gotPath string + var gotAuth string + var gotForm url.Values + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + if err := r.ParseForm(); err != nil { + t.Errorf("ParseForm: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + gotForm = r.Form + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + c := &Client{token: "xoxc-test", cookie: "d-cookie", apiBaseURL: srv.URL + "/api/"} + if err := c.ExecuteSlashCommand(context.Background(), "C1", "/invite @alice"); err != nil { + t.Fatalf("ExecuteSlashCommand: %v", err) + } + if gotPath != "/api/chat.command" { + t.Errorf("path = %q, want /api/chat.command", gotPath) + } + if gotAuth != "Bearer xoxc-test" { + t.Errorf("Authorization = %q, want bearer token", gotAuth) + } + if gotForm.Get("channel") != "C1" { + t.Errorf("channel = %q, want C1", gotForm.Get("channel")) + } + if gotForm.Get("command") != "/invite" { + t.Errorf("command = %q, want /invite", gotForm.Get("command")) + } + if gotForm.Get("text") != "@alice" { + t.Errorf("text = %q, want @alice", gotForm.Get("text")) + } +} + +func TestExecuteSlashCommand_ReturnsSlackError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":false,"error":"unknown_command"}`)) + })) + defer srv.Close() + + c := &Client{token: "xoxc-test", cookie: "d-cookie", apiBaseURL: srv.URL + "/api/"} + if err := c.ExecuteSlashCommand(context.Background(), "C1", "/doesnotexist"); err == nil || !strings.Contains(err.Error(), "unknown_command") { + t.Fatalf("expected unknown_command error, got %v", err) + } +} + // mockSlackAPI implements SlackAPI for testing. // Function fields allow tests to override default behavior. type mockSlackAPI struct { diff --git a/internal/ui/app.go b/internal/ui/app.go index 27dfeb3e..eafa8e82 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" @@ -37,6 +36,7 @@ import ( "github.com/gammons/slk/internal/ui/presencemenu" "github.com/gammons/slk/internal/ui/reactionpicker" "github.com/gammons/slk/internal/ui/sidebar" + "github.com/gammons/slk/internal/ui/slashpicker" "github.com/gammons/slk/internal/ui/statusbar" "github.com/gammons/slk/internal/ui/styles" "github.com/gammons/slk/internal/ui/themeswitcher" @@ -44,6 +44,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 @@ -134,6 +135,10 @@ type ( ChannelID string Text string } + SlashCommandMsg struct { + ChannelID string + Text string + } ThreadOpenedMsg struct { ChannelID string ThreadTS string @@ -251,6 +256,7 @@ type ( ExternalUsers map[string]bool UserID string CustomEmoji map[string]string + SlashCommands []slashpicker.Command // SectionsProvider supplies Slack-native sidebar sections for this // workspace. Nil means "use config-glob behavior" (the App's // sidebar reverts to its existing name-keyed buckets). @@ -310,6 +316,7 @@ type ( ExternalUsers map[string]bool UserID string CustomEmoji map[string]string + SlashCommands []slashpicker.Command // SectionsProvider supplies Slack-native sidebar sections for this // workspace. Nil means "use config-glob behavior" (the App's // sidebar reverts to its existing name-keyed buckets). @@ -349,7 +356,7 @@ type ( UserID string WorkspaceID string } - TypingExpiredMsg struct{} + TypingExpiredMsg struct{} PresenceChangeMsg struct { UserID string Presence string @@ -480,6 +487,8 @@ type OlderMessagesFetchFunc func(channelID, oldestTS string) tea.Msg // MessageSendFunc is called when the user sends a message. Returns a tea.Msg with the result. type MessageSendFunc func(channelID, text string) tea.Msg +type SlashCommandFunc func(channelID, text string) tea.Msg + // MessageSentMsg is returned after a message is successfully sent. // LocalTS, if non-empty, identifies the optimistic placeholder added // when the user pressed Enter. The handler uses it to swap the @@ -694,9 +703,9 @@ type ChannelJoinFailedMsg struct { // clipboard contents. Production code uses the real clipboard.Read. type clipboardReader func(format clipboard.Format) []byte -// defaultClipboardReader is the real clipboard read function. It's +// defaultClipboardReader is the platform clipboard reader. It's // overridable per-App via SetClipboardReader for tests. -var defaultClipboardReader clipboardReader = clipboard.Read +var defaultClipboardReader clipboardReader = platformClipboardReader() type App struct { // Sub-models @@ -725,10 +734,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 @@ -750,6 +759,7 @@ type App struct { // Current context activeChannelID string activeTeamID string // workspace whose data is currently loaded into the side panels + pendingTopKey bool // bootstrapActiveClaimed flips on the first WorkspaceReadyMsg whose // InitialActive=true is observed. Subsequent InitialActive=true @@ -774,11 +784,12 @@ type App struct { // always). channelSyncedAtReader func(channelID string) int64 olderMessagesFetcher OlderMessagesFetchFunc - messageSender MessageSendFunc - messageEditor MessageEditFunc - messageDeleter MessageDeleteFunc - messageMarkUnreader MarkUnreadFunc - uploader UploadFunc + messageSender MessageSendFunc + slashCommandRunner SlashCommandFunc + 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 +799,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 +1018,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. @@ -1933,6 +1944,13 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + case SlashCommandMsg: + if a.slashCommandRunner != nil { + runner := a.slashCommandRunner + chID, text := msg.ChannelID, msg.Text + cmds = append(cmds, func() tea.Msg { return runner(chID, text) }) + } + case SendMessageMsg: // Mark in-flight regardless of whether a sender is wired — // the user's send intent is what controls WS-echo suppression @@ -2407,6 +2425,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.SetExternalUsers(msg.ExternalUsers) a.SetUserNames(msg.UserNames) a.SetCustomEmoji(msg.CustomEmoji) + a.SetSlashCommands(msg.SlashCommands) a.currentUserID = msg.UserID a.activeTeamID = msg.TeamID if st, ok := a.statusByTeam[a.activeTeamID]; ok { @@ -2879,6 +2898,24 @@ func (a *App) dropStaleStackEntries(stack *navStack, stale []int) { } func (a *App) handleNormalMode(msg tea.KeyMsg) tea.Cmd { + if key.Matches(msg, a.keys.Top) { + if a.pendingTopKey { + a.pendingTopKey = false + if cmd := a.handleGoToTop(); cmd != nil { + return cmd + } + } else { + a.pendingTopKey = true + } + return nil + } + if msg.String() == ":" || (msg.Key().Code == ':' && msg.Key().Text == ":") { + a.SetMode(ModeCommand) + return nil + } + if a.pendingTopKey { + a.pendingTopKey = false + } // Reaction-nav sub-state (intercept before normal keys) if a.focusedPanel == PanelMessages && a.messagepane.ReactionNavActive() { return a.handleReactionNav(msg) @@ -3090,6 +3127,10 @@ func (a *App) handleInsertMode(msg tea.KeyMsg) tea.Cmd { a.threadCompose.CloseChannel() return nil } + if a.threadCompose.IsSlashActive() { + a.threadCompose.CloseSlash() + return nil + } } else { if a.compose.IsEmojiActive() { a.compose.CloseEmoji() @@ -3103,6 +3144,10 @@ func (a *App) handleInsertMode(msg tea.KeyMsg) tea.Cmd { a.compose.CloseChannel() return nil } + if a.compose.IsSlashActive() { + a.compose.CloseSlash() + return nil + } } a.cancelEdit() return nil @@ -3122,6 +3167,10 @@ func (a *App) handleInsertMode(msg tea.KeyMsg) tea.Cmd { a.threadCompose.CloseChannel() return nil } + if a.threadCompose.IsSlashActive() { + a.threadCompose.CloseSlash() + return nil + } } else { if a.compose.IsEmojiActive() { a.compose.CloseEmoji() @@ -3135,6 +3184,10 @@ func (a *App) handleInsertMode(msg tea.KeyMsg) tea.Cmd { a.compose.CloseChannel() return nil } + if a.compose.IsSlashActive() { + a.compose.CloseSlash() + return nil + } } a.SetMode(ModeNormal) a.compose.Blur() @@ -3144,8 +3197,7 @@ func (a *App) handleInsertMode(msg tea.KeyMsg) tea.Cmd { code := msg.Key().Code mod := msg.Key().Mod - isPaste := code == 'v' && mod == tea.ModCtrl - if isPaste { + if isCtrlV(msg) { return a.smartPaste() } @@ -3165,7 +3217,7 @@ func (a *App) handleInsertMode(msg tea.KeyMsg) tea.Cmd { // let it own Up/Down so users can navigate the suggestion list. Without // this guard, the jump-to-start/end shortcuts below swallow the arrow // keys before the picker ever sees them. - pickerActive := target.IsEmojiActive() || target.IsMentionActive() || target.IsChannelActive() + pickerActive := target.IsEmojiActive() || target.IsMentionActive() || target.IsChannelActive() || target.IsSlashActive() if !pickerActive { if code == tea.KeyUp && mod == 0 && target.CursorAtFirstLine() { target.MoveCursorToStart() @@ -3185,7 +3237,7 @@ func (a *App) handleInsertMode(msg tea.KeyMsg) tea.Cmd { // Determine which compose box is active based on focused panel if a.focusedPanel == PanelThread && a.threadVisible { // If a picker is active, forward all keys to compose (including Enter). - if a.threadCompose.IsEmojiActive() || a.threadCompose.IsMentionActive() || a.threadCompose.IsChannelActive() { + if a.threadCompose.IsEmojiActive() || a.threadCompose.IsMentionActive() || a.threadCompose.IsChannelActive() || a.threadCompose.IsSlashActive() { var cmd tea.Cmd a.threadCompose, cmd = a.threadCompose.Update(msg) return cmd @@ -3233,7 +3285,7 @@ func (a *App) handleInsertMode(msg tea.KeyMsg) tea.Cmd { // Channel message compose // If a picker is active, forward all keys to compose (including Enter). - if a.compose.IsEmojiActive() || a.compose.IsMentionActive() || a.compose.IsChannelActive() { + if a.compose.IsEmojiActive() || a.compose.IsMentionActive() || a.compose.IsChannelActive() || a.compose.IsSlashActive() { var cmd tea.Cmd a.compose, cmd = a.compose.Update(msg) return cmd @@ -3260,6 +3312,14 @@ func (a *App) handleInsertMode(msg tea.KeyMsg) tea.Cmd { text = a.compose.TranslateMentionsForSend(text) a.compose.Reset() a.exitInsertAfterSend() + if isSlashCommandText(text) { + return func() tea.Msg { + return SlashCommandMsg{ + ChannelID: a.activeChannelID, + Text: text, + } + } + } return func() tea.Msg { return SendMessageMsg{ ChannelID: a.activeChannelID, @@ -3887,6 +3947,37 @@ func (a *App) handleUp() tea.Cmd { return nil } +func (a *App) handleGoToTop() tea.Cmd { + switch a.focusedPanel { + case PanelSidebar: + a.sidebar.GoToTop() + case PanelMessages: + if a.view == ViewThreads { + a.threadsView.GoToTop() + return a.openSelectedThreadCmd(false) + } + a.messagepane.GoToTop() + if a.messagepane.AtTop() && !a.fetchingOlder && a.olderMessagesFetcher != nil { + a.fetchingOlder = true + a.messagepane.SetLoading(true) + chID := a.activeChannelID + oldestTS := a.messagepane.OldestTS() + fetcher := a.olderMessagesFetcher + return tea.Batch( + tea.Tick(100*time.Millisecond, func(time.Time) tea.Msg { + return SpinnerTickMsg{} + }), + func() tea.Msg { + return fetcher(chID, oldestTS) + }, + ) + } + case PanelThread: + a.threadPanel.GoToTop() + } + return nil +} + func (a *App) handleGoToBottom() tea.Cmd { switch a.focusedPanel { case PanelSidebar: @@ -4515,6 +4606,10 @@ func (a *App) SetMessageSender(fn MessageSendFunc) { a.messageSender = fn } +func (a *App) SetSlashCommandRunner(fn SlashCommandFunc) { + a.slashCommandRunner = fn +} + // SetMessageEditor wires the chat.update callback used by edit submit. func (a *App) SetMessageEditor(fn MessageEditFunc) { a.messageEditor = fn @@ -4922,6 +5017,11 @@ func (a *App) SetCustomEmoji(customs map[string]string) { } } +func (a *App) SetSlashCommands(commands []slashpicker.Command) { + a.compose.SetSlashCommands(commands) + a.threadCompose.SetSlashCommands(commands) +} + // SetInitialChannel sets the active channel and its messages before the TUI starts. func (a *App) SetInitialChannel(channelID, channelName string, msgs []messages.MessageItem) { a.activeChannelID = channelID @@ -5249,6 +5349,13 @@ func (a *App) typingIndicatorText(names []string) string { } } +func withKeyboardEnhancements(v tea.View) tea.View { + v.KeyboardEnhancements.ReportAlternateKeys = true + v.KeyboardEnhancements.ReportAllKeysAsEscapeCodes = true + v.KeyboardEnhancements.ReportAssociatedText = true + return v +} + func (a *App) View() tea.View { // Before the terminal reports its size, we can't lay out the // real three-panel UI. Render the loading overlay (or a minimal @@ -5268,7 +5375,7 @@ func (a *App) View() tea.View { } v := tea.NewView(screen) v.AltScreen = true - return v + return withKeyboardEnhancements(v) } statusHeight := 1 @@ -5472,6 +5579,8 @@ func (a *App) View() tea.View { composeView = mentionView + "\n" + composeView } else if channelView := a.compose.ChannelPickerView(msgWidth - 2); channelView != "" { composeView = channelView + "\n" + composeView + } else if slashView := a.compose.SlashPickerView(msgWidth - 2); slashView != "" { + composeView = slashView + "\n" + composeView } // Add a background-colored spacer line above the compose box // (replaces MarginTop which produced unstyled/black margin cells) @@ -5567,6 +5676,8 @@ func (a *App) View() tea.View { threadComposeView = mentionView + "\n" + threadComposeView } else if channelView := a.threadCompose.ChannelPickerView(threadWidth - 2); channelView != "" { threadComposeView = channelView + "\n" + threadComposeView + } else if slashView := a.threadCompose.SlashPickerView(threadWidth - 2); slashView != "" { + threadComposeView = slashView + "\n" + threadComposeView } threadComposeSpacer := lipgloss.NewStyle().Background(styles.Background).Width(threadWidth - 2).Render("") threadComposeView = threadComposeSpacer + "\n" + threadComposeView @@ -5711,7 +5822,7 @@ func (a *App) View() tea.View { v := tea.NewView(finalScreen) v.AltScreen = true v.MouseMode = tea.MouseModeCellMotion - return v + return withKeyboardEnhancements(v) } // cancelEdit exits edit mode, restoring the stashed draft to its @@ -6201,6 +6312,19 @@ func resolveFilePath(text string) (string, bool) { return filepath.Clean(s), true } +func isSlashCommandText(text string) bool { + trimmed := strings.TrimSpace(text) + return strings.HasPrefix(trimmed, "/") && len(trimmed) > 1 +} + +func isCtrlV(msg tea.KeyMsg) bool { + keyEvent := msg.Key() + if (keyEvent.Code == 'v' || keyEvent.Code == 'V' || keyEvent.BaseCode == 'v' || keyEvent.BaseCode == 'V') && keyEvent.Mod.Contains(tea.ModCtrl) { + return true + } + return msg.String() == "ctrl+v" || keyEvent.Keystroke() == "ctrl+v" +} + // uploadToastCmd builds a tea.Cmd that sets the status bar to the // given message and schedules a CopiedClearMsg after dur. func (a *App) uploadToastCmd(text string, dur time.Duration) tea.Cmd { diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index 80371659..7dd470b3 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) { @@ -73,6 +73,54 @@ func TestAppToggleSidebar(t *testing.T) { } } +func TestHandleNormalMode_ColonEntersCommandMode(t *testing.T) { + app := NewApp() + + app.handleNormalMode(tea.KeyPressMsg{Code: ':', Text: ":"}) + + if app.mode != ModeCommand { + t.Fatalf("expected command mode, got %v", app.mode) + } +} + +func TestHandleNormalMode_GGJumpsToTop(t *testing.T) { + app := NewApp() + app.focusedPanel = PanelMessages + app.messagepane.SetMessages([]messages.MessageItem{ + {TS: "1", Text: "one"}, + {TS: "2", Text: "two"}, + {TS: "3", Text: "three"}, + }) + if app.messagepane.SelectedIndex() != 2 { + t.Fatalf("test setup: expected selection at bottom, got %d", app.messagepane.SelectedIndex()) + } + + app.handleNormalMode(tea.KeyPressMsg{Code: 'g', Text: "g"}) + if app.messagepane.SelectedIndex() != 2 { + t.Fatalf("single g should wait for second g, selected=%d", app.messagepane.SelectedIndex()) + } + app.handleNormalMode(tea.KeyPressMsg{Code: 'g', Text: "g"}) + + if app.messagepane.SelectedIndex() != 0 { + t.Fatalf("expected gg to jump to first message, got %d", app.messagepane.SelectedIndex()) + } +} + +func TestAppViewRequestsKeyboardDisambiguation(t *testing.T) { + app := NewApp() + view := app.View() + + if !view.KeyboardEnhancements.ReportAlternateKeys { + t.Fatal("expected alternate key reporting for shifted printable keys") + } + if !view.KeyboardEnhancements.ReportAllKeysAsEscapeCodes { + t.Fatal("expected all keys as escape codes so tmux can pass modified keys") + } + if !view.KeyboardEnhancements.ReportAssociatedText { + t.Fatal("expected associated text for normal text input while all keys are escape codes") + } +} + func TestTypingStateAddAndExpire(t *testing.T) { app := NewApp() app.activeChannelID = "C1" @@ -302,6 +350,32 @@ func TestHandleInsertMode_PlainEnterSends(t *testing.T) { } } +func TestHandleInsertMode_PlainEnterRunsSlashCommand(t *testing.T) { + app := NewApp() + app.activeChannelID = "C1" + app.focusedPanel = PanelMessages + app.SetMode(ModeInsert) + app.compose.SetValue("/invite @alice") + + cmd := app.handleInsertMode(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil { + t.Fatalf("plain Enter with slash command should return a command cmd") + } + msg, ok := cmd().(SlashCommandMsg) + if !ok { + t.Fatalf("expected SlashCommandMsg, got %T", cmd()) + } + if msg.ChannelID != "C1" || msg.Text != "/invite @alice" { + t.Fatalf("unexpected slash command msg: %+v", msg) + } + if app.compose.Value() != "" { + t.Fatalf("expected compose to be reset after slash command, got %q", app.compose.Value()) + } + if app.mode != ModeNormal { + t.Fatalf("after slash command, mode = %v, want ModeNormal", app.mode) + } +} + // 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. @@ -1951,6 +2025,33 @@ func TestSmartPaste_ImagePresent_AttachesToCompose(t *testing.T) { } } +func TestInsertModeCtrlVKey_AttachesClipboardImage(t *testing.T) { + app := NewApp() + app.SetClipboardAvailable(true) + app.activeChannelID = "C1" + app.focusedPanel = PanelMessages + app.SetMode(ModeInsert) + pngBytes := []byte("\x89PNG\r\n\x1a\nfake") + app.SetClipboardReader(fakeClipboard(pngBytes, nil)) + + _, _ = app.Update(tea.KeyPressMsg{Code: 'v', Mod: tea.ModCtrl}) + + atts := app.compose.Attachments() + if len(atts) != 1 { + t.Fatalf("expected 1 attachment, got %d", len(atts)) + } + if string(atts[0].Bytes) != string(pngBytes) { + t.Errorf("attachment bytes did not come from clipboard image") + } +} + +func TestIsCtrlVMatchesBaseCode(t *testing.T) { + msg := tea.KeyPressMsg{Code: 'ㅍ', BaseCode: 'v', Mod: tea.ModCtrl} + if !isCtrlV(msg) { + t.Fatal("expected Ctrl+V to match by BaseCode") + } +} + func TestSmartPaste_ImageTooLarge_Refuses(t *testing.T) { app := NewApp() app.SetClipboardAvailable(true) diff --git a/internal/ui/clipboard_darwin.go b/internal/ui/clipboard_darwin.go new file mode 100644 index 00000000..0b6575dd --- /dev/null +++ b/internal/ui/clipboard_darwin.go @@ -0,0 +1,9 @@ +//go:build darwin && !cgo + +package ui + +import "golang.design/x/clipboard" + +func platformClipboardReader() clipboardReader { + return clipboard.Read +} diff --git a/internal/ui/clipboard_darwin_cgo.go b/internal/ui/clipboard_darwin_cgo.go new file mode 100644 index 00000000..a7a83275 --- /dev/null +++ b/internal/ui/clipboard_darwin_cgo.go @@ -0,0 +1,91 @@ +//go:build darwin && cgo + +package ui + +/* +#cgo CFLAGS: -x objective-c +#cgo LDFLAGS: -framework Foundation -framework Cocoa +#import +#import +#include + +static unsigned int slk_copy_nsdata(NSData *data, void **out) { + if (data == nil || [data length] == 0) { + *out = NULL; + return 0; + } + NSUInteger size = [data length]; + void *buf = malloc(size); + if (buf == NULL) { + *out = NULL; + return 0; + } + [data getBytes:buf length:size]; + *out = buf; + return (unsigned int)size; +} + +unsigned int slk_clipboard_read_image_png(void **out) { + @autoreleasepool { + *out = NULL; + NSPasteboard *pasteboard = [NSPasteboard generalPasteboard]; + + NSData *png = [pasteboard dataForType:NSPasteboardTypePNG]; + if (png != nil) { + return slk_copy_nsdata(png, out); + } + + NSData *tiff = [pasteboard dataForType:NSPasteboardTypeTIFF]; + if (tiff != nil) { + NSBitmapImageRep *rep = [NSBitmapImageRep imageRepWithData:tiff]; + NSData *converted = [rep representationUsingType:NSBitmapImageFileTypePNG properties:@{}]; + if (converted != nil) { + return slk_copy_nsdata(converted, out); + } + } + + NSImage *image = [[NSImage alloc] initWithPasteboard:pasteboard]; + if (image == nil) { + return 0; + } + CGImageRef cgImage = [image CGImageForProposedRect:NULL context:nil hints:nil]; + if (cgImage == NULL) { + [image release]; + return 0; + } + NSBitmapImageRep *rep = [[NSBitmapImageRep alloc] initWithCGImage:cgImage]; + NSData *converted = [rep representationUsingType:NSBitmapImageFileTypePNG properties:@{}]; + unsigned int n = slk_copy_nsdata(converted, out); + [rep release]; + [image release]; + return n; + } +} +*/ +import "C" + +import ( + "unsafe" + + "golang.design/x/clipboard" +) + +func platformClipboardReader() clipboardReader { + return func(format clipboard.Format) []byte { + b := clipboard.Read(format) + if format != clipboard.FmtImage || len(b) > 0 { + return b + } + return readDarwinClipboardImagePNG() + } +} + +func readDarwinClipboardImagePNG() []byte { + var data unsafe.Pointer + n := C.slk_clipboard_read_image_png(&data) + if data == nil || n == 0 { + return nil + } + defer C.free(data) + return C.GoBytes(data, C.int(n)) +} diff --git a/internal/ui/clipboard_default.go b/internal/ui/clipboard_default.go new file mode 100644 index 00000000..4cb80f2c --- /dev/null +++ b/internal/ui/clipboard_default.go @@ -0,0 +1,9 @@ +//go:build !darwin + +package ui + +import "golang.design/x/clipboard" + +func platformClipboardReader() clipboardReader { + return clipboard.Read +} diff --git a/internal/ui/compose/model.go b/internal/ui/compose/model.go index 27a92f55..b79ff052 100644 --- a/internal/ui/compose/model.go +++ b/internal/ui/compose/model.go @@ -14,6 +14,7 @@ import ( "github.com/gammons/slk/internal/ui/channelpicker" "github.com/gammons/slk/internal/ui/emojipicker" "github.com/gammons/slk/internal/ui/mentionpicker" + "github.com/gammons/slk/internal/ui/slashpicker" "github.com/gammons/slk/internal/ui/styles" ) @@ -74,6 +75,14 @@ type Model struct { emojiActive bool emojiStartCol int + // Slash-command picker state. slashStartCol is the byte offset of the + // first character after '/' within input.Value(); the trigger '/' sits + // at slashStartCol-1. + slashPicker slashpicker.Model + slashActive bool + slashStartCol int + commands []slashpicker.Command + // placeholderOverride, when non-empty, replaces the default // "Message #channel..." placeholder. Used by edit mode to display // "Editing message — Enter to save, Esc to cancel". @@ -316,6 +325,8 @@ func (m *Model) Reset() { m.channelPicker.Close() m.emojiActive = false m.emojiPicker.Close() + m.slashActive = false + m.slashPicker.Close() m.pending = nil m.uploading = false m.dirty() @@ -432,6 +443,11 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m2.dirty() return m2, cmd } + if m.slashActive && isKey { + m2, cmd := m.handleSlashKey(keyMsg) + m2.dirty() + return m2, cmd + } // Normal textarea update var cmd tea.Cmd @@ -469,6 +485,18 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { } } } + if isKey && keyMsg.Key().Text == "/" { + val := m.input.Value() + cursorAbsPos := m.cursorPosition() + slashPos := cursorAbsPos - 1 + if slashPos >= 0 && slashPos < len(val) && val[slashPos] == '/' { + if slashPos == 0 || val[slashPos-1] == ' ' || val[slashPos-1] == '\n' { + m.slashActive = true + m.slashStartCol = cursorAbsPos + m.slashPicker.Open() + } + } + } // Emoji trigger: ':' at word boundary, plus 2 query chars before the // popup opens. We re-check on every keystroke (cheap) so the popup @@ -684,6 +712,55 @@ func (m Model) handleChannelKey(msg tea.KeyMsg) (Model, tea.Cmd) { } } +func (m Model) handleSlashKey(msg tea.KeyMsg) (Model, tea.Cmd) { + k := msg.Key() + switch { + case k.Code == tea.KeyUp || (k.Code == 'p' && k.Mod == tea.ModCtrl): + m.slashPicker.MoveUp() + return m, nil + case k.Code == tea.KeyDown || (k.Code == 'n' && k.Mod == tea.ModCtrl): + m.slashPicker.MoveDown() + return m, nil + case k.Code == tea.KeyEnter || k.Code == tea.KeyTab: + result := m.slashPicker.Select() + if result != nil { + m.insertSlash(result) + } + m.slashActive = false + m.slashPicker.Close() + return m, nil + case k.Code == tea.KeyEscape: + m.slashActive = false + m.slashPicker.Close() + return m, nil + case k.Code == tea.KeyBackspace: + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + pos := m.cursorPosition() + if pos < m.slashStartCol { + m.slashActive = false + m.slashPicker.Close() + } else { + m.updateSlashQuery() + } + m.autoGrow() + return m, cmd + case len(k.Text) > 0: + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + m.updateSlashQuery() + m.autoGrow() + return m, cmd + default: + m.slashActive = false + m.slashPicker.Close() + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + m.autoGrow() + return m, cmd + } +} + // updateChannelQuery extracts the text between the # trigger and the // cursor and updates the channel picker's filter query. func (m *Model) updateChannelQuery() { @@ -946,6 +1023,20 @@ func (m *Model) SetEmojiEntries(entries []emoji.EmojiEntry) { m.dirty() } +func (m *Model) SetSlashCommands(commands []slashpicker.Command) { + m.commands = commands + m.slashPicker.SetCommands(commands) + m.dirty() +} + +func (m Model) IsSlashActive() bool { return m.slashActive } + +func (m *Model) CloseSlash() { + m.slashActive = false + m.slashPicker.Close() + m.dirty() +} + // IsEmojiActive returns whether the emoji picker is currently showing. func (m Model) IsEmojiActive() bool { return m.emojiActive } @@ -964,6 +1055,13 @@ func (m Model) EmojiPickerView(width int) string { return m.emojiPicker.View(width) } +func (m Model) SlashPickerView(width int) string { + if !m.slashActive { + return "" + } + return m.slashPicker.View(width) +} + // emojiQueryChar reports whether r is a valid character inside an emoji // shortcode query (the run of chars after ':' the user is currently typing). // Mirrors the character set kyokomi recognizes in shortcodes. @@ -1036,6 +1134,51 @@ func (m *Model) maybeOpenEmojiPicker() { } } +func slashQueryChar(r byte) bool { + switch { + case r >= 'a' && r <= 'z': + return true + case r >= 'A' && r <= 'Z': + return true + case r >= '0' && r <= '9': + return true + case r == '_' || r == '-': + return true + } + return false +} + +func (m *Model) updateSlashQuery() { + val := m.input.Value() + pos := m.cursorPosition() + if pos > len(val) { + pos = len(val) + } + if m.slashStartCol > pos { + m.slashActive = false + m.slashPicker.Close() + return + } + query := val[m.slashStartCol:pos] + m.slashPicker.SetQuery(query) +} + +func (m *Model) insertSlash(result *slashpicker.Result) { + val := m.input.Value() + pos := m.cursorPosition() + slashPos := m.slashStartCol - 1 + if slashPos < 0 { + slashPos = 0 + } + before := val[:slashPos] + after := "" + if pos < len(val) { + after = val[pos:] + } + newText := before + slashpicker.FormatCommandInsert(result.Name) + after + m.input.SetValue(newText) +} + // handleEmojiKey processes key events when the emoji picker is active. // Mirrors handleMentionKey. func (m Model) handleEmojiKey(msg tea.KeyMsg) (Model, tea.Cmd) { diff --git a/internal/ui/compose/model_test.go b/internal/ui/compose/model_test.go index a1e5fcd5..79fd1b01 100644 --- a/internal/ui/compose/model_test.go +++ b/internal/ui/compose/model_test.go @@ -9,6 +9,7 @@ import ( "github.com/gammons/slk/internal/config" "github.com/gammons/slk/internal/emoji" "github.com/gammons/slk/internal/ui/mentionpicker" + "github.com/gammons/slk/internal/ui/slashpicker" "github.com/gammons/slk/internal/ui/styles" ) @@ -392,6 +393,55 @@ func TestCloseMention(t *testing.T) { } } +func TestSlashTrigger_OpensAtWordBoundary(t *testing.T) { + m := New("general") + m.SetSlashCommands([]slashpicker.Command{{Name: "/invite"}, {Name: "/topic"}}) + m.SetWidth(80) + m.Focus() + + m, _ = m.Update(tea.KeyPressMsg{Code: '/', Text: "/"}) + if !m.IsSlashActive() { + t.Fatal("expected slash picker to open on /") + } + if got := m.SlashPickerView(80); got == "" { + t.Fatal("expected slash picker view when active") + } +} + +func TestSlashTrigger_SelectInsertsCommand(t *testing.T) { + m := New("general") + m.SetSlashCommands([]slashpicker.Command{{Name: "/invite"}, {Name: "/topic"}}) + m.SetWidth(80) + m.Focus() + + m, _ = m.Update(tea.KeyPressMsg{Code: '/', Text: "/"}) + m, _ = m.Update(tea.KeyPressMsg{Code: 'i', Text: "i"}) + m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + + if m.IsSlashActive() { + t.Fatal("expected slash picker to close after selection") + } + if got := m.Value(); got != "/invite " { + t.Fatalf("expected slash command inserted, got %q", got) + } +} + +func TestSlashTrigger_BackspaceClosesWhenTriggerDeleted(t *testing.T) { + m := New("general") + m.SetSlashCommands([]slashpicker.Command{{Name: "/invite"}}) + m.SetWidth(80) + m.Focus() + + m, _ = m.Update(tea.KeyPressMsg{Code: '/', Text: "/"}) + if !m.IsSlashActive() { + t.Fatal("expected slash picker to open") + } + m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyBackspace}) + if m.IsSlashActive() { + t.Fatal("expected slash picker to close after deleting /") + } +} + func sampleEmojiEntries() []emoji.EmojiEntry { return []emoji.EmojiEntry{ {Name: "rock", Display: "🪨"}, diff --git a/internal/ui/slashpicker/model.go b/internal/ui/slashpicker/model.go new file mode 100644 index 00000000..fdab887f --- /dev/null +++ b/internal/ui/slashpicker/model.go @@ -0,0 +1,144 @@ +package slashpicker + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/gammons/slk/internal/text" + "github.com/gammons/slk/internal/ui/styles" +) + +const MaxVisible = 7 + +type Command struct { + Name string + Description string + UsageHint string +} + +type Result struct { + Name string +} + +type Model struct { + commands []Command + filtered []Command + query string + selected int + visible bool +} + +func New() Model { + return Model{} +} + +func (m *Model) SetCommands(commands []Command) { + m.commands = commands + if m.visible { + m.filter() + } +} + +func (m *Model) Open() { + m.visible = true + m.query = "" + m.selected = 0 + m.filter() +} + +func (m *Model) Close() { + m.visible = false + m.query = "" + m.selected = 0 + m.filtered = nil +} + +func (m *Model) IsVisible() bool { return m.visible } + +func (m *Model) SetQuery(q string) { + m.query = q + m.selected = 0 + m.filter() +} + +func (m *Model) Query() string { return m.query } + +func (m *Model) Filtered() []Command { return m.filtered } + +func (m *Model) Selected() int { return m.selected } + +func (m *Model) MoveUp() { + if m.selected > 0 { + m.selected-- + } +} + +func (m *Model) MoveDown() { + if m.selected < len(m.filtered)-1 { + m.selected++ + } +} + +func (m *Model) Select() *Result { + if len(m.filtered) == 0 || m.selected < 0 || m.selected >= len(m.filtered) { + return nil + } + return &Result{Name: m.filtered[m.selected].Name} +} + +func (m *Model) filter() { + q := text.Fold(m.query) + results := make([]Command, 0, len(m.commands)) + for _, c := range m.commands { + name := strings.TrimPrefix(c.Name, "/") + if q == "" || strings.HasPrefix(text.Fold(name), q) || strings.HasPrefix(text.Fold(c.Name), q) { + results = append(results, c) + } + } + if len(results) > MaxVisible { + results = results[:MaxVisible] + } + m.filtered = results +} + +func (m *Model) View(width int) string { + if !m.visible || len(m.filtered) == 0 { + return "" + } + + var rows []string + for i, c := range m.filtered { + indicator := " " + nameStyle := lipgloss.NewStyle().Foreground(styles.TextPrimary) + metaStyle := lipgloss.NewStyle().Foreground(styles.TextMuted) + if i == m.selected { + indicator = lipgloss.NewStyle().Foreground(styles.Accent).Render("▌ ") + nameStyle = nameStyle.Bold(true) + } + + label := indicator + nameStyle.Render(c.Name) + if c.UsageHint != "" { + label += metaStyle.Render(" " + c.UsageHint) + } + rows = append(rows, label) + if c.Description != "" { + rows = append(rows, " "+metaStyle.Render(c.Description)) + } + } + + content := strings.Join(rows, "\n") + return lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(styles.Primary). + Background(styles.SurfaceDark). + Width(width - 2). + Render(content) +} + +func FormatCommandInsert(name string) string { + if strings.HasPrefix(name, "/") { + return fmt.Sprintf("%s ", name) + } + return fmt.Sprintf("/%s ", name) +} From 1addc0fd23c1156c985db79d6ab1c85ac4ad49f4 Mon Sep 17 00:00:00 2001 From: Gavin Jeong Date: Sat, 23 May 2026 00:05:29 +0900 Subject: [PATCH 2/3] fix: handle modified Enter and langmap shortcuts Treat the observed alt+enter runtime event as a newline in compose and merge the langmap worktree's Korean keyboard shortcut normalization so modal shortcuts work under non-English layouts. --- internal/ui/app.go | 20 +++++-- internal/ui/app_test.go | 108 +++++++++++++++++++++++++++++++++++- internal/ui/keys.go | 118 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 239 insertions(+), 7 deletions(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index eafa8e82..4722e9a4 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -2728,7 +2728,7 @@ func (a *App) handleKey(msg tea.KeyMsg) tea.Cmd { // user. `Q` (capital) remains the no-prompt force-quit, and an // already-open quit prompt isn't reopened (Enter confirms, Esc // cancels via the existing confirm-mode handler). - if key.Matches(msg, a.keys.Quit) { + if key.Matches(normalizeShortcutKeyMsg(msg), a.keys.Quit) { if a.mode != ModeConfirm { a.openQuitConfirm() } @@ -2898,6 +2898,7 @@ func (a *App) dropStaleStackEntries(stack *navStack, stale []int) { } func (a *App) handleNormalMode(msg tea.KeyMsg) tea.Cmd { + msg = normalizeShortcutKeyMsg(msg) if key.Matches(msg, a.keys.Top) { if a.pendingTopKey { a.pendingTopKey = false @@ -3228,10 +3229,15 @@ func (a *App) handleInsertMode(msg tea.KeyMsg) tea.Cmd { return nil } } - // 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 @@ -3471,6 +3477,9 @@ func (a *App) handleThemeSwitcherMode(msg tea.KeyMsg) tea.Cmd { // handleHelpMode dispatches key events to the help overlay and tears down // the mode when the overlay closes itself (esc/q/?). func (a *App) handleHelpMode(msg tea.KeyMsg) tea.Cmd { + if !a.help.IsSearching() { + msg = normalizeShortcutKeyMsg(msg) + } keyStr := msg.String() switch msg.Key().Code { case tea.KeyEnter: @@ -3648,6 +3657,7 @@ func (a *App) handleReactionPickerMode(msg tea.KeyMsg) tea.Cmd { } func (a *App) handleConfirmMode(msg tea.KeyMsg) tea.Cmd { + msg = normalizeShortcutKeyMsg(msg) keyStr := msg.String() switch msg.Key().Code { case tea.KeyEscape: diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index 7dd470b3..0974e341 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -289,7 +289,6 @@ func TestHandleInsertMode_ShiftEnterInsertsNewline(t *testing.T) { cmd := app.handleInsertMode(tea.KeyPressMsg{Code: tea.KeyEnter, Mod: tea.ModShift}) if cmd != nil { - // Anything non-nil here likely means a SendMessageMsg was queued. if msg := cmd(); msg != nil { if _, ok := msg.(SendMessageMsg); ok { t.Fatalf("Shift+Enter should not send the message") @@ -308,6 +307,70 @@ func TestHandleInsertMode_ShiftEnterInsertsNewline(t *testing.T) { } } +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") + } + } + } + val := app.compose.Value() + if !strings.Contains(val, "\n") { + t.Fatalf("expected newline in compose value, got %q", val) + } +} + +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()) + } +} + // Regression: Shift+Enter must keep working past the visible-row cap of // the compose box. The textarea's MaxHeight used to be 5, which also // gated InsertNewline via atContentLimit, so users hit a silent @@ -2451,6 +2514,49 @@ func TestHandleInsertMode_Up_OnSecondLine_ForwardsToTextarea(t *testing.T) { } } +func TestNormalMode_KoreanKeyboardJMovesDown(t *testing.T) { + app := NewApp() + app.focusedPanel = PanelSidebar + app.sidebar.SetItems([]sidebar.ChannelItem{ + {ID: "C1", Name: "general", Type: "channel"}, + {ID: "C2", Name: "random", Type: "channel"}, + }) + app.sidebar.SelectByID("C1") + + app.handleNormalMode(tea.KeyPressMsg{Code: 'ㅓ', Text: "ㅓ"}) + + if got := app.sidebar.SelectedID(); got != "C2" { + t.Fatalf("Korean keyboard j should move down; selected ID = %q", got) + } +} + +func TestNormalMode_KoreanKeyboardShiftQOpensConfirmPrompt(t *testing.T) { + app := NewApp() + + cmd := app.handleNormalMode(tea.KeyPressMsg{Code: 'ㅃ', Text: "ㅃ"}) + if cmd != nil { + if _, ok := cmd().(tea.QuitMsg); ok { + t.Fatal("Korean keyboard Q should open confirm prompt, not quit immediately") + } + } + if !app.confirmPrompt.IsVisible() { + t.Fatal("Korean keyboard Q should open the confirm prompt") + } + if app.mode != ModeConfirm { + t.Errorf("expected mode=ModeConfirm, got %v", app.mode) + } +} + +func TestShortcutNormalization_KoreanTextMapsOnlyWhenRequested(t *testing.T) { + msg := tea.KeyPressMsg{Code: 'ㅑ', Text: "ㅑ"} + if got := msg.String(); got != "ㅑ" { + t.Fatalf("precondition: raw key string = %q", got) + } + if got := normalizeShortcutKeyMsg(msg).String(); got != "i" { + t.Fatalf("normalized Korean keyboard i = %q, want i", got) + } +} + // --- quit bindings: Q (confirm) / Ctrl+C (confirm); q (close thread, else no-op) --- func TestNormalMode_CapitalQ_OpensConfirmPrompt(t *testing.T) { diff --git a/internal/ui/keys.go b/internal/ui/keys.go index 8b1d3a3b..72aab64e 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -1,7 +1,14 @@ // internal/ui/keys.go package ui -import "charm.land/bubbles/v2/key" +import ( + "strings" + "unicode" + "unicode/utf8" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" +) type KeyMap struct { Up key.Binding @@ -88,3 +95,112 @@ func DefaultKeyMap() KeyMap { Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "show keybindings")), } } + +type shortcutKeyMsg struct { + tea.KeyMsg + keyString string +} + +func (m shortcutKeyMsg) String() string { return m.keyString } + +func normalizeShortcutKeyMsg(msg tea.KeyMsg) tea.KeyMsg { + keyString, ok := koreanShortcutKeyString(msg) + if !ok { + return msg + } + return shortcutKeyMsg{KeyMsg: msg, keyString: keyString} +} + +func koreanShortcutKeyString(msg tea.KeyMsg) (string, bool) { + k := msg.Key() + r, ok := koreanShortcutRune(k.Text) + if !ok { + r, ok = koreanDubeolsikShortcut[k.Code] + if !ok { + return "", false + } + } + return shortcutStringWithModifiers(r, k.Mod), true +} + +func koreanShortcutRune(text string) (rune, bool) { + if text == "" || text == " " { + return 0, false + } + r, size := utf8.DecodeRuneInString(text) + if r == utf8.RuneError || size != len(text) { + return 0, false + } + mapped, ok := koreanDubeolsikShortcut[r] + return mapped, ok +} + +func shortcutStringWithModifiers(r rune, mod tea.KeyMod) string { + withChordModifier := mod.Contains(tea.ModCtrl) || + mod.Contains(tea.ModAlt) || + mod.Contains(tea.ModMeta) || + mod.Contains(tea.ModHyper) || + mod.Contains(tea.ModSuper) + + if !withChordModifier { + if mod.Contains(tea.ModShift) && isASCIIAlpha(r) { + r = unicode.ToUpper(r) + } + return string(r) + } + + var b strings.Builder + if mod.Contains(tea.ModCtrl) { + b.WriteString("ctrl+") + } + if mod.Contains(tea.ModAlt) { + b.WriteString("alt+") + } + if mod.Contains(tea.ModShift) { + b.WriteString("shift+") + } + if mod.Contains(tea.ModMeta) { + b.WriteString("meta+") + } + if mod.Contains(tea.ModHyper) { + b.WriteString("hyper+") + } + if mod.Contains(tea.ModSuper) { + b.WriteString("super+") + } + b.WriteRune(unicode.ToLower(r)) + return b.String() +} + +func isASCIIAlpha(r rune) bool { + return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') +} + +var koreanDubeolsikShortcut = map[rune]rune{ + 'ㅂ': 'q', 'ㅃ': 'Q', + 'ㅈ': 'w', 'ㅉ': 'W', + 'ㄷ': 'e', 'ㄸ': 'E', + 'ㄱ': 'r', 'ㄲ': 'R', + 'ㅅ': 't', 'ㅆ': 'T', + 'ㅛ': 'y', + 'ㅕ': 'u', + 'ㅑ': 'i', + 'ㅐ': 'o', 'ㅒ': 'O', + 'ㅔ': 'p', 'ㅖ': 'P', + 'ㅁ': 'a', + 'ㄴ': 's', + 'ㅇ': 'd', + 'ㄹ': 'f', + 'ㅎ': 'g', + 'ㅗ': 'h', + 'ㅓ': 'j', + 'ㅏ': 'k', + 'ㅣ': 'l', + 'ㅋ': 'z', + 'ㅌ': 'x', + 'ㅊ': 'c', + 'ㅍ': 'v', + 'ㅠ': 'b', + 'ㅜ': 'n', + 'ㅡ': 'm', +} From 4ec5576fc60c41f11ad15c64b1ae17426afb2364 Mon Sep 17 00:00:00 2001 From: Gavin Jeong Date: Sat, 23 May 2026 00:34:12 +0900 Subject: [PATCH 3/3] fix: use real cell metrics for image previews Use the terminal's detected cell pixel size for preview, kitty, and sixel image sizing so fullscreen previews no longer render unexpectedly tiny. --- cmd/slk/main.go | 1 + internal/image/cellmetrics.go | 18 ++++++++++++++++++ internal/image/kitty.go | 22 +++------------------- internal/image/preview.go | 34 ++++++++++++++++++---------------- internal/image/preview_test.go | 7 ++++--- internal/image/sixel.go | 5 +++-- internal/ui/app.go | 8 +++++++- 7 files changed, 54 insertions(+), 41 deletions(-) diff --git a/cmd/slk/main.go b/cmd/slk/main.go index 056192c8..7f1c36dc 100644 --- a/cmd/slk/main.go +++ b/cmd/slk/main.go @@ -632,6 +632,7 @@ func run() error { // Cell pixel metrics for sizing decisions. pxW, pxH := imgpkg.CellPixels(int(os.Stdout.Fd())) + imgpkg.SetRenderCellPixels(image.Pt(pxW, pxH)) debuglog.ImgRender("cell pixels: %dx%d", pxW, pxH) // Wire the inline-image pipeline into the messages pane. SendMsg diff --git a/internal/image/cellmetrics.go b/internal/image/cellmetrics.go index 840eebdc..7b199c5e 100644 --- a/internal/image/cellmetrics.go +++ b/internal/image/cellmetrics.go @@ -1,11 +1,29 @@ package image import ( + "image" "strconv" "github.com/gammons/slk/internal/debuglog" ) +var renderCellPixels = image.Pt(8, 16) + +// SetRenderCellPixels records the actual terminal cell size used by renderers +// that emit pixel-addressed protocols like kitty and sixel. +func SetRenderCellPixels(px image.Point) { + if px.X > 0 { + renderCellPixels.X = px.X + } + if px.Y > 0 { + renderCellPixels.Y = px.Y + } +} + +func currentRenderCellPixels() image.Point { + return renderCellPixels +} + // CellPixels returns the (width, height) of a terminal cell in pixels. // It honors $COLORTERM_CELL_WIDTH/$COLORTERM_CELL_HEIGHT, then attempts // TIOCGWINSZ on the given fd (unix only), then falls back to (8, 16). diff --git a/internal/image/kitty.go b/internal/image/kitty.go index 0befff77..49f484a3 100644 --- a/internal/image/kitty.go +++ b/internal/image/kitty.go @@ -144,8 +144,9 @@ func (k *KittyRenderer) RenderKey(key string, target image.Point) Render { // On repeat calls (fresh=false) the registered ID has already // been confirmed delivered via MarkUploaded; no need to re-do // the bilinear downscale or PNG encode. - pxW := target.X * 8 - pxH := target.Y * 16 + px := currentRenderCellPixels() + pxW := target.X * px.X + pxH := target.Y * px.Y resized := image.NewRGBA(image.Rect(0, 0, pxW, pxH)) draw.BiLinear.Scale(resized, resized.Bounds(), src, src.Bounds(), draw.Over, nil) var pngBuf bytes.Buffer @@ -155,14 +156,6 @@ func (k *KittyRenderer) RenderKey(key string, target image.Point) Render { cellsCols := target.X cellsRows := target.Y reg := k.registry - // fired guards against per-closure double-emission (e.g. the - // same viewEntry being flushed twice in one frame). The - // registry's MarkUploaded guards against double-emission - // across DIFFERENT closures for the same (key, target) — - // without that, a cache rebuild that discards an unfired - // closure (e.g. SetMessages on the messages pane) would - // leave the registry thinking the upload had landed when in - // fact no bytes were ever sent. var fired atomic.Bool r.OnFlush = func(w io.Writer) error { if !fired.CompareAndSwap(false, true) { @@ -218,15 +211,6 @@ func emitKittyUpload(w io.Writer, id uint32, payload string, cols, rows int) err } func buildPlaceholderLines(id uint32, cells image.Point) []string { - // Per kitty spec: image ID is encoded in the foreground color as a - // 24-bit number. In truecolor SGR \e[38;2;R;G;Bm the natural - // interpretation is (R << 16) | (G << 8) | B, so R = byte 2 (high), - // G = byte 1, B = byte 0 (low). Verified against the spec's worked - // example: ID 42 in 256-color mode is \e[38;5;42m, which means the - // truecolor equivalent is \e[38;2;0;0;42m (low byte → B), NOT - // \e[38;2;42;0;0m. The high byte (byte 3) of a >24-bit ID would - // require the optional third diacritic; we don't need that since - // our IDs are well under 2^24. r := byte((id >> 16) & 0xFF) g := byte((id >> 8) & 0xFF) b := byte(id & 0xFF) diff --git a/internal/image/preview.go b/internal/image/preview.go index fce01431..74688e8f 100644 --- a/internal/image/preview.go +++ b/internal/image/preview.go @@ -138,13 +138,14 @@ func (p *Preview) SwapImage(in PreviewInput) { var previewSpinnerFrames = []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'} // View renders the preview into a string of size width × height. proto is -// the active rendering protocol (kitty / sixel / halfblock). Reserves -// 1 row top for the caption, 1 row bottom for the hint, and centers the -// image (aspect-preserved) in the remaining area. +// the active rendering protocol (kitty / sixel / halfblock). cellPixels is +// the terminal cell size in pixels; when unknown, the renderer falls back to +// 8×16. Reserves 1 row top for the caption, 1 row bottom for the hint, and +// centers the image (aspect-preserved) in the remaining area. // // While loading, the image area shows a centered spinner + filename // instead of an image. Caption and hint render the same way. -func (p *Preview) View(width, height int, proto Protocol) string { +func (p *Preview) View(width, height int, proto Protocol, cellPixels image.Point) string { if !p.open || width <= 0 || height <= 0 { return "" } @@ -165,7 +166,7 @@ func (p *Preview) View(width, height int, proto Protocol) string { imgCols := width srcW, srcH := p.img.Bounds().Dx(), p.img.Bounds().Dy() - target := fitInto(srcW, srcH, imgCols, imgRows) + target := fitInto(srcW, srcH, imgCols, imgRows, cellPixels) render := RenderImage(proto, p.img, target) @@ -276,17 +277,18 @@ func (p *Preview) viewLoading(width, height int) string { // fitInto returns the largest (cols, rows) that preserve the source // image's pixel aspect ratio when rendered into terminal cells. // -// Terminal cells are roughly twice as tall as wide (typical font metric: -// 8×16 px). A square pixel image therefore covers twice as many columns -// as rows: e.g. a 100×100 image in 8×16 cells fills 12.5 cols × 6.25 rows. -// The cell aspect ratio in cell units is thus: -// -// cols/rows = (srcW/srcH) × (cellH/cellW) = (srcW/srcH) × cellAspect -// -// Given maxCols and maxRows we pick the larger axis-fit that respects -// this ratio. -func fitInto(srcW, srcH, maxCols, maxRows int) image.Point { - const cellAspect = 2.0 // cellH / cellW +// cellPixels describes the terminal cell size in pixels. When unknown, +// callers can pass image.Point{} and the function falls back to 8×16. +func fitInto(srcW, srcH, maxCols, maxRows int, cellPixels image.Point) image.Point { + cellW := cellPixels.X + cellH := cellPixels.Y + if cellW <= 0 { + cellW = 8 + } + if cellH <= 0 { + cellH = 16 + } + cellAspect := float64(cellH) / float64(cellW) cellRatio := float64(srcW) / float64(srcH) * cellAspect // Try filling width; compute the height that preserves ratio. diff --git a/internal/image/preview_test.go b/internal/image/preview_test.go index 52f00eb8..65678f42 100644 --- a/internal/image/preview_test.go +++ b/internal/image/preview_test.go @@ -1,6 +1,7 @@ package image import ( + "image" "image/color" "strings" "testing" @@ -12,7 +13,7 @@ func TestPreview_RenderShape(t *testing.T) { FileID: "F1", Img: makeSolid(800, 600, color.RGBA{1, 2, 3, 255}), }) - out := p.View(60, 30, ProtoHalfBlock) + out := p.View(60, 30, ProtoHalfBlock, image.Point{}) if out == "" { t.Fatal("empty view") } @@ -39,7 +40,7 @@ func TestPreview_SiblingsShownInCaptionAndHint(t *testing.T) { FileID: "F1", Img: makeSolid(50, 50, color.RGBA{0, 0, 0, 255}), }) - out := solo.View(80, 30, ProtoHalfBlock) + out := solo.View(80, 30, ProtoHalfBlock, image.Point{}) if strings.Contains(out, "(1/1)") { t.Error("solo preview should not show sibling counter") } @@ -55,7 +56,7 @@ func TestPreview_SiblingsShownInCaptionAndHint(t *testing.T) { SiblingCount: 4, SiblingIndex: 2, }) - out = multi.View(80, 30, ProtoHalfBlock) + out = multi.View(80, 30, ProtoHalfBlock, image.Point{}) if !strings.Contains(out, "(3/4)") { t.Errorf("expected '(3/4)' in caption, got: %s", out) } diff --git a/internal/image/sixel.go b/internal/image/sixel.go index 6c1ae15b..87ff5bbc 100644 --- a/internal/image/sixel.go +++ b/internal/image/sixel.go @@ -29,8 +29,9 @@ func (s *SixelRenderer) Render(img image.Image, target image.Point) Render { return Render{Cells: target} } - pxW := target.X * 8 - pxH := target.Y * 16 + px := currentRenderCellPixels() + pxW := target.X * px.X + pxH := target.Y * px.Y resized := image.NewRGBA(image.Rect(0, 0, pxW, pxH)) draw.BiLinear.Scale(resized, resized.Bounds(), img, img.Bounds(), draw.Over, nil) diff --git a/internal/ui/app.go b/internal/ui/app.go index 4722e9a4..b708da9b 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -964,6 +964,11 @@ type App struct { // startup. Used to render the full-screen preview overlay. imgProtocol imgpkg.Protocol + // imgCellPixels is the actual terminal cell size used by the active image + // protocol renderers. The preview overlay uses the same metric so fullscreen + // sizing matches inline image sizing on terminals whose cells aren't 8×16. + imgCellPixels image.Point + // previewOverlay holds the full-screen image preview state. nil when // no preview is open. View() composes its output over the // messages+thread region; key handling routes through it while @@ -4746,6 +4751,7 @@ func (a *App) SetAvatarFunc(fn messages.AvatarFunc) { func (a *App) SetImageContext(ctx imgrender.ImageContext) { a.messagepane.SetImageContext(ctx) a.threadPanel.SetImageContext(ctx) + a.imgCellPixels = ctx.CellPixels } // SetImageFetcher records the image fetcher so the preview overlay can @@ -5745,7 +5751,7 @@ func (a *App) View() tea.View { if a.threadVisible && threadWidth > 0 { overlayW += threadWidth + threadBorder } - overlayContent := a.previewOverlay.View(overlayW, contentHeight, a.imgProtocol) + overlayContent := a.previewOverlay.View(overlayW, contentHeight, a.imgProtocol, a.imgCellPixels) overlayPanel := exactSize(overlayContent, overlayW, contentHeight) panels = append(panels, overlayPanel) }