-- SPDX-License-Identifier: ISC---- A small VT100/xterm screen emulator, so a test can read the grid an-- ncurses application would have painted. It covers what ncurses emits-- under xterm-256color: cursor motion, erase, scroll region, insert and-- delete, SGR text attributes. Mouse reports, palette changes and titles-- are parsed and dropped. Colours are dropped too -- assertions are about-- layout and emphasis, and every cell carries a text-attribute mask.local vt = {} local BOLD = 1local ITALIC = 2local UNDERLINE = 4local BLINK = 8local REVERSE = 16local STRIKE = 32 vt.BOLD = BOLDvt.ITALIC = ITALICvt.UNDERLINE = UNDERLINEvt.BLINK = BLINKvt.REVERSE = REVERSEvt.STRIKE = STRIKE -- SGR parameter -> {bit, on}.local SGR_TEXT = { [1] = { BOLD, true }, [3] = { ITALIC, true }, [4] = { UNDERLINE, true }, [5] = { BLINK, true }, [7] = { REVERSE, true }, [9] = { STRIKE, true }, [22] = { BOLD, false }, [23] = { ITALIC, false }, [24] = { UNDERLINE, false }, [25] = { BLINK, false }, [27] = { REVERSE, false }, [29] = { STRIKE, false },} -- Modes are tracked by number; private ones are shifted so DECAWM (private-- 7) never collides with the ANSI mode 7.local IRM = 4local DECOM = 6 * 32local DECAWM = 7 * 32 -- Cells are immutable and interned, so a screen of spaces costs one table.local cell_cache = {} local function cell(ch, attr) local by_attr = cell_cache[attr] if by_attr == nil then by_attr = {} cell_cache[attr] = by_attr end local c = by_attr[ch] if c == nil then c = { ch = ch, attr = attr } by_attr[ch] = c end return cend local BLANK = cell(" ", 0) -- Character width. Everything outside the wide and zero-width ranges is one-- column, which is all a terminal needs to lay text out.local WIDE = { { 0x1100, 0x115f }, { 0x2e80, 0x303e }, { 0x3041, 0x33ff }, { 0x3400, 0x4dbf }, { 0x4e00, 0x9fff }, { 0xa000, 0xa4cf }, { 0xac00, 0xd7a3 }, { 0xf900, 0xfaff }, { 0xfe30, 0xfe6f }, { 0xff00, 0xff60 }, { 0xffe0, 0xffe6 }, { 0x1f300, 0x1f64f }, { 0x1f900, 0x1f9ff }, { 0x20000, 0x3fffd },} local function char_width(cp) if cp == 0 then return 0 end if cp < 32 or (cp >= 0x7f and cp < 0xa0) then return -1 end if (cp >= 0x300 and cp <= 0x36f) or (cp >= 0x200b and cp <= 0x200f) then return 0 end for i = 1, #WIDE do if cp >= WIDE[i][1] and cp <= WIDE[i][2] then return 2 end end return 1end local Screen = {}Screen.__index = Screen -- Rows and cells are sparse: an absent entry reads as a blank with default-- attributes, which is what "never written" means on a real terminal.local function row(self, y) local r = self.buf[y] if r == nil then r = {} self.buf[y] = r end return rend local function at(self, y, x) local r = self.buf[y] if r == nil then return BLANK end return r[x] or BLANKend function vt.new(rows, cols) local self = setmetatable({}, Screen) self.rows = rows self.cols = cols self:reset() return selfend function Screen:reset() self.buf = {} self.margins = nil self.mode = { [DECAWM] = true } self.cy, self.cx, self.attr = 0, 0, 0 self.savepoints = {} self.tabstops = {} local x = 8 while x < self.cols do self.tabstops[x] = true x = x + 8 end self.pending = "" -- undecoded utf-8 tail self.state = "ground"end function Screen:margin_top() return self.margins and self.margins.top or 0end function Screen:margin_bottom() return self.margins and self.margins.bottom or self.rows - 1end local function ensure_h(self) if self.cx < 0 then self.cx = 0 elseif self.cx > self.cols - 1 then self.cx = self.cols - 1 endend local function ensure_v(self, use_margins) local top, bottom = 0, self.rows - 1 if (use_margins or self.mode[DECOM]) and self.margins then top, bottom = self.margins.top, self.margins.bottom end if self.cy < top then self.cy = top elseif self.cy > bottom then self.cy = bottom endend -- ---- rendering primitives ---- function Screen:draw_char(ch, cp) local w = char_width(cp) if self.cx == self.cols then if self.mode[DECAWM] then self.cx = 0 self:index() elseif w > 0 then self.cx = self.cx - w end end if self.mode[IRM] and w > 0 then self:insert_characters(w) end local r = row(self, self.cy) if w == 1 then r[self.cx] = cell(ch, self.attr) elseif w == 2 then r[self.cx] = cell(ch, self.attr) if self.cx + 1 < self.cols then r[self.cx + 1] = cell("", self.attr) end elseif w == 0 then -- A combining mark joins the character before it. if self.cx > 0 then local prev = r[self.cx - 1] or BLANK r[self.cx - 1] = cell(prev.ch .. ch, prev.attr) end else return end if w > 0 then self.cx = math.min(self.cx + w, self.cols) endend function Screen:index() local top, bottom = self:margin_top(), self:margin_bottom() if self.cy == bottom then for y = top, bottom - 1 do self.buf[y] = self.buf[y + 1] end self.buf[bottom] = nil else self:cursor_down(1) endend function Screen:reverse_index() local top, bottom = self:margin_top(), self:margin_bottom() if self.cy == top then for y = bottom, top + 1, -1 do self.buf[y] = self.buf[y - 1] end self.buf[top] = nil else self:cursor_up(1) endend function Screen:scroll_up(n) local top, bottom = self:margin_top(), self:margin_bottom() for _ = 1, n do for y = top, bottom - 1 do self.buf[y] = self.buf[y + 1] end self.buf[bottom] = nil endend function Screen:scroll_down(n) local top, bottom = self:margin_top(), self:margin_bottom() for _ = 1, n do for y = bottom, top + 1, -1 do self.buf[y] = self.buf[y - 1] end self.buf[top] = nil endend function Screen:cursor_up(n) self.cy = math.max(self.cy - (n or 1), self:margin_top())end function Screen:cursor_down(n) self.cy = math.min(self.cy + (n or 1), self:margin_bottom())end function Screen:cursor_position(line, col) col = (col or 1) - 1 line = (line or 1) - 1 if self.margins and self.mode[DECOM] then line = line + self.margins.top if line < self.margins.top or line > self.margins.bottom then return end end self.cx, self.cy = col, line ensure_h(self) ensure_v(self)end function Screen:erase_in_line(how) local first, last if how == 1 then first, last = 0, self.cx elseif how == 2 then first, last = 0, self.cols - 1 else first, last = self.cx, self.cols - 1 end local r = row(self, self.cy) for x = first, last do r[x] = cell(" ", self.attr) endend function Screen:erase_in_display(how) local first, last if how == 1 then first, last = 0, self.cy - 1 elseif how == 2 or how == 3 then first, last = 0, self.rows - 1 else first, last = self.cy + 1, self.rows - 1 end local blank = cell(" ", self.attr) for y = first, last do local r = self.buf[y] if r ~= nil then for x in pairs(r) do r[x] = blank end end end if how == 0 or how == 1 then self:erase_in_line(how) endend function Screen:insert_characters(n) n = n or 1 local r = row(self, self.cy) for x = self.cols, self.cx, -1 do if x + n <= self.cols then r[x + n] = r[x] end r[x] = nil endend function Screen:delete_characters(n) n = n or 1 local r = row(self, self.cy) for x = self.cx, self.cols - 1 do if x + n <= self.cols then r[x] = r[x + n] r[x + n] = nil else r[x] = nil end endend function Screen:erase_characters(n) n = n or 1 local r = row(self, self.cy) local blank = cell(" ", self.attr) for x = self.cx, math.min(self.cx + n, self.cols) - 1 do r[x] = blank endend function Screen:insert_lines(n) n = n or 1 local top, bottom = self:margin_top(), self:margin_bottom() if self.cy < top or self.cy > bottom then return end for y = bottom, self.cy, -1 do if y + n <= bottom then self.buf[y + n] = self.buf[y] end self.buf[y] = nil end self.cx = 0end function Screen:delete_lines(n) n = n or 1 local top, bottom = self:margin_top(), self:margin_bottom() if self.cy < top or self.cy > bottom then return end for y = self.cy, bottom do if y + n <= bottom then self.buf[y] = self.buf[y + n] self.buf[y + n] = nil else self.buf[y] = nil end end self.cx = 0end function Screen:set_margins(top, bottom) if (top == nil or top == 0) and bottom == nil then self.margins = nil return end local cur_top = self:margin_top() local cur_bottom = self:margin_bottom() if top == nil then top = cur_top else top = math.max(0, math.min(top - 1, self.rows - 1)) end if bottom == nil then bottom = cur_bottom else bottom = math.max(0, math.min(bottom - 1, self.rows - 1)) end if bottom - top >= 1 then self.margins = { top = top, bottom = bottom } self:cursor_position() endend function Screen:sgr(params) if #params == 0 or (#params == 1 and params[1] == 0) then self.attr = 0 return end local i = 1 while i <= #params do local p = params[i] local t = SGR_TEXT[p] if p == 0 then self.attr = 0 elseif t then if t[2] then self.attr = self.attr | t[1] else self.attr = self.attr & ~t[1] end elseif p == 38 or p == 48 then -- Extended colour: skip its arguments. local n = params[i + 1] if n == 5 then i = i + 2 elseif n == 2 then i = i + 4 end end i = i + 1 endend function Screen:set_mode(private, params) for _, p in ipairs(params) do self.mode[private and p * 32 or p] = true endend function Screen:reset_mode(private, params) for _, p in ipairs(params) do self.mode[private and p * 32 or p] = nil endend function Screen:save_cursor() self.savepoints[#self.savepoints + 1] = { cy = self.cy, cx = self.cx, attr = self.attr }end function Screen:restore_cursor() local sp = table.remove(self.savepoints) if sp == nil then self.mode[DECOM] = nil self:cursor_position() return end self.cy, self.cx, self.attr = sp.cy, sp.cx, sp.attr ensure_h(self) ensure_v(self, true)end function Screen:tab() local column = self.cols - 1 for x = self.cx + 1, self.cols - 1 do if self.tabstops[x] then column = x break end end self.cx = columnend -- ---- control dispatch ---- local function ctrl_char(self, b) if b == 0x08 then -- BS if self.cx == self.cols then self.cx = self.cx - 1 end self.cx = self.cx - 1 ensure_h(self) elseif b == 0x09 then -- HT self:tab() elseif b == 0x0a or b == 0x0b or b == 0x0c then -- LF, VT, FF self:index() elseif b == 0x0d then -- CR self.cx = 0 end -- BEL, SO and SI have no effect on the grid.end local function is_basic(b) return b == 0x07 or (b >= 0x08 and b <= 0x0d) or b == 0x0e or b == 0x0fend Lines 1–500 of 770. Load more
-- SPDX-License-Identifier: ISC -- -- A small VT100/xterm screen emulator, so a test can read the grid an -- ncurses application would have painted. It covers what ncurses emits -- under xterm-256color: cursor motion, erase, scroll region, insert and -- delete, SGR text attributes. Mouse reports, palette changes and titles -- are parsed and dropped. Colours are dropped too -- assertions are about -- layout and emphasis, and every cell carries a text-attribute mask. local vt = {} local BOLD = 1 local ITALIC = 2 local UNDERLINE = 4 local BLINK = 8 local REVERSE = 16 local STRIKE = 32 vt.BOLD = BOLD vt.ITALIC = ITALIC vt.UNDERLINE = UNDERLINE vt.BLINK = BLINK vt.REVERSE = REVERSE vt.STRIKE = STRIKE -- SGR parameter -> {bit, on}. local SGR_TEXT = { [1] = { BOLD, true }, [3] = { ITALIC, true }, [4] = { UNDERLINE, true }, [5] = { BLINK, true }, [7] = { REVERSE, true }, [9] = { STRIKE, true }, [22] = { BOLD, false }, [23] = { ITALIC, false }, [24] = { UNDERLINE, false }, [25] = { BLINK, false }, [27] = { REVERSE, false }, [29] = { STRIKE, false }, } -- Modes are tracked by number; private ones are shifted so DECAWM (private -- 7) never collides with the ANSI mode 7. local IRM = 4 local DECOM = 6 * 32 local DECAWM = 7 * 32 -- Cells are immutable and interned, so a screen of spaces costs one table. local cell_cache = {} local function cell(ch, attr) local by_attr = cell_cache[attr] if by_attr == nil then by_attr = {} cell_cache[attr] = by_attr end local c = by_attr[ch] if c == nil then c = { ch = ch, attr = attr } by_attr[ch] = c end return c end local BLANK = cell(" ", 0) -- Character width. Everything outside the wide and zero-width ranges is one -- column, which is all a terminal needs to lay text out. local WIDE = { { 0x1100, 0x115f }, { 0x2e80, 0x303e }, { 0x3041, 0x33ff }, { 0x3400, 0x4dbf }, { 0x4e00, 0x9fff }, { 0xa000, 0xa4cf }, { 0xac00, 0xd7a3 }, { 0xf900, 0xfaff }, { 0xfe30, 0xfe6f }, { 0xff00, 0xff60 }, { 0xffe0, 0xffe6 }, { 0x1f300, 0x1f64f }, { 0x1f900, 0x1f9ff }, { 0x20000, 0x3fffd }, } local function char_width(cp) if cp == 0 then return 0 end if cp < 32 or (cp >= 0x7f and cp < 0xa0) then return -1 end if (cp >= 0x300 and cp <= 0x36f) or (cp >= 0x200b and cp <= 0x200f) then return 0 end for i = 1, #WIDE do if cp >= WIDE[i][1] and cp <= WIDE[i][2] then return 2 end end return 1 end local Screen = {} Screen.__index = Screen -- Rows and cells are sparse: an absent entry reads as a blank with default -- attributes, which is what "never written" means on a real terminal. local function row(self, y) local r = self.buf[y] if r == nil then r = {} self.buf[y] = r end return r end local function at(self, y, x) local r = self.buf[y] if r == nil then return BLANK end return r[x] or BLANK end function vt.new(rows, cols) local self = setmetatable({}, Screen) self.rows = rows self.cols = cols self:reset() return self end function Screen:reset() self.buf = {} self.margins = nil self.mode = { [DECAWM] = true } self.cy, self.cx, self.attr = 0, 0, 0 self.savepoints = {} self.tabstops = {} local x = 8 while x < self.cols do self.tabstops[x] = true x = x + 8 end self.pending = "" -- undecoded utf-8 tail self.state = "ground" end function Screen:margin_top() return self.margins and self.margins.top or 0 end function Screen:margin_bottom() return self.margins and self.margins.bottom or self.rows - 1 end local function ensure_h(self) if self.cx < 0 then self.cx = 0 elseif self.cx > self.cols - 1 then self.cx = self.cols - 1 end end local function ensure_v(self, use_margins) local top, bottom = 0, self.rows - 1 if (use_margins or self.mode[DECOM]) and self.margins then top, bottom = self.margins.top, self.margins.bottom end if self.cy < top then self.cy = top elseif self.cy > bottom then self.cy = bottom end end -- ---- rendering primitives ---- function Screen:draw_char(ch, cp) local w = char_width(cp) if self.cx == self.cols then if self.mode[DECAWM] then self.cx = 0 self:index() elseif w > 0 then self.cx = self.cx - w end end if self.mode[IRM] and w > 0 then self:insert_characters(w) end local r = row(self, self.cy) if w == 1 then r[self.cx] = cell(ch, self.attr) elseif w == 2 then r[self.cx] = cell(ch, self.attr) if self.cx + 1 < self.cols then r[self.cx + 1] = cell("", self.attr) end elseif w == 0 then -- A combining mark joins the character before it. if self.cx > 0 then local prev = r[self.cx - 1] or BLANK r[self.cx - 1] = cell(prev.ch .. ch, prev.attr) end else return end if w > 0 then self.cx = math.min(self.cx + w, self.cols) end end function Screen:index() local top, bottom = self:margin_top(), self:margin_bottom() if self.cy == bottom then for y = top, bottom - 1 do self.buf[y] = self.buf[y + 1] end self.buf[bottom] = nil else self:cursor_down(1) end end function Screen:reverse_index() local top, bottom = self:margin_top(), self:margin_bottom() if self.cy == top then for y = bottom, top + 1, -1 do self.buf[y] = self.buf[y - 1] end self.buf[top] = nil else self:cursor_up(1) end end function Screen:scroll_up(n) local top, bottom = self:margin_top(), self:margin_bottom() for _ = 1, n do for y = top, bottom - 1 do self.buf[y] = self.buf[y + 1] end self.buf[bottom] = nil end end function Screen:scroll_down(n) local top, bottom = self:margin_top(), self:margin_bottom() for _ = 1, n do for y = bottom, top + 1, -1 do self.buf[y] = self.buf[y - 1] end self.buf[top] = nil end end function Screen:cursor_up(n) self.cy = math.max(self.cy - (n or 1), self:margin_top()) end function Screen:cursor_down(n) self.cy = math.min(self.cy + (n or 1), self:margin_bottom()) end function Screen:cursor_position(line, col) col = (col or 1) - 1 line = (line or 1) - 1 if self.margins and self.mode[DECOM] then line = line + self.margins.top if line < self.margins.top or line > self.margins.bottom then return end end self.cx, self.cy = col, line ensure_h(self) ensure_v(self) end function Screen:erase_in_line(how) local first, last if how == 1 then first, last = 0, self.cx elseif how == 2 then first, last = 0, self.cols - 1 else first, last = self.cx, self.cols - 1 end local r = row(self, self.cy) for x = first, last do r[x] = cell(" ", self.attr) end end function Screen:erase_in_display(how) local first, last if how == 1 then first, last = 0, self.cy - 1 elseif how == 2 or how == 3 then first, last = 0, self.rows - 1 else first, last = self.cy + 1, self.rows - 1 end local blank = cell(" ", self.attr) for y = first, last do local r = self.buf[y] if r ~= nil then for x in pairs(r) do r[x] = blank end end end if how == 0 or how == 1 then self:erase_in_line(how) end end function Screen:insert_characters(n) n = n or 1 local r = row(self, self.cy) for x = self.cols, self.cx, -1 do if x + n <= self.cols then r[x + n] = r[x] end r[x] = nil end end function Screen:delete_characters(n) n = n or 1 local r = row(self, self.cy) for x = self.cx, self.cols - 1 do if x + n <= self.cols then r[x] = r[x + n] r[x + n] = nil else r[x] = nil end end end function Screen:erase_characters(n) n = n or 1 local r = row(self, self.cy) local blank = cell(" ", self.attr) for x = self.cx, math.min(self.cx + n, self.cols) - 1 do r[x] = blank end end function Screen:insert_lines(n) n = n or 1 local top, bottom = self:margin_top(), self:margin_bottom() if self.cy < top or self.cy > bottom then return end for y = bottom, self.cy, -1 do if y + n <= bottom then self.buf[y + n] = self.buf[y] end self.buf[y] = nil end self.cx = 0 end function Screen:delete_lines(n) n = n or 1 local top, bottom = self:margin_top(), self:margin_bottom() if self.cy < top or self.cy > bottom then return end for y = self.cy, bottom do if y + n <= bottom then self.buf[y] = self.buf[y + n] self.buf[y + n] = nil else self.buf[y] = nil end end self.cx = 0 end function Screen:set_margins(top, bottom) if (top == nil or top == 0) and bottom == nil then self.margins = nil return end local cur_top = self:margin_top() local cur_bottom = self:margin_bottom() if top == nil then top = cur_top else top = math.max(0, math.min(top - 1, self.rows - 1)) end if bottom == nil then bottom = cur_bottom else bottom = math.max(0, math.min(bottom - 1, self.rows - 1)) end if bottom - top >= 1 then self.margins = { top = top, bottom = bottom } self:cursor_position() end end function Screen:sgr(params) if #params == 0 or (#params == 1 and params[1] == 0) then self.attr = 0 return end local i = 1 while i <= #params do local p = params[i] local t = SGR_TEXT[p] if p == 0 then self.attr = 0 elseif t then if t[2] then self.attr = self.attr | t[1] else self.attr = self.attr & ~t[1] end elseif p == 38 or p == 48 then -- Extended colour: skip its arguments. local n = params[i + 1] if n == 5 then i = i + 2 elseif n == 2 then i = i + 4 end end i = i + 1 end end function Screen:set_mode(private, params) for _, p in ipairs(params) do self.mode[private and p * 32 or p] = true end end function Screen:reset_mode(private, params) for _, p in ipairs(params) do self.mode[private and p * 32 or p] = nil end end function Screen:save_cursor() self.savepoints[#self.savepoints + 1] = { cy = self.cy, cx = self.cx, attr = self.attr } end function Screen:restore_cursor() local sp = table.remove(self.savepoints) if sp == nil then self.mode[DECOM] = nil self:cursor_position() return end self.cy, self.cx, self.attr = sp.cy, sp.cx, sp.attr ensure_h(self) ensure_v(self, true) end function Screen:tab() local column = self.cols - 1 for x = self.cx + 1, self.cols - 1 do if self.tabstops[x] then column = x break end end self.cx = column end -- ---- control dispatch ---- local function ctrl_char(self, b) if b == 0x08 then -- BS if self.cx == self.cols then self.cx = self.cx - 1 end self.cx = self.cx - 1 ensure_h(self) elseif b == 0x09 then -- HT self:tab() elseif b == 0x0a or b == 0x0b or b == 0x0c then -- LF, VT, FF self:index() elseif b == 0x0d then -- CR self.cx = 0 end -- BEL, SO and SI have no effect on the grid. end local function is_basic(b) return b == 0x07 or (b >= 0x08 and b <= 0x0d) or b == 0x0e or b == 0x0f end