From fcb2e9a7ed5012ad05f2a4d1a09a1f5554de7cc5 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Sun, 30 Nov 2025 17:19:46 -0800 Subject: [PATCH] Add horizontal scrolling support and refactor mouse click handling in GUI. - Introduce horizontal scrolling with column offset synchronization in GUI. - Refactor mouse click handling for improved accuracy and viewport alignment. - Enhance tab expansion and cursor rendering logic for better user experience. - Replace redundant variable declarations in `Buffer` for cleaner code. --- .idea/workspace.xml | 27 ++++--- Buffer.cc | 43 ++++++----- GUIRenderer.cc | 183 ++++++++++++++++++++++++++++++++------------ KKeymap.cc | 10 ++- main.cc | 9 +++ 5 files changed, 189 insertions(+), 83 deletions(-) diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 0cdb6dc..5f75a5e 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -33,18 +33,12 @@ - - + - - - - - - + @@ -273,7 +275,8 @@ - diff --git a/Buffer.cc b/Buffer.cc index f171f4b..8603cee 100644 --- a/Buffer.cc +++ b/Buffer.cc @@ -347,8 +347,8 @@ Buffer::insert_text(int row, int col, std::string_view text) if (static_cast(row) >= rows_.size()) rows_.emplace_back(""); - std::size_t y = static_cast(row); - std::size_t x = static_cast(col); + auto y = static_cast(row); + auto x = static_cast(col); if (x > rows_[y].size()) x = rows_[y].size(); @@ -384,13 +384,13 @@ Buffer::delete_text(int row, int col, std::size_t len) row = 0; if (static_cast(row) >= rows_.size()) return; - std::size_t y = static_cast(row); - std::size_t x = std::min(static_cast(col), rows_[y].size()); + const auto y = static_cast(row); + const auto x = std::min(static_cast(col), rows_[y].size()); std::size_t remaining = len; while (remaining > 0 && y < rows_.size()) { - auto &line = rows_[y]; - std::size_t in_line = std::min(remaining, line.size() - std::min(x, line.size())); + auto &line = rows_[y]; + const std::size_t in_line = std::min(remaining, line.size() - std::min(x, line.size())); if (x < line.size() && in_line > 0) { line.erase(x, in_line); remaining -= in_line; @@ -414,15 +414,18 @@ Buffer::delete_text(int row, int col, std::size_t len) void -Buffer::split_line(int row, int col) +Buffer::split_line(int row, const int col) { - if (row < 0) + if (row < 0) { row = 0; - if (static_cast(row) >= rows_.size()) + } + + if (static_cast(row) >= rows_.size()) { rows_.resize(static_cast(row) + 1); - std::size_t y = static_cast(row); - std::size_t x = std::min(static_cast(col), rows_[y].size()); - std::string tail = rows_[y].substr(x); + } + const auto y = static_cast(row); + const auto x = std::min(static_cast(col), rows_[y].size()); + const auto tail = rows_[y].substr(x); rows_[y].erase(x); rows_.insert(rows_.begin() + static_cast(y + 1), tail); } @@ -431,24 +434,28 @@ Buffer::split_line(int row, int col) void Buffer::join_lines(int row) { - if (row < 0) + if (row < 0) { row = 0; - std::size_t y = static_cast(row); - if (y + 1 >= rows_.size()) + } + + const auto y = static_cast(row); + if (y + 1 >= rows_.size()) { return; + } + rows_[y] += rows_[y + 1]; rows_.erase(rows_.begin() + static_cast(y + 1)); } void -Buffer::insert_row(int row, std::string_view text) +Buffer::insert_row(int row, const std::string_view text) { if (row < 0) row = 0; if (static_cast(row) > rows_.size()) row = static_cast(rows_.size()); - rows_.insert(rows_.begin() + static_cast(row), std::string(text)); + rows_.insert(rows_.begin() + row, std::string(text)); } @@ -459,7 +466,7 @@ Buffer::delete_row(int row) row = 0; if (static_cast(row) >= rows_.size()) return; - rows_.erase(rows_.begin() + static_cast(row)); + rows_.erase(rows_.begin() + row); } diff --git a/GUIRenderer.cc b/GUIRenderer.cc index 236490f..db763a3 100644 --- a/GUIRenderer.cc +++ b/GUIRenderer.cc @@ -8,6 +8,8 @@ #include #include #include +#include +#include // Version string expected to be provided by build system as KTE_VERSION_STR #ifndef KTE_VERSION_STR @@ -63,30 +65,45 @@ GUIRenderer::Draw(Editor &ed) bool forced_scroll = false; { static long prev_buf_rowoffs = -1; // previous frame's Buffer::Rowoffs + static long prev_buf_coloffs = -1; // previous frame's Buffer::Coloffs static float prev_scroll_y = -1.0f; // previous frame's ImGui scroll Y in pixels + static float prev_scroll_x = -1.0f; // previous frame's ImGui scroll X in pixels const long buf_rowoffs = static_cast(buf->Rowoffs()); + const long buf_coloffs = static_cast(buf->Coloffs()); const long scroll_top = static_cast(scroll_y / row_h); + const long scroll_left = static_cast(scroll_x / space_w); // Detect programmatic change (e.g., keyboard navigation ensured visibility) if (prev_buf_rowoffs >= 0 && buf_rowoffs != prev_buf_rowoffs) { ImGui::SetScrollY(static_cast(buf_rowoffs) * row_h); scroll_y = ImGui::GetScrollY(); forced_scroll = true; - } else { - // If user scrolled (scroll_y changed), update buffer row offset accordingly - if (prev_scroll_y >= 0.0f && scroll_y != prev_scroll_y) { - if (Buffer *mbuf = const_cast(buf)) { - // Keep horizontal offset owned by GUI; only update vertical offset here - mbuf->SetOffsets(static_cast(std::max(0L, scroll_top)), - mbuf->Coloffs()); - } + } + if (prev_buf_coloffs >= 0 && buf_coloffs != prev_buf_coloffs) { + ImGui::SetScrollX(static_cast(buf_coloffs) * space_w); + scroll_x = ImGui::GetScrollX(); + forced_scroll = true; + } + // If user scrolled, update buffer offsets accordingly + if (prev_scroll_y >= 0.0f && scroll_y != prev_scroll_y) { + if (Buffer *mbuf = const_cast(buf)) { + mbuf->SetOffsets(static_cast(std::max(0L, scroll_top)), + mbuf->Coloffs()); + } + } + if (prev_scroll_x >= 0.0f && scroll_x != prev_scroll_x) { + if (Buffer *mbuf = const_cast(buf)) { + mbuf->SetOffsets(mbuf->Rowoffs(), + static_cast(std::max(0L, scroll_left))); } } // Update trackers for next frame prev_buf_rowoffs = static_cast(buf->Rowoffs()); + prev_buf_coloffs = static_cast(buf->Coloffs()); prev_scroll_y = ImGui::GetScrollY(); + prev_scroll_x = ImGui::GetScrollX(); } // Synchronize cursor and scrolling. // Ensure the cursor is visible even on the first frame or when it didn't move, @@ -120,61 +137,127 @@ GUIRenderer::Draw(Editor &ed) // Handle mouse click before rendering to avoid dependent on drawn items if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { ImVec2 mp = ImGui::GetIO().MousePos; - // Map Y to row - float rel_y = scroll_y + (mp.y - list_origin.y); - long row = static_cast(rel_y / row_h); - if (row < 0) - row = 0; - if (row >= static_cast(lines.size())) - row = static_cast(lines.empty() ? 0 : (lines.size() - 1)); - // Map X to column by measuring text width - std::size_t col = 0; - if (!lines.empty()) { - const std::string &line = lines[static_cast(row)]; - float rel_x = scroll_x + (mp.x - list_origin.x); - if (rel_x <= 0.0f) { - col = 0; - } else { - float prev_w = 0.0f; - for (std::size_t i = 1; i <= line.size(); ++i) { - ImVec2 sz = ImGui::CalcTextSize( - line.c_str(), line.c_str() + static_cast(i)); - if (sz.x >= rel_x) { - // Pick closer between i-1 and i - float d_prev = rel_x - prev_w; - float d_curr = sz.x - rel_x; - col = (d_prev <= d_curr) ? (i - 1) : i; - break; - } - prev_w = sz.x; - if (i == line.size()) { - // clicked beyond EOL - float eol_w = sz.x; - col = (rel_x > eol_w + space_w * 0.5f) - ? line.size() - : line.size(); - } + // Compute viewport-relative row so (0) is top row of the visible area + float vy_f = (mp.y - list_origin.y - scroll_y) / row_h; + long vy = static_cast(vy_f); + if (vy < 0) + vy = 0; + + // Clamp vy within visible content height to avoid huge jumps + ImVec2 cr_min = ImGui::GetWindowContentRegionMin(); + ImVec2 cr_max = ImGui::GetWindowContentRegionMax(); + float child_h = (cr_max.y - cr_min.y); + long vis_rows = static_cast(child_h / row_h); + if (vis_rows < 1) + vis_rows = 1; + if (vy >= vis_rows) + vy = vis_rows - 1; + + // Translate viewport row to buffer row using Buffer::Rowoffs + std::size_t by = buf->Rowoffs() + static_cast(vy); + if (by >= lines.size()) { + if (!lines.empty()) + by = lines.size() - 1; + else + by = 0; + } + + // Compute desired pixel X inside the viewport content (subtract horizontal scroll) + float px = (mp.x - list_origin.x - scroll_x); + if (px < 0.0f) + px = 0.0f; + + // Convert pixel X to a render-column target including horizontal col offset + // Use our own tab expansion of width 8 to match command layer logic. + const std::string &line_clicked = lines[by]; + const std::size_t tabw = 8; + // We iterate source columns computing absolute rendered column (rx_abs) from 0, + // then translate to viewport-space by subtracting Coloffs. + std::size_t coloffs = buf->Coloffs(); + std::size_t rx_abs = 0; // absolute rendered column + std::size_t i = 0; // source column iterator + + // Fast-forward i until rx_abs >= coloffs to align with leftmost visible column + if (!line_clicked.empty() && coloffs > 0) { + while (i < line_clicked.size() && rx_abs < coloffs) { + if (line_clicked[i] == '\t') { + rx_abs += (tabw - (rx_abs % tabw)); + } else { + rx_abs += 1; } + ++i; } } - // Dispatch command to move cursor + + // Now search for closest source column to clicked px within/after viewport + std::size_t best_col = i; // default to first visible column + float best_dist = std::numeric_limits::infinity(); + while (true) { + // For i in [current..size], evaluate candidate including the implicit end position + std::size_t rx_view = (rx_abs >= coloffs) ? (rx_abs - coloffs) : 0; + float rx_px = static_cast(rx_view) * space_w; + float dist = std::fabs(px - rx_px); + if (dist <= best_dist) { + best_dist = dist; + best_col = i; + } + if (i == line_clicked.size()) + break; + // advance to next source column + if (line_clicked[i] == '\t') { + rx_abs += (tabw - (rx_abs % tabw)); + } else { + rx_abs += 1; + } + ++i; + } + + // Dispatch absolute buffer coordinates (row:col) char tmp[64]; - std::snprintf(tmp, sizeof(tmp), "%ld:%zu", row, col); + std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, best_col); Execute(ed, CommandId::MoveCursorTo, std::string(tmp)); } + // Cache current horizontal offset in rendered columns + const std::size_t coloffs_now = buf->Coloffs(); for (std::size_t i = rowoffs; i < lines.size(); ++i) { // Capture the screen position before drawing the line ImVec2 line_pos = ImGui::GetCursorScreenPos(); const std::string &line = lines[i]; - ImGui::TextUnformatted(line.c_str()); + + // Expand tabs to spaces with width=8 and apply horizontal scroll offset + const std::size_t tabw = 8; + std::string expanded; + expanded.reserve(line.size() + 16); + std::size_t rx_abs_draw = 0; // rendered column for drawing + // Emit entire line (ImGui child scrolling will handle clipping) + for (std::size_t src = 0; src < line.size(); ++src) { + char c = line[src]; + if (c == '\t') { + std::size_t adv = (tabw - (rx_abs_draw % tabw)); + // Emit spaces for the tab + expanded.append(adv, ' '); + rx_abs_draw += adv; + } else { + expanded.push_back(c); + rx_abs_draw += 1; + } + } + + ImGui::TextUnformatted(expanded.c_str()); // Draw a visible cursor indicator on the current line if (i == cy) { - // Compute X offset by measuring text width up to cursor column - std::size_t px_count = std::min(cx, line.size()); - ImVec2 pre_sz = ImGui::CalcTextSize(line.c_str(), - line.c_str() + static_cast(px_count)); - ImVec2 p0 = ImVec2(line_pos.x + pre_sz.x, line_pos.y); + // Compute rendered X (rx) from source column with tab expansion + std::size_t rx_abs = 0; + for (std::size_t k = 0; k < std::min(cx, line.size()); ++k) { + if (line[k] == '\t') + rx_abs += (tabw - (rx_abs % tabw)); + else + rx_abs += 1; + } + // Convert to viewport x by subtracting horizontal col offset + std::size_t rx_viewport = (rx_abs > coloffs_now) ? (rx_abs - coloffs_now) : 0; + ImVec2 p0 = ImVec2(line_pos.x + static_cast(rx_viewport) * space_w, line_pos.y); ImVec2 p1 = ImVec2(p0.x + space_w, p0.y + line_h); ImU32 col = IM_COL32(200, 200, 255, 128); // soft highlight ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col); diff --git a/KKeymap.cc b/KKeymap.cc index a1ba404..a938717 100644 --- a/KKeymap.cc +++ b/KKeymap.cc @@ -1,5 +1,8 @@ #include "KKeymap.h" + +#include #include +#include auto @@ -21,9 +24,7 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool out = CommandId::SaveAndQuit; return true; default: - // Important: do not return here — fall through to non-ctrl table - // so that C-k u/U still work even if Ctrl is (incorrectly) held - break; + return false; } } @@ -49,6 +50,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool case 'e': out = CommandId::OpenFileStart; return true; + case 'E': + std::cerr << "E is not a valid command" << std::endl; + return false; case 'f': out = CommandId::FlushKillRing; return true; diff --git a/main.cc b/main.cc index d3ca77a..cfcd609 100644 --- a/main.cc +++ b/main.cc @@ -189,6 +189,15 @@ main(int argc, const char *argv[]) fe = std::make_unique(); } +#if defined(KTE_BUILD_GUI) && defined(__APPLE__) + if (use_gui) { + /* likely using the .app, so need to cd */ + if (chdir(getenv("HOME")) != 0) { + std::cerr << "kge.app: failed to chdir to HOME" << std::endl; + } + } +#endif + if (!fe->Init(editor)) { std::cerr << "kte: failed to initialize frontend" << std::endl; return 1;