From d105b9a1db6f14c4f0ec4f695c33a0a76078f6a3 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Thu, 9 Jul 2026 20:21:59 -0400 Subject: [PATCH 1/7] feat(gui): GIMP-style resize handles on the preview Drag the rendered art directly instead of typing numbers: right edge = width, bottom edge = height stretch/squish, corner = free resize with Shift for aspect lock; live cols x rows readout; double-click resets height to auto. The sandboxed preview reports its pixel box via an injected postMessage snippet (handles the video being CSS-scaled through its natural size), and the server response now carries the post-scaling art grid so drags convert exactly to cells. Server enablers: exact/max sizing is honored on the colorize-off path (previously silently ignored), and video gains exact rows via cell-grid frame pre-resize (braille 2x4 / glyph 8x16 px cells make the rows formula land exactly), wired as --rows on the CLI and video_rows in the API/GUI. Height handles hide when a caption is stitched into the block (the pixel math would conflate caption rows with art rows). Co-Authored-By: Claude Fable 5 --- README.MD | 2 + src/asciimagic/static/app.js | 157 ++++++++++++++++++++++++++++++- src/asciimagic/static/index.html | 11 ++- src/asciimagic/static/style.css | 36 +++++++ src/asciimagic/video.py | 19 +++- src/asciimagic/webapp.py | 26 +++++ tests/test_video.py | 19 ++++ tests/test_webapp.py | 33 +++++++ uv.lock | 2 +- 9 files changed, 299 insertions(+), 6 deletions(-) diff --git a/README.MD b/README.MD index 7051eac..328db62 100644 --- a/README.MD +++ b/README.MD @@ -344,6 +344,8 @@ pip install -e ".[web]" ascii-magic-web # http://127.0.0.1:8000 ``` +**Resize like GIMP**: the rendered art gets drag handles — pull the right edge for width, the bottom edge to stretch/squish the height, or the corner for a free resize (hold **Shift** to keep aspect). A live `cols × rows` readout follows the drag; double-click the ring to reset the height to auto. Works on images, text, and video (video applies on the next Render). + The **Profile** switch in the header applies sensible defaults per target: *Terminal* (SSH greetings, `.ans` output) or *Web page* (larger canvas, filled spaces, `.html` output). ## Docker diff --git a/src/asciimagic/static/app.js b/src/asciimagic/static/app.js index 8efe751..c8d356e 100644 --- a/src/asciimagic/static/app.js +++ b/src/asciimagic/static/app.js @@ -35,6 +35,7 @@ function collectOptions() { // video source video_fps: num("video_fps"), video_max_frames: num("video_max_frames"), + video_rows: num("video_rows"), video_mode: $("video_mode").value, // text source text: $("text").value, @@ -139,7 +140,9 @@ async function render() { if (!res.ok) throw new Error(body.detail || `HTTP ${res.status}`); state.result = body; - $("preview").srcdoc = body.html; + state.art = body.art || null; + $("ring").hidden = true; // re-shown when the new preview reports its box + $("preview").srcdoc = injectMeasure(body.html); if (body.seed !== null && body.seed !== undefined) { $("matrix_seed").value = body.seed; } @@ -286,6 +289,7 @@ const TABS = ["image", "text", "video"]; function setTab(name) { state.tab = name; + $("ring").hidden = true; // stale box from another source for (const t of TABS) { $(`panel-${t}`).hidden = t !== name; $(`tab-${t}`).classList.toggle("active", t === name); @@ -370,6 +374,157 @@ for (const id of ["threshold", "gamma", "matrix_gamma", "caption_scale"]) { $(id).addEventListener("input", () => { $(`${id}-out`).value = $(id).value; }); } +// ---------- GIMP-style resize handles ---------- + +// The preview iframe is an opaque origin (sandbox without allow-same-origin), +// so the art reports its own pixel box via postMessage from this snippet. +const MEASURE_SNIPPET = + '(function(){function r(){var e=document.querySelector("#m")||document.querySelector("pre,img");' + + 'if(!e)return;var b=e.getBoundingClientRect();var m={am:"artbox",x:b.left,y:b.top,w:b.width,h:b.height};' + + 'if(e.tagName==="IMG"){m.nw=e.naturalWidth;m.nh=e.naturalHeight;}parent.postMessage(m,"*");}' + + 'window.addEventListener("load",r);window.addEventListener("resize",r);window.addEventListener("scroll",r,true);' + + 'setTimeout(r,60);setTimeout(r,300);})();'; + +function injectMeasure(html) { + return html.includes("") + ? html.replace("", MEASURE_SNIPPET + "") + : html + MEASURE_SNIPPET; +} + +const ring = $("ring"); +let ringBox = null; +let dragging = null; + +window.addEventListener("message", (ev) => { + const d = ev.data; + if (!d || d.am !== "artbox" || dragging) return; + state.measure = d; + showRing(d); +}); + +function showRing(d) { + if (!state.result || !state.art || !state.art.cols || d.w < 4) { + ring.hidden = true; + return; + } + ringBox = { x: d.x, y: d.y, w: d.w, h: d.h }; + ring.hidden = false; + applyRing(); + updateLabel(state.art.cols, state.art.rows); + // Height math gets ambiguous with a caption stitched into the same block — + // width dragging stays available, height reverts to the number knobs. + const capOn = !!$("caption_text").value.trim(); + $("handle-s").style.display = capOn ? "none" : ""; + $("handle-se").style.display = capOn ? "none" : ""; +} + +function applyRing() { + ring.style.left = ringBox.x + "px"; + ring.style.top = ringBox.y + "px"; + ring.style.width = ringBox.w + "px"; + ring.style.height = ringBox.h + "px"; +} + +function cellSize() { + const m = state.measure; + const art = state.art; + if (m.nw) { + // Video preview is an that may be CSS-downscaled; convert through + // its natural size so a dragged pixel means a consistent fraction of a cell. + const scale = m.w / m.nw; + return { w: (m.nw / art.cols) * scale, h: (m.nh / art.rows) * scale }; + } + return { w: m.w / art.cols, h: num("html_font_size") || 12 }; +} + +function dragDims(w, h) { + const c = cellSize(); + return { + cols: Math.min(500, Math.max(4, Math.round(w / c.w))), + rows: Math.min(500, Math.max(2, Math.round(h / c.h))), + }; +} + +function updateLabel(c, r) { + $("ring-label").textContent = `${c} × ${r}`; +} + +function startDrag(axis) { + return (e) => { + e.preventDefault(); + dragging = { + axis, x: e.clientX, y: e.clientY, + w: ringBox.w, h: ringBox.h, + aspect: ringBox.w / Math.max(1, ringBox.h), + }; + ring.classList.add("dragging"); + const move = (ev) => { + let w = dragging.w; + let h = dragging.h; + if (axis !== "s") w = Math.max(16, dragging.w + (ev.clientX - dragging.x)); + if (axis !== "e") h = Math.max(8, dragging.h + (ev.clientY - dragging.y)); + if (axis === "se" && ev.shiftKey) h = w / dragging.aspect; // aspect lock + ringBox.w = w; + ringBox.h = h; + applyRing(); + const d = dragDims(w, h); + updateLabel(d.cols, d.rows); + }; + const up = (ev) => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + ring.classList.remove("dragging"); + dragging = null; + commitResize(axis, dragDims(ringBox.w, ringBox.h), ev.shiftKey); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); + }; +} +$("handle-e").addEventListener("pointerdown", startDrag("e")); +$("handle-s").addEventListener("pointerdown", startDrag("s")); +$("handle-se").addEventListener("pointerdown", startDrag("se")); + +function commitResize(axis, d, shift) { + if (state.tab === "video") { + if (axis !== "s") $("video_cols").value = d.cols; + if (axis === "s" || (axis === "se" && !shift)) $("video_rows").value = d.rows; + if (axis === "se" && shift) $("video_rows").value = ""; + setStatus(`Size set to ${d.cols} × ${d.rows} — press Render`, "busy"); + return; + } + const widthInput = state.tab === "text" ? "text_width" : "cols"; + if (axis === "e") { + $(widthInput).value = d.cols; + if ($("out_rows").value !== "") $("out_cols").value = d.cols; // keep the squish + } else if (axis === "s") { + $("out_rows").value = d.rows; + $("out_cols").value = d.cols; // pin width so height alone squishes + } else if (shift) { + // aspect-locked corner: width drives, height back to auto + $(widthInput).value = d.cols; + $("out_rows").value = ""; + $("out_cols").value = ""; + } else { + // free stretch: exact box, like GIMP's Scale with the chain broken + $(widthInput).value = d.cols; + $("out_cols").value = d.cols; + $("out_rows").value = d.rows; + } + render(); +} + +ring.addEventListener("dblclick", () => { + $("out_rows").value = ""; + $("out_cols").value = ""; + $("video_rows").value = ""; + if (state.tab === "video") { + setStatus("Height reset to auto — press Render", "busy"); + } else { + render(); + } +}); + document.querySelectorAll("#controls input, #controls select, #controls textarea").forEach((el) => { el.addEventListener("change", () => { syncVisibility(); autoRender(); }); if (el.tagName === "TEXTAREA" || el.type === "text" || el.type === "range") { diff --git a/src/asciimagic/static/index.html b/src/asciimagic/static/index.html index 676360b..bc1dc5c 100644 --- a/src/asciimagic/static/index.html +++ b/src/asciimagic/static/index.html @@ -154,6 +154,7 @@

