Scale box-drawing characters vertically to fill line height

Box-drawing characters (│┃║┌┐└┘├┤etc) are designed to connect between
lines but the font's em-box is smaller than our line-height (14px vs
16.8px), creating visible gaps.

Solution: Render box-drawing characters as separate text elements with
a vertical scale transform of 1.2 (matching line-height) to stretch
them to fill the full cell height and connect properly.

This fixes disconnected vertical lines and corners in TUI applications.
This commit is contained in:
GitHub Copilot
2026-01-24 20:11:46 +00:00
parent 3701a3df31
commit ba23994c68
4 changed files with 50 additions and 2 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "textual-webterm" name = "textual-webterm"
version = "0.3.13" version = "0.3.14"
description = "Serve terminal sessions over the web" description = "Serve terminal sessions over the web"
authors = ["Will McGugan <will@textualize.io>"] authors = ["Will McGugan <will@textualize.io>"]
license = "MIT" license = "MIT"
+33 -1
View File
@@ -50,6 +50,25 @@ FONT_SIZE = 14
LINE_HEIGHT = 1.2 LINE_HEIGHT = 1.2
CHAR_WIDTH = 8 # Width of monospace character at 14px (typically ~0.57 ratio) CHAR_WIDTH = 8 # Width of monospace character at 14px (typically ~0.57 ratio)
# Box drawing characters that need vertical scaling to fill line height
# These are designed to connect between lines but the font's em-box is smaller
# than our line height, creating gaps
BOX_DRAWING_CHARS = frozenset(
# Light and heavy box drawing (U+2500-U+257F)
"─━│┃┄┅┆┇┈┉┊┋┌┍┎┏┐┑┒┓└┕┖┗┘┙┚┛├┝┞┟┠┡┢┣┤┥┦┧┨┩┪┫┬┭┮┯┰┱┲┳┴┵┶┷┸┹┺┻┼┽┾┿╀╁╂╃╄╅╆╇╈╉╊╋"
# Double box drawing
"═║╒╓╔╕╖╗╘╙╚╛╜╝╞╟╠╡╢╣╤╥╦╧╨╩╪╫╬"
# Rounded corners
"╭╮╯╰"
# Light and heavy dashed (U+2571-U+257F)
"\u2571\u2572\u2573╴╵╶╷╸╹╺╻╼╽╾╿"
)
def _is_box_drawing(char: str) -> bool:
"""Check if character is a box-drawing character that needs scaling."""
return len(char) == 1 and char in BOX_DRAWING_CHARS
class CharData(TypedDict): class CharData(TypedDict):
"""Character data from pyte screen buffer.""" """Character data from pyte screen buffer."""
@@ -232,7 +251,20 @@ def render_terminal_svg(
if classes: if classes:
attrs.append(f'class="{" ".join(classes)}"') attrs.append(f'class="{" ".join(classes)}"')
row_tspans.append(f'<tspan {" ".join(attrs)}>{_escape_xml(char_data)}</tspan>') # Box-drawing characters need vertical scaling to fill line height
# Render them as separate text elements with transform
if _is_box_drawing(char_data):
# Scale vertically by line_height ratio, anchored at top of cell
# The transform scales around (x, rect_y) to stretch the glyph
fill_attr = f' fill="{fg}"' if fg != foreground else ""
class_attr = f' class="{" ".join(classes)}"' if classes else ""
row_bg_rects.append(
f'<text x="{x:.1f}" y="{text_y:.1f}" '
f'transform="translate(0,{rect_y:.1f}) scale(1,{line_height}) translate(0,{-rect_y:.1f})"'
f'{fill_attr}{class_attr}>{_escape_xml(char_data)}</text>'
)
else:
row_tspans.append(f'<tspan {" ".join(attrs)}>{_escape_xml(char_data)}</tspan>')
col += char_cols col += char_cols
+16
View File
@@ -328,6 +328,22 @@ class TestRenderTerminalSvg:
# Text tspan with yellow # Text tspan with yellow
assert f'fill="{ANSI_COLORS["yellow"]}"' in svg assert f'fill="{ANSI_COLORS["yellow"]}"' in svg
def test_box_drawing_vertical_scale(self) -> None:
"""Box-drawing characters are scaled vertically to fill line height."""
buffer = [[self._char("")]] # Vertical line
svg = render_terminal_svg(buffer, width=80, height=24)
# Box drawing chars rendered with transform for vertical scaling
assert 'scale(1,1.2)' in svg
# Should be a separate text element, not a tspan
assert '<text x="' in svg
def test_box_drawing_corners(self) -> None:
"""Box-drawing corner characters are scaled."""
buffer = [[self._char(""), self._char("")]]
svg = render_terminal_svg(buffer, width=80, height=24)
# Both corners should have scale transforms
assert svg.count('scale(1,1.2)') == 2
def test_unicode_text(self) -> None: def test_unicode_text(self) -> None:
"""Unicode text is preserved.""" """Unicode text is preserved."""
buffer = self._make_buffer(["你好世界"]) buffer = self._make_buffer(["你好世界"])