Skip to content
Closed
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
17 changes: 9 additions & 8 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ The main entry point is `src/terminal/init.lua`, which exposes the `terminal` mo
- Holds version metadata and high-level helpers:
- `terminal.size()` – wrapper around `system.termsize`.
- `terminal.bell()` / `terminal.bell_seq()` – terminal bell.
- `terminal.preload_widths()` – preloads characters into the width cache for box drawing and progress spinners.
- `terminal.preload_widths()` – detects the terminal’s ambiguous character width (for East Asian width). Call after init if you use `terminal.draw` or `terminal.progress`.
- Manages initialization/shutdown and integration with `system`:
- Console flags, non-blocking input, code page, alternate screen buffer.
- Sleep function wiring for async usage.
Expand Down Expand Up @@ -237,12 +237,13 @@ Terminal UI must align and truncate text by **display columns**, not by bytes or

### 5.1 Display width

- **`terminal.text.width`** provides the width primitives:
- **`utf8cwidth(char)`** – width in columns of a single character (string or codepoint). Uses a cache when available; otherwise falls back to `system.utf8cwidth`.
- **`utf8swidth(str)`** – total display width of a string in columns.
- **Width cache:** Not all characters have a fixed width (e.g. East Asian ambiguous). The library maintains a cache of **tested** widths. To populate it:
- **`terminal.text.width.test(str)`** – writes characters invisibly, measures cursor movement, and records each character’s width. Call during startup or when you first display unknown glyphs.
- **`terminal.preload_widths(str)`** – convenience that tests the library’s own box-drawing and progress characters plus any optional `str`. Call once after `terminal.initialize` if you use `terminal.draw` or `terminal.progress`.
- **`terminal.text.width`** provides the width primitives (delegates to LuaSystem >= 0.7.0):
- **`utf8cwidth(char)`** – width in columns of a single character (string or codepoint). Uses **`system.utf8cwidth(char, ambiguous_width)`**.
- **`utf8swidth(str)`** – total display width of a string in columns. Uses **`system.utf8swidth(str, ambiguous_width)`**.
- **Ambiguous width:** East Asian ambiguous characters can be 1 or 2 columns. The library probes **one** ambiguous character at initialization and stores the result in **`terminal.text.width.ambiguous_width`** (1 or 2). All width calls pass this value to LuaSystem.
- **`terminal.text.width.detect_ambiguous_width()`** – probes the terminal (when initialized and TTY) and sets `ambiguous_width`; idempotent. Called automatically by `preload_widths` and by `test` / `test_write`.
- **`terminal.preload_widths(str)`** – calls `detect_ambiguous_width()`. Call once after `terminal.initialize` if you use `terminal.draw` or `terminal.progress`. The optional `str` is ignored (kept for API compatibility).
- **`terminal.text.width.test(str)`** / **`test_write(str)`** – ensure detection has run, then return `utf8swidth(str)` (and optionally write). No per-character cache or probing.
- Use **`terminal.size()`** to get terminal dimensions (rows × columns) so you can fit text to the visible area.

**Rule of thumb:** For correct alignment and truncation, always reason in **columns**. Use `utf8swidth` to measure strings and `utf8cwidth` for per-character width when implementing substrings or cursors.
Expand Down Expand Up @@ -289,7 +290,7 @@ Key methods for display and layout:

- **Simple truncation or fixed-width slice:** use **`utils.utf8sub_col(str, 1, max_col)`** (and optionally ellipsis).
- **Editable single/multi-line text with cursor and word wrap:** use **EditLine** and **`EditLine:format(...)`**.
- **Measuring or testing width:** use **`terminal.text.width.utf8swidth`** / **`utf8cwidth`** and **`terminal.text.width.test`** / **`terminal.preload_widths`** as above.
- **Measuring or testing width:** use **`terminal.text.width.utf8swidth`** / **`utf8cwidth`**; call **`terminal.preload_widths()`** after init to detect ambiguous width.

All terminal output must go through **`terminal.output`** (e.g. `terminal.output.write`), not raw `print` or `io.write`, so that the library’s stream and any patching behave correctly.

Expand Down
170 changes: 170 additions & 0 deletions spec/19-text_width_spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
local helpers = require "spec.helpers"


