Skip to content

Commit f26c71b

Browse files
authored
feat: major improvements (#7)
## What? Improves rendering for [matcha](https://github.com/floatpane/matcha) ## Why? Some bugs still persist --------- Signed-off-by: drew <me@andrinoff.com>
1 parent 4633ddd commit f26c71b

6 files changed

Lines changed: 301 additions & 44 deletions

File tree

docs/content/api.mdx

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,38 @@ Returns the first error encountered (fetch, decode, or render failure).
2727
func DisplayContext(ctx context.Context, w io.Writer, src string, opts Options) error
2828
```
2929

30-
Same as `Display` but takes a `context.Context` for cancellation of HTTP
31-
fetches and sandboxed decoding.
30+
Same as `Display` with a `context.Context` for cancellation of HTTP fetches and sandboxed decoding.
31+
32+
## `termimage.DisplayWithSize`
33+
34+
```go
35+
func DisplayWithSize(w io.Writer, src string, opts Options) (cols, rows int, err error)
36+
func DisplayContextWithSize(ctx context.Context, w io.Writer, src string, opts Options) (cols, rows int, err error)
37+
```
38+
39+
Same as `Display` but also returns the terminal character-cell dimensions the rendered image occupies. Use this instead of `Display` + a separate size calculation to avoid decoding the image twice.
40+
41+
| Protocol | `cols` | `rows` |
42+
|----------|--------|--------|
43+
| HalfBlock | image pixel width | `ceil(image pixel height / 2)` |
44+
| Kitty / Sixel | `ceil(pixel width / cell width)` | `ceil(pixel height / cell height)` |
45+
46+
Cell pixel dimensions are read from the terminal via `TIOCGWINSZ`; fallback is 8×16 px/cell.
47+
48+
## `termimage.Clear`
49+
50+
```go
51+
func Clear(w io.Writer, proto Protocol, rows int) error
52+
```
53+
54+
Erases a previously rendered image.
55+
56+
| Protocol | Behaviour |
57+
|----------|-----------|
58+
| `Kitty` | Sends `\x1b_Ga=d,d=A\x1b\\` (delete all visible placements). `rows` is ignored. |
59+
| `Sixel`, `HalfBlock` | Moves cursor up `rows` lines then erases to end of screen (`\x1b[{rows}A\x1b[J`). Cursor must be on the last row of the image before calling. |
60+
61+
Pass the `rows` value returned by `DisplayWithSize`.
3262
3363
## `termimage.Options`
3464
@@ -42,11 +72,15 @@ type Options struct {
4272

4373
| Field | Default | Description |
4474
|-------|---------|-------------|
45-
| `MaxWidth` | terminal pixel width | Max output width in pixels |
46-
| `MaxHeight` | terminal pixel height × 2 | Max output height in pixels |
75+
| `MaxWidth` | terminal cols (character cells) | Max pixel width. For HalfBlock: 1 px = 1 character column. |
76+
| `MaxHeight` | `(terminal rows − 2) × 2` for HalfBlock; `(terminal rows − 2) × cell height px` for Kitty/Sixel | Max pixel height. Minus 2 rows leaves headroom for the shell prompt. |
4777
| `Protocol` | `Auto` | `Auto`, `Kitty`, `Sixel`, `HalfBlock` |
4878
| `Sandboxed` | `false` | Run decode in isolated subprocess |
4979

80+
**Aspect ratio:** `MaxWidth` and `MaxHeight` are independent upper bounds. `resize.Fit` computes `scale = min(MaxWidth/srcW, MaxHeight/srcH)` and applies it uniformly — the image fills whichever dimension is the tighter constraint. When only one dimension is set explicitly, the other comes from terminal detection.
81+
82+
**HalfBlock and text layout:** HalfBlock renders using real terminal character cells (`` U+2580). Unlike Kitty or Sixel, the output occupies rows × cols cells in the scroll buffer. Cursor save/restore (`\x1b[s` / `\x1b[u`) does **not** undo cell content. TUIs that need pixel-layer rendering should use Kitty or Sixel: call `detect.Best()` first and handle the case where it returns `HalfBlock`.
83+
5084
## `termimage.MaybeRunWorker`
5185

5286
```go
@@ -66,4 +100,4 @@ image, writes raw RGBA to stdout, and calls `os.Exit(0)`.
66100
| `render` | `render.Kitty`, `render.Sixel`, `render.HalfBlock` |
67101
| `sandbox` | Subprocess worker. `sandbox.Decode(path)`, `sandbox.DecodeBytes(data)` |
68102
| `internal/source` | Source resolver. Branches file path / `data:` URI / `http(s)://` URL |
69-
| `internal/resize` | `resize.Fit(img, maxW, maxH)` — BiLinear scale |
103+
| `internal/resize` | `resize.Fit(img, maxW, maxH)` — aspect-preserving BiLinear scale |

internal/resize/resize.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import (
88
)
99

1010
// Fit scales src to fit within maxW×maxH while preserving aspect ratio.
11-
// Returns src unchanged if it already fits.
11+
// The scale factor is min(maxW/srcW, maxH/srcH), so both dimensions are
12+
// satisfied simultaneously. Returns src unchanged if it already fits within
13+
// the bounds.
1214
func Fit(src *image.NRGBA, maxW, maxH int) *image.NRGBA {
1315
b := src.Bounds()
1416
sw, sh := b.Dx(), b.Dy()

termimage.go

Lines changed: 133 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,20 @@
99
// termimage.MaybeRunWorker()
1010
// // ... rest of your app
1111
// }
12+
//
13+
// # HalfBlock and text layout
14+
//
15+
// HalfBlock renders using real terminal character cells (▀ U+2580). Unlike
16+
// Kitty or Sixel, the output occupies rows × cols cells in the terminal scroll
17+
// buffer. Cursor save/restore (\x1b[s / \x1b[u) does not undo cell content.
18+
// TUIs that need pixel-layer rendering should use Kitty or Sixel and set
19+
// AllowHalfBlock: false on Options to prevent fallback.
1220
package termimage
1321

1422
import (
23+
"bufio"
1524
"context"
25+
"fmt"
1626
"image"
1727
"io"
1828
"os"
@@ -42,10 +52,23 @@ const (
4252

4353
// Options configures image display.
4454
type Options struct {
45-
// MaxWidth / MaxHeight in pixels. 0 = detect from terminal.
55+
// MaxWidth / MaxHeight are the pixel bounds for image scaling.
56+
//
57+
// For HalfBlock, 1 pixel = 1 character column (width) or half a character
58+
// row (height), so MaxHeight=100 produces at most 50 character rows.
59+
// For Kitty and Sixel, pixels map to physical screen pixels.
60+
//
61+
// 0 = detect from terminal. Default caps at (terminal_cols, terminal_rows-2)
62+
// in character-cell units, leaving two rows of headroom for the shell prompt.
63+
// Aspect ratio is always preserved: Fit scales uniformly so neither dimension
64+
// exceeds the limit. When only one dimension is set, the other is detected.
4665
MaxWidth, MaxHeight int
4766

4867
// Protocol selects the rendering protocol. Auto detects from $TERM etc.
68+
// See detect.Best for detection rules.
69+
// TUIs that must avoid HalfBlock (which occupies real text cells — see
70+
// package doc) should call detect.Best() first; if it returns HalfBlock,
71+
// set Protocol explicitly or return an error before calling Display.
4972
Protocol Protocol
5073

5174
// Sandboxed runs the decoder in a subprocess with Landlock + seccomp.
@@ -54,24 +77,90 @@ type Options struct {
5477
}
5578

5679
// Display decodes the image at src and writes terminal graphics to w.
57-
// src may be a local file path, a data: URI, or an http(s):// URL.
80+
// src may be a local file path, a data: URI (base64), or an http(s):// URL.
5881
func Display(w io.Writer, src string, opts Options) error {
59-
return DisplayContext(context.Background(), w, src, opts)
82+
_, _, err := display(context.Background(), w, src, opts)
83+
return err
6084
}
6185

6286
// DisplayContext is Display with caller-supplied context for cancellation of
6387
// remote fetches and sandboxed decoding.
6488
func DisplayContext(ctx context.Context, w io.Writer, src string, opts Options) error {
65-
proto := opts.Protocol
66-
if proto == Auto {
67-
proto = detect.Best()
89+
_, _, err := display(ctx, w, src, opts)
90+
return err
91+
}
92+
93+
// DisplayWithSize renders the image and returns the terminal character-cell
94+
// dimensions (cols, rows) it occupies. Use this instead of Display + Dims to
95+
// avoid decoding the image twice.
96+
//
97+
// For HalfBlock, cols = image pixel width, rows = ceil(image pixel height / 2).
98+
// For Kitty and Sixel, cols and rows are derived from the cell pixel size
99+
// reported by the terminal (TIOCGWINSZ), falling back to 8×16 px per cell.
100+
func DisplayWithSize(w io.Writer, src string, opts Options) (cols, rows int, err error) {
101+
return display(context.Background(), w, src, opts)
102+
}
103+
104+
// DisplayContextWithSize is DisplayWithSize with caller-supplied context.
105+
func DisplayContextWithSize(ctx context.Context, w io.Writer, src string, opts Options) (cols, rows int, err error) {
106+
return display(ctx, w, src, opts)
107+
}
108+
109+
// Clear erases a previously rendered image.
110+
//
111+
// For Kitty, rows is ignored — all visible image placements are deleted via the
112+
// Kitty graphics protocol delete command. Call immediately after the image is
113+
// no longer needed; no cursor positioning is required.
114+
//
115+
// For Sixel and HalfBlock, the caller must position the cursor on the last row
116+
// of the image before calling Clear. rows should be the value returned by
117+
// DisplayWithSize. Clear moves the cursor up by rows lines then erases to end
118+
// of screen (\x1b[{rows}A\x1b[J).
119+
func Clear(w io.Writer, proto Protocol, rows int) error {
120+
bw := bufio.NewWriterSize(w, 32)
121+
switch proto {
122+
case Kitty:
123+
if _, err := fmt.Fprint(bw, "\x1b_Ga=d,d=A\x1b\\"); err != nil {
124+
return err
125+
}
126+
default:
127+
if rows <= 0 {
128+
return nil
129+
}
130+
if _, err := fmt.Fprintf(bw, "\x1b[%dA\x1b[J", rows); err != nil {
131+
return err
132+
}
133+
}
134+
return bw.Flush()
135+
}
136+
137+
// display is the shared implementation for all Display* variants.
138+
func display(ctx context.Context, w io.Writer, src string, opts Options) (cols, rows int, err error) {
139+
proto := resolveProto(opts)
140+
141+
img, err := loadScaled(ctx, src, opts, proto)
142+
if err != nil {
143+
return 0, 0, err
144+
}
145+
146+
cols, rows = pixelsToCells(img.Bounds().Dx(), img.Bounds().Dy(), proto)
147+
return cols, rows, renderWith(w, img, proto)
148+
}
149+
150+
func resolveProto(opts Options) Protocol {
151+
if opts.Protocol != Auto {
152+
return opts.Protocol
68153
}
154+
return detect.Best()
155+
}
69156

157+
// loadScaled resolves src, decodes, and scales to effectiveDimensions.
158+
func loadScaled(ctx context.Context, src string, opts Options, proto Protocol) (*image.NRGBA, error) {
70159
maxW, maxH := effectiveDimensions(opts, proto)
71160

72161
resolved, err := source.Resolve(ctx, src)
73162
if err != nil {
74-
return err
163+
return nil, err
75164
}
76165

77166
var img *image.NRGBA
@@ -90,11 +179,10 @@ func DisplayContext(ctx context.Context, w io.Writer, src string, opts Options)
90179
}
91180
}
92181
if err != nil {
93-
return err
182+
return nil, err
94183
}
95184

96-
scaled := resize.Fit(img, maxW, maxH)
97-
return renderWith(w, scaled, proto)
185+
return resize.Fit(img, maxW, maxH), nil
98186
}
99187

100188
func renderWith(w io.Writer, img *image.NRGBA, proto Protocol) error {
@@ -108,22 +196,34 @@ func renderWith(w io.Writer, img *image.NRGBA, proto Protocol) error {
108196
}
109197
}
110198

111-
// effectiveDimensions returns the pixel bounds for image scaling.
112-
// For HalfBlock, terminal character dimensions drive the limit (1 char = 1px
113-
// wide, 2px tall) because the renderer emits one character per pixel column.
114-
// For Kitty/Sixel, the terminal's actual pixel viewport is used.
199+
// effectiveDimensions returns pixel bounds for image scaling.
200+
//
201+
// Both protocols share the same calculation: character grid dimensions drive
202+
// the limit, converted to pixels via cell size. Two rows are reserved as
203+
// headroom for the shell prompt / surrounding content.
204+
//
205+
// For HalfBlock: 1 col = 1 px wide, 1 row = 2 px tall.
206+
// For Kitty/Sixel: multiply by cell pixel dimensions from TIOCGWINSZ.
115207
func effectiveDimensions(opts Options, proto Protocol) (int, int) {
116208
w, h := opts.MaxWidth, opts.MaxHeight
117209
if w > 0 && h > 0 {
118210
return w, h
119211
}
120212

213+
cols, rows := detectTermChars()
214+
cw, ch := detectCellPixels()
215+
216+
const headroom = 2
217+
effectiveRows := rows - headroom
218+
if effectiveRows < 1 {
219+
effectiveRows = 1
220+
}
221+
121222
var tw, th int
122223
if proto == HalfBlock {
123-
cols, rows := detectTermChars()
124-
tw, th = cols, rows*2
224+
tw, th = cols, effectiveRows*2
125225
} else {
126-
tw, th = detectTermPixels()
226+
tw, th = cols*cw, effectiveRows*ch
127227
}
128228

129229
if w <= 0 {
@@ -135,13 +235,14 @@ func effectiveDimensions(opts Options, proto Protocol) (int, int) {
135235
return w, h
136236
}
137237

138-
func detectTermPixels() (int, int) {
139-
f, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
140-
if err != nil {
141-
return 1920, 1080
238+
// pixelsToCells converts scaled image pixel dimensions to terminal character
239+
// cell dimensions (cols, rows) for the given protocol.
240+
func pixelsToCells(pw, ph int, proto Protocol) (cols, rows int) {
241+
if proto == HalfBlock {
242+
return pw, (ph + 1) / 2
142243
}
143-
defer func() { _ = f.Close() }()
144-
return termPixels(f)
244+
cw, ch := detectCellPixels()
245+
return (pw + cw - 1) / cw, (ph + ch - 1) / ch
145246
}
146247

147248
func detectTermChars() (int, int) {
@@ -152,3 +253,12 @@ func detectTermChars() (int, int) {
152253
defer func() { _ = f.Close() }()
153254
return termChars(f)
154255
}
256+
257+
func detectCellPixels() (int, int) {
258+
f, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
259+
if err != nil {
260+
return 8, 16
261+
}
262+
defer func() { _ = f.Close() }()
263+
return cellPixels(f)
264+
}

0 commit comments

Comments
 (0)