diff options
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 228 |
1 files changed, 197 insertions, 31 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index fd33cafd..4b9e8b1d 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -1698,6 +1698,22 @@ test "selection pipe binding is file-normal-only" { try std.testing.expectEqualSlices(u8, "|x", pane.file.?.content); } +test "insert newline adds one indent level after a closing call" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = try p.hxOpenFileContent(" callback({})\n"); + pane.mode = .insert; + pane.cur_col = 16; + + p.update(.{ .key = .{ .cp = Key.enter } }); + + try std.testing.expectEqualSlices(u8, " callback({})\n \n", pane.file.?.content); + try std.testing.expectEqual(@as(i32, 1), pane.cur_row); + try std.testing.expectEqual(@as(i32, 8), pane.cur_col); +} + test "startup config runs builtin lines in order and isolates bad lines" { const p = try Pardes.init(std.testing.allocator, .{ .startup_config = @@ -2727,10 +2743,25 @@ pub const Surface = struct { (std.unicode.utf8Decode(text[i .. i + n]) catch null) else null; - const cp_slice = if (decoded == null) "\u{FFFD}" else text[i .. i + n]; + var cp_slice = if (decoded == null) "\u{FFFD}" else text[i .. i + n]; i += if (decoded == null) 1 else n; - const cp = decoded orelse 0xFFFD; + var cp = decoded orelse 0xFFFD; if (cp == '\r') continue; + // A Surface cell is already positioned, not a terminal byte + // stream. Expand tabs here so every Surface consumer — GUI, tty, + // web, and macOS — sees the same configured run of blank cells + // instead of asking its font for a control-character glyph. + if (cp == '\t') { + const spaces = @min(config.tab_width, end - col); + s.fill(col, y, spaces, 1, style); + col += spaces; + continue; + } + // No other C0/C1 control or DEL reaches a font fallback either. + if (cp < ' ' or cp == 0x7f or (cp >= 0x80 and cp <= 0x9f)) { + cp = 0xFFFD; + cp_slice = "\u{FFFD}"; + } const width: u16 = if (cp < 0x80) 1 else uucode.get(.width, cp); if (width == 0) continue; // a DOUBLE-width glyph with one column left is not drawn at all. @@ -2777,6 +2808,78 @@ pub const Surface = struct { } }; +test "surface print expands configured tabs and normalizes other controls" { + const tab_cells: usize = @as(usize, config.tab_width) * 2; + const replacement_cells = 5; + const cell_count = tab_cells + replacement_cells + 1; + var cells: [cell_count]Cell = @splat(.{}); + var surface = Surface{ .cols = cells.len, .rows = 1, .cells = &cells }; + + const end = surface.print(0, 0, cells.len, "\t\t\x00\x0b\x0c\r\x7f\xc2\x85A", .{}); + try std.testing.expectEqual(@as(u16, cells.len), end); + for (cells[0..tab_cells]) |cell| + try std.testing.expectEqualStrings(" ", cell.grapheme()); + for (cells[tab_cells .. tab_cells + replacement_cells]) |cell| + try std.testing.expectEqualStrings("\u{FFFD}", cell.grapheme()); + try std.testing.expectEqualStrings("A", cells[cell_count - 1].grapheme()); +} + +test "tabbed file aligns syntax cursor and mouse with display cells" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12 }); + defer p.deinit(); + const pane = try p.hxOpenFileContent("\treturn x;\n"); + const f = &pane.file.?; + f.highlights = try p.tree_sitter_gpa.alloc(u8, f.content.len); + @memset(f.highlights, @intFromEnum(syntax.Syn.none)); + @memset(f.highlights[1..7], @intFromEnum(syntax.Syn.keyword)); + f.syntax_dirty = false; + pane.cur_row = 0; + pane.cur_col = 1; + + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + const surface = try p.render(frame.allocator()); + const rect = p.rects[0]; + const text_x = rect.x + config.GUTTER + config.PREFIX_W; + const body_y = rect.y + BOX_H; + for (0..config.tab_width) |col| + try std.testing.expect(!surface.at(text_x + @as(u16, @intCast(col)), body_y).style.bold); + for (0.."return".len) |col| + try std.testing.expect(surface.at(text_x + config.tab_width + @as(u16, @intCast(col)), body_y).style.bold); + try std.testing.expectEqual(text_x + config.tab_width, surface.cursor.?.x); + + const click_x = text_x + config.tab_width + 2; + p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = click_x, .row = body_y } }); + p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = click_x, .row = body_y } }); + try std.testing.expectEqual(@as(i32, 3), pane.cur_col); +} + +test "plain left click clears explicit modal selection" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .cols = 40, .rows = 10 }); + defer p.deinit(); + const pane = try p.hxOpenFileContent("abcdef\n"); + pane.cur_row = 0; + pane.cur_col = 5; + pane.vsel = .{ .active = true, .row = 0, .col = 1, .explicit = true }; + pane.msel = .{ .active = true, .r0 = 0, .r1 = 0 }; + + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + const rect = p.rects[0]; + const click_x = rect.x + config.GUTTER + config.PREFIX_W + 3; + const body_y = rect.y + BOX_H; + p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = click_x, .row = body_y } }); + p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = click_x, .row = body_y } }); + + try std.testing.expectEqual(@as(i32, 3), pane.cur_col); + try std.testing.expect(!pane.vsel.active); + try std.testing.expect(!pane.msel.active); + try std.testing.expectEqual(.none, pane.sel[@intFromEnum(config.select_button)].state); +} + pub const Mouse = struct { // sel indexing relies on left/middle/right being 0/1/2 pub const Button = enum { left, middle, right, wheel_up, wheel_down, wheel_left, wheel_right, none }; @@ -3861,13 +3964,21 @@ pub const Pane = struct { // A wrapped body has nothing to scroll sideways, and its hscroll is // left ALONE rather than zeroed: turn the wrap back off and the view // you had is still there. - if (pane.file != null and pane.wrap_n == 0) { + if (pane.file) |f| { + if (pane.wrap_n != 0) return; const w: i32 = @max(1, @as(i32, pane.cols) - @as(i32, config.PREFIX_W)); const hmargin: i32 = @min(config.scroll_off, @divTrunc(w - 1, 2)); - if (pane.cur_col < pane.hscroll + hmargin) - pane.hscroll = @max(0, pane.cur_col - hmargin) - else if (pane.cur_col > pane.hscroll + w - 1 - hmargin) - pane.hscroll = pane.cur_col - (w - 1 - hmargin); + const line = modal.lineSlice(f.content, @intCast(@max(0, pane.cur_row))); + const raw_cur = @min(@as(usize, @intCast(@max(0, pane.cur_col))), line.len); + const raw_scroll = @min(@as(usize, @intCast(@max(0, pane.hscroll))), line.len); + const cur = @as(i32, @intCast(file_pane.displayWidth(line[0..raw_cur]))); + const visual_scroll = @as(i32, @intCast(file_pane.displayWidth(line[0..raw_scroll]))); + var target = visual_scroll; + if (cur < visual_scroll + hmargin) + target = @max(0, cur - hmargin) + else if (cur > visual_scroll + w - 1 - hmargin) + target = cur - (w - 1 - hmargin); + if (target != visual_scroll) pane.hscroll = @intCast(file_pane.byteAtDisplay(line, @intCast(target))); } } @@ -5150,6 +5261,49 @@ pub const Pardes = struct { if (col) |c| p.topbar_col = @intCast(@min(c, modal.prevGrapheme(bar, bar.len))); } + fn fileLine(pane: *const Pane, row: i32) []const u8 { + const f = pane.file orelse return ""; + if (row < 0) return ""; + return modal.lineSlice(f.content, @intCast(row)); + } + + fn fileDisplayOffset(pane: *const Pane, row: i32, from_raw: i32, to_raw: i32) i32 { + const line = fileLine(pane, row); + const from = @min(@as(usize, @intCast(@max(0, from_raw))), line.len); + const to = @min(@as(usize, @intCast(@max(0, to_raw))), line.len); + if (to >= from) return @intCast(file_pane.displayWidth(line[from..to])); + return -@as(i32, @intCast(file_pane.displayWidth(line[to..from]))); + } + + fn fileDisplayEndOffset(pane: *const Pane, row: i32, from_raw: i32, at_raw: i32) i32 { + const start = fileDisplayOffset(pane, row, from_raw, at_raw); + const line = fileLine(pane, row); + const at: usize = @intCast(@max(0, at_raw)); + if (at >= line.len) return start; + return start + @as(i32, @intCast(file_pane.byteDisplayWidth(line[at]))) - 1; + } + + fn fileByteAtDisplay(pane: *const Pane, row: i32, from_raw: i32, display_col: i32) i32 { + const line = fileLine(pane, row); + const from = @min(@as(usize, @intCast(@max(0, from_raw))), line.len); + const display: usize = @intCast(@max(0, display_col)); + return @intCast(from + file_pane.byteAtDisplay(line[from..], display)); + } + + fn renderedLineByteCol(pane: *const Pane, row: i32, line: []const u8, display_col: usize) usize { + if (pane.file == null or row < BOX_H) return @min(display_col, line.len); + const prefix = @min(@as(usize, config.PREFIX_W), line.len); + if (display_col <= prefix) return display_col; + return prefix + file_pane.byteAtDisplay(line[prefix..], display_col - prefix); + } + + fn renderedLineDisplayCol(pane: *const Pane, row: i32, line: []const u8, byte_col: usize) usize { + if (pane.file == null or row < BOX_H) return @min(byte_col, line.len); + const prefix = @min(@as(usize, config.PREFIX_W), line.len); + if (byte_col <= prefix) return byte_col; + return prefix + file_pane.displayWidth(line[prefix..@min(byte_col, line.len)]); + } + /// tag + '\n' + body: the full selectable pane text (row 0 = the tag) fn paneText(p: *Pardes, pane: *Pane) ![]u8 { const arena = p.scratch.allocator(); @@ -5171,8 +5325,8 @@ pub const Pardes = struct { var count_row: i32 = 0; while (count_it.next()) |line| : (count_row += 1) { if (count_row < r0 or count_row > r1) continue; - const b0 = @min(c0, line.len); - const b1 = @min(c1 + 1, line.len); + const b0 = renderedLineByteCol(pane, count_row, line, c0); + const b1 = @min(renderedLineByteCol(pane, count_row, line, c1) + 1, line.len); total += b1 - b0 + @intFromBool(selected > 0); selected += 1; } @@ -5188,8 +5342,8 @@ pub const Pardes = struct { at += 1; } first = false; - const b0 = @min(c0, line.len); - const b1 = @min(c1 + 1, line.len); + const b0 = renderedLineByteCol(pane, v, line, c0); + const b1 = @min(renderedLineByteCol(pane, v, line, c1) + 1, line.len); @memcpy(out[at..][0 .. b1 - b0], line[b0..b1]); at += b1 - b0; } @@ -5216,12 +5370,13 @@ pub const Pardes = struct { var v: i32 = 0; while (it.next()) |line| : (v += 1) { if (v != sl.r0) continue; - const col: usize = @intCast(@max(0, sl.c0)); + const display_col: usize = @intCast(@max(0, sl.c0)); + const col = renderedLineByteCol(pane, v, line, display_col); if (col > line.len) return; const b = config.wordBounds(line, col); if (b.hi > b.lo) { - sl.c0 = @intCast(b.lo); - sl.c1 = @intCast(b.hi - 1); + sl.c0 = @intCast(renderedLineDisplayCol(pane, v, line, b.lo)); + sl.c1 = @intCast(renderedLineDisplayCol(pane, v, line, b.hi) - 1); } return; } @@ -5232,7 +5387,7 @@ pub const Pardes = struct { fn cursorWordSel(pane: *Pane) Sel { const w = pane.wrapRow(pane.cur_row, pane.cur_col); const vrow = w.row + @as(i32, BOX_H); - const vcol = if (pane.file != null) pane.cur_col - w.at + @as(i32, config.PREFIX_W) else pane.cur_col; + const vcol = if (pane.file != null) fileDisplayOffset(pane, pane.cur_row, w.at, pane.cur_col) + @as(i32, config.PREFIX_W) else pane.cur_col; return .{ .state = .done, .c0 = vcol, .c1 = vcol, .r0 = vrow, .r1 = vrow }; } @@ -7950,15 +8105,16 @@ pub const Pardes = struct { } switch (key.cp) { Key.enter => { - // helix insert_newline: the new line copies the current - // line's full indent LEVELS (plain-text heuristic) - const ind = modal.hxIndentString(modal.lineSlice(text, c.row)); + const line = modal.lineSlice(text, c.row); + const indent = modal.hxNewlineIndentWidth(line, c.col); const arena = p.scratch.allocator(); - const block = std.fmt.allocPrint(arena, "\n{s}", .{ind}) catch return; + const block = arena.alloc(u8, 1 + indent) catch return; + block[0] = '\n'; + @memset(block[1..], ' '); const new = modal.insertAt(p.gpa, text, c, block) catch return; p.setEditText(pane, new); pane.cur_row += 1; - pane.cur_col = @intCast(ind.len); + pane.cur_col = @intCast(indent); pane.cur_pinned = true; pane.ensureCursorVisible(); }, @@ -9583,15 +9739,18 @@ pub const Pardes = struct { // pointer whether or not that row is a continuation const w = pane.wrapAt(body_vis); pane.cur_row = w.line; - pane.cur_col = if (pane.file != null) @max(0, sl.c1 - @as(i32, config.PREFIX_W)) + w.at else sl.c1; + pane.cur_col = if (pane.file != null) + fileByteAtDisplay(pane, w.line, w.at, sl.c1 - @as(i32, config.PREFIX_W)) + else + sl.c1; pane.cur_pinned = true; if (!pane.isTerminal()) pane.mode = .normal; pane.msel.active = false; pane.pending = 0; - // a chord/motion selection is residue: a fresh click - // dismisses it rather than dragging the anchored span - // to the click (explicit v/x keeps vim's click-extend) - if (!pane.vsel.explicit) pane.vsel.active = false; + // A fresh left gesture replaces every modal selection. + // Keeping an explicit v/x anchor made a plain click + // extend it, leaving no mouse-only way to dismiss it. + pane.vsel.active = false; pane.nsel = 0; // a click says WHERE the one cursor is // Ctrl-click IS `gd`, asked now that the cursor has // landed — the mouse spelling of the keyboard motion, @@ -9611,7 +9770,7 @@ pub const Pardes = struct { const cw = pane.wrapAt(cvis); const crow = cw.line; const ccol = if (pane.file != null) - @max(0, clk.c0 - @as(i32, config.PREFIX_W)) + cw.at + fileByteAtDisplay(pane, cw.line, cw.at, clk.c0 - @as(i32, config.PREFIX_W)) else clk.c0; // A click INSIDE a live selection takes the WHOLE selection @@ -12850,8 +13009,14 @@ pub const Pardes = struct { while (vr + @as(i32, BOX_H) < @as(i32, r.h)) : (vr += 1) { const w = pane.wrapAt(vr); if (w.line < bnd.lo_row or w.line > bnd.hi_row) continue; - const cstart: i32 = if (w.line == bnd.lo_row) bnd.lo_col - w.at + vpfx else vpfx; - const cend: i32 = if (w.line == bnd.hi_row) bnd.hi_col - w.at + vpfx else @as(i32, tw) - 1; + const cstart: i32 = if (w.line == bnd.lo_row) + (if (pane.file != null) fileDisplayOffset(pane, w.line, w.at, bnd.lo_col) else bnd.lo_col - w.at) + vpfx + else + vpfx; + const cend: i32 = if (w.line == bnd.hi_row) + (if (pane.file != null) fileDisplayEndOffset(pane, w.line, w.at, bnd.hi_col) else bnd.hi_col - w.at) + vpfx + else + @as(i32, tw) - 1; var col: i32 = @max(cstart, vpfx); while (col <= cend and col < tw) : (col += 1) { const cell = s.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(vr))); @@ -12863,7 +13028,7 @@ pub const Pardes = struct { if (primary and !preview) continue; // the hardware cursor IS the primary's const cw = pane.wrapRow(sr.row, sr.col); const crow = cw.row + @as(i32, BOX_H); - const ccol = sr.col - cw.at + vpfx; + const ccol = (if (pane.file != null) fileDisplayOffset(pane, sr.row, cw.at, sr.col) else sr.col - cw.at) + vpfx; if (crow >= BOX_H and crow < @as(i32, r.h) and ccol >= vpfx and ccol < tw) { const cell = s.at(tx + @as(u16, @intCast(ccol)), body_y + @as(u16, @intCast(crow - BOX_H))); cell.default = false; @@ -12886,9 +13051,10 @@ pub const Pardes = struct { // wrapped, so this is the same two lines it always was const cwp = pane.wrapRow(crow, ccol); const prow = cwp.row + @as(i32, BOX_H); - // files: cur_col is content-relative, add the lineno prefix + // Files store source-byte columns; the Surface stores display + // cells, so account for every expanded tab before the cursor. const cx = if (pane.file != null) - @as(i32, config.PREFIX_W) + ccol - cwp.at + @as(i32, config.PREFIX_W) + fileDisplayOffset(pane, crow, cwp.at, ccol) else ccol; if (prow >= BOX_H and cx >= 0 and prow < r.h and cx < tw) |