ASCIIMagic

+
@@ -331,7 +332,15 @@

ASCIIMagic

Upload an image or enter text to begin.
- +
+ + +
diff --git a/src/asciimagic/static/style.css b/src/asciimagic/static/style.css index 728e424..10bcd87 100644 --- a/src/asciimagic/static/style.css +++ b/src/asciimagic/static/style.css @@ -179,8 +179,44 @@ button.primary:hover { background: var(--accent); color: #06130b; } #status.error { color: var(--danger); } #status.busy { color: var(--accent); } +#preview-wrap { flex: 1; position: relative; min-height: 0; display: flex; } #preview { flex: 1; border: none; background: #000; width: 100%; } +/* GIMP-style resize ring over the rendered art */ +#ring { + position: absolute; + border: 1px dashed var(--accent-dim); + pointer-events: none; /* handles re-enable */ + box-sizing: border-box; +} +#ring.dragging { border-color: var(--accent); } +#ring .handle { + position: absolute; + width: 12px; + height: 12px; + background: var(--accent-dim); + border: 1px solid #06130b; + border-radius: 2px; + pointer-events: auto; +} +#ring .handle:hover { background: var(--accent); } +#handle-e { right: -7px; top: 50%; margin-top: -6px; cursor: ew-resize; } +#handle-s { bottom: -7px; left: 50%; margin-left: -6px; cursor: ns-resize; } +#handle-se { right: -7px; bottom: -7px; cursor: nwse-resize; } +#ring-label { + position: absolute; + top: -1.6rem; + right: 0; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--accent); + font-size: 0.75rem; + padding: 0.1rem 0.45rem; + pointer-events: auto; + white-space: nowrap; +} + @media (max-width: 760px) { main { flex-direction: column; } #controls { width: 100%; min-width: 0; max-height: 45vh; border-right: none; border-bottom: 1px solid var(--border); } diff --git a/src/asciimagic/video.py b/src/asciimagic/video.py index 28ba749..74b4b39 100644 --- a/src/asciimagic/video.py +++ b/src/asciimagic/video.py @@ -306,18 +306,27 @@ def video_to_ascii( quality: str = "balanced", matrix: Optional[MatrixOptions] = None, caption=None, # colorize_ascii.CaptionOptions + rows: Optional[int] = None, ) -> AsciiVideo: frames, out_fps = read_video_frames(path, sample_fps=sample_fps, max_frames=max_frames) converted = _convert_frames( frames, cols=cols, mode=mode, quality=quality, dither=dither, threshold=threshold, gamma=gamma, autocontrast=autocontrast, invert=invert, + rows=rows, ) cap_render = _resolve_video_caption(caption, converted, matrix) return AsciiVideo(converted, out_fps, matrix=matrix, caption=cap_render) def _convert_frame(img: Image.Image, *, cols, mode, quality, charset, - dither, threshold, gamma, autocontrast, invert) -> List[str]: + dither, threshold, gamma, autocontrast, invert, + rows: Optional[int] = None) -> List[str]: + if rows: + # Exact output height: pre-resize the frame onto the converter's cell + # grid (braille cells are 2x4 px, glyph cells 8x16), so the natural + # rows formula lands exactly on `rows`. Stretch/squish like GIMP. + cw, ch = (8, 16) if mode == "glyph" else (2, 4) + img = img.resize((max(1, cols) * cw, max(1, rows) * ch), Image.Resampling.LANCZOS) if mode == "glyph": art = image_to_text_glyph_from_image( img=img, cols=cols, cell_w=8, cell_h=16, charset=charset, @@ -333,14 +342,14 @@ def _convert_frame(img: Image.Image, *, cols, mode, quality, charset, def _convert_frames(frames, *, cols, mode, quality, dither, threshold, - gamma, autocontrast, invert): + gamma, autocontrast, invert, rows=None): charset = make_charset(unicode_mode="off", ascii_preset="dense") if mode == "glyph" else None return [ ( _convert_frame( img, cols=cols, mode=mode, quality=quality, charset=charset, dither=dither, threshold=threshold, gamma=gamma, - autocontrast=autocontrast, invert=invert, + autocontrast=autocontrast, invert=invert, rows=rows, ), img, ) @@ -480,6 +489,8 @@ def build_arg_parser() -> argparse.ArgumentParser: help="Output: .gif, .mp4 (keeps the source audio), or .frames " "(omit to play in the terminal; with a camera, omit to mirror live)") ap.add_argument("-c", "--cols", type=int, default=100, help="Width in characters") + ap.add_argument("--rows", type=int, default=None, metavar="N", + help="Exact output height in rows (stretches/squishes the frame)") ap.add_argument("--fps", type=float, default=10.0, help="Target sample/playback fps (default: 10)") ap.add_argument("--max-frames", type=int, default=300, @@ -573,6 +584,7 @@ def main(argv: Optional[List[str]] = None) -> int: dither=not args.no_dither, threshold=args.threshold, gamma=args.gamma, autocontrast=args.autocontrast, invert=args.invert, matrix=matrix, caption=caption, + rows=args.rows, ) else: video = video_to_ascii( @@ -589,6 +601,7 @@ def main(argv: Optional[List[str]] = None) -> int: quality=args.quality, matrix=matrix, caption=caption, + rows=args.rows, ) except RuntimeError as e: raise SystemExit(str(e)) diff --git a/src/asciimagic/webapp.py b/src/asciimagic/webapp.py index d0f7ff4..6948ad3 100644 --- a/src/asciimagic/webapp.py +++ b/src/asciimagic/webapp.py @@ -181,6 +181,7 @@ def _video_from_upload(upload: Optional[UploadFile], o: dict[str, Any]): cols=_ival(o, "cols", 100, 10, 240), sample_fps=_fval(o, "video_fps", 8.0, 1.0, 30.0), max_frames=_ival(o, "video_max_frames", 60, 1, 120), + rows=_ival(o, "video_rows", None, 1, 500), dither=bool(o.get("dither", True)), threshold=_fval(o, "threshold", 0.5, 0.0, 1.0), gamma=_fval(o, "gamma", 1.0, 0.05, 10.0), @@ -221,6 +222,10 @@ def _render_video(upload: Optional[UploadFile], o: dict[str, Any], t0: float) -> ) return { "ascii": "\n".join(first_lines), + "art": { + "cols": max((len(ln) for ln in first_lines), default=0), + "rows": len(first_lines), + }, "ansi": ansi_frames[0], "html": preview, "gif_b64": gif_b64, @@ -413,6 +418,14 @@ def render( ansi = colorize(ctx, opt=_build_options(o, "ansi")) html_doc = colorize(ctx, opt=_build_options(o, "html")) else: + # Exact/max sizing must work with colorize off too (the GUI's resize + # handles set it); colorize_ascii_text applies it internally on the + # colorized path. + size = _build_options(o, "ansi").size + if any((size.rows, size.cols, size.max_rows, size.max_cols)): + lines = ascii_display.splitlines() + target_h = colorize_mod.compute_target_art_height(size.max_rows, 0, len(lines)) + ascii_display = "\n".join(colorize_mod.scale_art_block(lines, target_h, size)) ansi = ascii_display + "\n" html_doc = _plain_html(ascii_display, o) @@ -431,8 +444,21 @@ def render( html_doc = animation.to_html(font_size_px=_ival(o, "html_font_size", 12, 4, 64)) gif_b64 = base64.b64encode(animation.to_gif_bytes()).decode("ascii") + # Post-scaling art grid dimensions (pre-caption) — the GUI's resize + # handles need them to convert pixel drags into cols/rows. + art_lines = (ctx.ascii_text or "").splitlines() + size = _build_options(o, "ansi").size + if art_lines and any((size.rows, size.cols, size.max_rows, size.max_cols)): + th = colorize_mod.compute_target_art_height(size.max_rows, 0, len(art_lines)) + art_lines = colorize_mod.scale_art_block(art_lines, th, size) + art_dims = { + "cols": max((len(ln) for ln in art_lines), default=0), + "rows": len(art_lines), + } + return { "ascii": ascii_display, + "art": art_dims, "ansi": ansi, "html": html_doc, "gif_b64": gif_b64, diff --git a/tests/test_video.py b/tests/test_video.py index a97d093..af3e6dc 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -82,6 +82,25 @@ def test_cli_rejects_bad_extension(clip, tmp_path): video_mod.main([str(clip), str(tmp_path / "out.html")]) +def test_video_exact_rows(clip): + v = video_mod.video_to_ascii(str(clip), cols=20, max_frames=2, rows=6) + for lines, _ in v.frames: + assert len(lines) == 6 + assert max(len(ln) for ln in lines) == 20 + + +def test_video_rows_cli_flag(clip, tmp_path): + out = tmp_path / "sq.frames" + rc = video_mod.main([str(clip), str(out), "-c", "20", "--rows", "5", "--max-frames", "2"]) + assert rc == 0 + from asciimagic.greet import read_frames_file + import re + + frames, _, _ = read_frames_file(out) + plain = re.sub(r"\x1b\[[0-9;]*m", "", frames[0]) + assert len(plain.splitlines()) == 5 + + def test_video_glyph_mode(clip): v = video_mod.video_to_ascii(str(clip), cols=20, max_frames=3, mode="glyph") lines, _ = v.frames[0] diff --git a/tests/test_webapp.py b/tests/test_webapp.py index 019a9ca..629227f 100644 --- a/tests/test_webapp.py +++ b/tests/test_webapp.py @@ -169,6 +169,39 @@ def test_render_bad_matrix_color_400(): assert r.status_code == 400 +def test_art_dims_in_response(): + r = _render({"source": "image", "mode": "braille", "cols": 16}) + body = r.json() + assert body["art"]["cols"] == 16 + assert body["art"]["rows"] == len(body["ascii"].splitlines()) + + +def test_exact_sizing_without_colorize(): + # The GUI resize handles set out_rows/out_cols; must work with colorize off + r = _render( + {"source": "image", "mode": "braille", "cols": 16, "colorize": False, + "out_rows": 5, "out_cols": 20} + ) + body = r.json() + lines = body["ascii"].splitlines() + assert len(lines) == 5 + assert max(len(ln) for ln in lines) == 20 + assert body["art"] == {"cols": 20, "rows": 5} + + +def test_video_rows_stretch(): + pytest.importorskip("imageio") + r = client.post( + "/api/render", + files={"image": ("clip.gif", _gif_clip_bytes(), "image/gif")}, + data={"options": json.dumps({"source": "video", "cols": 20, + "video_rows": 7, "video_max_frames": 2})}, + ) + assert r.status_code == 200 + body = r.json() + assert body["art"] == {"cols": 20, "rows": 7} + + def test_render_colorize_off_returns_plain(): r = _render({"source": "image", "mode": "braille", "cols": 16, "colorize": False}) body = r.json() diff --git a/uv.lock b/uv.lock index f5cc29f..76487ec 100644 --- a/uv.lock +++ b/uv.lock @@ -41,7 +41,7 @@ wheels = [ [[package]] name = "ascii-magic-tools" -version = "0.2.0" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, From bec9dd35bc09ed500efa3db3f7baccacd6a40158 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Fri, 10 Jul 2026 09:17:56 -0400 Subject: [PATCH 2/7] fix(gui): resize ring measures content, handles capture the pointer - A block-level pre stretches to the full iframe width, so the ring spanned the whole preview and every re-render snapped it back; measure the pre's CONTENT with a DOM Range instead (widest text line). - Fast drags lost the handle the moment the cursor crossed the iframe, which swallows pointer events: setPointerCapture on the handle keeps the drag, iframe pointer-events disabled while dragging, touch-action none on handles. Co-Authored-By: Claude Fable 5 --- src/asciimagic/static/app.js | 26 ++++++++++++++++++++------ src/asciimagic/static/style.css | 4 ++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/asciimagic/static/app.js b/src/asciimagic/static/app.js index c8d356e..a6083fd 100644 --- a/src/asciimagic/static/app.js +++ b/src/asciimagic/static/app.js @@ -380,8 +380,13 @@ for (const id of ["threshold", "gamma", "matrix_gamma", "caption_scale"]) { // so the art reports its own pixel box via postMessage from this snippet. const MEASURE_SNIPPET = '(function(){function r(){var e=document.querySelector("#m")||document.querySelector("pre,img");' + - 'if(!e)return;var b=e.getBoundingClientRect();var m={am:"artbox",x:b.left,y:b.top,w:b.width,h:b.height};' + - 'if(e.tagName==="IMG"){m.nw=e.naturalWidth;m.nh=e.naturalHeight;}parent.postMessage(m,"*");}' + + 'if(!e)return;var b,m;' + + 'if(e.tagName==="IMG"){b=e.getBoundingClientRect();m={am:"artbox",x:b.left,y:b.top,w:b.width,h:b.height,nw:e.naturalWidth,nh:e.naturalHeight};}' + + // A block-level
 stretches to the full page width; measure the CONTENT
+  // (widest text line) with a Range so the ring hugs the art itself.
+  'else{var g=document.createRange();g.selectNodeContents(e);b=g.getBoundingClientRect();' +
+  'm={am:"artbox",x:b.left,y:b.top,w:b.width,h:b.height};}' +
+  'if(m.w>0)parent.postMessage(m,"*");}' +
   'window.addEventListener("load",r);window.addEventListener("resize",r);window.addEventListener("scroll",r,true);' +
   'setTimeout(r,60);setTimeout(r,300);})();';
 
@@ -452,6 +457,11 @@ function updateLabel(c, r) {
 function startDrag(axis) {
   return (e) => {
     e.preventDefault();
+    const handle = e.currentTarget;
+    // Capture the pointer: without this, the iframe swallows pointermove the
+    // instant the cursor crosses it and fast drags "lose" the handle.
+    handle.setPointerCapture(e.pointerId);
+    document.getElementById("preview-wrap").classList.add("dragging");
     dragging = {
       axis, x: e.clientX, y: e.clientY,
       w: ringBox.w, h: ringBox.h,
@@ -471,14 +481,18 @@ function startDrag(axis) {
       updateLabel(d.cols, d.rows);
     };
     const up = (ev) => {
-      window.removeEventListener("pointermove", move);
-      window.removeEventListener("pointerup", up);
+      handle.removeEventListener("pointermove", move);
+      handle.removeEventListener("pointerup", up);
+      handle.removeEventListener("pointercancel", up);
+      try { handle.releasePointerCapture(ev.pointerId); } catch (_) {}
       ring.classList.remove("dragging");
+      document.getElementById("preview-wrap").classList.remove("dragging");
       dragging = null;
       commitResize(axis, dragDims(ringBox.w, ringBox.h), ev.shiftKey);
     };
-    window.addEventListener("pointermove", move);
-    window.addEventListener("pointerup", up);
+    handle.addEventListener("pointermove", move);
+    handle.addEventListener("pointerup", up);
+    handle.addEventListener("pointercancel", up);
   };
 }
 $("handle-e").addEventListener("pointerdown", startDrag("e"));
diff --git a/src/asciimagic/static/style.css b/src/asciimagic/static/style.css
index 10bcd87..9db9c76 100644
--- a/src/asciimagic/static/style.css
+++ b/src/asciimagic/static/style.css
@@ -198,7 +198,11 @@ button.primary:hover { background: var(--accent); color: #06130b; }
   border: 1px solid #06130b;
   border-radius: 2px;
   pointer-events: auto;
+  touch-action: none; /* pointer capture needs raw pointer events */
 }
+/* While dragging, the iframe must not steal pointer events */
+#preview-wrap.dragging #preview { pointer-events: none; }
+#preview-wrap.dragging { user-select: none; }
 #ring .handle:hover { background: var(--accent); }
 #handle-e { right: -7px; top: 50%; margin-top: -6px; cursor: ew-resize; }
 #handle-s { bottom: -7px; left: 50%; margin-left: -6px; cursor: ns-resize; }

From 7ef27eb32cf1265f6f613bdff5cf1cf459bc175d Mon Sep 17 00:00:00 2001
From: Ian Robinson 
Date: Fri, 10 Jul 2026 09:58:05 -0400
Subject: [PATCH 3/7] feat(gui): height handles work with captions
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The server now reports how many rows the caption occupies within the
rendered block (cap_rows + position; zero for the animation player, whose
caption is a separate element). The resize ring subtracts that strip and
wraps just the art, so vertical drags convert cleanly to art rows again —
the caption stays outside the selection, GIMP-style. Cell math simplified
to displayed-box / grid-count, which also handles the CSS-downscaled
video GIF without natural-size juggling.

Co-Authored-By: Claude Fable 5 
---
 src/asciimagic/static/app.js | 41 ++++++++++++++++++++++++------------
 src/asciimagic/webapp.py     | 20 ++++++++++++++++++
 tests/test_webapp.py         | 37 ++++++++++++++++++++++++++++++++
 3 files changed, 84 insertions(+), 14 deletions(-)

diff --git a/src/asciimagic/static/app.js b/src/asciimagic/static/app.js
index a6083fd..c15e8ff 100644
--- a/src/asciimagic/static/app.js
+++ b/src/asciimagic/static/app.js
@@ -407,20 +407,38 @@ window.addEventListener("message", (ev) => {
   showRing(d);
 });
 
+function cellHDisplay(d) {
+  if (d.nw) {
+    // video GIF: uniform cell rows across art + caption strip
+    const totalRows = state.art.rows + (state.art.cap_rows || 0);
+    return d.h / Math.max(1, totalRows);
+  }
+  return num("html_font_size") || 12; // pre line-height is pinned to font px
+}
+
+function artOnlyBox(d) {
+  // The measured block may include the caption rows; the ring wraps just
+  // the art (the server reports how many rows the caption occupies).
+  const cap = state.art.cap_rows || 0;
+  if (!cap) return { x: d.x, y: d.y, w: d.w, h: d.h };
+  const capPx = cap * cellHDisplay(d);
+  return {
+    x: d.x,
+    y: state.art.cap_pos === "top" ? d.y + capPx : d.y,
+    w: d.w,
+    h: Math.max(4, d.h - capPx),
+  };
+}
+
 function showRing(d) {
   if (!state.result || !state.art || !state.art.cols || d.w < 4) {
     ring.hidden = true;
     return;
   }
-  ringBox = { x: d.x, y: d.y, w: d.w, h: d.h };
+  ringBox = artOnlyBox(d);
   ring.hidden = false;
   applyRing();
   updateLabel(state.art.cols, state.art.rows);
-  // Height math gets ambiguous with a caption stitched into the same block —
-  // width dragging stays available, height reverts to the number knobs.
-  const capOn = !!$("caption_text").value.trim();
-  $("handle-s").style.display = capOn ? "none" : "";
-  $("handle-se").style.display = capOn ? "none" : "";
 }
 
 function applyRing() {
@@ -431,15 +449,10 @@ function applyRing() {
 }
 
 function cellSize() {
+  // Displayed box ÷ known grid counts — exact for pre text and for the video
+  //  even when the browser CSS-downscales it (caption padded to art width).
   const m = state.measure;
-  const art = state.art;
-  if (m.nw) {
-    // Video preview is an  that may be CSS-downscaled; convert through
-    // its natural size so a dragged pixel means a consistent fraction of a cell.
-    const scale = m.w / m.nw;
-    return { w: (m.nw / art.cols) * scale, h: (m.nh / art.rows) * scale };
-  }
-  return { w: m.w / art.cols, h: num("html_font_size") || 12 };
+  return { w: m.w / state.art.cols, h: cellHDisplay(m) };
 }
 
 function dragDims(w, h) {
diff --git a/src/asciimagic/webapp.py b/src/asciimagic/webapp.py
index 6948ad3..2ea6f65 100644
--- a/src/asciimagic/webapp.py
+++ b/src/asciimagic/webapp.py
@@ -225,6 +225,9 @@ def _render_video(upload: Optional[UploadFile], o: dict[str, Any], t0: float) ->
         "art": {
             "cols": max((len(ln) for ln in first_lines), default=0),
             "rows": len(first_lines),
+            # the GIF bakes the caption strip into every frame
+            "cap_rows": (len(v.caption.lines) + v.caption.gap) if v.caption else 0,
+            "cap_pos": v.caption.position if v.caption else "bottom",
         },
         "ansi": ansi_frames[0],
         "html": preview,
@@ -454,7 +457,24 @@ def render(
     art_dims = {
         "cols": max((len(ln) for ln in art_lines), default=0),
         "rows": len(art_lines),
+        "cap_rows": 0,
+        "cap_pos": "bottom",
     }
+    # Caption rows share the art's rendered block; report how many so the
+    # GUI's resize ring can exclude them. The animation player renders its
+    # caption in a separate element, so nothing to exclude there.
+    cap = _build_options(o, "ansi").caption
+    if cap.text and not do_animate and art_dims["cols"]:
+        try:
+            from .text_to_ascii import caption_lines as _caption_lines
+
+            cl = _caption_lines(
+                cap.text, art_dims["cols"], style=cap.style, scale=cap.scale, align=cap.align
+            )
+            art_dims["cap_rows"] = len(cl) + max(0, int(cap.gap))
+            art_dims["cap_pos"] = cap.position
+        except Exception:
+            pass  # caption metrics are best-effort; the ring just wraps everything
 
     return {
         "ascii": ascii_display,
diff --git a/tests/test_webapp.py b/tests/test_webapp.py
index 629227f..2200acf 100644
--- a/tests/test_webapp.py
+++ b/tests/test_webapp.py
@@ -176,6 +176,43 @@ def test_art_dims_in_response():
     assert body["art"]["rows"] == len(body["ascii"].splitlines())
 
 
+def test_art_dims_report_caption_rows():
+    r = _render(
+        {"source": "image", "mode": "braille", "cols": 20,
+         "caption_text": "Cat", "caption_style": "box", "caption_gap": 2,
+         "caption_pos": "top"}
+    )
+    art = r.json()["art"]
+    assert art["cap_rows"] == 5  # 3 box lines + 2 gap
+    assert art["cap_pos"] == "top"
+
+    # no caption -> zero
+    r2 = _render({"source": "image", "mode": "braille", "cols": 20})
+    assert r2.json()["art"]["cap_rows"] == 0
+
+
+def test_animation_caption_not_counted_in_art_block():
+    # The player renders its caption in a separate element
+    r = _render(
+        {"source": "image", "cols": 12, "matrix": True, "animate": True,
+         "anim_frames": 2, "caption_text": "Cat", "caption_style": "box"}
+    )
+    assert r.json()["art"]["cap_rows"] == 0
+
+
+def test_video_art_dims_report_caption_rows():
+    pytest.importorskip("imageio")
+    r = client.post(
+        "/api/render",
+        files={"image": ("clip.gif", _gif_clip_bytes(), "image/gif")},
+        data={"options": json.dumps({"source": "video", "cols": 24, "video_max_frames": 2,
+                                     "caption_text": "Cat", "caption_style": "box"})},
+    )
+    art = r.json()["art"]
+    assert art["cap_rows"] == 4  # 3 box lines + default gap 1
+    assert art["cap_pos"] == "bottom"
+
+
 def test_exact_sizing_without_colorize():
     # The GUI resize handles set out_rows/out_cols; must work with colorize off
     r = _render(

From 1fb5cef6621bfaebb900abb91084d39b728eec6d Mon Sep 17 00:00:00 2001
From: Ian Robinson 
Date: Fri, 10 Jul 2026 09:59:01 -0400
Subject: [PATCH 4/7] test: art dims are a superset now (cap_rows/cap_pos)

Co-Authored-By: Claude Fable 5 
---
 tests/test_webapp.py | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/tests/test_webapp.py b/tests/test_webapp.py
index 2200acf..b14badb 100644
--- a/tests/test_webapp.py
+++ b/tests/test_webapp.py
@@ -223,7 +223,8 @@ def test_exact_sizing_without_colorize():
     lines = body["ascii"].splitlines()
     assert len(lines) == 5
     assert max(len(ln) for ln in lines) == 20
-    assert body["art"] == {"cols": 20, "rows": 5}
+    assert body["art"]["cols"] == 20
+    assert body["art"]["rows"] == 5
 
 
 def test_video_rows_stretch():
@@ -236,7 +237,8 @@ def test_video_rows_stretch():
     )
     assert r.status_code == 200
     body = r.json()
-    assert body["art"] == {"cols": 20, "rows": 7}
+    assert body["art"]["cols"] == 20
+    assert body["art"]["rows"] == 7
 
 
 def test_render_colorize_off_returns_plain():

From 748eeb714ff3e0520c80fcc562b4fec9e748b907 Mon Sep 17 00:00:00 2001
From: Ian Robinson 
Date: Fri, 10 Jul 2026 10:10:25 -0400
Subject: [PATCH 5/7] feat(gui): caption resize ring; fix figlet wrap-smush
 corruption

- The caption gets its own ring (round handle, dashed outline) wrapping
  just the caption lines: dragging its corner maps width to the caption
  Size knob as a fraction of art width (snapped to the slider's 0.05
  steps), with a percentage readout; double-click resets to 60%. Shown
  only for scalable styles (block/small/shadow/figlet). Server art dims
  now report cap_lines/cap_gap/cap_style so both rings place exactly.

- Figlet corruption fix: the size ladder measured candidates rendered AT
  the art width, where pyfiglet wraps large fonts and smushes the wrapped
  blocks into each other (letters truncated, fragments of neighbors
  bleeding in - 'I see you' lost its I). Candidates now render unwrapped
  for measurement so a mangled wrap can never be selected; when nothing
  fits on one line, the smallest font wraps cleanly into stacked blocks.

Co-Authored-By: Claude Fable 5 
---
 src/asciimagic/static/app.js     | 92 +++++++++++++++++++++++++++++++-
 src/asciimagic/static/index.html |  4 ++
 src/asciimagic/static/style.css  | 32 +++++++++++
 src/asciimagic/text_to_ascii.py  | 17 ++++--
 src/asciimagic/webapp.py         | 12 +++--
 tests/test_caption.py            | 18 +++++++
 tests/test_webapp.py             | 11 ++--
 7 files changed, 174 insertions(+), 12 deletions(-)

diff --git a/src/asciimagic/static/app.js b/src/asciimagic/static/app.js
index c15e8ff..a8e0d65 100644
--- a/src/asciimagic/static/app.js
+++ b/src/asciimagic/static/app.js
@@ -142,6 +142,7 @@ async function render() {
     state.result = body;
     state.art = body.art || null;
     $("ring").hidden = true; // re-shown when the new preview reports its box
+    $("cap-ring").hidden = true;
     $("preview").srcdoc = injectMeasure(body.html);
     if (body.seed !== null && body.seed !== undefined) {
       $("matrix_seed").value = body.seed;
@@ -290,6 +291,7 @@ const TABS = ["image", "text", "video"];
 function setTab(name) {
   state.tab = name;
   $("ring").hidden = true; // stale box from another source
+  $("cap-ring").hidden = true;
   for (const t of TABS) {
     $(`panel-${t}`).hidden = t !== name;
     $(`tab-${t}`).classList.toggle("active", t === name);
@@ -397,7 +399,9 @@ function injectMeasure(html) {
 }
 
 const ring = $("ring");
+const capRing = $("cap-ring");
 let ringBox = null;
+let capBox = null;
 let dragging = null;
 
 window.addEventListener("message", (ev) => {
@@ -407,10 +411,14 @@ window.addEventListener("message", (ev) => {
   showRing(d);
 });
 
+function capRowsTotal() {
+  return (state.art.cap_lines || 0) + (state.art.cap_gap || 0);
+}
+
 function cellHDisplay(d) {
   if (d.nw) {
     // video GIF: uniform cell rows across art + caption strip
-    const totalRows = state.art.rows + (state.art.cap_rows || 0);
+    const totalRows = state.art.rows + capRowsTotal();
     return d.h / Math.max(1, totalRows);
   }
   return num("html_font_size") || 12; // pre line-height is pinned to font px
@@ -419,7 +427,7 @@ function cellHDisplay(d) {
 function artOnlyBox(d) {
   // The measured block may include the caption rows; the ring wraps just
   // the art (the server reports how many rows the caption occupies).
-  const cap = state.art.cap_rows || 0;
+  const cap = capRowsTotal();
   if (!cap) return { x: d.x, y: d.y, w: d.w, h: d.h };
   const capPx = cap * cellHDisplay(d);
   return {
@@ -430,15 +438,38 @@ function artOnlyBox(d) {
   };
 }
 
+// Caption ring: wraps just the caption lines (gap excluded), only for the
+// styles where the Size knob actually scales the lettering.
+const CAP_SCALABLE = new Set(["block", "small", "shadow", "figlet"]);
+
+function captionBox(d) {
+  const lines = state.art.cap_lines || 0;
+  if (!lines || !CAP_SCALABLE.has(state.art.cap_style)) return null;
+  const ch = cellHDisplay(d);
+  const h = lines * ch;
+  const y = state.art.cap_pos === "top" ? d.y : d.y + d.h - h;
+  return { x: d.x, y, w: d.w, h };
+}
+
 function showRing(d) {
   if (!state.result || !state.art || !state.art.cols || d.w < 4) {
     ring.hidden = true;
+    capRing.hidden = true;
     return;
   }
   ringBox = artOnlyBox(d);
   ring.hidden = false;
   applyRing();
   updateLabel(state.art.cols, state.art.rows);
+
+  capBox = captionBox(d);
+  if (capBox) {
+    capRing.hidden = false;
+    applyCapRing();
+    updateCapLabel(Math.round((num("caption_scale") || 0.6) * 100));
+  } else {
+    capRing.hidden = true;
+  }
 }
 
 function applyRing() {
@@ -448,6 +479,17 @@ function applyRing() {
   ring.style.height = ringBox.h + "px";
 }
 
+function applyCapRing() {
+  capRing.style.left = capBox.x + "px";
+  capRing.style.top = capBox.y + "px";
+  capRing.style.width = capBox.w + "px";
+  capRing.style.height = capBox.h + "px";
+}
+
+function updateCapLabel(pct) {
+  $("cap-ring-label").textContent = `caption ${pct}%`;
+}
+
 function cellSize() {
   // Displayed box ÷ known grid counts — exact for pre text and for the video
   //  even when the browser CSS-downscales it (caption padded to art width).
@@ -512,6 +554,52 @@ $("handle-e").addEventListener("pointerdown", startDrag("e"));
 $("handle-s").addEventListener("pointerdown", startDrag("s"));
 $("handle-se").addEventListener("pointerdown", startDrag("se"));
 
+// Caption drag: width maps to the caption Size knob (fraction of art width).
+$("cap-handle").addEventListener("pointerdown", (e) => {
+  e.preventDefault();
+  const handle = e.currentTarget;
+  handle.setPointerCapture(e.pointerId);
+  document.getElementById("preview-wrap").classList.add("dragging");
+  capRing.classList.add("dragging");
+  const start = { x: e.clientX, w: capBox.w };
+  const artW = ringBox.w; // caption scale is relative to the art width
+
+  const toScale = (w) => Math.min(1, Math.max(0.05, w / Math.max(1, artW)));
+  const snap = (s) => Math.round(s * 20) / 20; // the Size slider steps by 0.05
+
+  const move = (ev) => {
+    const w = Math.max(12, start.w + (ev.clientX - start.x));
+    capBox.w = w;
+    applyCapRing();
+    updateCapLabel(Math.round(snap(toScale(w)) * 100));
+  };
+  const up = (ev) => {
+    handle.removeEventListener("pointermove", move);
+    handle.removeEventListener("pointerup", up);
+    handle.removeEventListener("pointercancel", up);
+    try { handle.releasePointerCapture(ev.pointerId); } catch (_) {}
+    capRing.classList.remove("dragging");
+    document.getElementById("preview-wrap").classList.remove("dragging");
+    $("caption_scale").value = snap(toScale(capBox.w));
+    $("caption_scale-out").value = $("caption_scale").value;
+    if (state.tab === "video") {
+      setStatus(`Caption size set to ${Math.round(snap(toScale(capBox.w)) * 100)}% — press Render`, "busy");
+    } else {
+      render();
+    }
+  };
+  handle.addEventListener("pointermove", move);
+  handle.addEventListener("pointerup", up);
+  handle.addEventListener("pointercancel", up);
+});
+
+capRing.addEventListener("dblclick", () => {
+  $("caption_scale").value = 0.6;
+  $("caption_scale-out").value = "0.6";
+  if (state.tab === "video") setStatus("Caption size reset — press Render", "busy");
+  else render();
+});
+
 function commitResize(axis, d, shift) {
   if (state.tab === "video") {
     if (axis !== "s") $("video_cols").value = d.cols;
diff --git a/src/asciimagic/static/index.html b/src/asciimagic/static/index.html
index bc1dc5c..49ebc34 100644
--- a/src/asciimagic/static/index.html
+++ b/src/asciimagic/static/index.html
@@ -340,6 +340,10 @@ 

ASCIIMagic

+ diff --git a/src/asciimagic/static/style.css b/src/asciimagic/static/style.css index 9db9c76..b2752a5 100644 --- a/src/asciimagic/static/style.css +++ b/src/asciimagic/static/style.css @@ -207,6 +207,38 @@ button.primary:hover { background: var(--accent); color: #06130b; } #handle-e { right: -7px; top: 50%; margin-top: -6px; cursor: ew-resize; } #handle-s { bottom: -7px; left: 50%; margin-left: -6px; cursor: ns-resize; } #handle-se { right: -7px; bottom: -7px; cursor: nwse-resize; } +#cap-ring { + position: absolute; + border: 1px dashed color-mix(in srgb, var(--accent-dim) 60%, transparent); + pointer-events: none; + box-sizing: border-box; +} +#cap-ring.dragging { border-color: var(--accent); } +#cap-ring .handle { + position: absolute; + width: 12px; + height: 12px; + background: var(--accent-dim); + border: 1px solid #06130b; + border-radius: 50%; /* round = caption, square = art */ + pointer-events: auto; + touch-action: none; +} +#cap-ring .handle:hover { background: var(--accent); } +#cap-handle { right: -7px; bottom: -7px; cursor: nwse-resize; } +#cap-ring-label { + position: absolute; + bottom: -1.6rem; + right: 0; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--accent); + font-size: 0.75rem; + padding: 0.1rem 0.45rem; + white-space: nowrap; +} + #ring-label { position: absolute; top: -1.6rem; diff --git a/src/asciimagic/text_to_ascii.py b/src/asciimagic/text_to_ascii.py index 5caf6d9..4bf673d 100755 --- a/src/asciimagic/text_to_ascii.py +++ b/src/asciimagic/text_to_ascii.py @@ -245,14 +245,19 @@ def text_to_figlet(text: str, width: int = 80, font: str = "standard") -> str: def _figlet_sized(text: str, width: int, scale: float) -> str: - """Figlet text sized toward scale x width using the font ladder.""" + """Figlet text sized toward scale x width using the font ladder. + + Candidates render UNWRAPPED for measurement: pyfiglet's width-wrapping + smushes the wrapped blocks of large fonts into each other (letters merge + and truncate), so a wrapped render must never be chosen as "fitting". + """ import pyfiglet target = max(1, int(width * min(1.0, max(0.05, float(scale))))) rendered = [] for font in _FIGLET_SIZES: try: - block = pyfiglet.figlet_format(text, font=font, width=max(20, int(width))) + block = pyfiglet.figlet_format(text, font=font, width=100_000) except Exception: continue w = max((len(ln.rstrip()) for ln in block.splitlines()), default=0) @@ -263,7 +268,13 @@ def _figlet_sized(text: str, width: int, scale: float) -> str: fitting = [r for r in rendered if r[0] <= width] if fitting: return min(fitting, key=lambda r: abs(r[0] - target))[1] - return min(rendered, key=lambda r: r[0])[1] # nothing fits; smallest, then grid-shrink + # Nothing fits on one line even in the smallest font: let the small font + # wrap into stacked blocks (it wraps cleanly, unlike the giants). A single + # overlong word still falls through to caption_lines' grid-shrink. + try: + return pyfiglet.figlet_format(text, font=_FIGLET_SIZES[0], width=max(20, int(width))) + except Exception: + return text def caption_lines( diff --git a/src/asciimagic/webapp.py b/src/asciimagic/webapp.py index 2ea6f65..47f828b 100644 --- a/src/asciimagic/webapp.py +++ b/src/asciimagic/webapp.py @@ -226,8 +226,10 @@ def _render_video(upload: Optional[UploadFile], o: dict[str, Any], t0: float) -> "cols": max((len(ln) for ln in first_lines), default=0), "rows": len(first_lines), # the GIF bakes the caption strip into every frame - "cap_rows": (len(v.caption.lines) + v.caption.gap) if v.caption else 0, + "cap_lines": len(v.caption.lines) if v.caption else 0, + "cap_gap": v.caption.gap if v.caption else 0, "cap_pos": v.caption.position if v.caption else "bottom", + "cap_style": (o.get("caption_style", "block") if v.caption else None), }, "ansi": ansi_frames[0], "html": preview, @@ -457,8 +459,10 @@ def render( art_dims = { "cols": max((len(ln) for ln in art_lines), default=0), "rows": len(art_lines), - "cap_rows": 0, + "cap_lines": 0, + "cap_gap": 0, "cap_pos": "bottom", + "cap_style": None, } # Caption rows share the art's rendered block; report how many so the # GUI's resize ring can exclude them. The animation player renders its @@ -471,8 +475,10 @@ def render( cl = _caption_lines( cap.text, art_dims["cols"], style=cap.style, scale=cap.scale, align=cap.align ) - art_dims["cap_rows"] = len(cl) + max(0, int(cap.gap)) + art_dims["cap_lines"] = len(cl) + art_dims["cap_gap"] = max(0, int(cap.gap)) art_dims["cap_pos"] = cap.position + art_dims["cap_style"] = cap.style except Exception: pass # caption metrics are best-effort; the ring just wraps everything diff --git a/tests/test_caption.py b/tests/test_caption.py index 138f5d5..faf3c87 100644 --- a/tests/test_caption.py +++ b/tests/test_caption.py @@ -172,3 +172,21 @@ def test_caption_width_matches_scaled_art(): caption_rows = [ln for ln in out.splitlines() if "┌" in ln or "└" in ln or "│" in ln] assert caption_rows assert all(len(ln) == 24 for ln in caption_rows) + + +def test_figlet_never_chooses_a_wrapped_render(): + """Regression: at scale 1.0 the ladder used to pick a giant font whose + pyfiglet-wrapped output smushed letters into each other ('I see you' + lost its I and grew fragments of other letters).""" + lines = caption_lines("I see you", width=110, style="figlet", scale=1.0) + ink = max(len(ln.strip()) for ln in lines if ln.strip()) + assert ink <= 110 + # one unwrapped block, not stacked wrapped blocks (doh-wrapped was 46 rows) + assert len(lines) <= 16 + + +def test_figlet_narrow_width_wraps_cleanly_with_small_font(): + lines = caption_lines("I see you", width=24, style="figlet", scale=0.6) + assert all(len(ln) == 24 for ln in lines) + ink = max(len(ln.strip()) for ln in lines if ln.strip()) + assert ink <= 24 diff --git a/tests/test_webapp.py b/tests/test_webapp.py index b14badb..deb59bd 100644 --- a/tests/test_webapp.py +++ b/tests/test_webapp.py @@ -183,12 +183,14 @@ def test_art_dims_report_caption_rows(): "caption_pos": "top"} ) art = r.json()["art"] - assert art["cap_rows"] == 5 # 3 box lines + 2 gap + assert art["cap_lines"] == 3 # box is 3 lines tall + assert art["cap_gap"] == 2 assert art["cap_pos"] == "top" + assert art["cap_style"] == "box" # no caption -> zero r2 = _render({"source": "image", "mode": "braille", "cols": 20}) - assert r2.json()["art"]["cap_rows"] == 0 + assert r2.json()["art"]["cap_lines"] == 0 def test_animation_caption_not_counted_in_art_block(): @@ -197,7 +199,7 @@ def test_animation_caption_not_counted_in_art_block(): {"source": "image", "cols": 12, "matrix": True, "animate": True, "anim_frames": 2, "caption_text": "Cat", "caption_style": "box"} ) - assert r.json()["art"]["cap_rows"] == 0 + assert r.json()["art"]["cap_lines"] == 0 def test_video_art_dims_report_caption_rows(): @@ -209,7 +211,8 @@ def test_video_art_dims_report_caption_rows(): "caption_text": "Cat", "caption_style": "box"})}, ) art = r.json()["art"] - assert art["cap_rows"] == 4 # 3 box lines + default gap 1 + assert art["cap_lines"] == 3 + assert art["cap_gap"] == 1 assert art["cap_pos"] == "bottom" From fabd9e23a081c1945c4bd7c67cfa1e8abd2751ad Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Fri, 10 Jul 2026 22:11:17 -0400 Subject: [PATCH 6/7] fix(caption): block-uniform alignment; denser figlet ladder - Center/right alignment padded each ROW independently, shearing multi-row letterforms apart (short rows of a glyph drifted toward the middle while long rows stayed put) - the real culprit behind mangled centered captions. The block now shifts as a unit. - The figlet ladder had big gaps (43 -> 61 -> 135 wide for a short phrase), so at common art widths half the Size range picked the same font and the caption drag felt dead. Added chunky/banner3/epic/ larry3d/roman/univers rungs; the sweep now moves through distinct sizes across the range. Co-Authored-By: Claude Fable 5 --- src/asciimagic/text_to_ascii.py | 28 +++++++++++++++++----------- tests/test_caption.py | 24 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/src/asciimagic/text_to_ascii.py b/src/asciimagic/text_to_ascii.py index 4bf673d..5a85b1a 100755 --- a/src/asciimagic/text_to_ascii.py +++ b/src/asciimagic/text_to_ascii.py @@ -241,7 +241,12 @@ def text_to_figlet(text: str, width: int = 80, font: str = "standard") -> str: # Real figlet fonts in ascending size — scaling picks a font instead of # stretching character cells, so letterforms stay clean at every size. -_FIGLET_SIZES = ("mini", "small", "standard", "big", "colossal", "doh") +# Ascending size. Dense on purpose: the sizer picks the fitting font closest +# to the drag/slider target, so gaps in the ladder feel like a dead knob. +_FIGLET_SIZES = ( + "mini", "small", "standard", "big", "chunky", "banner3", + "epic", "larry3d", "roman", "univers", "colossal", "doh", +) def _figlet_sized(text: str, width: int, scale: float) -> str: @@ -320,18 +325,19 @@ def caption_lines( new_h = max(1, round(len(lines) * factor)) lines = [ln.rstrip() for ln in scale_grid([ln.ljust(nat_w) for ln in lines], new_h, width)] + # Align the block as a UNIT: rows in multi-row letterforms have different + # ink widths, so per-line centering shears the letters apart. + block_w = max((len(ln) for ln in lines), default=0) + if align == "right": + pad_left = max(0, width - block_w) + elif align == "center": + pad_left = max(0, (width - block_w) // 2) + else: + pad_left = 0 out = [] for ln in lines: - ln = ln[:width] - pad = width - len(ln) - if align == "left": - ln = ln + " " * pad - elif align == "right": - ln = " " * pad + ln - else: - left = pad // 2 - ln = " " * left + ln + " " * (pad - left) - out.append(ln) + ln = (" " * pad_left + ln)[:width] + out.append(ln.ljust(width)) return out diff --git a/tests/test_caption.py b/tests/test_caption.py index faf3c87..8ac415b 100644 --- a/tests/test_caption.py +++ b/tests/test_caption.py @@ -190,3 +190,27 @@ def test_figlet_narrow_width_wraps_cleanly_with_small_font(): assert all(len(ln) == 24 for ln in lines) ink = max(len(ln.strip()) for ln in lines if ln.strip()) assert ink <= 24 + + +def test_center_alignment_shifts_block_uniformly(): + """Regression: center used to pad each ROW independently, shearing + multi-row letterforms apart (short rows drifted toward the middle).""" + left = caption_lines("I see you", width=90, style="figlet", scale=1.0, align="left") + center = caption_lines("I see you", width=90, style="figlet", scale=1.0, align="center") + shifts = { + len(c) - len(c.lstrip()) - (len(l) - len(l.lstrip())) + for l, c in zip(left, center) + if c.strip() + } + assert len(shifts) == 1 # every row moved by the same amount + assert shifts.pop() > 0 + + +def test_figlet_ladder_has_fine_steps(): + """Regression: big gaps in the font ladder made the caption drag feel + dead — half the scale range mapped to the same font.""" + widths = set() + for s in (0.2, 0.35, 0.5, 0.65, 0.85, 1.0): + lines = caption_lines("I see you", width=110, style="figlet", scale=s) + widths.add(max(len(ln.strip()) for ln in lines if ln.strip())) + assert len(widths) >= 4 From 031d64488a17322e70731c67ed5d848044f41b53 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Fri, 10 Jul 2026 22:21:18 -0400 Subject: [PATCH 7/7] feat(gui): exact caption width/height controls + true free-transform drag The caption ring's discrete font-ladder mapping felt dead between rungs. Captions now take explicit cols/rows (CaptionOptions.cols/rows, threaded through static, animation, and video paths): the closest natural rendering is grid-transformed to the exact size, with a missing dimension derived from aspect. New Width/Height fields in the Caption panel and --caption-cols/--caption-rows on the image, colorize, and video commands; the Auto-size slider applies only when the exact fields are empty. The caption ring gains E/S/SE handles committing exact chars x rows continuously (Shift-corner keeps aspect; double-click resets to auto), now hugs the caption's ink instead of the padded row (server reports ink width and offset), and shows a cols x rows readout. All caption styles can free-transform, so the style gate is gone. Co-Authored-By: Claude Fable 5 --- src/asciimagic/animate.py | 3 +- src/asciimagic/colorize_ascii.py | 15 +++- src/asciimagic/image_to_ascii.py | 6 ++ src/asciimagic/static/app.js | 120 ++++++++++++++++++------------- src/asciimagic/static/index.html | 12 +++- src/asciimagic/static/style.css | 4 +- src/asciimagic/text_to_ascii.py | 36 +++++++--- src/asciimagic/video.py | 6 ++ src/asciimagic/webapp.py | 10 ++- tests/test_caption.py | 20 ++++++ tests/test_webapp.py | 11 +++ 11 files changed, 175 insertions(+), 68 deletions(-) diff --git a/src/asciimagic/animate.py b/src/asciimagic/animate.py index c956e23..aec1bda 100644 --- a/src/asciimagic/animate.py +++ b/src/asciimagic/animate.py @@ -487,7 +487,8 @@ def _resolve_caption( from .text_to_ascii import caption_lines lines = caption_lines( - caption.text, width, style=caption.style, scale=caption.scale, align=caption.align + caption.text, width, style=caption.style, scale=caption.scale, align=caption.align, + cols=caption.cols, rows=caption.rows, ) if not lines: return None diff --git a/src/asciimagic/colorize_ascii.py b/src/asciimagic/colorize_ascii.py index 7349994..e63490e 100755 --- a/src/asciimagic/colorize_ascii.py +++ b/src/asciimagic/colorize_ascii.py @@ -102,8 +102,10 @@ class CaptionOptions: text: Optional[str] = None position: str = "bottom" # "top" | "bottom" - style: str = "block" # block | small | shadow | box | banner - scale: float = 0.6 # fraction of art width for rendered styles + style: str = "block" # block | small | shadow | box | banner | figlet + scale: float = 0.6 # AUTO sizing: fraction of art width for rendered styles + cols: Optional[int] = None # EXACT width in chars (overrides scale; free transform) + rows: Optional[int] = None # EXACT height in rows gap: int = 1 # blank lines between caption and art color: Optional[str] = None # theme, #RRGGBB, or "image" (sample the picture); None = default fg align: str = "center" # left | center | right @@ -244,6 +246,10 @@ def build_arg_parser() -> argparse.ArgumentParser: default="block") g.add_argument("--caption-scale", type=float, default=0.6, metavar="F", help="Caption width as a fraction of art width (rendered styles)") + g.add_argument("--caption-cols", type=int, default=None, metavar="N", + help="Exact caption width in chars (free transform; overrides --caption-scale)") + g.add_argument("--caption-rows", type=int, default=None, metavar="N", + help="Exact caption height in rows") g.add_argument("--caption-gap", type=int, default=1, metavar="N", help="Blank lines between caption and art") g.add_argument("--caption-color", default=None, metavar="COLOR", @@ -307,6 +313,8 @@ def parse_args(argv) -> Tuple[str, str, Optional[str], Options]: position=ns.caption_pos, style=ns.caption_style, scale=ns.caption_scale, + cols=ns.caption_cols, + rows=ns.caption_rows, gap=ns.caption_gap, color=ns.caption_color, align=ns.caption_align, @@ -763,7 +771,8 @@ def _build_caption_lines(cap: CaptionOptions, width: int) -> List[str]: if not cap.text: return [] return text_mod.caption_lines( - cap.text, width, style=cap.style, scale=cap.scale, align=cap.align + cap.text, width, style=cap.style, scale=cap.scale, align=cap.align, + cols=cap.cols, rows=cap.rows, ) diff --git a/src/asciimagic/image_to_ascii.py b/src/asciimagic/image_to_ascii.py index 6345c56..16632c4 100755 --- a/src/asciimagic/image_to_ascii.py +++ b/src/asciimagic/image_to_ascii.py @@ -607,6 +607,10 @@ def build_arg_parser() -> argparse.ArgumentParser: ap.add_argument("--caption-pos", choices=["top", "bottom"], default="bottom") ap.add_argument("--caption-style", choices=["block", "small", "shadow", "box", "banner", "figlet"], default="block") + ap.add_argument("--caption-cols", type=int, default=None, metavar="N", + help="Exact caption width in chars (free transform)") + ap.add_argument("--caption-rows", type=int, default=None, metavar="N", + help="Exact caption height in rows") ap.add_argument("--caption-scale", type=float, default=0.6, metavar="F", help="Caption width as a fraction of art width") ap.add_argument("--caption-gap", type=int, default=1, metavar="N") @@ -698,6 +702,8 @@ def main(): position=args.caption_pos, style=args.caption_style, scale=args.caption_scale, + cols=args.caption_cols, + rows=args.caption_rows, gap=args.caption_gap, align=args.caption_align, ) diff --git a/src/asciimagic/static/app.js b/src/asciimagic/static/app.js index a8e0d65..787038e 100644 --- a/src/asciimagic/static/app.js +++ b/src/asciimagic/static/app.js @@ -99,6 +99,8 @@ function collectOptions() { caption_pos: $("caption_pos").value, caption_style: $("caption_style").value, caption_scale: num("caption_scale"), + caption_cols: num("caption_cols"), + caption_rows: num("caption_rows"), caption_align: $("caption_align").value, caption_color: $("caption_color_mode").value === "custom" ? $("caption_custom_color").value @@ -438,17 +440,17 @@ function artOnlyBox(d) { }; } -// Caption ring: wraps just the caption lines (gap excluded), only for the -// styles where the Size knob actually scales the lettering. -const CAP_SCALABLE = new Set(["block", "small", "shadow", "figlet"]); - +// Caption ring: wraps the caption's INK (gap and padding excluded). Every +// style can free-transform now that exact cols/rows grid-scale the block. function captionBox(d) { const lines = state.art.cap_lines || 0; - if (!lines || !CAP_SCALABLE.has(state.art.cap_style)) return null; - const ch = cellHDisplay(d); - const h = lines * ch; + if (!lines) return null; + const c = { w: d.w / state.art.cols, h: cellHDisplay(d) }; + const h = lines * c.h; const y = state.art.cap_pos === "top" ? d.y : d.y + d.h - h; - return { x: d.x, y, w: d.w, h }; + const inkCols = state.art.cap_cols || state.art.cols; + const x = d.x + (state.art.cap_x || 0) * c.w; + return { x, y, w: inkCols * c.w, h }; } function showRing(d) { @@ -466,7 +468,7 @@ function showRing(d) { if (capBox) { capRing.hidden = false; applyCapRing(); - updateCapLabel(Math.round((num("caption_scale") || 0.6) * 100)); + updateCapLabel(`${state.art.cap_cols || "?"} × ${state.art.cap_lines}`); } else { capRing.hidden = true; } @@ -486,8 +488,8 @@ function applyCapRing() { capRing.style.height = capBox.h + "px"; } -function updateCapLabel(pct) { - $("cap-ring-label").textContent = `caption ${pct}%`; +function updateCapLabel(text) { + $("cap-ring-label").textContent = `caption ${text}`; } function cellSize() { @@ -554,49 +556,65 @@ $("handle-e").addEventListener("pointerdown", startDrag("e")); $("handle-s").addEventListener("pointerdown", startDrag("s")); $("handle-se").addEventListener("pointerdown", startDrag("se")); -// Caption drag: width maps to the caption Size knob (fraction of art width). -$("cap-handle").addEventListener("pointerdown", (e) => { - e.preventDefault(); - const handle = e.currentTarget; - handle.setPointerCapture(e.pointerId); - document.getElementById("preview-wrap").classList.add("dragging"); - capRing.classList.add("dragging"); - const start = { x: e.clientX, w: capBox.w }; - const artW = ringBox.w; // caption scale is relative to the art width - - const toScale = (w) => Math.min(1, Math.max(0.05, w / Math.max(1, artW))); - const snap = (s) => Math.round(s * 20) / 20; // the Size slider steps by 0.05 - - const move = (ev) => { - const w = Math.max(12, start.w + (ev.clientX - start.x)); - capBox.w = w; - applyCapRing(); - updateCapLabel(Math.round(snap(toScale(w)) * 100)); - }; - const up = (ev) => { - handle.removeEventListener("pointermove", move); - handle.removeEventListener("pointerup", up); - handle.removeEventListener("pointercancel", up); - try { handle.releasePointerCapture(ev.pointerId); } catch (_) {} - capRing.classList.remove("dragging"); - document.getElementById("preview-wrap").classList.remove("dragging"); - $("caption_scale").value = snap(toScale(capBox.w)); - $("caption_scale-out").value = $("caption_scale").value; - if (state.tab === "video") { - setStatus(`Caption size set to ${Math.round(snap(toScale(capBox.w)) * 100)}% — press Render`, "busy"); - } else { - render(); - } +// Caption drag: continuous free transform — commits exact chars x rows to +// the caption Width/Height knobs (the Auto-size slider only applies when +// those are empty). +function capStartDrag(axis) { + return (e) => { + e.preventDefault(); + const handle = e.currentTarget; + handle.setPointerCapture(e.pointerId); + document.getElementById("preview-wrap").classList.add("dragging"); + capRing.classList.add("dragging"); + const start = { x: e.clientX, y: e.clientY, w: capBox.w, h: capBox.h, + aspect: capBox.w / Math.max(1, capBox.h) }; + + const capDims = () => { + const c = cellSize(); + return { + cols: Math.min(state.art.cols, Math.max(2, Math.round(capBox.w / c.w))), + rows: Math.min(200, Math.max(1, Math.round(capBox.h / c.h))), + }; + }; + + const move = (ev) => { + if (axis !== "s") capBox.w = Math.max(12, start.w + (ev.clientX - start.x)); + if (axis !== "e") capBox.h = Math.max(6, start.h + (ev.clientY - start.y)); + if (axis === "se" && ev.shiftKey) capBox.h = capBox.w / start.aspect; + applyCapRing(); + const d = capDims(); + updateCapLabel(`${d.cols} × ${d.rows}`); + }; + const up = (ev) => { + handle.removeEventListener("pointermove", move); + handle.removeEventListener("pointerup", up); + handle.removeEventListener("pointercancel", up); + try { handle.releasePointerCapture(ev.pointerId); } catch (_) {} + capRing.classList.remove("dragging"); + document.getElementById("preview-wrap").classList.remove("dragging"); + const d = capDims(); + if (axis !== "s") $("caption_cols").value = d.cols; + if (axis !== "e") $("caption_rows").value = d.rows; + if (axis === "se") { $("caption_cols").value = d.cols; $("caption_rows").value = d.rows; } + if (state.tab === "video") { + setStatus(`Caption size set to ${d.cols} × ${d.rows} — press Render`, "busy"); + } else { + render(); + } + }; + handle.addEventListener("pointermove", move); + handle.addEventListener("pointerup", up); + handle.addEventListener("pointercancel", up); }; - handle.addEventListener("pointermove", move); - handle.addEventListener("pointerup", up); - handle.addEventListener("pointercancel", up); -}); +} +$("cap-handle-e").addEventListener("pointerdown", capStartDrag("e")); +$("cap-handle-s").addEventListener("pointerdown", capStartDrag("s")); +$("cap-handle-se").addEventListener("pointerdown", capStartDrag("se")); capRing.addEventListener("dblclick", () => { - $("caption_scale").value = 0.6; - $("caption_scale-out").value = "0.6"; - if (state.tab === "video") setStatus("Caption size reset — press Render", "busy"); + $("caption_cols").value = ""; + $("caption_rows").value = ""; + if (state.tab === "video") setStatus("Caption size reset to auto — press Render", "busy"); else render(); }); diff --git a/src/asciimagic/static/index.html b/src/asciimagic/static/index.html index 49ebc34..df4aa38 100644 --- a/src/asciimagic/static/index.html +++ b/src/asciimagic/static/index.html @@ -195,9 +195,13 @@

ASCIIMagic

- +
+
+
+
+