describe("terminal.text.width", function()

local width

setup(function()
helpers.load()
width = require("terminal.text.width")
end)


teardown(function()
helpers.unload()
end)



describe("utf8cwidth()", function()

it("returns 1 for ASCII characters", function()
assert.are.equal(1, width.utf8cwidth(65))
assert.are.equal(1, width.utf8cwidth("A"))
assert.are.equal(1, width.utf8cwidth(" "))
end)


it("accepts string or codepoint and returns same width", function()
assert.are.equal(width.utf8cwidth("x"), width.utf8cwidth(0x78))
end)


it("returns 2 for fullwidth characters (e.g. CJK)", function()
assert.are.equal(2, width.utf8cwidth("你"))
assert.are.equal(2, width.utf8cwidth(0x4F60))
end)


it("uses ambiguous_width for ambiguous characters", function()
local mid = require("utf8").char(0x00B7)
width.set_ambiguous_width(1)
assert.are.equal(1, width.utf8cwidth(mid), "ambiguous_width=1 should give 1")
width.set_ambiguous_width(2)
local w2 = width.utf8cwidth(mid)
assert.is_true(w2 == 1 or w2 == 2, "ambiguous_width=2 should give 1 or 2, got " .. tostring(w2))
width.set_ambiguous_width(1)
end)


it("errors on invalid type", function()
assert.has_error(function()
width.utf8cwidth({})
end, "expected string or number, got table")
end)

end)



describe("utf8swidth()", function()

it("returns 0 for empty string", function()
assert.are.equal(0, width.utf8swidth(""))
end)


it("returns correct width for ASCII string", function()
assert.are.equal(5, width.utf8swidth("Hello"))
end)


it("returns correct width for double-width characters", function()
assert.are.equal(4, width.utf8swidth("你好"))
end)


it("returns correct width for mixed ASCII and wide", function()
assert.are.equal(6, width.utf8swidth("Hi你好"))
end)


it("respects set ambiguous_width", function()
local mid = require("utf8").char(0x00B7)
width.set_ambiguous_width(1)
local w1 = width.utf8swidth(mid)
width.set_ambiguous_width(2)
local w2 = width.utf8swidth(mid)
assert.are.equal(1, w1)
assert.is_true(w2 == 1 or w2 == 2, "ambiguous_width=2 should give 1 or 2, got " .. tostring(w2))
width.set_ambiguous_width(1)
end)

end)



describe("set_ambiguous_width()", function()

it("accepts only 1 or 2", function()
width.set_ambiguous_width(1)
width.set_ambiguous_width(2)
assert.has_error(function()
width.set_ambiguous_width(0)
end, "ambiguous_width must be 1 or 2, got 0")
assert.has_error(function()
width.set_ambiguous_width(3)
end, "ambiguous_width must be 1 or 2, got 3")
end)

end)



describe("detect_ambiguous_width()", function()

it("returns 1 when terminal not ready (no write)", function()
width.ambiguous_width = nil
local w = width.detect_ambiguous_width()
assert.are.equal(1, w)
assert.are.equal(1, width.ambiguous_width)
end)


it("is idempotent when ambiguous_width already set", function()
width.set_ambiguous_width(2)
local w = width.detect_ambiguous_width()
assert.are.equal(2, w)
width.set_ambiguous_width(1)
end)

end)



describe("test()", function()

it("returns same value as utf8swidth for given string", function()
width.set_ambiguous_width(1)
local str = "hello"
assert.are.equal(width.utf8swidth(str), width.test(str))
end)


it("returns 0 for empty or nil", function()
assert.are.equal(0, width.test(""))
assert.are.equal(0, width.test(nil))
end)

end)



describe("test_write()", function()

it("returns width of written string", function()
local str = "ab"
local w = width.test_write(str)
assert.are.equal(2, w)
end)


it("returns 0 for empty or nil", function()
assert.are.equal(0, width.test_write(""))
assert.are.equal(0, width.test_write(nil))
end)

end)

end)
13 changes: 6 additions & 7 deletions src/terminal/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -83,16 +83,15 @@ end



--- Preload known characters into the width-cache.
-- Typically this should be called right after initialization. It will check default
-- characters in use by this library, and the optional specified characters in `str`.
-- Characters loaded will be the `terminal.draw.box_fmt` formats, and the `progress` spinner sprites.
-- Uses `terminal.text.width.test` to test the widths of the characters.
-- @tparam[opt] string str additional character string to preload
--- Detect the terminal's ambiguous character width (for East Asian width).
-- Call once after `initialize` so that `terminal.text.width.utf8cwidth` and
-- `utf8swidth` use the correct width (1 or 2) for ambiguous-width characters.
-- Optional `str` is ignored; kept for API compatibility.
-- @tparam[opt] string str ignored; kept for backward compatibility
-- @return true
-- @within Initialization
function M.preload_widths(str)
text.width.test((str or "") .. M.progress._spinner_fmt_chars() .. M.draw._box_fmt_chars())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since this is removed, the 2 functions; _spinner_fmt_chars() and _box_fmt_chars() have lost their utility and can be removed from their respective modules.

text.width.detect_ambiguous_width()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this function name no longer covers what it does. This can be removed all together. We can just call the detection from the terminal initialization.

return true
end

Expand Down
8 changes: 8 additions & 0 deletions src/terminal/output.lua
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ end



--- Returns the current output stream (e.g. for isatty checks).
-- @treturn file the stream set by `set_stream` or the default
function M.get_stream()
return t
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

an alternative could be to add t.output.isatty() (and to keep it symmetrical also t.input.isatty())

end



--- Writes to the stream.
-- This is a safer write-function than the standard Lua one.
-- It doesn't add add tabs between arguments, and it doesn't add a newline at the end (like `print` does).
Expand Down
Loading
Loading