From edddfdd6019138fa919c43bb02cba854cd19a2bb Mon Sep 17 00:00:00 2001 From: retoor Date: Sun, 25 Oct 2026 01:38:47 +0200 Subject: [PATCH] mplex: port MplexCopyMode with differential test against tai.py and tmux 3.6 Co-Authored-By: Claude Sonnet 5.5 --- taicpp/src/mplex/mplex_copymode.cpp | 1258 +++++++++++++++++++++++++++ taicpp/src/mplex/mplex_copymode.h | 201 +++++ taicpp/tests/mplex_copy_gen.py | 318 +++++++ taicpp/tests/mplex_copy_run.sh | 27 + taicpp/tests/mplex_copy_test.cpp | 328 +++++++ 5 files changed, 2132 insertions(+) create mode 100644 taicpp/src/mplex/mplex_copymode.cpp create mode 100644 taicpp/src/mplex/mplex_copymode.h create mode 100644 taicpp/tests/mplex_copy_gen.py create mode 100755 taicpp/tests/mplex_copy_run.sh create mode 100644 taicpp/tests/mplex_copy_test.cpp diff --git a/taicpp/src/mplex/mplex_copymode.cpp b/taicpp/src/mplex/mplex_copymode.cpp new file mode 100644 index 0000000..be2f2b7 --- /dev/null +++ b/taicpp/src/mplex/mplex_copymode.cpp @@ -0,0 +1,1258 @@ +// MplexCopyMode: literal port of tai.py:12043-12997. +#include "mplex_copymode.h" + +#include + +#include "mplex_canvas.h" +#include "mplex_clientconn.h" +#include "mplex_config.h" +#include "mplex_server.h" +#include "mplex_style.h" + +namespace detz::mplex { + +namespace { + +UStr slice(const UStr& text, long start, long stop) { + long n = static_cast(text.size()); + if (start < 0) start = std::max(0L, start + n); + if (stop < 0) stop = std::max(0L, stop + n); + start = std::min(start, n); + stop = std::min(stop, n); + if (start >= stop) return UStr(); + return text.substr(static_cast(start), static_cast(stop - start)); +} + +bool contains(const UStr& set, wchar_t ch) { return set.find(ch) != UStr::npos; } + +} // namespace + +// ---- construction and geometry ----------------------------------------------------------- + +MplexCopyMode::MplexCopyMode(MplexServer* server_in, std::shared_ptr pane_in, + const std::vector* lines, bool, bool exit_at_bottom_in, bool show_position_in) + : MplexMode(server_in, std::move(pane_in)), show_position(show_position_in), exit_at_bottom(exit_at_bottom_in) { + name = "copy-mode"; + if (lines != nullptr) { + name = "view-mode"; + Grid rowsv; + for (const auto& line : *lines) { + Row r; + for (wchar_t ch : to_ustr(line)) r.push_back(Cell{ch, 0, 0, 0}); + rowsv.push_back(std::move(r)); + } + while (static_cast(rowsv.size()) < pane->h) rowsv.push_back(Row()); + static_rows = std::move(rowsv); + } + oy = 0; + cx = lines != nullptr ? 0 : pane->screen.cx; + cy = lines != nullptr ? 0 : pane->screen.cy; + if (lines == nullptr && pane->last_search) { + search = MplexPane::SearchState{pane->last_search->text, false, pane->last_search->literal}; + } + if (lines != nullptr) oy = hist(); +} + +int MplexCopyMode::rows() const { return pane->h; } +int MplexCopyMode::cols() const { return pane->w; } + +int MplexCopyMode::hist() const { + if (static_rows) return std::max(0, static_cast(static_rows->size()) - rows()); + return static_cast(pane->history_size()); +} + +int MplexCopyMode::total() const { return hist() + rows(); } +int MplexCopyMode::top() const { return hist() - oy; } +int MplexCopyMode::abs_y() const { return top() + cy; } + +const Row& MplexCopyMode::row(int ay) const { + static const Row empty; + if (static_rows) { + return (ay >= 0 && ay < static_cast(static_rows->size())) ? (*static_rows)[static_cast(ay)] : empty; + } + int h = hist(); + if (ay < h) { + // Python: self.pane.screen.history[ay]; a negative ay counts from the end and can fall off the front + // (an anchor whose line was evicted), which raises IndexError there and here. + if (!pane->screen.history) throw std::runtime_error("'NoneType' object is not subscriptable"); + int index = ay < 0 ? ay + h : ay; + if (index < 0) throw std::runtime_error("deque index out of range"); + return pane->screen.history->rows[static_cast(index)]; + } + const Grid& grid = pane->screen.grid; + int index = ay - h; + return (index >= 0 && index < static_cast(grid.size())) ? grid[static_cast(index)] : empty; +} + +UStr MplexCopyMode::utext(int ay) const { + const Row& source = row(ay); + UStr out; + out.reserve(source.size()); + for (const Cell& cell : source) out.push_back(cell.present() ? cell.ch : L' '); + return py::rstrip(out); +} + +std::string MplexCopyMode::text(int ay) const { return to_utf8(utext(ay)); } + +void MplexCopyMode::clamp() { + oy = std::max(0, std::min(hist(), oy)); + cy = std::max(0, std::min(rows() - 1, cy)); + cx = std::max(0, std::min(cols(), cx)); +} + +void MplexCopyMode::goto_pos(int x, int ay) { + ay = std::max(0, std::min(total() - 1, ay)); + int t = top(); + if (ay < t) { + t = ay; + } else if (ay >= t + rows()) { + t = ay - rows() + 1; + } + oy = std::max(0, std::min(hist(), hist() - t)); + cy = ay - top(); + cx = std::max(0, std::min(cols(), x)); +} + +void MplexCopyMode::on_output(long pushed, long evicted) { + if (static_rows) return; + if (oy > 0 && pushed) oy += static_cast(pushed); + if (evicted) { + if (anchor) anchor = Point{anchor->first, anchor->second - static_cast(evicted)}; + if (mark) mark = Point{mark->first, mark->second - static_cast(evicted)}; + } + clamp(); +} + +void MplexCopyMode::on_resize() { clamp(); } + +// ---- rendering ------------------------------------------------------------------------------- + +bool MplexCopyMode::vi_keys() const { return server->pane_options(pane)->get("mode-keys").s == "vi"; } + +bool MplexCopyMode::selected(int x, int ay) const { + if (!anchor) return false; + int ax = anchor->first, ay0 = anchor->second; + int ccx = cx, ccy = abs_y(); + int extra = vi_keys() ? 1 : 0; + if (rect) { + return std::min(ay0, ccy) <= ay && ay <= std::max(ay0, ccy) && std::min(ax, ccx) <= x && + x < std::max(ax, ccx) + extra; + } + if (line_select) return std::min(ay0, ccy) <= ay && ay <= std::max(ay0, ccy); + std::pair a{ay0, ax}, b{ccy, ccx}; + std::pair first = std::min(a, b), last = std::max(a, b); + std::pair here{ay, x}; + return first <= here && here < std::pair{last.first, last.second + extra}; +} + +std::optional MplexCopyMode::compile(const std::string& text, bool literal) { + UStr utf = to_ustr(text); + bool ignore = utf == py::lower(utf); + if (literal) return py::Regex(py::regex_escape(utf), ignore); + try { + return py::Regex(utf, ignore); + } catch (const py::RegexError&) { + return std::nullopt; + } +} + +std::optional MplexCopyMode::pattern() const { + if (!search) return std::nullopt; + return compile(search->text, search->literal); +} + +Cell MplexCopyMode::tint(const Cell& cell, const Style& style) { + return Cell{cell.ch, style.fg ? style.fg : cell.fg, style.bg ? style.bg : cell.bg, cell.attr | style.attr}; +} + +ModeBlock MplexCopyMode::render(MplexClientConn&) { + MplexOptions* opts = server->pane_options(pane); + Style mode_style = MplexStyle::parse(opts->get("mode-style").s); + Style match_style = MplexStyle::parse(opts->get("copy-mode-match-style").s); + std::optional pat = pattern(); + int t = top(); + Grid out; + for (int view_row = 0; view_row < rows(); ++view_row) { + int ay = t + view_row; + const Row& source = row(ay); + Row cells; + for (int col = 0; col < cols(); ++col) { + if (col < static_cast(source.size()) && source[static_cast(col)].present()) + cells.push_back(source[static_cast(col)]); + else + cells.push_back(MplexCanvas::BLANK); + } + if (pat) { + UStr line; + for (const Cell& cell : cells) line.push_back(cell.ch); + for (const auto& match : pat->finditer(line)) { + if (match.end == match.start) continue; + for (long col = match.start; col < match.end; ++col) + cells[static_cast(col)] = tint(cells[static_cast(col)], match_style); + } + } + if (anchor) { + for (int col = 0; col < cols(); ++col) + if (selected(col, ay)) cells[static_cast(col)] = tint(cells[static_cast(col)], mode_style); + } + out.push_back(std::move(cells)); + } + if (show_position && !out.empty()) { + std::string label_utf = "[" + std::to_string(oy) + "/" + std::to_string(hist()) + "]"; + UStr label = to_ustr(label_utf); + int start = std::max(0, cols() - static_cast(label.size())); + UStr cut = label.substr(0, static_cast(cols())); + for (size_t index = 0; index < cut.size(); ++index) + out[0][static_cast(start) + index] = Cell{cut[index], mode_style.fg, mode_style.bg, mode_style.attr}; + } + ModeBlock block; + block.grid = std::move(out); + block.cursor = std::make_pair(cx, cy); + return block; +} + +// ---- keys ------------------------------------------------------------------------------------ + +void MplexCopyMode::key(const std::string& key_name, const Bytes&, MplexClientConn& client) { + std::string tbl = server->pane_options(pane)->get("mode-keys").s == "vi" ? "copy-mode-vi" : "copy-mode"; + const MplexBinding* found = server->config->lookup(tbl, key_name); + if (found != nullptr) { + MplexBinding binding = *found; + server->run_binding(client.shared_from_this(), binding, pane); + } +} + +void MplexCopyMode::command(const std::string& cname, const std::vector& words, MplexClientConn* client) { + std::string normal = py::replace(cname, "-", "_"); + Handler handler = nullptr; + for (const Entry& entry : table()) { + if (py::replace(entry.name, "-", "_") == normal) { + handler = entry.handler; + break; + } + } + if (handler == nullptr) throw MplexError("unknown copy mode command: " + cname); + int n = std::max(1, std::min(pane->prefix_count, 10000)); + pane->prefix_count = 1; + count = n; + cache_ay_ = -1; + cache_text_.clear(); + bool cancel = (this->*handler)(words, client); + clamp(); + server->mark_dirty(pane->window()); + if (cancel) close(); +} + +// ---- cursor and scrolling ----------------------------------------------------------------------- + +void MplexCopyMode::snap() { + int px = llen(abs_y()); + if ((cx >= lastsx && cx != px) || cx > px) cx = px; +} + +bool MplexCopyMode::c_cancel(const Words&, MplexClientConn*) { return true; } + +void MplexCopyMode::step_up(bool scroll_only) { + int ox = llen(abs_y()); + if (cx != ox) { + lastcx = cx; + lastsx = ox; + } + cx = lastcx; + if (scroll_only || cy == 0) { + if (oy < hist()) oy += 1; + } else { + cy -= 1; + } + snap(); +} + +void MplexCopyMode::step_down(bool scroll_only) { + int ox = llen(abs_y()); + if (cx != ox) { + lastcx = cx; + lastsx = ox; + } + cx = lastcx; + if (scroll_only || cy == rows() - 1) { + if (oy > 0) oy -= 1; + } else { + cy += 1; + } + snap(); +} + +bool MplexCopyMode::c_cursor_up(const Words&, MplexClientConn*) { + for (int i = 0; i < count; ++i) step_up(false); + return false; +} + +bool MplexCopyMode::c_cursor_down(const Words&, MplexClientConn*) { + for (int i = 0; i < count; ++i) step_down(false); + return bottom_exit(); +} + +bool MplexCopyMode::c_cursor_down_and_cancel(const Words&, MplexClientConn*) { + for (int i = 0; i < count; ++i) step_down(false); + return oy == 0 && cy == rows() - 1; +} + +bool MplexCopyMode::bottom_exit() const { return exit_at_bottom && oy == 0; } + +bool MplexCopyMode::c_cursor_left(const Words&, MplexClientConn*) { + for (int i = 0; i < count; ++i) { + int ay = abs_y(); + if (cx == 0 && ay > 0) { + goto_pos(llen(ay - 1), ay - 1); + } else if (cx > 0) { + cx -= 1; + } + } + return false; +} + +bool MplexCopyMode::c_cursor_right(const Words&, MplexClientConn*) { + for (int i = 0; i < count; ++i) { + int ay = abs_y(); + if (cx >= llen(ay)) { + if (ay < total() - 1) goto_pos(0, ay + 1); + } else { + cx += 1; + } + } + return false; +} + +bool MplexCopyMode::c_start_of_line(const Words&, MplexClientConn*) { + cx = 0; + return false; +} + +bool MplexCopyMode::c_end_of_line(const Words&, MplexClientConn*) { + cx = llen(abs_y()); + return false; +} + +bool MplexCopyMode::c_back_to_indentation(const Words&, MplexClientConn*) { + UStr line = utext(abs_y()); + if (!line.empty()) { + size_t lead = 0; + while (lead < line.size() && line[lead] == L' ') ++lead; + cx = static_cast(lead); + } + return false; +} + +bool MplexCopyMode::c_top_line(const Words&, MplexClientConn*) { + cx = 0; + cy = 0; + return false; +} + +bool MplexCopyMode::c_middle_line(const Words&, MplexClientConn*) { + cx = 0; + cy = (rows() - 1) / 2; + return false; +} + +bool MplexCopyMode::c_bottom_line(const Words&, MplexClientConn*) { + cx = 0; + cy = rows() - 1; + return false; +} + +bool MplexCopyMode::c_history_top(const Words&, MplexClientConn*) { + oy = hist(); + cx = 0; + cy = 0; + return false; +} + +bool MplexCopyMode::c_history_bottom(const Words&, MplexClientConn*) { + oy = 0; + cy = rows() - 1; + cx = llen(abs_y()); + return bottom_exit(); +} + +int MplexCopyMode::page_size(bool half) const { + if (rows() > 2) return std::max(1, half ? rows() / 2 : rows() - 2); + return 1; +} + +void MplexCopyMode::remember_column() { + int ox = llen(abs_y()); + if (cx != ox) { + lastcx = cx; + lastsx = ox; + } + cx = lastcx; +} + +void MplexCopyMode::page_up(bool half) { + int size = page_size(half); + remember_column(); + if (oy + size > hist()) { + oy = hist(); + cy = cy > size ? cy - size : 0; + } else { + oy += size; + } + snap(); +} + +void MplexCopyMode::page_down(bool half) { + int size = page_size(half); + remember_column(); + if (oy < size) { + cy = std::min(rows() - 1, cy + size); + oy = 0; + } else { + oy -= size; + } + snap(); +} + +bool MplexCopyMode::c_page_up(const Words&, MplexClientConn*) { + for (int i = 0; i < count; ++i) page_up(false); + return false; +} + +bool MplexCopyMode::c_page_down(const Words&, MplexClientConn*) { + for (int i = 0; i < count; ++i) page_down(false); + return bottom_exit(); +} + +bool MplexCopyMode::c_page_down_and_cancel(const Words&, MplexClientConn*) { + for (int i = 0; i < count; ++i) page_down(false); + return oy == 0; +} + +bool MplexCopyMode::c_halfpage_up(const Words&, MplexClientConn*) { + for (int i = 0; i < count; ++i) page_up(true); + return false; +} + +bool MplexCopyMode::c_halfpage_down(const Words&, MplexClientConn*) { + for (int i = 0; i < count; ++i) page_down(true); + return bottom_exit(); +} + +bool MplexCopyMode::c_halfpage_down_and_cancel(const Words&, MplexClientConn*) { + for (int i = 0; i < count; ++i) page_down(true); + return oy == 0; +} + +bool MplexCopyMode::c_scroll_up(const Words&, MplexClientConn*) { + for (int i = 0; i < count; ++i) step_up(true); + return false; +} + +bool MplexCopyMode::c_scroll_down(const Words&, MplexClientConn*) { + for (int i = 0; i < count; ++i) step_down(true); + return bottom_exit(); +} + +bool MplexCopyMode::c_scroll_down_and_cancel(const Words&, MplexClientConn*) { + for (int i = 0; i < count; ++i) step_down(true); + return oy == 0; +} + +bool MplexCopyMode::c_scroll_middle(const Words&, MplexClientConn*) { + int mid = (rows() - 1) / 2; + int delta = cy - mid; + if (delta > 0 && oy >= delta) { + oy -= delta; + cy = mid; + } else if (delta < 0 && hist() - oy >= -delta) { + oy += -delta; + cy = mid; + } + return false; +} + +bool MplexCopyMode::c_cursor_centre_vertical(const Words&, MplexClientConn*) { + cy = rows() / 2; + return false; +} + +bool MplexCopyMode::c_cursor_centre_horizontal(const Words&, MplexClientConn*) { return false; } + +bool MplexCopyMode::c_scroll_top(const Words&, MplexClientConn*) { + if (oy >= cy) { + oy -= cy; + cy = 0; + } + return false; +} + +bool MplexCopyMode::c_scroll_bottom(const Words&, MplexClientConn*) { + int need = rows() - 1 - cy; + if (hist() - oy >= need) { + oy += need; + cy = rows() - 1; + } + return false; +} + +bool MplexCopyMode::c_goto_line(const Words& words, MplexClientConn*) { + long long line = 0; + try { + if (words.empty()) throw ValueError("index"); + line = py::to_int(words[0]); + } catch (const ValueError&) { + throw MplexError("goto-line needs a line number"); + } + long long bounded = std::max(0LL, std::min(static_cast(hist()), line)); + oy = static_cast(bounded); + return false; +} + +bool MplexCopyMode::c_refresh_from_pane(const Words&, MplexClientConn*) { return false; } + +bool MplexCopyMode::c_toggle_position(const Words&, MplexClientConn*) { + show_position = !show_position; + return false; +} + +// ---- words, paragraphs, brackets ------------------------------------------------------------------ + +bool MplexCopyMode::in_ws(int x, int ay) { return char_at(x, ay) == L' '; } + +bool MplexCopyMode::in_sep(int x, int ay, const UStr& seps) { return contains(seps, char_at(x, ay)); } + +UStr MplexCopyMode::separators(bool big) const { + if (big) return UStr(); + return to_ustr(server->pane_options(pane)->get("word-separators").s); +} + +wchar_t MplexCopyMode::char_at(int x, int ay) { + if (cache_ay_ != ay) { + cache_ay_ = ay; + cache_text_ = utext(ay); + } + if (x >= 0 && x < static_cast(cache_text_.size())) return cache_text_[static_cast(x)]; + if (x < 0 && -x <= static_cast(cache_text_.size())) return cache_text_[cache_text_.size() + static_cast(x)]; + return L' '; +} + +MplexCopyMode::Blanks MplexCopyMode::advance_blanks(int x, int ay, int xx) { + int last = total() - 1; + while (x > xx || in_ws(x, ay)) { + if (x > xx) { + if (ay == last) return Blanks{x, ay, xx, true}; + ay += 1; + x = 0; + xx = llen(ay); + } else { + x += 1; + } + } + return Blanks{x, ay, xx, false}; +} + +int MplexCopyMode::run_end(int x, int ay, int xx, const UStr& seps) { + if (in_sep(x, ay, seps) && !in_ws(x, ay)) { + x += 1; + while (x <= xx && in_sep(x, ay, seps) && !in_ws(x, ay)) x += 1; + } else { + x += 1; + while (x <= xx && !(in_sep(x, ay, seps) || in_ws(x, ay))) x += 1; + } + return x; +} + +bool MplexCopyMode::next_word(bool big) { + UStr seps = separators(big); + int x = cx, ay = abs_y(); + for (int i = 0; i < count; ++i) { + int xx = llen(ay); + if (x > xx) { + if (ay == total() - 1) break; + ay += 1; + x = 0; + xx = llen(ay); + } + if (!in_ws(x, ay)) x = run_end(x, ay, xx, seps); + Blanks b = advance_blanks(x, ay, xx); + x = b.x; + ay = b.ay; + xx = b.xx; + } + goto_pos(x, ay); + return false; +} + +bool MplexCopyMode::c_next_word(const Words&, MplexClientConn*) { return next_word(false); } +bool MplexCopyMode::c_next_space(const Words&, MplexClientConn*) { return next_word(true); } + +bool MplexCopyMode::next_word_end(bool big) { + UStr seps = separators(big); + bool vi = server->pane_options(pane)->get("mode-keys").s == "vi"; + int x = cx, ay = abs_y(); + for (int i = 0; i < count; ++i) { + int xx = llen(ay); + if (vi) { + if (x < xx) { + x += 1; + } else if (ay < total() - 1) { + goto_pos(0, ay + 1); + x = 0; + ay = ay + 1; + xx = llen(ay); + } + } + Blanks b = advance_blanks(x, ay, xx); + x = b.x; + ay = b.ay; + xx = b.xx; + if (!b.bottom) x = run_end(x, ay, xx, seps); + if (vi && x > 0) x -= 1; + if (b.bottom) break; + } + goto_pos(x, ay); + return false; +} + +bool MplexCopyMode::c_next_word_end(const Words&, MplexClientConn*) { return next_word_end(false); } +bool MplexCopyMode::c_next_space_end(const Words&, MplexClientConn*) { return next_word_end(true); } + +bool MplexCopyMode::previous_word(bool big) { + UStr seps = separators(big); + int x = cx, ay = abs_y(); + for (int i = 0; i < count; ++i) { + while (true) { + if (x == 0) { + if (ay == 0) break; + ay -= 1; + x = llen(ay); + } else { + x -= 1; + } + if (!in_ws(x, ay)) break; + } + if (in_ws(x, ay)) break; + bool letters = !in_sep(x, ay, seps); + while (x > 0 && !in_ws(x - 1, ay) && letters == !in_sep(x - 1, ay, seps)) x -= 1; + } + goto_pos(x, ay); + return false; +} + +bool MplexCopyMode::c_previous_word(const Words&, MplexClientConn*) { return previous_word(false); } +bool MplexCopyMode::c_previous_space(const Words&, MplexClientConn*) { return previous_word(true); } + +bool MplexCopyMode::c_next_paragraph(const Words&, MplexClientConn*) { + int ay = abs_y(); + int last = total() - 1; + for (int i = 0; i < count; ++i) { + while (ay < last && !llen(ay)) ay += 1; + while (ay < last && llen(ay)) ay += 1; + } + scroll_to(llen(ay), ay); + return false; +} + +bool MplexCopyMode::c_previous_paragraph(const Words&, MplexClientConn*) { + int ay = abs_y(); + for (int i = 0; i < count; ++i) { + while (ay > 0 && !llen(ay)) ay -= 1; + while (ay > 0 && llen(ay)) ay -= 1; + } + scroll_to(0, ay); + return false; +} + +std::optional MplexCopyMode::match_bracket(int x, int ay, bool forward) { + wchar_t opener = char_at(x, ay); + wchar_t closer = 0; + if (forward) { + if (opener == L'(') closer = L')'; + else if (opener == L'[') closer = L']'; + else if (opener == L'{') closer = L'}'; + } else { + if (opener == L')') closer = L'('; + else if (opener == L']') closer = L'['; + else if (opener == L'}') closer = L'{'; + } + if (closer == 0) return std::nullopt; + int depth = 0; + Point pos{x, ay}; + for (int i = 0; i < 20000; ++i) { + wchar_t ch = char_at(pos.first, pos.second); + if (ch == opener) { + depth += 1; + } else if (ch == closer) { + depth -= 1; + if (depth == 0) return pos; + } + std::optional next = step(pos.first, pos.second, forward); + if (!next) return std::nullopt; + pos = *next; + } + return std::nullopt; +} + +std::optional MplexCopyMode::step(int x, int ay, bool forward) const { + if (forward) { + if (x < llen(ay)) return Point{x + 1, ay}; + if (ay + 1 < total()) return Point{0, ay + 1}; + return std::nullopt; + } + if (x > 0) return Point{x - 1, ay}; + if (ay > 0) return Point{llen(ay - 1), ay - 1}; + return std::nullopt; +} + +bool MplexCopyMode::c_next_matching_bracket(const Words&, MplexClientConn*) { + int ay = abs_y(); + if (!llen(ay)) return false; + wchar_t here = char_at(cx, ay); + std::optional found = std::nullopt; + if (vi_keys()) { + if (contains(L"([{", here)) found = match_bracket(cx, ay, true); + else if (contains(L")]}", here)) found = match_bracket(cx, ay, false); + } else { + for (int x : {cx, cx + 1}) { + if (contains(L"([{", char_at(x, ay)) && !found.has_value() && (x == cx || here == L' ')) + found = match_bracket(x, ay, true); + } + } + if (found.has_value()) { + Point target = found.value(); + goto_pos(target.first, target.second); + } else { + next_word_end(true); + } + return false; +} + +bool MplexCopyMode::c_previous_matching_bracket(const Words&, MplexClientConn*) { + int ay = abs_y(); + if (!llen(ay)) return false; + wchar_t here = char_at(cx, ay); + std::optional found; + if (contains(L")]}", here)) { + found = match_bracket(cx, ay, false); + } else if (vi_keys()) { + if (contains(L"([{", here) || contains(L"([{", char_at(cx + 1, ay))) return false; + } else if (cx > 0 && contains(L")]}", char_at(cx - 1, ay))) { + found = match_bracket(cx - 1, ay, false); + } + if (found) { + goto_pos(found->first, found->second); + } else { + previous_word(true); + } + return false; +} + +// ---- jump ---------------------------------------------------------------------------------------------- + +void MplexCopyMode::do_jump(char kind, wchar_t ch) { + UStr line = utext(abs_y()); + long x = cx; + long n = static_cast(line.size()); + auto find_from = [&](long start) -> long { + if (start > n) return -1; + size_t p = line.find(ch, static_cast(start)); + return p == UStr::npos ? -1 : static_cast(p); + }; + auto rfind_to = [&](long stop) -> long { // line.rfind(ch, 0, stop) + if (stop < 0) stop = std::max(0L, stop + n); + stop = std::min(stop, n); + for (long i = stop - 1; i >= 0; --i) + if (line[static_cast(i)] == ch) return i; + return -1; + }; + for (int i = 0; i < count; ++i) { + long found; + if (kind == 'f') { + found = find_from(x + 1); + } else if (kind == 't') { + found = find_from(x + 2); + found = found >= 0 ? found - 1 : -1; + } else if (kind == 'F') { + found = rfind_to(x); + } else { + found = rfind_to(std::max(0L, x - 1)); + found = found >= 0 ? found + 1 : -1; + } + if (found < 0) return; + x = found; + } + cx = static_cast(x); +} + +bool MplexCopyMode::set_jump(char kind, const Words& words) { + if (words.empty() || words[0].empty()) throw MplexError("jump needs a character"); + UStr first = to_ustr(words[0]); + jump_ = std::make_pair(kind, first[0]); + do_jump(jump_->first, jump_->second); + return false; +} + +bool MplexCopyMode::c_jump_forward(const Words& w, MplexClientConn*) { return set_jump('f', w); } +bool MplexCopyMode::c_jump_backward(const Words& w, MplexClientConn*) { return set_jump('F', w); } +bool MplexCopyMode::c_jump_to_forward(const Words& w, MplexClientConn*) { return set_jump('t', w); } +bool MplexCopyMode::c_jump_to_backward(const Words& w, MplexClientConn*) { return set_jump('T', w); } + +bool MplexCopyMode::c_jump_again(const Words&, MplexClientConn*) { + if (jump_) do_jump(jump_->first, jump_->second); + return false; +} + +bool MplexCopyMode::c_jump_reverse(const Words&, MplexClientConn*) { + if (jump_) { + char kind = jump_->first; + char opposite = kind == 'f' ? 'F' : kind == 'F' ? 'f' : kind == 't' ? 'T' : 't'; + do_jump(opposite, jump_->second); + } + return false; +} + +// ---- selection and copy ----------------------------------------------------------------------------------- + +bool MplexCopyMode::c_begin_selection(const Words&, MplexClientConn*) { + anchor = Point{cx, abs_y()}; + line_select = false; + return false; +} + +bool MplexCopyMode::c_stop_selection(const Words&, MplexClientConn*) { return false; } + +bool MplexCopyMode::c_clear_selection(const Words&, MplexClientConn*) { + anchor.reset(); + line_select = false; + return false; +} + +bool MplexCopyMode::c_select_line(const Words&, MplexClientConn*) { + anchor = Point{0, abs_y()}; + line_select = true; + rect = false; + for (int i = 0; i < count - 1; ++i) step_down(false); + cx = llen(abs_y()); + return false; +} + +bool MplexCopyMode::c_select_word(const Words&, MplexClientConn*) { + int ay = abs_y(); + UStr seps = separators(false); + UStr line = utext(ay); + if (line.empty()) return false; + int size = static_cast(line.size()); + int start = std::min(cx, size - 1); + while (start > 0 && in_ws(start, ay)) start -= 1; + if (in_ws(start, ay)) return false; + bool letters = !in_sep(start, ay, seps); + int end = start; + while (start > 0 && !in_ws(start - 1, ay) && letters == !in_sep(start - 1, ay, seps)) start -= 1; + while (end + 1 < size && !in_ws(end + 1, ay) && letters == !in_sep(end + 1, ay, seps)) end += 1; + anchor = Point{start, ay}; + line_select = false; + cx = vi_keys() ? end : end + 1; + return false; +} + +bool MplexCopyMode::c_rectangle_toggle(const Words&, MplexClientConn*) { + rect = !rect; + return false; +} +bool MplexCopyMode::c_rectangle_on(const Words&, MplexClientConn*) { + rect = true; + return false; +} +bool MplexCopyMode::c_rectangle_off(const Words&, MplexClientConn*) { + rect = false; + return false; +} + +bool MplexCopyMode::c_other_end(const Words&, MplexClientConn*) { + if (!anchor) return false; + Point old{cx, abs_y()}; + goto_pos(anchor->first, anchor->second); + anchor = old; + return false; +} + +bool MplexCopyMode::c_set_mark(const Words&, MplexClientConn*) { + mark = Point{cx, abs_y()}; + return false; +} + +bool MplexCopyMode::c_jump_to_mark(const Words&, MplexClientConn*) { + if (!mark) return false; + Point old{cx, abs_y()}; + goto_pos(mark->first, mark->second); + mark = old; + return false; +} + +std::string MplexCopyMode::selection_text() const { + if (!anchor) return ""; + bool vi = vi_keys(); + int ax = anchor->first, ay0 = anchor->second; + int ccx = cx, ccy = abs_y(); + int extra = vi ? 1 : 0; + if (line_select) { + std::string joined; + bool first = true; + for (int ay = std::min(ay0, ccy); ay <= std::max(ay0, ccy); ++ay) { + if (!first) joined += "\n"; + first = false; + joined += text(ay); + } + return joined + (vi ? "\n" : ""); + } + if (rect) { + int left = std::min(ax, ccx), right = std::max(ax, ccx) + extra; + std::string joined; + bool first = true; + for (int ay = std::min(ay0, ccy); ay <= std::max(ay0, ccy); ++ay) { + if (!first) joined += "\n"; + first = false; + joined += to_utf8(slice(utext(ay), left, right)); + } + return joined; + } + std::pair a{ay0, ax}, b{ccy, ccx}; + std::pair s = std::min(a, b), e = std::max(a, b); + int sy = s.first, sx = s.second, ey = e.first, ex = e.second; + std::string joined; + for (int ay = sy; ay <= ey; ++ay) { + UStr line = utext(ay); + int first = ay == sy ? sx : 0; + int last = ay == ey ? ex + extra : static_cast(line.size()); + UStr piece = slice(line, first, last); + if (ay == ey && vi && last > static_cast(line.size())) piece += L"\n"; + if (ay != sy) joined += "\n"; + joined += to_utf8(piece); + } + return joined; +} + +void MplexCopyMode::deliver(const std::string& text_in, const Words& words, bool append) { + if (text_in.empty()) return; + std::string command = words.empty() ? std::string() : words[0]; + server->store_copy(text_in, command, append); +} + +bool MplexCopyMode::c_copy_selection(const Words& w, MplexClientConn* c) { + deliver(selection_text(), {}); + c_clear_selection(w, c); + return false; +} + +bool MplexCopyMode::c_copy_selection_and_cancel(const Words&, MplexClientConn*) { + deliver(selection_text(), {}); + return true; +} + +bool MplexCopyMode::c_copy_pipe(const Words& w, MplexClientConn* c) { + deliver(selection_text(), w); + c_clear_selection(w, c); + return false; +} + +bool MplexCopyMode::c_copy_pipe_and_cancel(const Words& w, MplexClientConn*) { + deliver(selection_text(), w); + return true; +} + +bool MplexCopyMode::c_append_selection(const Words& w, MplexClientConn* c) { + deliver(selection_text(), {}, true); + c_clear_selection(w, c); + return false; +} + +bool MplexCopyMode::c_append_selection_and_cancel(const Words&, MplexClientConn*) { + deliver(selection_text(), {}, true); + return true; +} + +bool MplexCopyMode::copy_span(bool from_start, const Words& words, bool cancel) { + int ay = abs_y(); + anchor = Point{from_start ? 0 : cx, ay}; + line_select = false; + rect = false; + for (int i = 0; i < count - 1; ++i) step_down(false); + cx = llen(abs_y()); + deliver(selection_text(), words); + c_clear_selection(words, nullptr); + return cancel; +} + +bool MplexCopyMode::c_copy_end_of_line(const Words&, MplexClientConn*) { return copy_span(false, {}, false); } +bool MplexCopyMode::c_copy_end_of_line_and_cancel(const Words&, MplexClientConn*) { return copy_span(false, {}, true); } +bool MplexCopyMode::c_copy_pipe_end_of_line(const Words& w, MplexClientConn*) { return copy_span(false, w, false); } +bool MplexCopyMode::c_copy_pipe_end_of_line_and_cancel(const Words& w, MplexClientConn*) { return copy_span(false, w, true); } +bool MplexCopyMode::c_copy_line(const Words&, MplexClientConn*) { return copy_span(true, {}, false); } +bool MplexCopyMode::c_copy_line_and_cancel(const Words&, MplexClientConn*) { return copy_span(true, {}, true); } +bool MplexCopyMode::c_copy_pipe_line(const Words& w, MplexClientConn*) { return copy_span(true, w, false); } +bool MplexCopyMode::c_copy_pipe_line_and_cancel(const Words& w, MplexClientConn*) { return copy_span(true, w, true); } + +// ---- search ------------------------------------------------------------------------------------------------- + +std::optional MplexCopyMode::find(const py::Regex& pat, bool forward, int x, int ay, bool wrap) { + int tot = total(); + auto matches_of = [&](int index) { + std::vector kept; + for (const auto& m : pat.finditer(utext(index))) + if (m.end > m.start) kept.push_back(m); + return kept; + }; + auto pick = [&](const std::vector& found, int index) { + const auto& m = forward ? found.front() : found.back(); + return Found{static_cast(m.start), static_cast(m.end), index}; + }; + auto visit = [&](int index, bool filter) -> std::optional { + auto found = matches_of(index); + if (filter) { + std::vector kept; + for (const auto& m : found) + if (forward ? m.start >= x : m.start < x) kept.push_back(m); + found = kept; + } + if (!found.empty()) return pick(found, index); + return std::nullopt; + }; + if (forward) { + for (int index = ay; index < tot; ++index) + if (auto hit = visit(index, index == ay)) return hit; + } else { + for (int index = ay; index >= 0; --index) + if (auto hit = visit(index, index == ay)) return hit; + } + if (wrap) { + if (forward) { + for (int index = 0; index <= ay; ++index) + if (auto hit = visit(index, false)) return hit; + } else { + for (int index = tot - 1; index >= ay; --index) + if (auto hit = visit(index, false)) return hit; + } + } + return std::nullopt; +} + +void MplexCopyMode::scroll_to(int x, int ay) { + int h = hist(), r = rows(); + cx = std::max(0, std::min(cols(), x)); + int t = h - oy; + if (t <= ay && ay < t + r) { + cy = ay - t; + return; + } + int gap = r / 4; + int offset; + if (ay < r) { + offset = 0; + cy = ay; + } else if (ay > h + r - gap) { + offset = h; + cy = ay - h; + } else { + offset = ay + gap - r; + cy = r - gap; + } + oy = std::max(0, std::min(h, h - offset)); +} + +bool MplexCopyMode::run_search(const std::string& text_in, bool forward, bool literal, const std::optional& origin) { + if (text_in.empty()) return false; + search = MplexPane::SearchState{text_in, forward, literal}; + if (!static_rows) pane->last_search = search; + std::optional pat = compile(text_in, literal); + if (!pat) return false; + MplexOptions* opts = server->pane_options(pane); + bool wrap = opts->get("wrap-search").truthy(); + bool vi = opts->get("mode-keys").s == "vi"; + int x, ay; + if (origin) { + x = origin->first; + ay = origin->second; + } else { + x = cx; + ay = abs_y(); + } + if (forward && vi && !origin) x += 1; + std::optional found = find(*pat, forward, x, ay, wrap); + if (!found) return false; + scroll_to(forward && !vi ? found->end : found->start, found->line); + return true; +} + +void MplexCopyMode::search_failed(MplexClientConn* client) { + server->message(client ? client->shared_from_this() : nullptr, "Search failed"); +} + +void MplexCopyMode::search_command(const Words& words, bool forward, bool literal, MplexClientConn* client) { + std::string t = words.empty() ? std::string() : words[0]; + if (t.empty() && search) t = search->text; + if (!run_search(t, forward, literal)) search_failed(client); +} + +bool MplexCopyMode::c_search_forward(const Words& w, MplexClientConn* c) { + search_command(w, true, false, c); + return false; +} +bool MplexCopyMode::c_search_backward(const Words& w, MplexClientConn* c) { + search_command(w, false, false, c); + return false; +} +bool MplexCopyMode::c_search_forward_text(const Words& w, MplexClientConn* c) { + search_command(w, true, true, c); + return false; +} +bool MplexCopyMode::c_search_backward_text(const Words& w, MplexClientConn* c) { + search_command(w, false, true, c); + return false; +} + +bool MplexCopyMode::c_search_again(const Words&, MplexClientConn* client) { + if (!search) return false; + MplexPane::SearchState s = *search; + if (!run_search(s.text, s.forward, s.literal)) search_failed(client); + return false; +} + +bool MplexCopyMode::c_search_reverse(const Words&, MplexClientConn* client) { + if (!search) return false; + MplexPane::SearchState s = *search; + if (!run_search(s.text, !s.forward, s.literal)) search_failed(client); + search = s; + return false; +} + +void MplexCopyMode::incremental(const Words& words, bool forward) { + UStr arg = to_ustr(words.empty() ? std::string() : words[0]); + if (!inc_origin) inc_origin = Point{cx, abs_y()}; + wchar_t mk = arg.empty() ? 0 : arg[0]; + UStr rest = arg.empty() ? UStr() : arg.substr(1); + if (mk == L'=') { + Point origin{forward ? inc_origin->first - 1 : inc_origin->first + 1, inc_origin->second}; + run_search(to_utf8(rest), forward, false, origin); + } else if (mk == L'+' || mk == L'-') { + run_search(to_utf8(rest), mk == L'+'); + } else { + inc_origin.reset(); + if (!arg.empty()) search = MplexPane::SearchState{to_utf8(arg), forward, false}; + } +} + +bool MplexCopyMode::c_search_forward_incremental(const Words& w, MplexClientConn*) { + incremental(w, true); + return false; +} +bool MplexCopyMode::c_search_backward_incremental(const Words& w, MplexClientConn*) { + incremental(w, false); + return false; +} + +void MplexCopyMode::search_cancel() { + if (inc_origin) { + goto_pos(inc_origin->first, inc_origin->second); + inc_origin.reset(); + server->mark_dirty(pane->window()); + } +} + +// ---- dispatch table ----------------------------------------------------------------------------------------------- + +const std::vector& MplexCopyMode::table() { + static const std::vector entries = { + {"cancel", &MplexCopyMode::c_cancel}, + {"cursor-up", &MplexCopyMode::c_cursor_up}, + {"cursor-down", &MplexCopyMode::c_cursor_down}, + {"cursor-down-and-cancel", &MplexCopyMode::c_cursor_down_and_cancel}, + {"cursor-left", &MplexCopyMode::c_cursor_left}, + {"cursor-right", &MplexCopyMode::c_cursor_right}, + {"start-of-line", &MplexCopyMode::c_start_of_line}, + {"end-of-line", &MplexCopyMode::c_end_of_line}, + {"back-to-indentation", &MplexCopyMode::c_back_to_indentation}, + {"top-line", &MplexCopyMode::c_top_line}, + {"middle-line", &MplexCopyMode::c_middle_line}, + {"bottom-line", &MplexCopyMode::c_bottom_line}, + {"history-top", &MplexCopyMode::c_history_top}, + {"history-bottom", &MplexCopyMode::c_history_bottom}, + {"page-up", &MplexCopyMode::c_page_up}, + {"page-down", &MplexCopyMode::c_page_down}, + {"page-down-and-cancel", &MplexCopyMode::c_page_down_and_cancel}, + {"halfpage-up", &MplexCopyMode::c_halfpage_up}, + {"halfpage-down", &MplexCopyMode::c_halfpage_down}, + {"halfpage-down-and-cancel", &MplexCopyMode::c_halfpage_down_and_cancel}, + {"scroll-up", &MplexCopyMode::c_scroll_up}, + {"scroll-down", &MplexCopyMode::c_scroll_down}, + {"scroll-down-and-cancel", &MplexCopyMode::c_scroll_down_and_cancel}, + {"scroll-middle", &MplexCopyMode::c_scroll_middle}, + {"cursor-centre-vertical", &MplexCopyMode::c_cursor_centre_vertical}, + {"cursor-centre-horizontal", &MplexCopyMode::c_cursor_centre_horizontal}, + {"scroll-top", &MplexCopyMode::c_scroll_top}, + {"scroll-bottom", &MplexCopyMode::c_scroll_bottom}, + {"goto-line", &MplexCopyMode::c_goto_line}, + {"refresh-from-pane", &MplexCopyMode::c_refresh_from_pane}, + {"toggle-position", &MplexCopyMode::c_toggle_position}, + {"next-word", &MplexCopyMode::c_next_word}, + {"next-space", &MplexCopyMode::c_next_space}, + {"next-word-end", &MplexCopyMode::c_next_word_end}, + {"next-space-end", &MplexCopyMode::c_next_space_end}, + {"previous-word", &MplexCopyMode::c_previous_word}, + {"previous-space", &MplexCopyMode::c_previous_space}, + {"next-paragraph", &MplexCopyMode::c_next_paragraph}, + {"previous-paragraph", &MplexCopyMode::c_previous_paragraph}, + {"next-matching-bracket", &MplexCopyMode::c_next_matching_bracket}, + {"previous-matching-bracket", &MplexCopyMode::c_previous_matching_bracket}, + {"jump-forward", &MplexCopyMode::c_jump_forward}, + {"jump-backward", &MplexCopyMode::c_jump_backward}, + {"jump-to-forward", &MplexCopyMode::c_jump_to_forward}, + {"jump-to-backward", &MplexCopyMode::c_jump_to_backward}, + {"jump-again", &MplexCopyMode::c_jump_again}, + {"jump-reverse", &MplexCopyMode::c_jump_reverse}, + {"begin-selection", &MplexCopyMode::c_begin_selection}, + {"stop-selection", &MplexCopyMode::c_stop_selection}, + {"clear-selection", &MplexCopyMode::c_clear_selection}, + {"select-line", &MplexCopyMode::c_select_line}, + {"select-word", &MplexCopyMode::c_select_word}, + {"rectangle-toggle", &MplexCopyMode::c_rectangle_toggle}, + {"rectangle-on", &MplexCopyMode::c_rectangle_on}, + {"rectangle-off", &MplexCopyMode::c_rectangle_off}, + {"other-end", &MplexCopyMode::c_other_end}, + {"set-mark", &MplexCopyMode::c_set_mark}, + {"jump-to-mark", &MplexCopyMode::c_jump_to_mark}, + {"copy-selection", &MplexCopyMode::c_copy_selection}, + {"copy-selection-and-cancel", &MplexCopyMode::c_copy_selection_and_cancel}, + {"copy-pipe", &MplexCopyMode::c_copy_pipe}, + {"copy-pipe-and-cancel", &MplexCopyMode::c_copy_pipe_and_cancel}, + {"append-selection", &MplexCopyMode::c_append_selection}, + {"append-selection-and-cancel", &MplexCopyMode::c_append_selection_and_cancel}, + {"copy-end-of-line", &MplexCopyMode::c_copy_end_of_line}, + {"copy-end-of-line-and-cancel", &MplexCopyMode::c_copy_end_of_line_and_cancel}, + {"copy-pipe-end-of-line", &MplexCopyMode::c_copy_pipe_end_of_line}, + {"copy-pipe-end-of-line-and-cancel", &MplexCopyMode::c_copy_pipe_end_of_line_and_cancel}, + {"copy-line", &MplexCopyMode::c_copy_line}, + {"copy-line-and-cancel", &MplexCopyMode::c_copy_line_and_cancel}, + {"copy-pipe-line", &MplexCopyMode::c_copy_pipe_line}, + {"copy-pipe-line-and-cancel", &MplexCopyMode::c_copy_pipe_line_and_cancel}, + {"search-forward", &MplexCopyMode::c_search_forward}, + {"search-backward", &MplexCopyMode::c_search_backward}, + {"search-forward-text", &MplexCopyMode::c_search_forward_text}, + {"search-backward-text", &MplexCopyMode::c_search_backward_text}, + {"search-again", &MplexCopyMode::c_search_again}, + {"search-reverse", &MplexCopyMode::c_search_reverse}, + {"search-forward-incremental", &MplexCopyMode::c_search_forward_incremental}, + {"search-backward-incremental", &MplexCopyMode::c_search_backward_incremental}, + }; + return entries; +} + +const std::vector& MplexCopyMode::command_names() { + static const std::vector names = [] { + std::vector out; + for (const Entry& entry : table()) out.push_back(entry.name); + return out; + }(); + return names; +} + +} // namespace detz::mplex diff --git a/taicpp/src/mplex/mplex_copymode.h b/taicpp/src/mplex/mplex_copymode.h new file mode 100644 index 0000000..304658c --- /dev/null +++ b/taicpp/src/mplex/mplex_copymode.h @@ -0,0 +1,201 @@ +// MplexCopyMode: scrollback browser: movement, search, selection and copy (tai.py:12043-12997). +// The public names below are what the server, commands and format variables use. +#pragma once +#include +#include +#include +#include +#include +#include +#include + +#include "mplex_mode.h" +#include "mplex_pane.h" +#include "mplex_py.h" + +namespace detz::mplex { + +class MplexCopyMode : public MplexMode { + public: + using Point = std::pair; // (x, absolute y) + // lines != null makes a read-only view over static text (view-mode); view is accepted for parity. + MplexCopyMode(MplexServer* server_in, std::shared_ptr pane_in, const std::vector* lines = nullptr, + bool view = false, bool exit_at_bottom = false, bool show_position = true); + + std::optional static_rows; + int oy = 0, cx = 0, cy = 0; + std::optional anchor; + bool rect = false, line_select = false; + std::optional mark; + std::optional inc_origin; + bool show_position, exit_at_bottom; + int count = 1; + int lastcx = 0, lastsx = 0; + std::optional search; + + // Every `send-keys -X ` command, hyphenated, in the order of the Python class body. + // command() dispatches through this same table. + static const std::vector& command_names(); + + int rows() const; + int cols() const; + int hist() const; + int total() const; + int top() const; + int abs_y() const; + const Row& row(int ay) const; // [] beyond the grid + std::string text(int ay) const; // "".join(cell char or " ").rstrip(), UTF-8 + UStr utext(int ay) const; // the same as code points + void clamp(); + void goto_pos(int x, int ay); // Python: goto() + void on_output(long pushed, long evicted) override; + void on_resize() override; + ModeBlock render(MplexClientConn& client) override; + void key(const std::string& name, const Bytes& raw, MplexClientConn& client) override; + // command(name, words, client): runs one `send-keys -X name`; throws MplexError for unknown commands. + void command(const std::string& name, const std::vector& words, MplexClientConn* client); + void search_cancel(); + bool vi_keys() const; + bool selected(int x, int ay) const; + + private: + using Words = std::vector; + using Handler = bool (MplexCopyMode::*)(const Words&, MplexClientConn*); // true == "cancel" + struct Entry { + const char* name; + Handler handler; + }; + static const std::vector& table(); + + std::optional> jump_; // (kind, char) + mutable int cache_ay_ = -1; + mutable UStr cache_text_; + + struct Blanks { + int x, ay, xx; + bool bottom; + }; + + std::optional pattern() const; + static std::optional compile(const std::string& text, bool literal); + static Cell tint(const Cell& cell, const Style& style); + int llen(int ay) const { return static_cast(utext(ay).size()); } + void snap(); + void step_up(bool scroll_only); + void step_down(bool scroll_only); + bool bottom_exit() const; + int page_size(bool half) const; + void remember_column(); + void page_up(bool half); + void page_down(bool half); + bool in_ws(int x, int ay); + bool in_sep(int x, int ay, const UStr& separators); + UStr separators(bool big) const; + wchar_t char_at(int x, int ay); + Blanks advance_blanks(int x, int ay, int xx); + int run_end(int x, int ay, int xx, const UStr& separators); + bool next_word(bool big); + bool next_word_end(bool big); + bool previous_word(bool big); + std::optional match_bracket(int x, int ay, bool forward); + std::optional step(int x, int ay, bool forward) const; + void do_jump(char kind, wchar_t ch); + bool set_jump(char kind, const Words& words); + std::string selection_text() const; + void deliver(const std::string& text, const Words& words, bool append = false); + bool copy_span(bool from_start, const Words& words, bool cancel); + struct Found { + int start, end, line; + }; + std::optional find(const py::Regex& pattern, bool forward, int x, int ay, bool wrap); + void scroll_to(int x, int ay); + bool run_search(const std::string& text, bool forward, bool literal = false, + const std::optional& origin = std::nullopt); + void search_failed(MplexClientConn* client); + void search_command(const Words& words, bool forward, bool literal, MplexClientConn* client); + void incremental(const Words& words, bool forward); + + // c_ handlers: return true for "cancel". + bool c_cancel(const Words&, MplexClientConn*); + bool c_cursor_up(const Words&, MplexClientConn*); + bool c_cursor_down(const Words&, MplexClientConn*); + bool c_cursor_down_and_cancel(const Words&, MplexClientConn*); + bool c_cursor_left(const Words&, MplexClientConn*); + bool c_cursor_right(const Words&, MplexClientConn*); + bool c_start_of_line(const Words&, MplexClientConn*); + bool c_end_of_line(const Words&, MplexClientConn*); + bool c_back_to_indentation(const Words&, MplexClientConn*); + bool c_top_line(const Words&, MplexClientConn*); + bool c_middle_line(const Words&, MplexClientConn*); + bool c_bottom_line(const Words&, MplexClientConn*); + bool c_history_top(const Words&, MplexClientConn*); + bool c_history_bottom(const Words&, MplexClientConn*); + bool c_page_up(const Words&, MplexClientConn*); + bool c_page_down(const Words&, MplexClientConn*); + bool c_page_down_and_cancel(const Words&, MplexClientConn*); + bool c_halfpage_up(const Words&, MplexClientConn*); + bool c_halfpage_down(const Words&, MplexClientConn*); + bool c_halfpage_down_and_cancel(const Words&, MplexClientConn*); + bool c_scroll_up(const Words&, MplexClientConn*); + bool c_scroll_down(const Words&, MplexClientConn*); + bool c_scroll_down_and_cancel(const Words&, MplexClientConn*); + bool c_scroll_middle(const Words&, MplexClientConn*); + bool c_cursor_centre_vertical(const Words&, MplexClientConn*); + bool c_cursor_centre_horizontal(const Words&, MplexClientConn*); + bool c_scroll_top(const Words&, MplexClientConn*); + bool c_scroll_bottom(const Words&, MplexClientConn*); + bool c_goto_line(const Words&, MplexClientConn*); + bool c_refresh_from_pane(const Words&, MplexClientConn*); + bool c_toggle_position(const Words&, MplexClientConn*); + bool c_next_word(const Words&, MplexClientConn*); + bool c_next_space(const Words&, MplexClientConn*); + bool c_next_word_end(const Words&, MplexClientConn*); + bool c_next_space_end(const Words&, MplexClientConn*); + bool c_previous_word(const Words&, MplexClientConn*); + bool c_previous_space(const Words&, MplexClientConn*); + bool c_next_paragraph(const Words&, MplexClientConn*); + bool c_previous_paragraph(const Words&, MplexClientConn*); + bool c_next_matching_bracket(const Words&, MplexClientConn*); + bool c_previous_matching_bracket(const Words&, MplexClientConn*); + bool c_jump_forward(const Words&, MplexClientConn*); + bool c_jump_backward(const Words&, MplexClientConn*); + bool c_jump_to_forward(const Words&, MplexClientConn*); + bool c_jump_to_backward(const Words&, MplexClientConn*); + bool c_jump_again(const Words&, MplexClientConn*); + bool c_jump_reverse(const Words&, MplexClientConn*); + bool c_begin_selection(const Words&, MplexClientConn*); + bool c_stop_selection(const Words&, MplexClientConn*); + bool c_clear_selection(const Words&, MplexClientConn*); + bool c_select_line(const Words&, MplexClientConn*); + bool c_select_word(const Words&, MplexClientConn*); + bool c_rectangle_toggle(const Words&, MplexClientConn*); + bool c_rectangle_on(const Words&, MplexClientConn*); + bool c_rectangle_off(const Words&, MplexClientConn*); + bool c_other_end(const Words&, MplexClientConn*); + bool c_set_mark(const Words&, MplexClientConn*); + bool c_jump_to_mark(const Words&, MplexClientConn*); + bool c_copy_selection(const Words&, MplexClientConn*); + bool c_copy_selection_and_cancel(const Words&, MplexClientConn*); + bool c_copy_pipe(const Words&, MplexClientConn*); + bool c_copy_pipe_and_cancel(const Words&, MplexClientConn*); + bool c_append_selection(const Words&, MplexClientConn*); + bool c_append_selection_and_cancel(const Words&, MplexClientConn*); + bool c_copy_end_of_line(const Words&, MplexClientConn*); + bool c_copy_end_of_line_and_cancel(const Words&, MplexClientConn*); + bool c_copy_pipe_end_of_line(const Words&, MplexClientConn*); + bool c_copy_pipe_end_of_line_and_cancel(const Words&, MplexClientConn*); + bool c_copy_line(const Words&, MplexClientConn*); + bool c_copy_line_and_cancel(const Words&, MplexClientConn*); + bool c_copy_pipe_line(const Words&, MplexClientConn*); + bool c_copy_pipe_line_and_cancel(const Words&, MplexClientConn*); + bool c_search_forward(const Words&, MplexClientConn*); + bool c_search_backward(const Words&, MplexClientConn*); + bool c_search_forward_text(const Words&, MplexClientConn*); + bool c_search_backward_text(const Words&, MplexClientConn*); + bool c_search_again(const Words&, MplexClientConn*); + bool c_search_reverse(const Words&, MplexClientConn*); + bool c_search_forward_incremental(const Words&, MplexClientConn*); + bool c_search_backward_incremental(const Words&, MplexClientConn*); +}; + +} // namespace detz::mplex diff --git a/taicpp/tests/mplex_copy_gen.py b/taicpp/tests/mplex_copy_gen.py new file mode 100644 index 0000000..2647afa --- /dev/null +++ b/taicpp/tests/mplex_copy_gen.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +# retoor +"""Scenario generator for taicpp/tests/mplex_copy_test.cpp. + +Runs tai.py's real MplexCopyMode (the oracle) over recorded tmux fixtures from test_tai.py, every default +copy-mode binding, and a seeded random fuzz, and writes one scenario file. The C++ test replays each scenario +against the C++ MplexCopyMode and compares the state line after every step byte for byte. + +usage: python3 -I mplex_copy_gen.py REPO_ROOT OUT_FILE [fuzz_count] [seed] +""" +import os +import random +import sys +import tempfile +import zlib + +root = sys.argv[1] +out_path = sys.argv[2] +fuzz_count = int(sys.argv[3]) if len(sys.argv) > 3 else 3600 +seed = int(sys.argv[4]) if len(sys.argv) > 4 else 20251025 +sys.path.insert(0, root) +import tai # noqa: E402 +import test_tai # noqa: E402 + + +def esc(text): + return text.replace("%", "%25").replace("\t", "%09").replace("\n", "%0A").replace("\r", "%0D") + + +def digest(mode): + block, cursor = mode.render(None) + parts = [] + for row in block: + for cell in row: + parts.append("%s|%s|%s|%d;" % (cell[0], cell[1] or "", cell[2] or "", cell[3])) + parts.append("\n") + parts.append("%r" % (cursor,)) + return zlib.crc32("".join(parts).encode("utf-8")) & 0xFFFFFFFF + + +def pair(value): + return "-" if value is None else "%d,%d" % value + + +def search_text(value): + if not value: + return "-" + return "%s,%d,%d" % (esc(value[0]), 1 if value[1] else 0, 1 if value[2] else 0) + + +class Recorder: + def __init__(self): + self.lines = [] + + def add(self, *fields): + self.lines.append("\t".join(str(f) for f in fields)) + + +def state(mode, pane, server, err, closed): + last_copy = "-" + if server.copied: + text, command, append = server.copied[-1] + last_copy = "%s/%s/%d" % (esc(text), esc(command), 1 if append else 0) + return "\t".join([ + "E", str(mode.cx), str(mode.cy), str(mode.oy), str(mode.hist()), pair(mode.anchor), pair(mode.mark), + "1" if mode.rect else "0", "1" if mode.line_select else "0", search_text(mode.search), + search_text(pane.last_search), "1" if mode.show_position else "0", "1" if closed else "0", + str(len(server.copied)), last_copy, str(len(server.messages)), + esc(server.messages[-1]) if server.messages else "-", str(mode.count), str(mode.lastcx), str(mode.lastsx), + esc(err), "%08x" % digest(mode), str(pane.history_size()), str(pane.prefix_count), + ]) + + +rec = Recorder() +scenarios = 0 +steps_total = 0 + + +def run_scenario(name, rows, cols, history, keys, content, steps, lines=None, exit_at_bottom=False, expect=None): + """steps: ("C", count, words) | ("F", text) | ("R", rows, cols)""" + global scenarios, steps_total + pane = tai.MplexPane(1, cols, rows, history, tempfile.gettempdir()) + if lines is None: + pane.feed(content.replace("\n", "\r\n").encode("utf-8")) + server = test_tai.MplexStubServer() + server.options.set("mode-keys", keys) + if lines is not None: + mode = tai.MplexCopyMode(server, pane, lines=lines) + else: + mode = tai.MplexCopyMode(server, pane, exit_at_bottom=exit_at_bottom) + pane.push_mode(mode) + rec.add("S", esc(name), rows, cols, history, keys, esc(content), "1" if lines is not None else "0", + "1" if exit_at_bottom else "0", len(steps)) + rec.add("E0", str(mode.cx), str(mode.cy), str(mode.oy), str(mode.hist()), mode.name, "%08x" % digest(mode)) + scenarios += 1 + closed = False + for step in steps: + steps_total += 1 + if closed: + break + err = "" + if step[0] == "C": + _kind, count, words = step + rec.add("C", count, *[esc(w) for w in words]) + pane.prefix_count = count + try: + mode.command(words[0], words[1:], None) + except tai.MplexError as exc: + err = "M:" + str(exc) + except Exception as exc: # noqa: BLE001 + err = "X:" + str(exc) + elif step[0] == "K": + rec.add("K", esc(step[1])) + try: + mode.key(step[1], b"", None) + except tai.MplexError as exc: + err = "M:" + str(exc) + except Exception as exc: # noqa: BLE001 + err = "X:" + str(exc) + elif step[0] == "F": + rec.add("F", esc(step[1])) + pane.feed(step[1].replace("\n", "\r\n").encode("utf-8")) + else: + rec.add("R", step[1], step[2]) + pane.place(0, 0, step[2], step[1]) + closed = mode not in pane.modes + rec.lines.append(state(mode, pane, server, err, closed)) + if closed: + break + if expect is not None: + rec.add("T", expect[0], esc(expect[1])) + rec.add("X") + + +# ---- command table ----------------------------------------------------------------------------- +names = [n[2:].replace("_", "-") for n in tai.MplexCopyMode.__dict__ if n.startswith("c_")] +rec.add("N", *names) + +expand = test_tai.MplexCopyModeVersusTmuxTests.expand + +# ---- recorded tmux cases (cursor, scroll) --------------------------------------------------------- +for index, (keys, commands, count, expected) in enumerate(test_tai.MPLEX_TMUX_CASES): + pieces = expand(commands) + steps = [] + for position, step in enumerate(pieces): + words = step.split() + steps.append(("C", count if position == len(pieces) - 1 and count > 1 else 1, words)) + run_scenario("tmux-case-%d" % index, 6, 30, 100, keys, "\n".join(test_tai.MPLEX_TMUX_CONTENT) + "\n", steps, + expect=("cursor", expected)) + +# ---- recorded tmux selections (paste buffer text) -------------------------------------------------- +for index, (keys, commands, expected) in enumerate(test_tai.MPLEX_TMUX_SELECTIONS): + pieces = expand("history-top;" + commands) + steps = [("C", 1, step.split()) for step in pieces] + run_scenario("tmux-sel-%d" % index, 6, 30, 100, keys, "\n".join(test_tai.MPLEX_TMUX_CONTENT) + "\n", steps, + expect=("copied", expected)) + +# ---- every default copy-mode binding, as test_tai runs them ------------------------------------------ +config = tai.MplexConfig() +for table in ("copy-mode", "copy-mode-vi"): + for key, binding in config.tables[table].items(): + words = tai.MplexCommandParser.parse(binding.command)[0] + if words[0] == "command-prompt": + words = tai.MplexCommandParser.parse(words[-1].replace("%%", "3"))[0] + if words[:2] != ["send-keys", "-X"]: + continue + words = [item for item in words[2:] if item != "--"] + content = "\n".join("line %d (a b) {c} a,b,c" % n for n in range(40)) + "\n" + run_scenario("binding-%s-%s" % (table, key), 6, 30, 100, "vi" if table == "copy-mode-vi" else "emacs", content, + [("C", 1, ["page-up"]), ("C", 1, ["begin-selection"]), ("C", 2, words)]) +# every binding of both tables through key() as well (the server run_binding stub dispatches like test_tai's) +for table in ("copy-mode", "copy-mode-vi"): + for key in list(config.tables[table]): + content = "\n".join("line %d (a b) {c} a,b,c" % n for n in range(40)) + "\n" + run_scenario("key-%s-%s" % (table, key), 6, 30, 100, "vi" if table == "copy-mode-vi" else "emacs", content, + [("C", 1, ["page-up"]), ("C", 1, ["begin-selection"]), ("K", key)]) + +# ---- seeded fuzz ---------------------------------------------------------------------------------------- +rng = random.Random(seed) +WORDS = ["alpha", "beta", "gamma-delta", "foo(bar)", "baz[qux]", "{z}", "key=value;", "other_one,", "a-b", "c.d", + "/path/to/f.py", "UPPER", "Mixed_Case", "x", "tab\tw", "é日本", "((", "))", "a,b,c", "...", "'q'", + "(nested [mix {ok}] here)", "end", "z"] +NO_ARG = [n for n in names if not n.startswith(("jump-", "goto-line", "search-", "copy-pipe")) + or n in ("jump-again", "jump-reverse", "jump-to-mark", "search-again", "search-reverse")] +PIPE = ["copy-pipe", "copy-pipe-and-cancel", "copy-pipe-end-of-line", "copy-pipe-end-of-line-and-cancel", + "copy-pipe-line", "copy-pipe-line-and-cancel"] +JUMPS = ["jump-forward", "jump-backward", "jump-to-forward", "jump-to-backward"] +SEARCHES = ["search-forward", "search-backward", "search-forward-text", "search-backward-text"] +INCR = ["search-forward-incremental", "search-backward-incremental"] +PATTERNS = ["a", "e", "A", "foo", "(", "[a-c]+", "\\bkey", "x*", "", "line", "Mixed", "b.", "(a|b)c", "l.ne", "\\d+", "end$", + "^ *a", "[", "a{2}", "o+", "\\w+", "é", "\\.", "t", "(?i)ALPHA"] +JARGS = ["a", "e", "(", " ", "x", "é", "-", "="] + + +def pick_content(): + lines = [] + for _n in range(rng.choice([0, 2, 5, 8, 14, 25, 45, 60])): + indent = " " * rng.choice([0, 0, 0, 2, 4]) + count = rng.choice([0, 1, 2, 3, 4, 6, 9]) + sep = rng.choice([" ", " ", " ", ""]) + lines.append(indent + sep.join(rng.choice(WORDS) for _k in range(count))) + return "\n".join(lines) + ("\n" if lines else "") + + +def pick_step(): + roll = rng.random() + if roll < 0.07: + return ("F", rng.choice(["\n", "new line\n", "more text (x) y\nand\n\n", "a" * rng.choice([5, 25, 70]) + "\n"])) + if roll < 0.10: + return ("R", rng.choice([3, 4, 6, 8, 10]), rng.choice([8, 12, 20, 30, 40])) + count = rng.choice([1, 1, 1, 1, 2, 3, 5, 12]) + if rng.random() < 0.08: + table = config.tables["copy-mode-vi" if rng.random() < 0.5 else "copy-mode"] + return ("K", rng.choice(list(table))) + pick = rng.random() + if pick < 0.08: + return ("C", count, [rng.choice(JUMPS), rng.choice(JARGS)]) + if pick < 0.14: + return ("C", count, [rng.choice(SEARCHES), rng.choice(PATTERNS)]) + if pick < 0.17: + return ("C", count, [rng.choice(INCR), rng.choice(["=", "+", "-", ""]) + rng.choice(PATTERNS)]) + if pick < 0.19: + return ("C", count, ["goto-line", rng.choice(["0", "3", "9", "40", "-2", "x", " 7 ", "1_0"])]) + if pick < 0.21: + return ("C", count, [rng.choice(PIPE), rng.choice(["cat", "tr a b"])]) + if pick < 0.215: + return ("C", count, ["bogus-command"]) + if pick < 0.22: + return ("C", count, [rng.choice(JUMPS)]) + return ("C", count, [rng.choice(NO_ARG)]) + + +for number in range(fuzz_count): + keys = rng.choice(["emacs", "vi"]) + rows = rng.choice([2, 3, 4, 5, 6, 8, 10]) + cols = rng.choice([6, 10, 14, 20, 30, 40]) + history = rng.choice([0, 3, 10, 50, 200]) + view = rng.random() < 0.06 + content = pick_content() + steps = [pick_step() for _k in range(rng.randint(1, 14))] + if rng.random() < 0.3: + steps.insert(0, ("C", 1, ["history-top"])) + if view: + text_lines = content.split("\n")[:40] or [""] + run_scenario("fuzz-view-%d" % number, rows, cols, history, keys, "\n".join(text_lines), steps, lines=text_lines) + else: + run_scenario("fuzz-%d" % number, rows, cols, history, keys, content, steps, exit_at_bottom=rng.random() < 0.1) + + +# ---- live tmux 3.6 oracle ----------------------------------------------------------------------------------- +# Fresh random movement sequences run in the installed tmux (an attached-size 30x6 window with the same text as +# the recorded fixtures); the C++ test reports how many final cursor/scroll states agree with tmux. +TMUX_MOVES = ["cursor-up", "cursor-down", "cursor-left", "cursor-right", "start-of-line", "end-of-line", + "back-to-indentation", "top-line", "middle-line", "bottom-line", "history-top", "history-bottom", + "page-up", "page-down", "halfpage-up", "halfpage-down", "scroll-up", "scroll-down", "next-word", + "next-word-end", "previous-word", "next-space", "next-space-end", "previous-space", "next-paragraph", + "previous-paragraph", "next-matching-bracket", "previous-matching-bracket", "jump-forward", + "jump-backward", "jump-to-forward", "jump-to-backward", "jump-again", "jump-reverse"] + + +def tmux_live(count): + import shutil + import subprocess + import time + if shutil.which("tmux") is None or count <= 0: + return + sock = "f4gen%d" % os.getpid() + base = ["tmux", "-L", sock, "-f", "/dev/null"] + content = "\n".join(test_tai.MPLEX_TMUX_CONTENT) + "\n" + with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as handle: + handle.write(content) + path = handle.name + live_rng = random.Random(seed + 1) + try: + for number in range(count): + keys = "vi" if number % 2 else "emacs" + steps = [] + for _k in range(live_rng.randint(1, 10)): + name = live_rng.choice(TMUX_MOVES) + words = [name] + if name.startswith("jump-") and name not in ("jump-again", "jump-reverse"): + words.append(live_rng.choice(["a", "e", "o", "t", " ", "l"])) + steps.append((1, words)) + if live_rng.random() < 0.3: + steps[-1] = (live_rng.choice([2, 3, 5]), steps[-1][1]) + session = "s%d" % number + subprocess.run(base + ["new-session", "-d", "-x", "30", "-y", "6", "-s", session, + "cat %s; exec sleep 600" % path], check=True, capture_output=True) + try: + for _t in range(100): + shot = subprocess.run(base + ["capture-pane", "-p", "-t", session], capture_output=True, text=True).stdout + if "last line of text here ok" in shot: + break + time.sleep(0.02) + subprocess.run(base + ["set-option", "-t", session, "mode-keys", keys], check=True, capture_output=True) + subprocess.run(base + ["copy-mode", "-t", session], check=True, capture_output=True) + for amount, words in steps: + argv = base + ["send-keys", "-t", session, "-X"] + if amount > 1: + argv = base + ["send-keys", "-t", session, "-N", str(amount), "-X"] + subprocess.run(argv + words, capture_output=True) + shown = subprocess.run(base + ["display-message", "-p", "-t", session, + "#{copy_cursor_x} #{copy_cursor_y} #{scroll_position}"], + capture_output=True, text=True).stdout.strip() + finally: + subprocess.run(base + ["kill-session", "-t", session], capture_output=True) + run_scenario("tmuxlive-%d" % number, 6, 30, 100, keys, content, + [("C", amount, words) for amount, words in steps], expect=("livecursor", shown)) + finally: + subprocess.run(base + ["kill-server"], capture_output=True) + os.unlink(path) + + +tmux_live(int(os.environ.get("F4_TMUX_LIVE", "240"))) + +with open(out_path, "w", encoding="utf-8") as handle: + handle.write("\n".join(rec.lines) + "\n") +sys.stderr.write("scenarios=%d steps=%d\n" % (scenarios, steps_total)) diff --git a/taicpp/tests/mplex_copy_run.sh b/taicpp/tests/mplex_copy_run.sh new file mode 100755 index 0000000..a1de759 --- /dev/null +++ b/taicpp/tests/mplex_copy_run.sh @@ -0,0 +1,27 @@ +#!/bin/sh +# Build and run the MplexCopyMode differential test. Writes only under taicpp/build/f4. +# usage: tests/mplex_copy_run.sh [fuzz_count] [seed] (SAN=1 builds with ASan and UBSan) +set -e +here=$(cd "$(dirname "$0")" && pwd) +tc=$(cd "$here/.." && pwd) +repo=$(cd "$tc/.." && pwd) +out="$tc/build/f4" +mkdir -p "$out" +srcs="" +for f in "$tc"/src/mplex/*.cpp; do + case "$f" in + */mplex_server.cpp|*/mplex_commands.cpp|*/mplex_client.cpp) continue ;; + esac + srcs="$srcs $f" +done +flags="-O2" +bin="$out/mplex_copy_test" +if [ -n "$SAN" ]; then + flags="-g -O1 -fsanitize=address,undefined" + bin="$out/mplex_copy_test_san" +fi +# The test supplies its own MplexServer stub and the MplexCommands destructor it needs. +g++ -std=c++23 -Wall -Wextra $flags -DF4_STUB_COMMANDS -I"$tc/src/mplex" "$tc/tests/mplex_copy_test.cpp" $srcs \ + -Wl,--unresolved-symbols=ignore-in-object-files -o "$bin" +python3 -I "$tc/tests/mplex_copy_gen.py" "$repo" "$out/scenarios.txt" "${1:-3600}" "${2:-20251025}" +"$bin" "$out/scenarios.txt" diff --git a/taicpp/tests/mplex_copy_test.cpp b/taicpp/tests/mplex_copy_test.cpp new file mode 100644 index 0000000..54ea12f --- /dev/null +++ b/taicpp/tests/mplex_copy_test.cpp @@ -0,0 +1,328 @@ +// Differential test of MplexCopyMode against tai.py's MplexCopyMode. Scenario file made by +// mplex_copy_gen.py (tmux 3.6 fixtures from test_tai.py, every default copy-mode binding, seeded fuzz); +// every step's full state line (cursor, scroll, selection, mark, search, copies, messages, render checksum) +// must match the Python oracle byte for byte. Fixture scenarios also carry the value tmux 3.6 produced. +// +// The MplexServer here is the small stub the Python tests use (MplexStubServer): options, bindings, a copy sink. +// Build and run: tests/mplex_copy_run.sh (writes only under taicpp/build/). +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "mplex_args.h" +#include "mplex_canvas.h" +#include "mplex_commands.h" +#include "mplex_config.h" +#include "mplex_copymode.h" +#include "mplex_renderer.h" +#include "mplex_server.h" + +using namespace detz::mplex; + +namespace { +std::vector> g_copied; +std::vector g_messages; +} // namespace + +#ifdef F4_STUB_COMMANDS +struct detz::mplex::MplexCommands::Impl {}; +detz::mplex::MplexCommands::~MplexCommands() = default; +#endif + +namespace detz::mplex { + +MplexServer::MplexServer(const std::string& home_in) : home(home_in) { + config = std::make_shared(); + options = &config->options; + started = 0.0; +} +MplexServer::~MplexServer() = default; + +MplexOptions* MplexServer::pane_options(const std::shared_ptr&) { return options; } +void MplexServer::message(const std::shared_ptr&, const std::string& text, std::optional) { + g_messages.push_back(text); +} +void MplexServer::mark_dirty(const std::shared_ptr&) {} +void MplexServer::store_copy(const std::string& text, const std::string& command, bool append) { + g_copied.emplace_back(text, command, append); +} +void MplexServer::run_binding(const std::shared_ptr&, const MplexBinding& binding, + const std::shared_ptr& pane) { + auto words = MplexCommandParser::parse(binding.command)[0]; + auto mode = std::dynamic_pointer_cast(pane->top_mode()); + mode->command(words[2], std::vector(words.begin() + 3, words.end()), nullptr); +} + +} // namespace detz::mplex + +namespace { + +std::string esc(const std::string& text) { + std::string out; + for (char ch : text) { + if (ch == '%') out += "%25"; + else if (ch == '\t') out += "%09"; + else if (ch == '\n') out += "%0A"; + else if (ch == '\r') out += "%0D"; + else out += ch; + } + return out; +} + +std::string unesc(const std::string& text) { + std::string out; + for (size_t i = 0; i < text.size(); ++i) { + if (text[i] == '%' && i + 2 < text.size()) { + out += static_cast(std::stoi(text.substr(i + 1, 2), nullptr, 16)); + i += 2; + } else { + out += text[i]; + } + } + return out; +} + +std::vector split_tabs(const std::string& line) { + std::vector out; + size_t start = 0; + while (true) { + size_t at = line.find('\t', start); + if (at == std::string::npos) { + out.push_back(line.substr(start)); + break; + } + out.push_back(line.substr(start, at - start)); + start = at + 1; + } + return out; +} + +uint32_t crc32(const std::string& data) { + static uint32_t table[256]; + static bool ready = false; + if (!ready) { + for (uint32_t n = 0; n < 256; ++n) { + uint32_t c = n; + for (int k = 0; k < 8; ++k) c = (c & 1) ? 0xEDB88320u ^ (c >> 1) : c >> 1; + table[n] = c; + } + ready = true; + } + uint32_t crc = 0xFFFFFFFFu; + for (unsigned char byte : data) crc = table[(crc ^ byte) & 0xFF] ^ (crc >> 8); + return crc ^ 0xFFFFFFFFu; +} + +std::string hex8(uint32_t value) { + char buf[16]; + std::snprintf(buf, sizeof buf, "%08x", value); + return buf; +} + +std::string digest(MplexCopyMode& mode) { + alignas(16) static char scratch[sizeof(MplexClientConn)]; // render() ignores its client + ModeBlock block = mode.render(*reinterpret_cast(scratch)); + std::string parts; + for (const Row& row : block.grid) { + for (const Cell& cell : row) { + parts += to_utf8(cell.ch); + parts += "|" + color_sgr(cell.fg) + "|" + color_sgr(cell.bg) + "|" + std::to_string(cell.attr) + ";"; + } + parts += "\n"; + } + if (block.cursor) parts += "(" + std::to_string(block.cursor->first) + ", " + std::to_string(block.cursor->second) + ")"; + else parts += "None"; + return hex8(crc32(parts)); +} + +std::string pair_text(const std::optional& value) { + if (!value) return "-"; + return std::to_string(value->first) + "," + std::to_string(value->second); +} + +std::string search_text(const std::optional& value) { + if (!value) return "-"; + return esc(value->text) + "," + (value->forward ? "1" : "0") + "," + (value->literal ? "1" : "0"); +} + +std::string state_line(MplexCopyMode& mode, const std::shared_ptr& pane, const std::string& err, bool closed) { + std::string last_copy = "-"; + if (!g_copied.empty()) { + const auto& [text, command, append] = g_copied.back(); + last_copy = esc(text) + "/" + esc(command) + "/" + (append ? "1" : "0"); + } + std::vector f = { + "E", std::to_string(mode.cx), std::to_string(mode.cy), std::to_string(mode.oy), std::to_string(mode.hist()), + pair_text(mode.anchor), pair_text(mode.mark), mode.rect ? "1" : "0", mode.line_select ? "1" : "0", + search_text(mode.search), search_text(pane->last_search), mode.show_position ? "1" : "0", closed ? "1" : "0", + std::to_string(g_copied.size()), last_copy, std::to_string(g_messages.size()), + g_messages.empty() ? "-" : esc(g_messages.back()), std::to_string(mode.count), std::to_string(mode.lastcx), + std::to_string(mode.lastsx), esc(err), digest(mode), std::to_string(pane->history_size()), + std::to_string(pane->prefix_count)}; + std::string out; + for (size_t i = 0; i < f.size(); ++i) out += (i ? "\t" : "") + f[i]; + return out; +} + +int failures = 0; +int shown = 0; +long checked_steps = 0; +long checked_scenarios = 0; +long tmux_checks = 0; +long live_total = 0; +long live_diff = 0; + +void fail(const std::string& scenario, const std::string& what, const std::string& want, const std::string& got) { + ++failures; + if (shown < 25) { + ++shown; + std::cerr << "MISMATCH " << scenario << " " << what << "\n python: " << want << "\n c++ : " << got << "\n"; + } +} + +} // namespace + +int main(int argc, char** argv) { + if (argc < 2) { + std::cerr << "usage: mplex_copy_test SCENARIOS\n"; + return 2; + } + std::ifstream in(argv[1]); + std::vector lines; + for (std::string line; std::getline(in, line);) lines.push_back(line); + size_t at = 0; + // command table + { + auto fields = split_tabs(lines.at(at++)); + std::vector want(fields.begin() + 1, fields.end()); + if (want != MplexCopyMode::command_names()) { + std::cerr << "command_names() differs from the Python c_* methods\n"; + ++failures; + } + } + while (at < lines.size()) { + auto s = split_tabs(lines[at++]); + if (s.empty() || s[0] != "S") { + std::cerr << "scenario file out of step at line " << at << "\n"; + return 2; + } + std::string name = unesc(s[1]); + int rows = std::stoi(s[2]), cols = std::stoi(s[3]), history = std::stoi(s[4]); + std::string keys = s[5]; + std::string content = unesc(s[6]); + bool view = s[7] == "1"; + bool exit_at_bottom = s[8] == "1"; + g_copied.clear(); + g_messages.clear(); + MplexServer server("/tmp"); + server.options->set("mode-keys", keys); + auto pane = std::make_shared(1, cols, rows, history, "/tmp"); + std::vector view_lines; + auto replace_nl = [](const std::string& text) { + std::string out; + for (char ch : text) { + if (ch == '\n') out += "\r\n"; + else out += ch; + } + return out; + }; + std::shared_ptr mode; + if (view) { + size_t start = 0; + while (true) { + size_t nl = content.find('\n', start); + if (nl == std::string::npos) { + view_lines.push_back(content.substr(start)); + break; + } + view_lines.push_back(content.substr(start, nl - start)); + start = nl + 1; + } + mode = std::make_shared(&server, pane, &view_lines); + } else { + pane->feed(replace_nl(content)); + mode = std::make_shared(&server, pane, nullptr, false, exit_at_bottom); + } + pane->push_mode(mode); + ++checked_scenarios; + { + auto e0 = split_tabs(lines[at++]); + std::string got = std::to_string(mode->cx) + " " + std::to_string(mode->cy) + " " + std::to_string(mode->oy) + " " + + std::to_string(mode->hist()) + " " + mode->name + " " + digest(*mode); + std::string want = e0[1] + " " + e0[2] + " " + e0[3] + " " + e0[4] + " " + e0[5] + " " + e0[6]; + if (got != want) fail(name, "initial", want, got); + } + std::string last_state_cursor; + std::vector tmux_line; + while (lines[at] != "X") { + auto step = split_tabs(lines[at++]); + std::string err; + if (step[0] == "T") { + tmux_line = step; + continue; + } + if (step[0] == "C") { + int count = std::stoi(step[1]); + std::vector words; + for (size_t i = 2; i < step.size(); ++i) words.push_back(unesc(step[i])); + pane->prefix_count = count; + try { + mode->command(words[0], std::vector(words.begin() + 1, words.end()), nullptr); + } catch (const MplexError& exc) { + err = std::string("M:") + exc.what(); + } catch (const std::exception& exc) { + err = std::string("X:") + exc.what(); + } + } else if (step[0] == "K") { + auto conn = std::make_shared(&server, -1, 1); + try { + mode->key(unesc(step[1]), Bytes(), *conn); + } catch (const MplexError& exc) { + err = std::string("M:") + exc.what(); + } catch (const std::exception& exc) { + err = std::string("X:") + exc.what(); + } + } else if (step[0] == "F") { + pane->feed(replace_nl(unesc(step[1]))); + } else if (step[0] == "R") { + pane->place(0, 0, std::stoi(step[2]), std::stoi(step[1])); + } else { + std::cerr << "bad step line " << lines[at - 1] << "\n"; + return 2; + } + bool closed = std::find(pane->modes.begin(), pane->modes.end(), mode) == pane->modes.end(); + std::string got = state_line(*mode, pane, err, closed); + std::string want = lines[at++]; + ++checked_steps; + if (got != want) fail(name, "after step [" + lines[at - 2] + "]", want, got); + last_state_cursor = std::to_string(mode->cx) + " " + std::to_string(mode->cy) + " " + std::to_string(mode->oy); + } + ++at; // "X" + if (!tmux_line.empty() && tmux_line[1] == "livecursor") { + ++live_total; + if (last_state_cursor != tmux_line[2]) { + ++live_diff; + if (live_diff <= 12) + std::cout << "tmux live differs: " << name << " tmux=" << tmux_line[2] << " mplex=" << last_state_cursor << "\n"; + } + } else if (!tmux_line.empty()) { + auto& t = tmux_line; + ++tmux_checks; + if (t[1] == "cursor") { + if (last_state_cursor != t[2]) fail(name, "tmux cursor", t[2], last_state_cursor); + } else { + std::string got = g_copied.empty() ? "" : std::get<0>(g_copied.back()); + if (got != unesc(t[2])) fail(name, "tmux copied text", unesc(t[2]), got); + } + } + } + std::cout << "scenarios=" << checked_scenarios << " steps=" << checked_steps << " tmux_fixture_checks=" << tmux_checks + << " failures=" << failures << " tmux_live=" << live_total << " tmux_live_differ=" << live_diff << "\n"; + return failures ? 1 : 0; +}