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
18 changes: 18 additions & 0 deletions internal/image/cellmetrics.go
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
22 changes: 3 additions & 19 deletions internal/image/kitty.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down
34 changes: 18 additions & 16 deletions internal/image/preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
}
Expand All @@ -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)

Expand Down Expand Up @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions internal/image/preview_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package image

import (
"image"
"image/color"
"strings"
"testing"
Expand All @@ -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")
}
Expand All @@ -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")
}
Expand All @@ -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)
}
Expand Down
5 changes: 3 additions & 2 deletions internal/image/sixel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
15 changes: 13 additions & 2 deletions internal/ui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
Loading