diff --git a/Buffer.h b/Buffer.h index ede95b5..f07d62e 100644 --- a/Buffer.h +++ b/Buffer.h @@ -370,6 +370,54 @@ public: } + // Visual-line selection support (multicursor/visual mode) + void VisualLineClear() + { + visual_line_active_ = false; + } + + + void VisualLineStart() + { + visual_line_active_ = true; + visual_line_anchor_y_ = cury_; + visual_line_active_y_ = cury_; + } + + + void VisualLineToggle() + { + if (visual_line_active_) + VisualLineClear(); + else + VisualLineStart(); + } + + + [[nodiscard]] bool VisualLineActive() const + { + return visual_line_active_; + } + + + void VisualLineSetActiveY(std::size_t y) + { + visual_line_active_y_ = y; + } + + + [[nodiscard]] std::size_t VisualLineStartY() const + { + return visual_line_anchor_y_ < visual_line_active_y_ ? visual_line_anchor_y_ : visual_line_active_y_; + } + + + [[nodiscard]] std::size_t VisualLineEndY() const + { + return visual_line_anchor_y_ < visual_line_active_y_ ? visual_line_active_y_ : visual_line_anchor_y_; + } + + [[nodiscard]] std::string AsString() const; // Syntax highlighting integration (per-buffer) @@ -466,11 +514,14 @@ private: std::size_t content_LineCount_() const; std::string filename_; - bool is_file_backed_ = false; - bool dirty_ = false; - bool read_only_ = false; - bool mark_set_ = false; - std::size_t mark_curx_ = 0, mark_cury_ = 0; + bool is_file_backed_ = false; + bool dirty_ = false; + bool read_only_ = false; + bool mark_set_ = false; + std::size_t mark_curx_ = 0, mark_cury_ = 0; + bool visual_line_active_ = false; + std::size_t visual_line_anchor_y_ = 0; + std::size_t visual_line_active_y_ = 0; // Per-buffer undo state std::unique_ptr undo_tree_; diff --git a/CMakeLists.txt b/CMakeLists.txt index 70437c3..9b87122 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -302,6 +302,7 @@ if (BUILD_TESTS) tests/test_piece_table.cc tests/test_search.cc tests/test_reflow_paragraph.cc + tests/test_visual_line_mode.cc # minimal engine sources required by Buffer PieceTable.cc diff --git a/Command.cc b/Command.cc index 6ea4fbf..65e3b6f 100644 --- a/Command.cc +++ b/Command.cc @@ -761,6 +761,15 @@ cmd_quit_now(CommandContext &ctx) static bool cmd_refresh(CommandContext &ctx) { + // C-g is mapped to Refresh and acts as a general cancel key. + // Cancel visual-line (multicursor) mode if active. + if (Buffer *buf = ctx.editor.CurrentBuffer()) { + if (buf->VisualLineActive()) { + buf->VisualLineClear(); + ctx.editor.SetStatus("Visual line: OFF"); + return true; + } + } // If a generic prompt is active, cancel it if (ctx.editor.PromptActive()) { // If also in search mode, restore state @@ -1968,11 +1977,40 @@ cmd_insert_text(CommandContext &ctx) return false; } ensure_at_least_one_line(*buf); - std::size_t y = buf->Cury(); - std::size_t x = buf->Curx(); + std::size_t y = buf->Cury(); + std::size_t x = buf->Curx(); + int repeat = ctx.count > 0 ? ctx.count : 1; + std::size_t cx = x; + std::size_t cy = y; + + // Visual-line mode: broadcast inserts to each selected line at the same column. + if (buf->VisualLineActive()) { + const std::size_t sy = buf->VisualLineStartY(); + const std::size_t ey = buf->VisualLineEndY(); + const auto &rows = buf->Rows(); + for (std::size_t yy = sy; yy <= ey; ++yy) { + if (yy >= rows.size()) + break; + std::size_t xx = x; + if (xx > rows[yy].size()) + xx = rows[yy].size(); + for (int i = 0; i < repeat; ++i) { + buf->insert_text(static_cast(yy), static_cast(xx), std::string_view(ctx.arg)); + xx += ctx.arg.size(); + } + if (yy == y) { + cx = xx; + cy = yy; + } + } + buf->SetDirty(true); + buf->SetCursor(cx, cy); + ensure_cursor_visible(ctx.editor, *buf); + return true; + } + std::size_t ins_y = y; std::size_t ins_x = x; // remember insertion start for undo positioning - int repeat = ctx.count > 0 ? ctx.count : 1; // Apply edits to the underlying PieceTable through Buffer::insert_text, // not directly to the legacy rows_ cache. This ensures Save() persists text. @@ -2784,6 +2822,41 @@ cmd_newline(CommandContext &ctx) std::size_t y = buf->Cury(); std::size_t x = buf->Curx(); int repeat = ctx.count > 0 ? ctx.count : 1; + + // Visual-line mode: broadcast newline splits across selected lines. + if (buf->VisualLineActive()) { + const std::size_t sy = buf->VisualLineStartY(); + const std::size_t ey = buf->VisualLineEndY(); + const auto &rows = buf->Rows(); + if (rows.empty()) + return true; + std::size_t splits_above = 0; + if (sy < y) + splits_above = std::min(ey, y - 1) - sy + 1; + + // Iterate bottom-up to keep row indices stable while splitting. + for (std::size_t yy = ey + 1; yy-- > sy;) { + const auto &rows_view = buf->Rows(); + if (yy >= rows_view.size()) + continue; + std::size_t xx = x; + if (xx > rows_view[yy].size()) + xx = rows_view[yy].size(); + // First split at the cursor column; subsequent splits create blank lines. + buf->split_line(static_cast(yy), static_cast(xx)); + for (int i = 1; i < repeat; ++i) { + buf->split_line(static_cast(yy + i), 0); + } + } + + buf->SetDirty(true); + // Cursor: end up on the final inserted line for the original cursor line. + std::size_t new_y = y + static_cast(repeat); + new_y += splits_above; + buf->SetCursor(0, new_y); + ensure_cursor_visible(ctx.editor, *buf); + return true; + } for (int i = 0; i < repeat; ++i) { buf->split_line(static_cast(y), static_cast(x)); // Move to start of next line @@ -2858,6 +2931,35 @@ cmd_backspace(CommandContext &ctx) std::size_t x = buf->Curx(); UndoSystem *u = buf->Undo(); int repeat = ctx.count > 0 ? ctx.count : 1; + + // Visual-line mode: broadcast backspace deletes within each selected line. + // For now, we do NOT join lines when at column 0 (too ambiguous across multiple lines). + if (buf->VisualLineActive()) { + const std::size_t sy = buf->VisualLineStartY(); + const std::size_t ey = buf->VisualLineEndY(); + const auto &rows = buf->Rows(); + std::size_t cx = x; + for (std::size_t yy = sy; yy <= ey; ++yy) { + if (yy >= rows.size()) + break; + std::size_t xx = x; + if (xx > rows[yy].size()) + xx = rows[yy].size(); + for (int i = 0; i < repeat; ++i) { + if (xx == 0) + break; + buf->delete_text(static_cast(yy), static_cast(xx - 1), 1); + --xx; + } + if (yy == y) + cx = xx; + } + buf->SetDirty(true); + buf->SetCursor(cx, y); + ensure_cursor_visible(ctx.editor, *buf); + (void) u; + return true; + } for (int i = 0; i < repeat; ++i) { // Refresh a read-only view of lines for char capture/lengths const auto &rows_view = buf->Rows(); @@ -2910,6 +3012,30 @@ cmd_delete_char(CommandContext &ctx) std::size_t x = buf->Curx(); UndoSystem *u = buf->Undo(); int repeat = ctx.count > 0 ? ctx.count : 1; + + // Visual-line mode: broadcast delete-char within each selected line. + // For now, we do NOT join lines when at end-of-line. + if (buf->VisualLineActive()) { + const std::size_t sy = buf->VisualLineStartY(); + const std::size_t ey = buf->VisualLineEndY(); + const auto &rows = buf->Rows(); + for (std::size_t yy = sy; yy <= ey; ++yy) { + if (yy >= rows.size()) + break; + std::size_t xx = x; + if (xx > rows[yy].size()) + xx = rows[yy].size(); + for (int i = 0; i < repeat; ++i) { + if (xx >= buf->Rows()[yy].size()) + break; + buf->delete_text(static_cast(yy), static_cast(xx), 1); + } + } + buf->SetDirty(true); + ensure_cursor_visible(ctx.editor, *buf); + (void) u; + return true; + } for (int i = 0; i < repeat; ++i) { const auto &rows_view = buf->Rows(); if (y >= rows_view.size()) @@ -3107,6 +3233,8 @@ cmd_move_file_start(CommandContext &ctx) return false; ensure_at_least_one_line(*buf); buf->SetCursor(0, 0); + if (buf->VisualLineActive()) + buf->VisualLineSetActiveY(buf->Cury()); ensure_cursor_visible(ctx.editor, *buf); return true; } @@ -3123,6 +3251,8 @@ cmd_move_file_end(CommandContext &ctx) std::size_t y = rows.empty() ? 0 : rows.size() - 1; std::size_t x = rows.empty() ? 0 : rows[y].size(); buf->SetCursor(x, y); + if (buf->VisualLineActive()) + buf->VisualLineSetActiveY(buf->Cury()); ensure_cursor_visible(ctx.editor, *buf); return true; } @@ -3145,6 +3275,18 @@ cmd_toggle_mark(CommandContext &ctx) } +static bool +cmd_visual_line_mode_toggle(CommandContext &ctx) +{ + Buffer *buf = ctx.editor.CurrentBuffer(); + if (!buf) + return false; + buf->VisualLineToggle(); + ctx.editor.SetStatus(std::string("Visual line: ") + (buf->VisualLineActive() ? "ON" : "OFF")); + return true; +} + + static bool cmd_jump_to_mark(CommandContext &ctx) { @@ -3306,6 +3448,8 @@ cmd_move_left(CommandContext &ctx) } } buf->SetCursor(x, y); + if (buf->VisualLineActive()) + buf->VisualLineSetActiveY(buf->Cury()); ensure_cursor_visible(ctx.editor, *buf); return true; } @@ -3383,6 +3527,8 @@ cmd_move_right(CommandContext &ctx) } } buf->SetCursor(x, y); + if (buf->VisualLineActive()) + buf->VisualLineSetActiveY(buf->Cury()); ensure_cursor_visible(ctx.editor, *buf); return true; } @@ -3423,6 +3569,8 @@ cmd_move_up(CommandContext &ctx) if (x > rows[y].size()) x = rows[y].size(); buf->SetCursor(x, y); + if (buf->VisualLineActive()) + buf->VisualLineSetActiveY(buf->Cury()); ensure_cursor_visible(ctx.editor, *buf); return true; } @@ -3464,6 +3612,8 @@ cmd_move_down(CommandContext &ctx) if (x > rows[y].size()) x = rows[y].size(); buf->SetCursor(x, y); + if (buf->VisualLineActive()) + buf->VisualLineSetActiveY(buf->Cury()); ensure_cursor_visible(ctx.editor, *buf); return true; } @@ -4463,6 +4613,10 @@ InstallDefaultCommands() }); CommandRegistry::Register({CommandId::MoveFileEnd, "file-end", "Move to end of file", cmd_move_file_end}); CommandRegistry::Register({CommandId::ToggleMark, "toggle-mark", "Toggle mark at cursor", cmd_toggle_mark}); + CommandRegistry::Register({ + CommandId::VisualLineModeToggle, "visual-line-toggle", "Toggle visual-line (multicursor) mode", + cmd_visual_line_mode_toggle, false, false + }); CommandRegistry::Register({ CommandId::JumpToMark, "jump-to-mark", "Jump to mark (swap mark)", cmd_jump_to_mark }); diff --git a/Command.h b/Command.h index ce9ed53..3a6c54f 100644 --- a/Command.h +++ b/Command.h @@ -47,6 +47,7 @@ enum class CommandId { MoveFileStart, // move to beginning of file MoveFileEnd, // move to end of file ToggleMark, // toggle mark at cursor + VisualLineModeToggle, // toggle visual-line (multicursor) mode (C-k /) JumpToMark, // jump to mark, set mark to previous cursor KillRegion, // kill region between mark and cursor (to kill ring) CopyRegion, // copy region to kill ring (Alt-w) diff --git a/ImGuiRenderer.cc b/ImGuiRenderer.cc index a64bc6a..f0dee1f 100644 --- a/ImGuiRenderer.cc +++ b/ImGuiRenderer.cc @@ -150,73 +150,84 @@ ImGuiRenderer::Draw(Editor &ed) // Cache current horizontal offset in rendered columns for click handling const std::size_t coloffs_now = buf->Coloffs(); - // Handle mouse click before rendering to avoid dependent on drawn items - if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + // Mark selection state (mark -> cursor), in source coordinates + bool sel_active = false; + std::size_t sel_sy = 0, sel_sx = 0, sel_ey = 0, sel_ex = 0; + if (buf->MarkSet()) { + sel_sy = buf->MarkCury(); + sel_sx = buf->MarkCurx(); + sel_ey = buf->Cury(); + sel_ex = buf->Curx(); + if (sel_sy > sel_ey || (sel_sy == sel_ey && sel_sx > sel_ex)) { + std::swap(sel_sy, sel_ey); + std::swap(sel_sx, sel_ex); + } + sel_active = !(sel_sy == sel_ey && sel_sx == sel_ex); + } + // Visual-line selection: full-line highlight range + const bool vsel_active = buf->VisualLineActive(); + const std::size_t vsel_sy = vsel_active ? buf->VisualLineStartY() : 0; + const std::size_t vsel_ey = vsel_active ? buf->VisualLineEndY() : 0; + + static bool mouse_selecting = false; + auto mouse_pos_to_buf = [&]() -> std::pair { ImVec2 mp = ImGui::GetIO().MousePos; - // Compute content-relative position accounting for scroll - // mp.y - child_window_pos.y gives us pixels from top of child window - // Adding scroll_y gives us pixels from top of content (buffer row 0) + // Convert mouse pos to buffer row float content_y = (mp.y - child_window_pos.y) + scroll_y; long by_l = static_cast(content_y / row_h); if (by_l < 0) by_l = 0; - - // Convert to buffer row std::size_t by = static_cast(by_l); - if (by >= lines.size()) { - if (!lines.empty()) - by = lines.size() - 1; - else - by = 0; - } + if (by >= lines.size()) + by = lines.empty() ? 0 : (lines.size() - 1); - // Compute click X position relative to left edge of child window (in pixels) - // This gives us the visual offset from the start of displayed content + // Convert mouse pos to rendered x float visual_x = mp.x - child_window_pos.x; if (visual_x < 0.0f) visual_x = 0.0f; - - // Convert visual pixel offset to rendered column, then add coloffs_now - // to get the absolute rendered column in the buffer std::size_t clicked_rx = static_cast(visual_x / space_w) + coloffs_now; - // Empty buffer guard: if there are no lines yet, just move to 0:0 - if (lines.empty()) { - Execute(ed, CommandId::MoveCursorTo, std::string("0:0")); - } else { - // Convert rendered column (clicked_rx) to source column accounting for tabs - std::string line_clicked = static_cast(lines[by]); - const std::size_t tabw = 8; - - // Iterate through source columns, computing rendered position, to find closest match - std::size_t rx = 0; // rendered column position - std::size_t best_col = 0; - float best_dist = std::numeric_limits::infinity(); - float clicked_rx_f = static_cast(clicked_rx); - - for (std::size_t i = 0; i <= line_clicked.size(); ++i) { - // Check current position - float dist = std::fabs(clicked_rx_f - static_cast(rx)); - if (dist < best_dist) { - best_dist = dist; - best_col = i; - } - - // Advance to next position if not at end - if (i < line_clicked.size()) { - if (line_clicked[i] == '\t') { - rx += (tabw - (rx % tabw)); - } else { - rx += 1; - } - } + // Convert rendered column to source column + if (lines.empty()) + return {0, 0}; + std::string line_clicked = static_cast(lines[by]); + const std::size_t tabw = 8; + std::size_t rx = 0; + std::size_t best_col = 0; + float best_dist = std::numeric_limits::infinity(); + float clicked_rx_f = static_cast(clicked_rx); + for (std::size_t i = 0; i <= line_clicked.size(); ++i) { + float dist = std::fabs(clicked_rx_f - static_cast(rx)); + if (dist < best_dist) { + best_dist = dist; + best_col = i; + } + if (i < line_clicked.size()) { + rx += (line_clicked[i] == '\t') ? (tabw - (rx % tabw)) : 1; } - - // Dispatch absolute buffer coordinates (row:col) - char tmp[64]; - std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, best_col); - Execute(ed, CommandId::MoveCursorTo, std::string(tmp)); } + return {by, best_col}; + }; + + // Mouse-driven selection: set mark on press, update cursor on drag + if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + mouse_selecting = true; + auto [by, bx] = mouse_pos_to_buf(); + char tmp[64]; + std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx); + Execute(ed, CommandId::MoveCursorTo, std::string(tmp)); + if (Buffer *mbuf = const_cast(buf)) { + mbuf->SetMark(bx, by); + } + } + if (mouse_selecting && ImGui::IsWindowHovered() && ImGui::IsMouseDown(ImGuiMouseButton_Left)) { + auto [by, bx] = mouse_pos_to_buf(); + char tmp[64]; + std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx); + Execute(ed, CommandId::MoveCursorTo, std::string(tmp)); + } + if (mouse_selecting && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { + mouse_selecting = false; } for (std::size_t i = rowoffs; i < lines.size(); ++i) { // Capture the screen position before drawing the line @@ -295,6 +306,51 @@ ImGuiRenderer::Draw(Editor &ed) ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col); } } + + // Draw selection background (over search highlight; under text) + if (sel_active || vsel_active) { + bool line_has = false; + std::size_t sx = 0, ex = 0; + if (vsel_active && i >= vsel_sy && i <= vsel_ey) { + sx = 0; + ex = line.size(); + line_has = ex > sx; + } else if (i < sel_sy || i > sel_ey) { + line_has = false; + } else if (sel_sy == sel_ey) { + sx = sel_sx; + ex = sel_ex; + line_has = ex > sx; + } else if (i == sel_sy) { + sx = sel_sx; + ex = line.size(); + line_has = ex > sx; + } else if (i == sel_ey) { + sx = 0; + ex = std::min(sel_ex, line.size()); + line_has = ex > sx; + } else { + sx = 0; + ex = line.size(); + line_has = ex > sx; + } + if (line_has) { + std::size_t rx_start = src_to_rx(sx); + std::size_t rx_end = src_to_rx(ex); + if (rx_end > coloffs_now) { + std::size_t vx0 = (rx_start > coloffs_now) + ? (rx_start - coloffs_now) + : 0; + std::size_t vx1 = rx_end - coloffs_now; + ImVec2 p0 = ImVec2(line_pos.x + static_cast(vx0) * space_w, + line_pos.y); + ImVec2 p1 = ImVec2(line_pos.x + static_cast(vx1) * space_w, + line_pos.y + line_h); + ImU32 col = ImGui::GetColorU32(ImGuiCol_TextSelectedBg); + ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col); + } + } + } // Emit entire line to an expanded buffer (tabs -> spaces) for (std::size_t src = 0; src < line.size(); ++src) { char c = line[src]; diff --git a/KKeymap.cc b/KKeymap.cc index 12f2f20..eebd917 100644 --- a/KKeymap.cc +++ b/KKeymap.cc @@ -114,6 +114,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool case '=': out = CommandId::IndentRegion; return true; + case '/': + out = CommandId::VisualLineModeToggle; + return true; case ';': out = CommandId::CommandPromptStart; // C-k ; : generic command prompt return true; diff --git a/TerminalInputHandler.cc b/TerminalInputHandler.cc index d7a77e8..ecbe8ce 100644 --- a/TerminalInputHandler.cc +++ b/TerminalInputHandler.cc @@ -3,6 +3,7 @@ #include "TerminalInputHandler.h" #include "KKeymap.h" +#include "Command.h" #include "Editor.h" namespace { @@ -23,6 +24,7 @@ map_key_to_command(const int ch, bool &k_prefix, bool &esc_meta, bool &k_ctrl_pending, + bool &mouse_selecting, Editor *ed, MappedInput &out) { @@ -54,12 +56,33 @@ map_key_to_command(const int ch, } #endif // React to left button click/press - if (ev.bstate & (BUTTON1_CLICKED | BUTTON1_PRESSED | BUTTON1_RELEASED)) { + if (ed && (ev.bstate & (BUTTON1_CLICKED | BUTTON1_PRESSED | BUTTON1_RELEASED | + REPORT_MOUSE_POSITION))) { char buf[64]; // Use screen coordinates; command handler will translate via offsets std::snprintf(buf, sizeof(buf), "@%d:%d", ev.y, ev.x); - out = {true, CommandId::MoveCursorTo, std::string(buf), 0}; - return true; + const bool pressed = (ev.bstate & (BUTTON1_PRESSED | BUTTON1_CLICKED)) != 0; + const bool released = (ev.bstate & BUTTON1_RELEASED) != 0; + const bool moved = (ev.bstate & REPORT_MOUSE_POSITION) != 0; + if (pressed) { + mouse_selecting = true; + Execute(*ed, CommandId::MoveCursorTo, std::string(buf)); + if (Buffer *b = ed->CurrentBuffer()) { + b->SetMark(b->Curx(), b->Cury()); + } + out.hasCommand = false; + return true; + } + if (mouse_selecting && moved) { + Execute(*ed, CommandId::MoveCursorTo, std::string(buf)); + out.hasCommand = false; + return true; + } + if (released) { + mouse_selecting = false; + out.hasCommand = false; + return true; + } } } // No actionable mouse event @@ -178,15 +201,15 @@ map_key_to_command(const int ch, ctrl = true; ascii_key = 'a' + (ch - 1); } - // If user typed literal 'C' or '^' as a qualifier, keep k-prefix and set pending - // Note: Do NOT treat lowercase 'c' as a qualifier, since 'c' is a valid C-k command (BufferClose). - if (ascii_key == 'C' || ascii_key == '^') { - k_ctrl_pending = true; - if (ed) - ed->SetStatus("C-k C _"); - out.hasCommand = false; - return true; - } + // If user typed literal 'C' or '^' as a qualifier, keep k-prefix and set pending + // Note: Do NOT treat lowercase 'c' as a qualifier, since 'c' is a valid C-k command (BufferClose). + if (ascii_key == 'C' || ascii_key == '^') { + k_ctrl_pending = true; + if (ed) + ed->SetStatus("C-k C _"); + out.hasCommand = false; + return true; + } // For actual suffix, consume the k-prefix k_prefix = false; // Do NOT lowercase here; KLookupKCommand handles case-sensitive bindings @@ -292,6 +315,7 @@ TerminalInputHandler::decode_(MappedInput &out) ch, k_prefix_, esc_meta_, k_ctrl_pending_, + mouse_selecting_, ed_, out); if (!consumed) diff --git a/TerminalInputHandler.h b/TerminalInputHandler.h index 7215202..44486ca 100644 --- a/TerminalInputHandler.h +++ b/TerminalInputHandler.h @@ -30,5 +30,8 @@ private: // Simple meta (ESC) state for ESC sequences like ESC b/f bool esc_meta_ = false; + // Mouse drag selection state + bool mouse_selecting_ = false; + Editor *ed_ = nullptr; // attached editor for uarg handling }; \ No newline at end of file diff --git a/TerminalRenderer.cc b/TerminalRenderer.cc index d23b2bd..4f5ad39 100644 --- a/TerminalRenderer.cc +++ b/TerminalRenderer.cc @@ -107,9 +107,44 @@ TerminalRenderer::Draw(Editor &ed) const std::size_t cur_mx = has_current ? ed.SearchMatchX() : 0; const std::size_t cur_my = has_current ? ed.SearchMatchY() : 0; const std::size_t cur_mend = has_current ? (ed.SearchMatchX() + ed.SearchMatchLen()) : 0; - bool hl_on = false; - bool cur_on = false; - int written = 0; + + // Mark selection (mark -> cursor), in source coordinates + bool sel_active = false; + std::size_t sel_sy = 0, sel_sx = 0, sel_ey = 0, sel_ex = 0; + if (buf->MarkSet()) { + sel_sy = buf->MarkCury(); + sel_sx = buf->MarkCurx(); + sel_ey = buf->Cury(); + sel_ex = buf->Curx(); + if (sel_sy > sel_ey || (sel_sy == sel_ey && sel_sx > sel_ex)) { + std::swap(sel_sy, sel_ey); + std::swap(sel_sx, sel_ex); + } + sel_active = !(sel_sy == sel_ey && sel_sx == sel_ex); + } + // Visual-line selection: full-line selection range + const bool vsel_active = buf->VisualLineActive(); + const std::size_t vsel_sy = vsel_active ? buf->VisualLineStartY() : 0; + const std::size_t vsel_ey = vsel_active ? buf->VisualLineEndY() : 0; + auto is_src_in_sel = [&](std::size_t y, std::size_t sx) -> bool { + (void) sx; + if (vsel_active) { + if (y >= vsel_sy && y <= vsel_ey) + return true; + } + if (!sel_active) + return false; + if (y < sel_sy || y > sel_ey) + return false; + if (sel_sy == sel_ey) + return sx >= sel_sx && sx < sel_ex; + if (y == sel_sy) + return sx >= sel_sx; + if (y == sel_ey) + return sx < sel_ex; + return true; + }; + int written = 0; if (li < lines.size()) { std::string line = static_cast(lines[li]); src_i = 0; @@ -156,27 +191,21 @@ TerminalRenderer::Draw(Editor &ed) } return kte::TokenKind::Default; }; - auto apply_token_attr = [&](kte::TokenKind k) { - // Map to simple attributes; search highlight uses A_STANDOUT which takes precedence below - attrset(A_NORMAL); + auto token_attr = [&](kte::TokenKind k) -> attr_t { switch (k) { case kte::TokenKind::Keyword: case kte::TokenKind::Type: case kte::TokenKind::Constant: case kte::TokenKind::Function: - attron(A_BOLD); - break; + return A_BOLD; case kte::TokenKind::Comment: - attron(A_DIM); - break; + return A_DIM; case kte::TokenKind::String: case kte::TokenKind::Char: case kte::TokenKind::Number: - // standout a bit using A_UNDERLINE if available - attron(A_UNDERLINE); - break; + return A_UNDERLINE; default: - break; + return 0; } }; while (written < cols) { @@ -218,36 +247,23 @@ TerminalRenderer::Draw(Editor &ed) } // Now render visible spaces while (next_tab > 0 && written < cols) { + bool in_sel = is_src_in_sel(li, src_i); bool in_hl = search_mode && is_src_in_hl(src_i); bool in_cur = has_current && li == cur_my && src_i >= cur_mx - && src_i < cur_mend; - // Toggle highlight attributes - int attr = 0; - if (in_hl) - attr |= A_STANDOUT; - if (in_cur) - attr |= A_BOLD; - if ((attr & A_STANDOUT) && !hl_on) { - attron(A_STANDOUT); - hl_on = true; - } - if (!(attr & A_STANDOUT) && hl_on) { - attroff(A_STANDOUT); - hl_on = false; - } - if ((attr & A_BOLD) && !cur_on) { - attron(A_BOLD); - cur_on = true; - } - if (!(attr & A_BOLD) && cur_on) { - attroff(A_BOLD); - cur_on = false; - } - // Apply syntax attribute only if not in search highlight - if (!in_hl) { - apply_token_attr(token_at(src_i)); + && + src_i < cur_mend; + attr_t a = A_NORMAL; + a |= token_attr(token_at(src_i)); + if (in_sel) { + a |= A_REVERSE; + } else { + if (in_hl) + a |= A_STANDOUT; + if (in_cur) + a |= A_BOLD; } + attrset(a); addch(' '); ++written; ++render_col; @@ -281,34 +297,27 @@ TerminalRenderer::Draw(Editor &ed) break; } + bool in_sel = from_src && is_src_in_sel(li, src_i); bool in_hl = search_mode && from_src && is_src_in_hl(src_i); - bool in_cur = - has_current && li == cur_my && from_src && src_i >= cur_mx && src_i < - cur_mend; - if (in_hl && !hl_on) { - attron(A_STANDOUT); - hl_on = true; - } - if (!in_hl && hl_on) { - attroff(A_STANDOUT); - hl_on = false; - } - if (in_cur && !cur_on) { - attron(A_BOLD); - cur_on = true; - } - if (!in_cur && cur_on) { - attroff(A_BOLD); - cur_on = false; - } - if (!in_hl && from_src) { - apply_token_attr(token_at(src_i)); + bool in_cur = has_current && li == cur_my && from_src && src_i >= cur_mx && + src_i < cur_mend; + attr_t a = A_NORMAL; + if (from_src) + a |= token_attr(token_at(src_i)); + if (in_sel) { + a |= A_REVERSE; + } else { + if (in_hl) + a |= A_STANDOUT; + if (in_cur) + a |= A_BOLD; } + attrset(a); if (from_src) { cchar_t cch; wchar_t warr[2] = {wch, L'\0'}; - setcchar(&cch, warr, A_NORMAL, 0, nullptr); + setcchar(&cch, warr, 0, 0, nullptr); add_wch(&cch); } else { addch(' '); @@ -322,14 +331,6 @@ TerminalRenderer::Draw(Editor &ed) break; } } - if (hl_on) { - attroff(A_STANDOUT); - hl_on = false; - } - if (cur_on) { - attroff(A_BOLD); - cur_on = false; - } attrset(A_NORMAL); clrtoeol(); } diff --git a/tests/test_visual_line_mode.cc b/tests/test_visual_line_mode.cc new file mode 100644 index 0000000..5dafaab --- /dev/null +++ b/tests/test_visual_line_mode.cc @@ -0,0 +1,125 @@ +#include "Test.h" + +#include "Buffer.h" +#include "Command.h" +#include "Editor.h" + +#include + + +static std::string +dump_buf(const Buffer &buf) +{ + std::string out; + for (const auto &r: buf.Rows()) { + out += static_cast(r); + out.push_back('\n'); + } + return out; +} + + +static std::string +dump_bytes(const std::string &s) +{ + static const char *hex = "0123456789abcdef"; + std::string out; + for (unsigned char c: s) { + out.push_back(hex[(c >> 4) & 0xF]); + out.push_back(hex[c & 0xF]); + out.push_back(' '); + } + return out; +} + + +TEST (VisualLineMode_BroadcastInsert) +{ + InstallDefaultCommands(); + + Editor ed; + ed.SetDimensions(24, 80); + + Buffer b; + b.insert_text(0, 0, "foo\nfoo\nfoo\n"); + b.SetCursor(1, 0); // fo|o + ed.AddBuffer(std::move(b)); + + ASSERT_TRUE(ed.CurrentBuffer() != nullptr); + + // Enter visual-line mode and extend selection to 3 lines + ASSERT_TRUE(Execute(ed, std::string("visual-line-toggle"))); + ASSERT_TRUE(Execute(ed, std::string("down"), std::string(), 2)); + + // Broadcast insert to all selected lines + ASSERT_TRUE(Execute(ed, std::string("insert"), std::string("X"))); + + const std::string got = dump_buf(*ed.CurrentBuffer()); + // Note: buffers that end with a trailing '\n' have an extra empty row. + const std::string exp = "fXoo\nfXoo\nfXoo\n\n"; + if (got != exp) { + std::cerr << "Expected (len=" << exp.size() << ") bytes: " << dump_bytes(exp) << "\n"; + std::cerr << "Got (len=" << got.size() << ") bytes: " << dump_bytes(got) << "\n"; + } + ASSERT_TRUE(got == exp); +} + + +TEST (VisualLineMode_BroadcastBackspace) +{ + InstallDefaultCommands(); + + Editor ed; + ed.SetDimensions(24, 80); + + Buffer b; + b.insert_text(0, 0, "abcd\nabcd\nabcd\n"); + b.SetCursor(2, 0); // ab|cd + ed.AddBuffer(std::move(b)); + + ASSERT_TRUE(Execute(ed, std::string("visual-line-toggle"))); + ASSERT_TRUE(Execute(ed, std::string("down"), std::string(), 2)); + + ASSERT_TRUE(Execute(ed, std::string("backspace"))); + const std::string got = dump_buf(*ed.CurrentBuffer()); + // Note: buffers that end with a trailing '\n' have an extra empty row. + const std::string exp = "acd\nacd\nacd\n\n"; + if (got != exp) { + std::cerr << "Expected (len=" << exp.size() << ") bytes: " << dump_bytes(exp) << "\n"; + std::cerr << "Got (len=" << got.size() << ") bytes: " << dump_bytes(got) << "\n"; + } + ASSERT_TRUE(got == exp); +} + + +TEST (VisualLineMode_CancelWithCtrlG) +{ + InstallDefaultCommands(); + + Editor ed; + ed.SetDimensions(24, 80); + + Buffer b; + b.insert_text(0, 0, "foo\nfoo\nfoo\n"); + b.SetCursor(1, 0); + ed.AddBuffer(std::move(b)); + + ASSERT_TRUE(Execute(ed, std::string("visual-line-toggle"))); + ASSERT_TRUE(Execute(ed, std::string("down"), std::string(), 2)); + + // C-g is mapped to "refresh" and should cancel visual-line mode. + ASSERT_TRUE(Execute(ed, std::string("refresh"))); + ASSERT_TRUE(ed.CurrentBuffer() != nullptr); + ASSERT_TRUE(!ed.CurrentBuffer()->VisualLineActive()); + + // After cancel, edits should only affect the primary cursor line. + ASSERT_TRUE(Execute(ed, std::string("insert"), std::string("X"))); + const std::string got = dump_buf(*ed.CurrentBuffer()); + // Cursor is still on the last line we moved to (down, down). + const std::string exp = "foo\nfoo\nfXoo\n\n"; + if (got != exp) { + std::cerr << "Expected (len=" << exp.size() << ") bytes: " << dump_bytes(exp) << "\n"; + std::cerr << "Got (len=" << got.size() << ") bytes: " << dump_bytes(got) << "\n"; + } + ASSERT_TRUE(got == exp); +} \ No newline at end of file