wcwidth
wcwidth
Unicode-correct terminal display width for Crystal.
A Crystal port of shpeckman/wcwidth, measuring text the way a terminal actually renders it: by grapheme cluster, not by code point.
Table of Contents
- Overview
- Features
- Installation
- Usage
- API Reference
- Configuration
- Behavior Notes
- Verification
- Performance
- License
Overview
String#size counts code points, which is the wrong unit for terminal layout. A family emoji is seven code points but occupies two columns; e followed by a combining acute is two code points but one column. Getting this wrong misaligns tables, corrupts progress bars, and leaves cursor artifacts.
This library segments text into grapheme clusters per UAX #29 and reports the display width of each.
"๐จโ๐ฉโ๐งโ๐ฆ".size # => 7
WCWidth.swidth("๐จโ๐ฉโ๐งโ๐ฆ") # => 2
Features
- Full UAX #29 grapheme cluster segmentation, including ZWJ emoji sequences, regional-indicator flag pairs, Hangul syllables, and Indic conjuncts (GB9c)
- Emoji presentation handling: VS15/VS16 variation selectors widen or narrow the base character
- Two-stage lookup tables generated from Unicode 17.0.0
- Streaming API that segments across buffer boundaries, for terminal input paths
- Configurable UTF-8 error handling and POSIX-compatible control-character semantics
- Zero dependencies; no allocation in the core measurement path
Installation
Add the dependency to shard.yml:
dependencies:
wcwidth:
github: shpeckman/wcwidth-cr
Then run:
shards install
And require it:
require "wcwidth"
Usage
Whole-string width
WCWidth.swidth("ๆๆฌ") # => 4
WCWidth.swidth("๐จโ๐ฉโ๐งโ๐ฆ") # => 2
WCWidth.width('ๆ') # => 2 (single code point)
Column helpers
WCWidth.grapheme_count("๐ฏ๐ต๐ฉ๐ช") # => 2
WCWidth.truncate("ๆๆๆ", 5) # => "ๆๆ"
WCWidth.truncate("Hello world", 8, "โฆ") # => "Hello wโฆ"
WCWidth.ljust("ๆ", 6) # => "ๆ "
truncate never splits a cluster. A wide cluster that would straddle the limit is dropped rather than half-rendered, so truncate("ๆๆๆ", 5) yields two columns of slack rather than a broken glyph.
One-shot measurement
measure returns the width of the first grapheme and how far it extends:
m = WCWidth.measure("e\u0301x")
m.width # => 1
m.consumed # => 3 (bytes)
The same works over code points, where consumed counts code points instead:
WCWidth.swidth(Slice[0x41_u32, 0x6587_u32]) # => 3
m = WCWidth.measure(Slice[0x65_u32, 0x301_u32, 0x78_u32])
m.width # => 1
m.consumed # => 2 (stops before 'x')
Grapheme iteration
WCWidth.each_grapheme("Aๆ") { |g, w| puts "#{g}=#{w}" }
# A=1
# ๆ=2
WCWidth.graphemes("Aๆ๐").map { |_, w| w }.to_a # => [1, 2, 2]
WCWidth.grapheme_next("e\u0301x") # => 3
graphemes returns a lazy Iterator({String, Int32}), so it composes with map, sum, select, and friends.
Streaming
Drive the state machine directly to segment across reads. A persistent State carries the context needed to decide breaks that span buffer boundaries:
st = WCWidth::State.new
cl = WCWidth::Cluster.new
total = 0
started = false
each_codepoint_from_terminal do |cp|
if st.grapheme_break?(cp) && started
total += cl.width
cl.reset
end
cl.push(cp)
started = true
end
total += cl.width if started
API Reference
Measurement
| Method | Returns |
|---|---|
.width(cp) |
Width of one code point: 0, 1, 2, or -1 for controls. Accepts Char, Int, or UInt32. |
.swidth(input, policy, ctrl) |
Summed width of every cluster. Accepts String, Bytes, or Slice(UInt32). |
.measure(input, policy) |
Measured(width, consumed) for the first cluster only. |
.grapheme_next(input, policy) |
Length of the next cluster, in bytes or code points. |
Iteration
| Method | Returns |
|---|---|
.each_grapheme(input, policy, &) |
Yields each cluster and its width. |
.graphemes(input, policy) |
Lazy Iterator({String, Int32}). |
.grapheme_count(str, policy) |
Number of clusters. |
Layout
| Method | Returns |
|---|---|
.truncate(str, columns) |
Longest prefix fitting columns; never splits a cluster. |
.truncate(str, columns, ellipsis) |
As above, appending ellipsis when truncated. Result still fits. |
.ljust(str, columns) / .rjust(str, columns) |
Pad by display width. |
Streaming types
| Type | Purpose |
|---|---|
State |
Segmentation state. #grapheme_break?(cp) returns whether a boundary precedes cp; #reset clears it. |
Cluster |
Width accumulator. #push(cp), #width, #reset. |
Policies
UTF8Policy controls malformed input:
Replace(default) โ malformed, truncated, overlong, or surrogate sequences become U+FFFD, advancing one byte. Never reads past the given length.Strictโ stop at the first malformed sequence and report what was consumed.
CtrlPolicy controls control characters in swidth:
Skip(default) โ controls contribute0; the result is the sum of printable clusters.Failโ any control makes the whole call return-1, matching POSIXwcswidth.
WCWidth.swidth("A\u001BB", WCWidth::UTF8Policy::Replace, WCWidth::CtrlPolicy::Fail) # => -1
WCWidth.swidth(Bytes[0x41, 0x80], WCWidth::UTF8Policy::Strict) # => 1
Introspection
WCWidth.unicode_version # => "17.0.0"
WCWidth::ACTIVE_WIDTH_CAP # => 2
Configuration
Cluster-width cap
By default a cluster is capped at two columns, matching the Mode 2027 majority (WezTerm, Ghostty, foot). To report the base character's own width instead, without a cap (kitty-style), compile with:
crystal build -Dwcw_cluster_width_cap_0 app.cr
The flag must be set consistently for the library and any code reading ACTIVE_WIDTH_CAP.
Regenerating tables
src/wcwidth/tables.cr is generated. To rebuild it from the upstream C header:
make tables
Behavior Notes
- Controls report
-1per code point and per cluster;swidthappliesCtrlPolicyto decide what that means for the whole string. - Under
UTF8Policy::Replacethe UTF-8 entry points always advance at least one byte, so iteration cannot stall on malformed input. - Regional indicators pair up: two form one two-column cluster, and a third begins a new one.
- All functions are reentrant. State lives only in caller-owned
StateandClustervalues; the tables are read-only and no globals are mutated.
Verification
The port is checked against the original C library rather than only against hand-written expectations:
- All 1,114,112 code point widths are byte-identical to the C implementation
- All 766 official Unicode
GraphemeBreakTestcases pass - 60,000 fuzz cases covering malformed UTF-8, overlong encodings, surrogates, and random bytes produce identical results across all nine public functions
- 200,000 randomized strings satisfy the layout invariants, including that
truncateis maximal โ no additional cluster could fit - 60 specs
make spec
Performance
Release build, measured against the same corpora as the C benchmark (make bench):
| Corpus | swidth_utf8 |
next_utf8 |
width_cp |
|---|---|---|---|
| ascii | 72 MiB/s | 46 MiB/s | 656 Mcp/s |
| cjk | 161 MiB/s | 96 MiB/s | 655 Mcp/s |
| emoji-zwj | 173 MiB/s | 142 MiB/s | 656 Mcp/s |
| combining | 97 MiB/s | 77 MiB/s | 645 Mcp/s |
| mixed | 104 MiB/s | 63 MiB/s | 654 Mcp/s |
Throughput is comparable to the gcc -O2 build of the original.
License
MIT, matching upstream. See LICENSE.
The lookup tables and spec/graphemebreaktest.txt are derived from the Unicode Character Database and are subject to the Unicode License v3; see LICENSE-UNICODE.
wcwidth
- 0
- 0
- 0
- 0
- 0
- 1 day ago
- July 19, 2026
MIT License
Sun, 19 Jul 2026 22:33:42 GMT