diff --git a/CHANGELOG.md b/CHANGELOG.md index c2e22d8..34a3173 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,8 +32,9 @@ The scope of what is covered by the version number excludes: ### Version X.Y.Z, unreleased -- a fix -- a change +- feat(progress): strip ANSI sequences for sprite width calculations. + Spinner frames and done sprites can now include color/style sequences + without breaking cursor rewind behavior. ### Version 0.1.0, released 01-Jan-2022 diff --git a/spec/12-draw_spec.lua b/spec/12-draw_spec.lua index 6d66609..245fa3a 100644 --- a/spec/12-draw_spec.lua +++ b/spec/12-draw_spec.lua @@ -138,6 +138,18 @@ describe("terminal.draw", function() assert.are.equal("…llo 测试!", result) end) + + it("preserves ANSI codes in title when no truncation is needed", function() + local result = line.title_seq(10, "\27[31mTest\27[0m") + assert.are.equal("───\27[31mTest\27[0m───", result) + end) + + + it("strips ANSI codes when title truncation is needed", function() + local result = line.title_seq(8, "\27[31mVeryLongTitle\27[0m") + assert.are.equal("VeryLon…", result) + end) + end) @@ -191,4 +203,4 @@ describe("terminal.draw", function() end) -end) +end) \ No newline at end of file diff --git a/spec/13-progress_spec.lua b/spec/13-progress_spec.lua new file mode 100644 index 0000000..1f53356 --- /dev/null +++ b/spec/13-progress_spec.lua @@ -0,0 +1,87 @@ +local helpers = require "spec.helpers" + + +describe("terminal.progress", function() + + local terminal + local progress + + setup(function() + terminal = helpers.load() + progress = require("terminal.progress") + end) + + + teardown(function() + progress = nil + terminal = nil + helpers.unload() + end) + + + + describe("spinner()", function() + + before_each(function() + helpers.clear_output() + end) + + + + it("uses visible width for ANSI-styled single-width sprites", function() + local spinner = progress.spinner({ + sprites = { + [0] = "", + "\27[31mX\27[0m", + }, + stepsize = 10, + }) + + spinner(false) + + assert.are.equal( + "\27[31mX\27[0m" .. terminal.cursor.position.left_seq(1), + helpers.get_output() + ) + end) + + + it("uses visible width for ANSI-styled double-width sprites", function() + local spinner = progress.spinner({ + sprites = { + [0] = "", + "\27[31m界\27[0m", + }, + stepsize = 10, + }) + + spinner(false) + + assert.are.equal( + "\27[31m界\27[0m" .. terminal.cursor.position.left_seq(2), + helpers.get_output() + ) + end) + + + it("uses visible width for ANSI-styled done_sprite", function() + local spinner = progress.spinner({ + sprites = { + [0] = "x", + "x", + }, + done_sprite = "\27[32mOK\27[0m", + stepsize = 10, + }) + + spinner(true) + + assert.are.equal( + "\27[32mOK\27[0m" .. terminal.cursor.position.left_seq(2), + helpers.get_output() + ) + end) + + end) + +end) \ No newline at end of file diff --git a/src/terminal/draw/line.lua b/src/terminal/draw/line.lua index c18cd46..7482f7c 100644 --- a/src/terminal/draw/line.lua +++ b/src/terminal/draw/line.lua @@ -76,6 +76,8 @@ end --- Creates a sequence to draw a horizontal line with a title centered in it without writing it to the terminal. -- Line is drawn left to right. If the width is too small for the title, the title is truncated. -- If less than 4 characters are available for the title, the title is omitted altogether. +-- ANSI escape sequences in title/prefix/postfix are ignored for width calculations. +-- If truncation is needed, the rendered title uses plain text. -- @tparam number width the total width of the line in columns -- @tparam[opt=""] string title the title to draw (if empty or nil, only the line is drawn) -- @tparam[opt="─"] string char the line-character to use @@ -92,11 +94,20 @@ function M.title_seq(width, title, char, pre, post, type, title_attr) pre = pre or "" post = post or "" - local pre_w = text.width.utf8swidth(pre) - local post_w = text.width.utf8swidth(post) + local pre_w = text.width.utf8swidth(utils.strip_ansi(pre)) + local post_w = text.width.utf8swidth(utils.strip_ansi(post)) local w_for_title = width - pre_w - post_w - local title, title_w = utils.truncate_ellipsis(w_for_title, title, type) + local stripped_title = utils.strip_ansi(title) + local stripped_title_w = text.width.utf8swidth(stripped_title) + local title_w + if stripped_title_w <= w_for_title then + title_w = stripped_title_w + else + stripped_title, title_w = utils.truncate_ellipsis(w_for_title, stripped_title, type) + title = stripped_title + end + if title_w == 0 then return M.horizontal_seq(width, char) end @@ -139,4 +150,4 @@ end -return M +return M \ No newline at end of file diff --git a/src/terminal/progress.lua b/src/terminal/progress.lua index 889bc52..82c673f 100644 --- a/src/terminal/progress.lua +++ b/src/terminal/progress.lua @@ -13,6 +13,12 @@ local gettime = require("system").gettime +local function visible_width(str) + return tw.utf8swidth(utils.strip_ansi(str)) +end + + + --- table with predefined sprites for progress spinners. -- The sprites are tables of strings, where each string is a frame in the spinner animation. -- The frame at index 0 is optional and is the "done" message, the rest are the animation frames. @@ -71,6 +77,8 @@ end -- If `row` and `col` are given then terminal memory is used to (re)store the cursor position. If they are not given -- then the spinner will be printed at the current cursor position, and the cursor will return to the same position -- after each update. +-- ANSI escape sequences in sprites are ignored for width calculations, allowing +-- styled/colorized sprite frames. -- @tparam table opts a table of options; -- @tparam table opts.sprites a table of strings to display, one at a time, overwriting the previous one. Index 0 is the "done" message. -- See `sprites` for a table of predefined sprites. @@ -118,10 +126,11 @@ function M.spinner(opts) if i == 0 then s = opts.done_sprite or s end + local w = visible_width(s) local sequence = Sequence() sequence[#sequence+1] = pos_set sequence[#sequence+1] = (i == 0 and attr_push_done) or attr_push or nil - sequence[#sequence+1] = s .. t.cursor.position.left_seq(t.text.width.utf8swidth(s)) + sequence[#sequence+1] = s .. t.cursor.position.left_seq(w) sequence[#sequence+1] = attr_pop sequence[#sequence+1] = pos_restore steps[i] = sequence @@ -168,7 +177,7 @@ function M.ticker(text, width, text_done) local max_len = 0 for i = 1, lengths[0] do result[i] = utils.utf8sub(base, i, i + width - 1) - lengths[i] = tw.utf8swidth(result[i]) + lengths[i] = visible_width(result[i]) max_len = math.max(max_len, lengths[i]) end result[0] = utils.utf8sub(result[0], 1, max_len) @@ -185,4 +194,4 @@ end -return M +return M \ No newline at end of file