From ee34131ab2fd5fa001bf3c1315c13036741efa64 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Mon, 13 Oct 2025 19:47:20 +0200 Subject: [PATCH 1/8] feat: add clipboard history support for internal clipboard --- src/tui/editor.zig | 331 +++++++++++++-------------------------------- src/tui/tui.zig | 77 +++++++++-- 2 files changed, 161 insertions(+), 247 deletions(-) diff --git a/src/tui/editor.zig b/src/tui/editor.zig index 9efdd02..c3f2f61 100644 --- a/src/tui/editor.zig +++ b/src/tui/editor.zig @@ -2580,20 +2580,6 @@ pub const Editor = struct { } pub const scroll_view_bottom_meta: Meta = .{}; - fn set_clipboard(self: *Self, text: []const u8) void { - tui.set_clipboard(text); - if (builtin.os.tag == .windows) { - @import("renderer").copy_to_windows_clipboard(text) catch |e| - self.logger.print_err("clipboard", "failed to set clipboard: {any}", .{e}); - } else { - tui.rdr().copy_to_system_clipboard(text); - } - } - - pub fn set_clipboard_internal(_: *Self, text: []const u8) void { - tui.set_clipboard(text); - } - pub fn copy_selection(root: Buffer.Root, sel: Selection, text_allocator: Allocator, metrics: Buffer.Metrics) ![]u8 { var size: usize = 0; _ = try root.get_range(sel, null, &size, null, metrics); @@ -2652,72 +2638,43 @@ pub const Editor = struct { return root_; } - pub fn cut_to(self: *Self, move: cursor_operator_const, root_: Buffer.Root, text_allocator: Allocator) !struct { []const u8, Buffer.Root } { + pub fn cut_to(self: *Self, move: cursor_operator_const, root_: Buffer.Root) !Buffer.Root { var all_stop = true; var root = root_; - var text = std.ArrayListUnmanaged(u8).empty; - defer text.deinit(text_allocator); - var first = true; for (self.cursels.items) |*cursel_| if (cursel_.*) |*cursel| { if (cursel.selection) |_| { - const cut_text, root = self.cut_selection(root, cursel, text_allocator) catch continue; - defer text_allocator.free(cut_text); + const cut_text, root = self.cut_selection(root, cursel, tui.clipboard_allocator()) catch continue; + tui.clipboard_add_chunk(cut_text); all_stop = false; - if (first) { - first = false; - } else { - try text.appendSlice(text_allocator, "\n"); - } - try text.appendSlice(text_allocator, cut_text); continue; } with_selection_const(root, move, cursel, self.metrics) catch continue; - const cut_text, root = self.cut_selection(root, cursel, text_allocator) catch continue; - defer text_allocator.free(cut_text); - - if (first) { - first = false; - } else { - try text.appendSlice(text_allocator, "\n"); - } - try text.appendSlice(text_allocator, cut_text); + const cut_text, root = self.cut_selection(root, cursel, tui.clipboard_allocator()) catch continue; + tui.clipboard_add_chunk(cut_text); all_stop = false; }; - if (all_stop) - return error.Stop; - return .{ try text.toOwnedSlice(text_allocator), root }; + return if (all_stop) error.Stop else root; } pub fn cut_internal_vim(self: *Self, _: Context) Result { const primary = self.get_primary(); const b = self.buf_for_update() catch return; var root = b.root; - var text = std.ArrayListUnmanaged(u8).empty; - defer text.deinit(self.allocator); if (self.cursels.items.len == 1) if (primary.selection) |_| {} else { - try text.appendSlice(self.allocator, "\n"); const sel = primary.enable_selection(root, self.metrics) catch return; try move_cursor_begin(root, &sel.begin, self.metrics); try move_cursor_end(root, &sel.end, self.metrics); try move_cursor_right(root, &sel.end, self.metrics); }; - var first = true; for (self.cursels.items) |*cursel_| if (cursel_.*) |*cursel| { - const cut_text, root = try self.cut_selection(root, cursel, self.allocator); - defer self.allocator.free(cut_text); - if (first) { - first = false; - } else { - try text.appendSlice(self.allocator, "\n"); - } - try text.appendSlice(self.allocator, cut_text); + const cut_text, root = try self.cut_selection(root, cursel, tui.clipboard_allocator()); + tui.clipboard_add_chunk(cut_text); }; try self.update_buf(root); - self.set_clipboard_internal(try text.toOwnedSlice(self.allocator)); self.clamp(); } pub const cut_internal_vim_meta: Meta = .{ .description = "Cut selection or current line to internal clipboard (vim)" }; @@ -2739,31 +2696,21 @@ pub const Editor = struct { else => return e, }; }; - var first = true; - var text = std.ArrayListUnmanaged(u8).empty; - defer text.deinit(self.allocator); + var count: usize = 0; for (self.cursels.items) |*cursel_| if (cursel_.*) |*cursel| { - const cut_text, root = try self.cut_selection(root, cursel, self.allocator); - defer self.allocator.free(cut_text); - if (first) { - first = false; - } else { - try text.appendSlice(self.allocator, "\n"); - } - try text.appendSlice(self.allocator, cut_text); + count += 1; + const cut_text, root = try self.cut_selection(root, cursel, tui.clipboard_allocator()); + tui.clipboard_add_chunk(cut_text); }; try self.update_buf(root); - self.set_clipboard(try text.toOwnedSlice(self.allocator)); self.clamp(); + try tui.clipboard_send_to_system(count); } pub const cut_meta: Meta = .{ .description = "Cut selection or current line to clipboard" }; pub fn copy(self: *Self, _: Context) Result { const primary = self.get_primary(); const root = self.buf_root() catch return; - var first = true; - var text = std.ArrayListUnmanaged(u8).empty; - defer text.deinit(self.allocator); if (self.cursels.items.len == 1) if (primary.selection) |_| {} else { const sel = primary.enable_selection(root, self.metrics) catch return; @@ -2771,46 +2718,26 @@ pub const Editor = struct { try move_cursor_end(root, &sel.end, self.metrics); try move_cursor_right(root, &sel.end, self.metrics); }; - for (self.cursels.items) |*cursel_| if (cursel_.*) |*cursel| { - if (cursel.selection) |sel| { - const copy_text = try copy_selection(root, sel, self.allocator, self.metrics); - defer self.allocator.free(copy_text); - - if (first) { - first = false; - } else { - try text.appendSlice(self.allocator, "\n"); - } - try text.appendSlice(self.allocator, copy_text); - } + var count: usize = 0; + for (self.cursels.items) |*cursel_| if (cursel_.*) |*cursel| if (cursel.selection) |sel| { + count += 1; + tui.clipboard_add_chunk(try copy_selection(root, sel, tui.clipboard_allocator(), self.metrics)); }; - if (text.items.len > 0) { - if (text.items.len > 100) { - self.logger.print("copy:{f}...", .{std.ascii.hexEscape(text.items[0..100], .lower)}); - } else { - self.logger.print("copy:{f}", .{std.ascii.hexEscape(text.items, .lower)}); - } - self.set_clipboard(try text.toOwnedSlice(self.allocator)); - } + return tui.clipboard_send_to_system(count); } pub const copy_meta: Meta = .{ .description = "Copy selection to clipboard" }; - fn copy_cursel_file_name( - self: *const Self, - writer: *std.Io.Writer, - ) Result { - if (self.file_path) |file_path| - try writer.writeAll(file_path) - else - try writer.writeByte('*'); + fn copy_cursel_file_name(self: *const Self) error{OutOfMemory}!usize { + tui.clipboard_add_chunk(try tui.clipboard_allocator().dupe(u8, self.file_path orelse "*")); + return 1; } - fn copy_cursel_file_name_and_location( - self: *const Self, - cursel: *const CurSel, - writer: *std.Io.Writer, - ) Result { - try self.copy_cursel_file_name(writer); + fn copy_cursel_file_name_and_location(self: *const Self, cursel: *const CurSel) error{ WriteFailed, OutOfMemory }!void { + var buffer: std.Io.Writer.Allocating = .init(tui.clipboard_allocator()); + defer buffer.deinit(); + const writer = &buffer.writer; + + try writer.writeAll(self.file_path orelse "*"); if (cursel.selection) |sel_| { var sel = sel_; sel.normalize(); @@ -2831,34 +2758,27 @@ pub const Editor = struct { try writer.print(":{d}:{d}", .{ cursel.cursor.row + 1, cursel.cursor.col + 1 }) else try writer.print(":{d}", .{cursel.cursor.row + 1}); + + tui.clipboard_add_chunk(try buffer.toOwnedSlice()); + } + + fn copy_cursels_file_name_and_location(self: *const Self) error{OutOfMemory}!usize { + var count: usize = 0; + for (self.cursels.items) |*cursel_| if (cursel_.*) |*cursel| { + count += 1; + self.copy_cursel_file_name_and_location(cursel) catch return error.OutOfMemory; + }; + return count; } pub fn copy_file_name(self: *Self, ctx: Context) Result { - var mode: enum { all, primary_only, file_name_only } = .all; + var mode: enum { all, file_name_only } = .all; _ = ctx.args.match(.{tp.extract(&mode)}) catch false; - var buffer: std.Io.Writer.Allocating = .init(self.allocator); - defer buffer.deinit(); - const writer = &buffer.writer; - var first = true; - switch (mode) { - .file_name_only => try self.copy_cursel_file_name(writer), - .primary_only => try self.copy_cursel_file_name_and_location( - self.get_primary(), - writer, - ), - else => for (self.cursels.items) |*cursel_| if (cursel_.*) |*cursel| { - if (first) first = false else try writer.writeByte('\n'); - try self.copy_cursel_file_name_and_location(cursel, writer); - }, - } - const text = try buffer.toOwnedSlice(); - if (text.len > 0) { - if (text.len > 100) - self.logger.print("copy:{f}...", .{std.ascii.hexEscape(text[0..100], .lower)}) - else - self.logger.print("copy:{f}", .{std.ascii.hexEscape(text, .lower)}); - self.set_clipboard(text); - } + const n = switch (mode) { + .file_name_only => try self.copy_cursel_file_name(), + .all => try self.copy_cursels_file_name_and_location(), + }; + return tui.clipboard_send_to_system(n); } pub const copy_file_name_meta: Meta = .{ .description = "Copy file name and location to clipboard", @@ -2866,97 +2786,51 @@ pub const Editor = struct { pub fn copy_internal_vim(self: *Self, _: Context) Result { const root = self.buf_root() catch return; - var first = true; - var text = std.ArrayListUnmanaged(u8).empty; - defer text.deinit(self.allocator); - for (self.cursels.items) |*cursel_| if (cursel_.*) |*cursel| { - if (cursel.selection) |sel| { - const copy_text = try copy_selection(root, sel, self.allocator, self.metrics); - defer self.allocator.free(copy_text); - if (first) { - first = false; - } else { - try text.appendSlice(self.allocator, "\n"); - } - try text.appendSlice(self.allocator, copy_text); - } - }; - if (text.items.len > 0) { - if (text.items.len > 100) { - self.logger.print("copy:{f}...", .{std.ascii.hexEscape(text.items[0..100], .lower)}); - } else { - self.logger.print("copy:{f}", .{std.ascii.hexEscape(text.items, .lower)}); - } - self.set_clipboard_internal(try text.toOwnedSlice(self.allocator)); - } + for (self.cursels.items) |*cursel_| if (cursel_.*) |*cursel| if (cursel.selection) |sel| + tui.clipboard_add_chunk(try copy_selection(root, sel, tui.clipboard_allocator(), self.metrics)); } pub const copy_internal_vim_meta: Meta = .{ .description = "Copy selection to internal clipboard (vim)" }; pub fn copy_line_internal_vim(self: *Self, _: Context) Result { const primary = self.get_primary(); const root = self.buf_root() catch return; - var first = true; - var text = std.ArrayListUnmanaged(u8).empty; - defer text.deinit(self.allocator); - try text.appendSlice(self.allocator, "\n"); if (primary.selection) |_| {} else { const sel = primary.enable_selection(root, self.metrics) catch return; try move_cursor_begin(root, &sel.begin, self.metrics); try move_cursor_end(root, &sel.end, self.metrics); try move_cursor_right(root, &sel.end, self.metrics); } - for (self.cursels.items) |*cursel_| if (cursel_.*) |*cursel| { - if (cursel.selection) |sel| { - const copy_text = try copy_selection(root, sel, self.allocator, self.metrics); - defer self.allocator.free(copy_text); - if (first) { - first = false; - } else { - try text.appendSlice(self.allocator, "\n"); - } - try text.appendSlice(self.allocator, copy_text); - } - }; - if (text.items.len > 0) { - if (text.items.len > 100) { - self.logger.print("copy:{f}...", .{std.ascii.hexEscape(text.items[0..100], .lower)}); - } else { - self.logger.print("copy:{f}", .{std.ascii.hexEscape(text.items, .lower)}); - } - self.set_clipboard_internal(try text.toOwnedSlice(self.allocator)); - } + for (self.cursels.items) |*cursel_| if (cursel_.*) |*cursel| if (cursel.selection) |sel| + tui.clipboard_add_chunk(try copy_selection(root, sel, tui.clipboard_allocator(), self.metrics)); } pub const copy_line_internal_vim_meta: Meta = .{ .description = "Copy line to internal clipboard (vim)" }; pub fn paste(self: *Self, ctx: Context) Result { - var text: []const u8 = undefined; - if (!(ctx.args.buf.len > 0 and try ctx.args.match(.{tp.extract(&text)}))) { - if (tui.get_clipboard()) |text_| text = text_ else return; - } - self.logger.print("paste: {d} bytes", .{text.len}); + var text_: []const u8 = undefined; + const clipboard: []const []const u8 = if (ctx.args.buf.len > 0 and try ctx.args.match(.{tp.extract(&text_)})) + &[_][]const u8{text_} + else + tui.clipboard_get_history() orelse return; + const b = try self.buf_for_update(); var root = b.root; - if (self.cursels.items.len == 1) { - const primary = self.get_primary(); - root = try self.insert(root, primary, text, b.allocator); - } else { - if (std.mem.indexOfScalar(u8, text, '\n')) |_| { - var pos: usize = 0; - for (self.cursels.items) |*cursel_| if (cursel_.*) |*cursel| { - if (std.mem.indexOfScalarPos(u8, text, pos, '\n')) |next| { - root = try self.insert(root, cursel, text[pos..next], b.allocator); - pos = next + 1; - } else { - root = try self.insert(root, cursel, text[pos..], b.allocator); - pos = 0; - } - }; - } else { - for (self.cursels.items) |*cursel_| if (cursel_.*) |*cursel| { - root = try self.insert(root, cursel, text, b.allocator); - }; + + var bytes: usize = 0; + var cursel_idx = self.cursels.items.len - 1; + var idx = clipboard.len - 1; + while (true) { + const cursel_ = &self.cursels.items[cursel_idx]; + if (cursel_.*) |*cursel| { + const text = clipboard[idx]; + root = try self.insert(root, cursel, text, b.allocator); + idx = if (idx == 0) clipboard.len - 1 else idx - 1; + bytes += text.len; } + if (cursel_idx == 0) break; + cursel_idx -= 1; } + self.logger.print("paste: {d} bytes", .{bytes}); + try self.update_buf(root); self.clamp(); self.need_render(); @@ -2964,43 +2838,30 @@ pub const Editor = struct { pub const paste_meta: Meta = .{ .description = "Paste from internal clipboard" }; pub fn paste_internal_vim(self: *Self, ctx: Context) Result { - var text: []const u8 = undefined; - if (!(ctx.args.buf.len > 0 and try ctx.args.match(.{tp.extract(&text)}))) { - if (tui.get_clipboard()) |text_| text = text_ else return; - } + var text_: []const u8 = undefined; + const clipboard: []const []const u8 = if (ctx.args.buf.len > 0 and try ctx.args.match(.{tp.extract(&text_)})) + &[_][]const u8{text_} + else + tui.clipboard_get_history() orelse return; - self.logger.print("paste: {d} bytes", .{text.len}); const b = try self.buf_for_update(); var root = b.root; - if (std.mem.eql(u8, text[text.len - 1 ..], "\n")) text = text[0 .. text.len - 1]; - - if (std.mem.indexOfScalar(u8, text, '\n')) |idx| { - if (idx == 0) { - for (self.cursels.items) |*cursel_| if (cursel_.*) |*cursel| { - try move_cursor_end(root, &cursel.cursor, self.metrics); - root = try self.insert(root, cursel, "\n", b.allocator); - }; - text = text[1..]; - } - if (self.cursels.items.len == 1) { - const primary = self.get_primary(); - root = try self.insert_line_vim(root, primary, text, b.allocator); - } else { - for (self.cursels.items) |*cursel_| if (cursel_.*) |*cursel| { - root = try self.insert_line_vim(root, cursel, text, b.allocator); - }; - } - } else { - if (self.cursels.items.len == 1) { - const primary = self.get_primary(); - root = try self.insert(root, primary, text, b.allocator); - } else { - for (self.cursels.items) |*cursel_| if (cursel_.*) |*cursel| { - root = try self.insert(root, cursel, text, b.allocator); - }; + var bytes: usize = 0; + var cursel_idx = self.cursels.items.len - 1; + var idx = clipboard.len - 1; + while (true) { + const cursel_ = &self.cursels.items[cursel_idx]; + if (cursel_.*) |*cursel| { + const text = clipboard[idx]; + root = try self.insert_line_vim(root, cursel, text, b.allocator); + idx = if (idx == 0) clipboard.len - 1 else idx - 1; + bytes += text.len; } + if (cursel_idx == 0) break; + cursel_idx -= 1; } + self.logger.print("paste: {d} bytes", .{bytes}); try self.update_buf(root); self.clamp(); @@ -3018,8 +2879,7 @@ pub const Editor = struct { pub fn cut_forward_internal(self: *Self, _: Context) Result { const b = try self.buf_for_update(); - const text, const root = try self.cut_to(move_cursor_right, b.root, self.allocator); - self.set_clipboard_internal(text); + const root = try self.cut_to(move_cursor_right, b.root); try self.update_buf(root); self.clamp(); } @@ -3102,8 +2962,7 @@ pub const Editor = struct { pub fn cut_buffer_end(self: *Self, _: Context) Result { const b = try self.buf_for_update(); - const text, const root = try self.cut_to(move_cursor_buffer_end, b.root, self.allocator); - self.set_clipboard_internal(text); + const root = try self.cut_to(move_cursor_buffer_end, b.root); try self.update_buf(root); self.clamp(); } @@ -3111,8 +2970,7 @@ pub const Editor = struct { pub fn cut_buffer_begin(self: *Self, _: Context) Result { const b = try self.buf_for_update(); - const text, const root = try self.cut_to(move_cursor_buffer_begin, b.root, self.allocator); - self.set_clipboard_internal(text); + const root = try self.cut_to(move_cursor_buffer_begin, b.root); try self.update_buf(root); self.clamp(); } @@ -3120,8 +2978,7 @@ pub const Editor = struct { pub fn cut_word_left_vim(self: *Self, _: Context) Result { const b = try self.buf_for_update(); - const text, const root = try self.cut_to(move_cursor_word_left_vim, b.root, self.allocator); - self.set_clipboard_internal(text); + const root = try self.cut_to(move_cursor_word_left_vim, b.root); try self.update_buf(root); self.clamp(); } @@ -3137,8 +2994,7 @@ pub const Editor = struct { pub fn cut_word_right_vim(self: *Self, _: Context) Result { const b = try self.buf_for_update(); - const text, const root = try self.cut_to(move_cursor_word_right_vim, b.root, self.allocator); - self.set_clipboard_internal(text); + const root = try self.cut_to(move_cursor_word_right_vim, b.root); try self.update_buf(root); self.clamp(); } @@ -3162,8 +3018,7 @@ pub const Editor = struct { pub fn cut_to_end_vim(self: *Self, _: Context) Result { const b = try self.buf_for_update(); - const text, const root = try self.cut_to(move_cursor_end_vim, b.root, self.allocator); - self.set_clipboard_internal(text); + const root = try self.cut_to(move_cursor_end_vim, b.root); try self.update_buf(root); self.clamp(); } diff --git a/src/tui/tui.zig b/src/tui/tui.zig index 5ddf392..54f8aee 100644 --- a/src/tui/tui.zig +++ b/src/tui/tui.zig @@ -75,7 +75,7 @@ fontfaces_: std.ArrayListUnmanaged([]const u8) = .{}, enable_mouse_idle_timer: bool = false, query_cache_: *syntax.QueryCache, frames_rendered_: usize = 0, -clipboard: ?[]const u8 = null, +clipboard: ?std.ArrayList([]const u8) = null, color_scheme: enum { dark, light } = .dark, color_scheme_locked: bool = false, @@ -270,7 +270,7 @@ fn deinit(self: *Self) void { self.logger.deinit(); self.query_cache_.deinit(); root.free_config(self.allocator, self.config_bufs); - if (self.clipboard) |text| self.allocator.free(text); + self.clipboard_deinit(); self.allocator.destroy(self); } @@ -1767,14 +1767,73 @@ fn widget_type_config_variable(widget_type: WidgetType) *ConfigWidgetStyle { }; } -pub fn get_clipboard() ?[]const u8 { - const self = current(); - return self.clipboard; +fn clipboard_deinit(self: *Self) void { + if (self.clipboard) |*clipboard| { + for (clipboard.items) |chunk| + self.allocator.free(chunk); + clipboard.deinit(self.allocator); + } + self.clipboard = null; } -pub fn set_clipboard(text: []const u8) void { +pub fn clipboard_allocator() Allocator { const self = current(); - if (self.clipboard) |old| - self.allocator.free(old); - self.clipboard = text; + return self.allocator; +} + +pub fn clipboard_get_history() ?[]const []const u8 { + const self = current(); + return if (self.clipboard) |clipboard| clipboard.items else null; +} + +pub fn clipboard_peek_chunk() ?[]const u8 { + const self = current(); + const clipboard = self.clipboard orelse return null; + return clipboard[clipboard.len - 1]; +} + +pub fn clipboard_clear_all() void { + const self = current(); + self.clipboard_deinit(); +} + +pub fn clipboard_add_chunk(text: []const u8) void { + const self = current(); + const clipboard = if (self.clipboard) |*clipboard| clipboard else blk: { + self.clipboard = .empty; + break :blk &self.clipboard.?; + }; + const chunk = clipboard.addOne(self.allocator) catch @panic("OOM clipboard_add_chunk"); + chunk.* = text; +} + +pub fn clipboard_send_to_system(n_chunks: usize) error{ Stop, WriteFailed }!void { + const self = current(); + var buffer: std.Io.Writer.Allocating = .init(self.allocator); + defer buffer.deinit(); + const writer = &buffer.writer; + const clipboard = if (self.clipboard) |clipboard| clipboard.items else return error.Stop; + if (clipboard.len < n_chunks) return error.Stop; + if (n_chunks == 1) return self.clipboard_send_to_system_internal(clipboard[clipboard.len - 1]); + var first = true; + const chunks = clipboard[clipboard.len - n_chunks ..]; + for (chunks) |chunk| { + if (first) first = false else try writer.writeByte('\n'); + try writer.writeAll(chunk); + } +} + +fn clipboard_send_to_system_internal(self: *Self, text: []const u8) void { + if (text.len > 0) { + if (text.len > 100) + self.logger.print("copy:{f}...", .{std.ascii.hexEscape(text[0..100], .lower)}) + else + self.logger.print("copy:{f}", .{std.ascii.hexEscape(text, .lower)}); + } + if (builtin.os.tag == .windows) { + @import("renderer").copy_to_windows_clipboard(text) catch |e| + self.logger.print_err("clipboard", "failed to set clipboard: {any}", .{e}); + } else { + self.rdr_.copy_to_system_clipboard(text); + } } From a8fe2c30a9b8bea1dd4a010ed31c93d421849793 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Mon, 13 Oct 2025 19:47:59 +0200 Subject: [PATCH 2/8] feat: save/restore clipboard history to/from session metadata --- src/tui/mainview.zig | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/tui/mainview.zig b/src/tui/mainview.zig index 2bb510e..9fe0104 100644 --- a/src/tui/mainview.zig +++ b/src/tui/mainview.zig @@ -1371,6 +1371,13 @@ pub fn write_restore_info(self: *Self) void { cbor.writeValue(writer, null) catch return; } + if (tui.clipboard_get_history()) |clipboard| { + cbor.writeArrayHeader(writer, clipboard.len) catch return; + for (clipboard) |item| cbor.writeValue(writer, item) catch return; + } else { + cbor.writeValue(writer, null) catch return; + } + const buffer_manager = tui.get_buffer_manager() orelse @panic("tabs no buffer manager"); buffer_manager.write_state(writer) catch return; @@ -1397,6 +1404,16 @@ fn read_restore_info(self: *Self) !void { tp.trace(tp.channel.debug, .{ "mainview", "extract" }); var editor_file_path: ?[]const u8 = undefined; if (!try cbor.matchValue(&iter, cbor.extract(&editor_file_path))) return error.Stop; + + tui.clipboard_clear_all(); + var len = try cbor.decodeArrayHeader(&iter); + const clipboard_allocator = tui.clipboard_allocator(); + while (len > 0) : (len -= 1) { + var chunk: []const u8 = undefined; + if (!try cbor.matchValue(&iter, cbor.extract(&chunk))) return error.Stop; + tui.clipboard_add_chunk(try clipboard_allocator.dupe(u8, chunk)); + } + try self.buffer_manager.extract_state(&iter); if (self.widgets.get("tabs")) |tabs_widget| From acb0e16621524b447ea7449f450775201842ed52 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Mon, 13 Oct 2025 19:49:20 +0200 Subject: [PATCH 3/8] feat: add helix mode support for clipboard history changes --- src/tui/mode/helix.zig | 76 ++++++++++++------------------------------ 1 file changed, 21 insertions(+), 55 deletions(-) diff --git a/src/tui/mode/helix.zig b/src/tui/mode/helix.zig index 8674d0b..7915e95 100644 --- a/src/tui/mode/helix.zig +++ b/src/tui/mode/helix.zig @@ -306,8 +306,7 @@ const cmds_ = struct { const mv = tui.mainview() orelse return; const ed = mv.get_active_editor() orelse return; const b = try ed.buf_for_update(); - const text, const root = try ed.cut_to(move_noop, b.root, ed.allocator); - ed.set_clipboard_internal(text); + const root = try ed.cut_to(move_noop, b.root); try ed.update_buf(root); ed.clamp(); } @@ -392,33 +391,9 @@ const cmds_ = struct { const mv = tui.mainview() orelse return; const ed = mv.get_active_editor() orelse return; const root = ed.buf_root() catch return; - var first = true; - var buffer: std.Io.Writer.Allocating = .init(ed.allocator); - defer buffer.deinit(); - const writer = &buffer.writer; - if (ed.get_primary().selection) |sel| if (sel.begin.col == 0 and sel.end.row > sel.begin.row) try writer.writeAll("\n"); - - for (ed.cursels.items) |*cursel_| if (cursel_.*) |*cursel| { - if (cursel.selection) |sel| { - const copy_text = try Editor.copy_selection(root, sel, ed.allocator, ed.metrics); - if (first) { - first = false; - } else { - try writer.writeAll("\n"); - } - try writer.writeAll(copy_text); - } - }; - const text = buffer.toOwnedSlice() catch &.{}; - if (text.len > 0) { - if (text.len > 100) { - ed.logger.print("copy:{f}...", .{std.ascii.hexEscape(text[0..100], .lower)}); - } else { - ed.logger.print("copy:{f}", .{std.ascii.hexEscape(text, .lower)}); - } - ed.set_clipboard_internal(text); - } + for (ed.cursels.items) |*cursel_| if (cursel_.*) |*cursel| if (cursel.selection) |sel| + tui.clipboard_add_chunk(try Editor.copy_selection(root, sel, tui.clipboard_allocator(), ed.metrics)); } pub const copy_helix_meta: Meta = .{ .description = "Copy selection to clipboard (helix)" }; @@ -426,26 +401,30 @@ const cmds_ = struct { const mv = tui.mainview() orelse return; const ed = mv.get_active_editor() orelse return; - var text: []const u8 = undefined; - if (!(ctx.args.buf.len > 0 and try ctx.args.match(.{tp.extract(&text)}))) { - if (tui.get_clipboard()) |text_| text = text_ else return; - } + var text_: []const u8 = undefined; + const clipboard: []const []const u8 = if (ctx.args.buf.len > 0 and try ctx.args.match(.{tp.extract(&text_)})) + &[_][]const u8{text_} + else + tui.clipboard_get_history() orelse return; - ed.logger.print("paste: {d} bytes", .{text.len}); const b = try ed.buf_for_update(); var root = b.root; - if (std.mem.eql(u8, text[text.len - 1 ..], "\n")) text = text[0 .. text.len - 1]; - - if (std.mem.indexOfScalar(u8, text, '\n') != null and text[0] == '\n') { - for (ed.cursels.items) |*cursel_| if (cursel_.*) |*cursel| { - root = try insert_line(ed, root, cursel, text, b.allocator); - }; - } else { - for (ed.cursels.items) |*cursel_| if (cursel_.*) |*cursel| { + var bytes: usize = 0; + var cursel_idx = ed.cursels.items.len - 1; + var idx = clipboard.len - 1; + while (true) { + const cursel_ = &ed.cursels.items[cursel_idx]; + if (cursel_.*) |*cursel| { + const text = clipboard[idx]; root = try insert(ed, root, cursel, text, b.allocator); - }; + idx = if (idx == 0) clipboard.len - 1 else idx - 1; + bytes += text.len; + } + if (cursel_idx == 0) break; + cursel_idx -= 1; } + ed.logger.print("paste: {d} bytes", .{bytes}); try ed.update_buf(root); ed.clamp(); @@ -497,19 +476,6 @@ fn insert(ed: *Editor, root: Buffer.Root, cursel: *CurSel, s: []const u8, alloca return root_; } -fn insert_line(ed: *Editor, root: Buffer.Root, cursel: *CurSel, s: []const u8, allocator: std.mem.Allocator) !Buffer.Root { - var root_ = root; - const cursor = &cursel.cursor; - cursel.disable_selection(root, ed.metrics); - cursel.cursor.move_end(root, ed.metrics); - var begin = cursel.cursor; - begin.move_right(root, ed.metrics) catch {}; - cursor.row, cursor.col, root_ = try root_.insert_chars(cursor.row, cursor.col, s, allocator, ed.metrics); - cursor.target = cursor.col; - cursel.selection = Selection{ .begin = begin, .end = cursor.* }; - return root_; -} - fn is_not_whitespace_or_eol(c: []const u8) bool { return !Editor.is_whitespace_or_eol(c); } From 634a18cb5685a3c3fcfc08301306e628d33c3256 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Mon, 13 Oct 2025 19:50:04 +0200 Subject: [PATCH 4/8] feat: add clipboard history palette --- src/tui/mainview.zig | 5 ++ src/tui/mode/overlay/clipboard_palette.zig | 73 ++++++++++++++++++++++ src/tui/tui.zig | 10 +++ 3 files changed, 88 insertions(+) create mode 100644 src/tui/mode/overlay/clipboard_palette.zig diff --git a/src/tui/mainview.zig b/src/tui/mainview.zig index 9fe0104..3b3dcb8 100644 --- a/src/tui/mainview.zig +++ b/src/tui/mainview.zig @@ -1018,6 +1018,11 @@ const cmds = struct { } pub const system_paste_meta: Meta = .{ .description = "Paste from system clipboard" }; + pub fn paste_history(_: *Self, _: Ctx) Result { + return try tui.open_overlay(@import("mode/overlay/clipboard_palette.zig").Type); + } + pub const paste_history_meta: Meta = .{ .description = "Paste from clipboard history" }; + pub fn find_in_files_query(self: *Self, ctx: Ctx) Result { var query: []const u8 = undefined; if (!try ctx.args.match(.{tp.extract(&query)})) return error.InvalidFindInFilesQueryArgument; diff --git a/src/tui/mode/overlay/clipboard_palette.zig b/src/tui/mode/overlay/clipboard_palette.zig new file mode 100644 index 0000000..1830065 --- /dev/null +++ b/src/tui/mode/overlay/clipboard_palette.zig @@ -0,0 +1,73 @@ +const std = @import("std"); +const cbor = @import("cbor"); +const tp = @import("thespian"); +const root = @import("soft_root").root; +const command = @import("command"); + +const tui = @import("../../tui.zig"); +pub const Type = @import("palette.zig").Create(@This()); +const module_name = @typeName(@This()); + +pub const label = "Clipboard history"; +pub const name = " clipboard"; +pub const description = "clipboard"; +pub const icon = " "; + +pub const Entry = struct { + label: []const u8, + idx: usize, +}; + +pub fn load_entries(palette: *Type) !usize { + const history = tui.clipboard_get_history() orelse &.{}; + + if (history.len > 0) { + var idx = history.len - 1; + while (true) : (idx -= 1) { + (try palette.entries.addOne(palette.allocator)).* = .{ + .label = history[idx], + .idx = idx, + }; + if (idx == 0) break; + } + } + return if (palette.entries.items.len == 0) label.len + 3 else 4; +} + +pub fn clear_entries(palette: *Type) void { + palette.entries.clearRetainingCapacity(); +} + +pub fn add_menu_entry(palette: *Type, entry: *Entry, matches: ?[]const usize) !void { + var value: std.Io.Writer.Allocating = .init(palette.allocator); + defer value.deinit(); + const writer = &value.writer; + try cbor.writeValue(writer, entry.label); + try cbor.writeValue(writer, entry.idx); + try cbor.writeValue(writer, matches orelse &[_]usize{}); + try palette.menu.add_item_with_handler(value.written(), select); + palette.items += 1; +} + +fn select(menu: **Type.MenuType, button: *Type.ButtonType, _: Type.Pos) void { + var unused: []const u8 = undefined; + var idx: usize = undefined; + var iter = button.opts.label; + if (!(cbor.matchString(&iter, &unused) catch false)) return; + if (!(cbor.matchValue(&iter, cbor.extract(&idx)) catch false)) return; + tp.self_pid().send(.{ "cmd", "exit_overlay_mode" }) catch |e| menu.*.opts.ctx.logger.err("navigate", e); + + const history = tui.clipboard_get_history() orelse return; + if (history.len <= idx) return; + tp.self_pid().send(.{ "cmd", "paste", .{history[idx]} }) catch {}; +} + +pub fn delete_item(menu: *Type.MenuType, button: *Type.ButtonType) bool { + var unused: []const u8 = undefined; + var idx: usize = undefined; + var iter = button.opts.label; + if (!(cbor.matchString(&iter, &unused) catch false)) return false; + if (!(cbor.matchValue(&iter, cbor.extract(&idx)) catch false)) return false; + command.executeName("clipboard_delete", command.fmt(.{idx})) catch |e| menu.*.opts.ctx.logger.err(module_name, e); + return true; //refresh list +} diff --git a/src/tui/tui.zig b/src/tui/tui.zig index 54f8aee..ffe3ed7 100644 --- a/src/tui/tui.zig +++ b/src/tui/tui.zig @@ -1275,6 +1275,16 @@ const cmds = struct { @import("mode/helix.zig").deinit(); } pub const exit_helix_mode_meta: Meta = .{}; + + pub fn clipboard_delete(self: *Self, ctx: Ctx) Result { + var idx: usize = 0; + if (!try ctx.args.match(.{tp.extract(&idx)})) + return error.InvalidClipboardDeleteArgument; + const clipboard = if (self.clipboard) |*clipboard| clipboard else return; + const removed = clipboard.orderedRemove(idx); + self.allocator.free(removed); + } + pub const clipboard_delete_meta: Meta = .{}; }; pub const MiniMode = struct { From acbf435a2e43f40eb1ade78f7c4667f856be90a1 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Mon, 13 Oct 2025 19:50:20 +0200 Subject: [PATCH 5/8] feat: use `alt+shift+v` keybind for clipboard history palette --- src/keybind/builtin/flow.json | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/keybind/builtin/flow.json b/src/keybind/builtin/flow.json index 0abb834..7824bdb 100644 --- a/src/keybind/builtin/flow.json +++ b/src/keybind/builtin/flow.json @@ -37,6 +37,7 @@ ["ctrl+page_down", "next_tab"], ["ctrl+page_up", "previous_tab"], ["ctrl+shift+e", "switch_buffers"], + ["alt+shift+v", "paste_history"], ["ctrl+0", "reset_fontsize"], ["ctrl+plus", "adjust_fontsize", 1.0], ["ctrl+minus", "adjust_fontsize", -1.0], @@ -147,7 +148,6 @@ ["alt+shift+d", "dupe_up"], ["alt+shift+f", "format"], ["alt+shift+s", "filter", "sort", "-u"], - ["alt+shift+v", "paste"], ["alt+shift+i", "add_cursors_to_line_ends"], ["alt+shift+left", "expand_selection"], ["alt+shift+right", "shrink_selection"], @@ -450,7 +450,6 @@ ["ctrl+space", "mini_mode_cancel"], ["ctrl+backspace", "mini_mode_reset"], ["alt+v", "system_paste"], - ["alt+shift+v", "system_paste"], ["tab", "mini_mode_try_complete_file"], ["escape", "mini_mode_cancel"], ["enter", "mini_mode_select"], @@ -469,7 +468,6 @@ ["ctrl+space", "mini_mode_cancel"], ["ctrl+backspace", "mini_mode_delete_to_previous_path_segment"], ["alt+v", "system_paste"], - ["alt+shift+v", "system_paste"], ["shift+tab", "mini_mode_reverse_complete_file"], ["up", "mini_mode_reverse_complete_file"], ["down", "mini_mode_try_complete_file"], @@ -500,7 +498,6 @@ ["ctrl+space", "exit_mini_mode"], ["ctrl+enter", "mini_mode_insert_bytes", "\n"], ["ctrl+backspace", "mini_mode_reset"], - ["alt+shift+v", "system_paste"], ["alt+v", "system_paste"], ["alt+n", "goto_next_file"], ["alt+p", "goto_prev_file"], @@ -534,7 +531,6 @@ ["ctrl+space", "mini_mode_cancel"], ["ctrl+enter", "mini_mode_insert_bytes", "\n"], ["ctrl+backspace", "mini_mode_reset"], - ["alt+shift+v", "system_paste"], ["alt+v", "system_paste"], ["alt+n", "goto_next_match"], ["alt+p", "goto_prev_match"], From 99c28b810290a46478be494ba11276aea3165553 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Mon, 13 Oct 2025 20:00:09 +0200 Subject: [PATCH 6/8] refactor: move clipboard_history command to tui module --- src/keybind/builtin/flow.json | 2 +- src/tui/mainview.zig | 5 ----- src/tui/tui.zig | 5 +++++ 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/keybind/builtin/flow.json b/src/keybind/builtin/flow.json index 7824bdb..b9ba0be 100644 --- a/src/keybind/builtin/flow.json +++ b/src/keybind/builtin/flow.json @@ -37,7 +37,7 @@ ["ctrl+page_down", "next_tab"], ["ctrl+page_up", "previous_tab"], ["ctrl+shift+e", "switch_buffers"], - ["alt+shift+v", "paste_history"], + ["alt+shift+v", "clipboard_history"], ["ctrl+0", "reset_fontsize"], ["ctrl+plus", "adjust_fontsize", 1.0], ["ctrl+minus", "adjust_fontsize", -1.0], diff --git a/src/tui/mainview.zig b/src/tui/mainview.zig index 3b3dcb8..9fe0104 100644 --- a/src/tui/mainview.zig +++ b/src/tui/mainview.zig @@ -1018,11 +1018,6 @@ const cmds = struct { } pub const system_paste_meta: Meta = .{ .description = "Paste from system clipboard" }; - pub fn paste_history(_: *Self, _: Ctx) Result { - return try tui.open_overlay(@import("mode/overlay/clipboard_palette.zig").Type); - } - pub const paste_history_meta: Meta = .{ .description = "Paste from clipboard history" }; - pub fn find_in_files_query(self: *Self, ctx: Ctx) Result { var query: []const u8 = undefined; if (!try ctx.args.match(.{tp.extract(&query)})) return error.InvalidFindInFilesQueryArgument; diff --git a/src/tui/tui.zig b/src/tui/tui.zig index ffe3ed7..18986ea 100644 --- a/src/tui/tui.zig +++ b/src/tui/tui.zig @@ -1276,6 +1276,11 @@ const cmds = struct { } pub const exit_helix_mode_meta: Meta = .{}; + pub fn clipboard_history(_: *Self, _: Ctx) Result { + return try open_overlay(@import("mode/overlay/clipboard_palette.zig").Type); + } + pub const clipboard_history_meta: Meta = .{ .description = "Paste from clipboard history" }; + pub fn clipboard_delete(self: *Self, ctx: Ctx) Result { var idx: usize = 0; if (!try ctx.args.match(.{tp.extract(&idx)})) From 6c6a8cee32e6c66c577816e16bcecc39c40ec861 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Mon, 13 Oct 2025 20:21:05 +0200 Subject: [PATCH 7/8] feat: add byte or line count hint to clipboard history palette --- src/tui/mode/overlay/clipboard_palette.zig | 25 ++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/tui/mode/overlay/clipboard_palette.zig b/src/tui/mode/overlay/clipboard_palette.zig index 1830065..1bd2da3 100644 --- a/src/tui/mode/overlay/clipboard_palette.zig +++ b/src/tui/mode/overlay/clipboard_palette.zig @@ -31,7 +31,7 @@ pub fn load_entries(palette: *Type) !usize { if (idx == 0) break; } } - return if (palette.entries.items.len == 0) label.len + 3 else 4; + return if (palette.entries.items.len == 0) label.len + 3 else 10; } pub fn clear_entries(palette: *Type) void { @@ -43,8 +43,21 @@ pub fn add_menu_entry(palette: *Type, entry: *Entry, matches: ?[]const usize) !v defer value.deinit(); const writer = &value.writer; try cbor.writeValue(writer, entry.label); - try cbor.writeValue(writer, entry.idx); + + var hint: std.Io.Writer.Allocating = .init(palette.allocator); + defer hint.deinit(); + var line_count: usize = 1; + for (0..entry.label.len) |i| if (entry.label[i] == '\n') { + line_count += 1; + }; + if (line_count > 1) + try hint.writer.print(" {d} lines", .{line_count}) + else + try hint.writer.print(" {d} {s}", .{ entry.label.len, if (entry.label.len == 1) "byte" else "bytes" }); + try cbor.writeValue(writer, hint.written()); + try cbor.writeValue(writer, matches orelse &[_]usize{}); + try cbor.writeValue(writer, entry.idx); try palette.menu.add_item_with_handler(value.written(), select); palette.items += 1; } @@ -54,6 +67,10 @@ fn select(menu: **Type.MenuType, button: *Type.ButtonType, _: Type.Pos) void { var idx: usize = undefined; var iter = button.opts.label; if (!(cbor.matchString(&iter, &unused) catch false)) return; + if (!(cbor.matchString(&iter, &unused) catch false)) return; + var len = cbor.decodeArrayHeader(&iter) catch return; + while (len > 0) : (len -= 1) + cbor.skipValue(&iter) catch return; if (!(cbor.matchValue(&iter, cbor.extract(&idx)) catch false)) return; tp.self_pid().send(.{ "cmd", "exit_overlay_mode" }) catch |e| menu.*.opts.ctx.logger.err("navigate", e); @@ -67,6 +84,10 @@ pub fn delete_item(menu: *Type.MenuType, button: *Type.ButtonType) bool { var idx: usize = undefined; var iter = button.opts.label; if (!(cbor.matchString(&iter, &unused) catch false)) return false; + if (!(cbor.matchString(&iter, &unused) catch false)) return false; + var len = cbor.decodeArrayHeader(&iter) catch return false; + while (len > 0) : (len -= 1) + cbor.skipValue(&iter) catch return false; if (!(cbor.matchValue(&iter, cbor.extract(&idx)) catch false)) return false; command.executeName("clipboard_delete", command.fmt(.{idx})) catch |e| menu.*.opts.ctx.logger.err(module_name, e); return true; //refresh list From 46def038bd282a021b0e7327d757c9ed8f1671ba Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Mon, 13 Oct 2025 20:27:43 +0200 Subject: [PATCH 8/8] feat: trip leading whitespace from clipboard history palette display --- src/tui/mode/overlay/clipboard_palette.zig | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/tui/mode/overlay/clipboard_palette.zig b/src/tui/mode/overlay/clipboard_palette.zig index 1bd2da3..c4b770e 100644 --- a/src/tui/mode/overlay/clipboard_palette.zig +++ b/src/tui/mode/overlay/clipboard_palette.zig @@ -24,8 +24,13 @@ pub fn load_entries(palette: *Type) !usize { if (history.len > 0) { var idx = history.len - 1; while (true) : (idx -= 1) { + var label_ = history[idx]; + while (label_.len > 0) switch (label_[0]) { + ' ', '\t', '\n' => label_ = label_[1..], + else => break, + }; (try palette.entries.addOne(palette.allocator)).* = .{ - .label = history[idx], + .label = label_, .idx = idx, }; if (idx == 0) break; @@ -46,14 +51,15 @@ pub fn add_menu_entry(palette: *Type, entry: *Entry, matches: ?[]const usize) !v var hint: std.Io.Writer.Allocating = .init(palette.allocator); defer hint.deinit(); + const item = if (tui.clipboard_get_history()) |clipboard| clipboard[entry.idx] else &.{}; var line_count: usize = 1; - for (0..entry.label.len) |i| if (entry.label[i] == '\n') { + for (0..item.len) |i| if (item[i] == '\n') { line_count += 1; }; if (line_count > 1) try hint.writer.print(" {d} lines", .{line_count}) else - try hint.writer.print(" {d} {s}", .{ entry.label.len, if (entry.label.len == 1) "byte" else "bytes" }); + try hint.writer.print(" {d} {s}", .{ item.len, if (item.len == 1) "byte " else "bytes" }); try cbor.writeValue(writer, hint.written()); try cbor.writeValue(writer, matches orelse &[_]usize{});