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 69525d57..84903c35 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -1002,6 +1002,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 @@ -2931,7 +2936,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() } @@ -3172,6 +3177,7 @@ var koreanIMEQWERTY = map[rune]string{ } 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 @@ -3817,6 +3823,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: @@ -3994,6 +4003,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: @@ -5148,6 +5158,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 @@ -6266,7 +6277,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) } diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index dc66a5db..81295ad9 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -294,7 +294,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") @@ -313,45 +312,25 @@ func TestHandleInsertMode_ShiftEnterInsertsNewline(t *testing.T) { } } -// 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 -// 4-newline ceiling once the box was full. -func TestHandleInsertMode_ShiftEnterPastVisibleHeight(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("a\nb\nc\nd\ne\nf") - app.compose.MoveCursorToEnd() - - app.handleInsertMode(tea.KeyPressMsg{Code: tea.KeyEnter, Mod: tea.ModShift}) - - val := app.compose.Value() - if got, want := strings.Count(val, "\n"), 6; got != want { - t.Fatalf("expected %d newlines after shift+enter on a 6-line draft, got %d (value=%q)", want, got, val) - } -} - -func TestHandleInsertMode_PlainEnterSends(t *testing.T) { - app := NewApp() - app.activeChannelID = "C1" - app.focusedPanel = PanelMessages - app.SetMode(ModeInsert) - app.compose.SetValue("hello") + app.compose.SetValue("hello\\") cmd := app.handleInsertMode(tea.KeyPressMsg{Code: tea.KeyEnter}) - if cmd == nil { - t.Fatalf("plain Enter with text should return a send cmd") - } - msg := cmd() - if _, ok := msg.(SendMessageMsg); !ok { - t.Fatalf("expected SendMessageMsg, got %T", msg) + if cmd != nil { + if msg := cmd(); msg != nil { + if _, ok := msg.(SendMessageMsg); ok { + t.Fatalf("backslash+Enter should not send the message") + } + } } - if app.compose.Value() != "" { - t.Fatalf("expected compose to be reset after send, got %q", app.compose.Value()) + val := app.compose.Value() + if !strings.Contains(val, "\n") { + t.Fatalf("expected newline in compose value, got %q", val) } } @@ -561,24 +540,45 @@ func TestHandleInsertMode_AltEnterInsertsNewline(t *testing.T) { } } -func TestHandleInsertMode_BackslashEnterInsertsNewline(t *testing.T) { +// 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 +// 4-newline ceiling once the box was full. +func TestHandleInsertMode_ShiftEnterPastVisibleHeight(t *testing.T) { app := NewApp() app.activeChannelID = "C1" app.focusedPanel = PanelMessages app.SetMode(ModeInsert) app.compose.Focus() - app.compose.SetValue("hello\\") + + app.compose.SetValue("a\nb\nc\nd\ne\nf") + app.compose.MoveCursorToEnd() + + app.handleInsertMode(tea.KeyPressMsg{Code: tea.KeyEnter, Mod: tea.ModShift}) + + val := app.compose.Value() + if got, want := strings.Count(val, "\n"), 6; got != want { + t.Fatalf("expected %d newlines after shift+enter on a 6-line draft, got %d (value=%q)", want, got, val) + } +} + +func TestHandleInsertMode_PlainEnterSends(t *testing.T) { + app := NewApp() + app.activeChannelID = "C1" + app.focusedPanel = PanelMessages + app.SetMode(ModeInsert) + 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 cmd == nil { + t.Fatalf("plain Enter with text should return a send cmd") } - if !strings.Contains(app.compose.Value(), "\n") { - t.Fatalf("expected newline in compose value, got %q", app.compose.Value()) + msg := cmd() + if _, ok := msg.(SendMessageMsg); !ok { + t.Fatalf("expected SendMessageMsg, got %T", msg) + } + if app.compose.Value() != "" { + t.Fatalf("expected compose to be reset after send, got %q", app.compose.Value()) } } @@ -2968,6 +2968,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/compose/model.go b/internal/ui/compose/model.go index c73ad52a..bb9017a2 100644 --- a/internal/ui/compose/model.go +++ b/internal/ui/compose/model.go @@ -360,11 +360,11 @@ func (m *Model) SetUploading(on bool) { if m.uploading == on { return } - m.uploading = on - if on { - m.selectedAttachment = -1 - } - m.dirty() + m.uploading = on + if on { + m.selectedAttachment = -1 + } + m.dirty() } // Uploading reports whether an upload is currently in flight. diff --git a/internal/ui/keys.go b/internal/ui/keys.go index ccc9b1a9..750e5245 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 @@ -90,3 +97,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', +}