From fc2738eea560769c8d150a7cc955fe8d54121298 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 15 Sep 2026 12:54:29 -0300 Subject: Align file clicks and insertion cursors --- docs/helix-keys.md | 5 ++ src/pardes.zig | 71 +++++++++++++++++++++------- test/panes.zig | 134 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 194 insertions(+), 16 deletions(-) diff --git a/docs/helix-keys.md b/docs/helix-keys.md index ccb92ad6..6130e493 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -433,3 +433,8 @@ keys go to the child. Use the `Togglettymode` tag to leave raw input in place. Completed mouse rectangles keep their source text when jump navigation scrolls the pane. Wrapped pieces stay separate copied rows. Changing file content or reflowing a terminal to a new width clears these rectangles. + +File clicks place the editing cursor at the nearest valid insertion position: +blank columns stop at the line end, rows below the file stop on its last row, +and wide glyphs or tabs stay on their character boundary. Clicking while +inserting keeps insert mode. Terminal overlays still support virtual spaces. diff --git a/src/pardes.zig b/src/pardes.zig index efd82474..1b2e7ff5 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -3879,7 +3879,7 @@ test "Unicode display cells map back to body and tag byte cursors" { try std.testing.expectEqualStrings(before, pane.tagSlice()); } -test "tabbed file aligns syntax cursor and mouse while preserving virtual columns" { +test "tabbed file aligns syntax cursor and mouse at the nearest insertion gap" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12 }); defer p.deinit(); @@ -3916,11 +3916,11 @@ test "tabbed file aligns syntax cursor and mouse while preserving virtual column const blank_x = text_x + @as(u16, @intCast(panes.File.displayWidth(line))) + virtual; p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = blank_x, .row = body_y } }); p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = blank_x, .row = body_y } }); - try std.testing.expectEqual(@as(i32, @intCast(line.len + @as(usize, virtual))), pane.cur_col); + try std.testing.expectEqual(@as(i32, @intCast(line.len)), pane.cur_col); _ = frame.reset(.retain_capacity); const virtual_surface = try p.render(frame.allocator()); - try std.testing.expectEqual(blank_x, virtual_surface.cursor.?.x); + try std.testing.expectEqual(blank_x - virtual, virtual_surface.cursor.?.x); const local_blank: i32 = @intCast(blank_x - rect.x - config.GUTTER); try std.testing.expect(p.expandedSel(pane, .{ .state = .dragging, .c0 = local_blank, .c1 = local_blank, .r0 = BOX_H, .r1 = BOX_H }) == null); @@ -7697,9 +7697,17 @@ pub const Pardes = struct { var result: PointerOperand = .{ .row = row, .col = col }; const kept = pane.sel[sel_slot]; - if (kept.state == .done and - spanHas(clicked.r0, clicked.c0, kept.r0, kept.c0, kept.r1, kept.c1)) - { + const inside_kept = if (pane.pointerSelection(kept)) |rows| inside: { + if (clicked.r0 < BOX_H) break :inside false; + if (pane.file != null and clicked.c0 < panes.File.gutterWidth(pane)) break :inside false; + const source_col: usize = @intCast(@max(0, col)); + for (rows) |span| { + if (span.row == row and source_col >= span.lo and source_col < span.hi) + break :inside true; + } + break :inside false; + } else spanHas(clicked.r0, clicked.c0, kept.r0, kept.c0, kept.r1, kept.c1); + if (kept.state == .done and inside_kept) { result.text = p.selectionText(pane, kept) catch null; result.preview = kept; return result; @@ -9420,9 +9428,23 @@ pub const Pardes = struct { const InsertAt = enum { at, append, line_start, line_end, open_below, open_above }; + /// Files insert only inside existing rows and at grapheme boundaries. + /// Terminal overlays can materialize virtual rows/columns, so keep those. + fn clampFileCursor(pane: *Pane) bool { + const file = if (pane.file) |*file| file else return false; + const old_row = pane.cur_row; + const old_col = pane.cur_col; + const last = panes.File.nlines(pane.gpa, file) -| 1; + pane.cur_row = @intCast(@min(last, @as(usize, @intCast(@max(0, pane.cur_row))))); + const line = panes.File.sourceLine(pane, pane.cur_row); + pane.cur_col = @intCast(modal.graphemeStart(line, @min(line.len, @as(usize, @intCast(@max(0, pane.cur_col)))))); + return old_row != pane.cur_row or old_col != pane.cur_col; + } + fn enterInsert(p: *Pardes, pane: *Pane, where: InsertAt, cnt: usize) void { if (pane.hasPdf()) return; p.pinPaneCursor(pane); + _ = clampFileCursor(pane); // snapshot once per insert session (WITH the pre-insert selection) so // `u` undoes the whole session and restores what was selected p.pushUndo(pane); @@ -9532,6 +9554,7 @@ pub const Pardes = struct { if (hit(key, config.insert_delete_alias)) return p.insertKey(pane, .{ .cp = Key.delete }); pane.vsel.active = false; if (!pane.cur_pinned) p.pinPaneCursor(pane); + _ = clampFileCursor(pane); switch (key.cp) { Key.left, Key.right, Key.up, Key.down => { const lines = p.paneCursorLines(pane) catch return; @@ -10942,7 +10965,7 @@ pub const Pardes = struct { p.drag = .{ .tag = .{ .id = id } }; } else { p.active = id; - exitTagEdit(pane); // clicking the body leaves tag editing + if (pane.tag_edit) exitTagEdit(pane); const sc: i32 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER); const v: i32 = @as(i32, mrow) - @as(i32, body_y) + @as(i32, BOX_H); pane.clearPointerSelection(sel_slot); @@ -11127,6 +11150,19 @@ pub const Pardes = struct { return .{ .q0 = q0, .q1 = @intCast(@min(q0 + text.len, tag.len)) }; } const f = if (pane.file) |*file| file else return .{}; + if (operand.preview) |selection| if (pane.pointerSelection(selection)) |rows| { + if (rows.len == 0) return .{}; + const first = rows[0]; + const last = rows[rows.len - 1]; + const q0 = panes.File.lineStart(p.gpa, f, @intCast(@max(0, first.row))) + + @min(first.lo, panes.File.sourceLine(pane, first.row).len); + const q1 = panes.File.lineStart(p.gpa, f, @intCast(@max(0, last.row))) + + @min(last.hi, panes.File.sourceLine(pane, last.row).len); + // A rectangle may contain disjoint pieces. Such an operand uses + // the event's expansion text instead of inventing a source range. + if (q1 < q0 or !std.mem.eql(u8, f.content[q0..q1], text)) return .{}; + return .{ .q0 = @intCast(q0), .q1 = @intCast(q1) }; + }; const start: ?modal.Cursor = if (operand.file_word) |w| .{ .row = @intCast(@max(0, w.row)), .col = @intCast(@max(0, w.lo)) } else if (operand.expanded orelse operand.preview) |sel| lead: { @@ -11248,11 +11284,14 @@ pub const Pardes = struct { sl.c1 - (if (pane.file != null) @as(i32, panes.File.gutterWidth(pane)) else 0), ); pane.cur_pinned = true; - if (!pane.isTerminal()) pane.mode = .normal; + if (clampFileCursor(pane)) pane.ensureCursorVisible(); pane.msel.active = false; pane.normal.clear(); pane.vsel.active = false; pane.nsel = 0; // a click says WHERE the one cursor is + pane.select = false; + pane.append_at = null; + pane.sticky_col = -1; if (s.ctrl) p.lspRequest(s.id, .definition, ""); } } else { @@ -11265,6 +11304,7 @@ pub const Pardes = struct { if (s.button == config.look_button and clk.r0 >= BOX_H and pane.mode != .tty) { pane.cur_row = operand.row; pane.cur_col = operand.col; + if (clampFileCursor(pane)) pane.ensureCursorVisible(); } const txt = operand.text; const cmd = if (s.button == config.look_button) config.look_cmd else config.exec_cmd; @@ -12770,8 +12810,8 @@ pub const Pardes = struct { } fn paintPointerSelection( - s: *Surface, - pane: *const Pane, + p: *Pardes, + pane: *Pane, r: Rect, tx: u16, tw: u16, @@ -12781,6 +12821,8 @@ pub const Pardes = struct { bg: [3]u8, fg: ?[3]u8, ) void { + if (pane.pointerSelection(sl)) |rows| return p.paintSourceSelection(pane, r, rows, bg, fg); + const s = &p.surface; const r0 = @min(sl.r0, sl.r1); const r1 = @max(sl.r0, sl.r1); const c0 = @max(0, @min(sl.c0, sl.c1)); @@ -12878,7 +12920,7 @@ pub const Pardes = struct { if (p.look_hover_preview) |preview| { if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < BOX_H) { const preview_bg = mix(page_rgb, mix(page_rgb, th.sel_bg)); - paintPointerSelection(s, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null); + p.paintPointerSelection(pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null); }; } if (pane.tag_edit and pane.tag_sel) { @@ -12968,7 +13010,7 @@ pub const Pardes = struct { panes.File.paintWordSelection(p, pane, r, word.row, word.lo, word.hi, preview_bg) else if (preview.sel) |sel| if (@max(sel.r0, sel.r1) >= BOX_H) - paintPointerSelection(s, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null); + p.paintPointerSelection(pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null); } } const modal_hover = if (p.look_hover_preview) |preview| @@ -12978,10 +13020,7 @@ pub const Pardes = struct { for (pane.sel, 0..) |sl, b| { if (sl.state == .none) continue; if (sl.state == .done and b != 0) continue; - if (pane.pointerSelection(sl)) |rows| - p.paintSourceSelection(pane, r, rows, sel_btn[b], th.sel_fg) - else - paintPointerSelection(s, pane, r, tx, tw, tag_y, body_y, sl, sel_btn[b], th.sel_fg); + p.paintPointerSelection(pane, r, tx, tw, tag_y, body_y, sl, sel_btn[b], th.sel_fg); } // modal line selection (helix `x`): whole body rows, normal mode only if ((pane.mode == .normal or modal_hover) and pane.msel.active) { diff --git a/test/panes.zig b/test/panes.zig index ab7726e1..ded5b075 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -12,6 +12,139 @@ const image = pardes.image; const syntax = pardes.syntax; const filesystem = pardes.filesystem; +const ClickCursorTests = struct { + fn click(p: *Pardes, button: pardes.Mouse.Button, x: u16, y: u16) void { + p.update(.{ .mouse = .{ .button = button, .kind = .press, .col = x, .row = y } }); + p.update(.{ .mouse = .{ .button = button, .kind = .release, .col = x, .row = y } }); + } + + test "click cursor and insertion agree beyond line and file ends" { + for ([_]bool{ false, true }) |already_insert| { + for ([_]bool{ false, true }) |bottom_tag| { + for ([_]struct { text: []const u8, click_row: u16, row: i32, col: i32, result: []const u8 }{ + .{ .text = "abc\nxy", .click_row = 0, .row = 0, .col = 3, .result = "abcZ\nxy" }, + .{ .text = "abc\nxy", .click_row = 5, .row = 1, .col = 2, .result = "abc\nxyZ" }, + .{ .text = "abc\n", .click_row = 5, .row = 1, .col = 0, .result = "abc\nZ" }, + .{ .text = "", .click_row = 5, .row = 0, .col = 0, .result = "Z" }, + }) |case| { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + p.settings.tag_bottom = bottom_tag; + const pane = try p.setTestFile(case.text); + p.presentation.enabled = false; + if (already_insert) p.update(.{ .key = .{ .cp = 'i' } }); + var frame = std.heap.ArenaAllocator.init(p.gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + const rect = p.rects[0]; + const x = rect.x + config.GUTTER + panes.File.gutterWidth(pane); + const y = if (bottom_tag) rect.y else rect.y + pardes.BOX_H; + click(p, .left, x + 15, y + case.click_row); + try std.testing.expectEqual(case.row, pane.cur_row); + try std.testing.expectEqual(case.col, pane.cur_col); + const clicked = (try p.render(frame.allocator())).cursor orelse return error.MissingClickCursor; + try std.testing.expectEqual(x + @as(u16, @intCast(case.col)), clicked.x); + try std.testing.expectEqual(y + @as(u16, @intCast(case.row)), clicked.y); + if (!already_insert) p.update(.{ .key = .{ .cp = 'i' } }); + try std.testing.expectEqual(Pane.Mode.insert, pane.mode); + const insert = (try p.render(frame.allocator())).cursor orelse return error.MissingInsertCursor; + try std.testing.expectEqual(clicked.x, insert.x); + try std.testing.expectEqual(clicked.y, insert.y); + p.update(.{ .key = .{ .cp = 'Z', .text = "Z" } }); + try std.testing.expectEqualStrings(case.result, pane.file.?.content); + } + } + } + } + + test "click cursor snaps tab and wrapped wide glyph cells before insertion" { + for ([_]bool{ false, true }) |already_insert| { + for ([_]bool{ false, true }) |wrap| { + const source = if (wrap) "012345678901234567890123456789界tail\n" else "\t界tail\n"; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 24, .rows = 12 }); + defer p.deinit(); + const pane = try p.setTestFile(source); + p.settings.wrap = wrap; + p.presentation.enabled = false; + if (already_insert) p.update(.{ .key = .{ .cp = 'i' } }); + var frame = std.heap.ArenaAllocator.init(p.gpa); + defer frame.deinit(); + const surface = try p.render(frame.allocator()); + var target: ?struct { x: u16, y: u16 } = null; + for (0..surface.rows) |row| for (0..surface.cols) |col| { + if (std.mem.eql(u8, surface.at(@intCast(col), @intCast(row)).grapheme(), "界")) + target = .{ .x = @intCast(col), .y = @intCast(row) }; + }; + const glyph = target orelse return error.MissingWideGlyph; + if (wrap) try std.testing.expect(glyph.y > p.rects[0].y + pardes.BOX_H); + click(p, .left, glyph.x + 1, glyph.y); + const byte = std.mem.indexOf(u8, source, "界").?; + try std.testing.expectEqual(@as(i32, @intCast(byte)), pane.cur_col); + const clicked = (try p.render(frame.allocator())).cursor orelse return error.MissingClickCursor; + try std.testing.expectEqual(glyph.x, clicked.x); + try std.testing.expectEqual(glyph.y, clicked.y); + if (!already_insert) p.update(.{ .key = .{ .cp = 'i' } }); + try std.testing.expectEqual(Pane.Mode.insert, pane.mode); + const insert = (try p.render(frame.allocator())).cursor orelse return error.MissingInsertCursor; + try std.testing.expectEqual(clicked.x, insert.x); + try std.testing.expectEqual(clicked.y, insert.y); + p.update(.{ .key = .{ .cp = 'Z', .text = "Z" } }); + const expected = try std.fmt.allocPrint(p.gpa, "{s}Z{s}", .{ source[0..byte], source[byte..] }); + defer p.gpa.free(expected); + try std.testing.expectEqualStrings(expected, pane.file.?.content); + } + } + } + + test "click operands and hover follow the scrolled source selection" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 14 }); + defer p.deinit(); + const pane = try p.setTestFile("zero\none\ntwo\nthree\nalpha beta\nfive\ngamma delta\nseven\n" ++ "tail\n" ** 20); + p.presentation.enabled = false; + p.settings.wrap = false; + var frame = std.heap.ArenaAllocator.init(p.gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + const rect = p.rects[0]; + const x = rect.x + config.GUTTER + panes.File.gutterWidth(pane); + const y = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; + JumpSelectionTests.drag(p, x + 1, y + 4, x + 3, y + 4); + try std.testing.expectEqualStrings("lph", pardes.test_api.heldSelection(p, 0).?); + for (0..2) |_| p.update(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = x + 2, .row = y + 4 } }); + try std.testing.expectEqual(@as(usize, 2), pane.file.?.scroll); + _ = try p.render(frame.allocator()); + const event_node = filesystem.Node.of(pane.serial, .event); + const opened = filesystem.handle(p, .{ .tag = 0, .op = .open, .node = event_node }); + try std.testing.expectEqual(.ok, opened.status); + defer _ = filesystem.handle(p, .{ .tag = 0, .op = .release, .node = event_node, .handle = opened.handle }); + + for ([_]pardes.Mouse.Button{ .middle, .right }) |button| { + const events = &p.fs.panes[0].events; + while (!events.empty()) events.pop(); + click(p, button, x + 2, y + 2); + const selected = events.peek() orelse return error.MissingSelectionGesture; + try std.testing.expect(std.mem.endsWith(u8, selected, " lph\n")); + try std.testing.expect(std.mem.startsWith(u8, selected[2..], "20 23 ")); + while (!events.empty()) events.pop(); + click(p, button, x + 2, y + 4); + const unselected = events.peek() orelse return error.MissingWordGesture; + try std.testing.expect(std.mem.endsWith(u8, unselected, " gamma\n")); + } + + const delay = config.look_preview_delay_frames orelse return; + p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = x + 2, .row = y + 2 } }); + for (0..delay) |_| p.update(.tick); + const selected_preview = p.look_hover_preview orelse return error.MissingSelectionHover; + try std.testing.expect(selected_preview.sel != null); + try std.testing.expect(pane.pointerSelection(selected_preview.sel.?) != null); + p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = x + 2, .row = y + 4 } }); + for (0..delay) |_| p.update(.tick); + const word_preview = p.look_hover_preview orelse return error.MissingWordHover; + try std.testing.expect(word_preview.file_word != null); + try std.testing.expectEqual(@as(i32, 6), word_preview.file_word.?.row); + } +}; + const JumpSelectionTests = struct { fn drag(p: *Pardes, col0: u16, row0: u16, col1: u16, row1: u16) void { p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = col0, .row = row0 } }); @@ -2691,6 +2824,7 @@ const TerminalTests = struct { }; test { + _ = ClickCursorTests; _ = JumpSelectionTests; _ = @import("output.zig"); _ = @import("pdf.zig"); -- cgit v1.3