diff --git a/src/Window.zig b/src/Window.zig index a472a21d5..e4ad2fa59 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -448,28 +448,58 @@ fn keyCallback( _ = scancode; - // Paste - if (action == .press and mods.super and key == .v) { - const win = window.getUserPointer(Window) orelse return; + if (action == .press and mods.super) { + switch (key) { + // Copy + .c => { + const win = window.getUserPointer(Window) orelse return; - // Ignore this character for writing - win.ignore_char = true; + // Ignore this character for writing + win.ignore_char = true; - const data = glfw.getClipboardString() catch |err| { - log.warn("error reading clipboard: {}", .{err}); - return; - }; + // If we have a selection, copy it. + if (win.terminal.selection) |sel| { + var buf = win.terminal.screen.selectionString(win.alloc, sel) catch |err| { + log.err("error reading selection string err={}", .{err}); + return; + }; + defer win.alloc.free(buf); - if (data.len > 0) { - if (win.bracketed_paste) win.queueWrite("\x1B[200~") catch |err| - log.err("error queueing write in keyCallback err={}", .{err}); - win.queueWrite(data) catch |err| - log.warn("error pasting clipboard: {}", .{err}); - if (win.bracketed_paste) win.queueWrite("\x1B[201~") catch |err| - log.err("error queueing write in keyCallback err={}", .{err}); + glfw.setClipboardString(buf) catch |err| { + log.err("error setting clipboard string err={}", .{err}); + return; + }; + } + + return; + }, + + // Paste + .v => { + const win = window.getUserPointer(Window) orelse return; + + // Ignore this character for writing + win.ignore_char = true; + + const data = glfw.getClipboardString() catch |err| { + log.warn("error reading clipboard: {}", .{err}); + return; + }; + + if (data.len > 0) { + if (win.bracketed_paste) win.queueWrite("\x1B[200~") catch |err| + log.err("error queueing write in keyCallback err={}", .{err}); + win.queueWrite(data) catch |err| + log.warn("error pasting clipboard: {}", .{err}); + if (win.bracketed_paste) win.queueWrite("\x1B[201~") catch |err| + log.err("error queueing write in keyCallback err={}", .{err}); + } + + return; + }, + + else => {}, } - - return; } //log.info("KEY {} {} {} {}", .{ key, scancode, mods, action }); diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 62c8d4550..e4fc6b247 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -30,6 +30,7 @@ const Allocator = std.mem.Allocator; const color = @import("color.zig"); const point = @import("point.zig"); const Point = point.Point; +const Selection = @import("Selection.zig"); const log = std.log.scoped(.screen); @@ -172,7 +173,7 @@ pub fn getVisible(self: Screen) []Cell { /// Get a single row in the active area by index (0-indexed). pub fn getRow(self: Screen, idx: usize) Row { // Get the index of the first byte of the the row at index. - const real_idx = self.rowIndex(idx); + const real_idx = self.viewportRowIndex(idx); // The storage is sliced to return exactly the number of columns. return self.storage[real_idx .. real_idx + self.cols]; @@ -182,19 +183,31 @@ pub fn getRow(self: Screen, idx: usize) Row { pub fn getCell(self: Screen, row: usize, col: usize) *Cell { assert(row < self.rows); assert(col < self.cols); - const row_idx = self.rowIndex(row); + const row_idx = self.viewportRowIndex(row); return &self.storage[row_idx + col]; } /// Returns the index for the given row (0-indexed) into the underlying -/// storage array. -fn rowIndex(self: Screen, idx: usize) usize { +/// storage array. The row is 0-indexed from the top of the viewport. +fn viewportRowIndex(self: Screen, idx: usize) usize { assert(idx < self.rows); - const val = (self.top + self.visible_offset + idx) * self.cols; + return self.rowIndex(self.visible_offset + idx); +} + +/// Returns the index for the given row (0-indexed) into the underlying +/// storage array. The row is 0-indexed from the top of the screen. +fn rowIndex(self: Screen, y: usize) usize { + assert(y < self.totalRows()); + const val = (self.top + y) * self.cols; if (val < self.storage.len) return val; return val - self.storage.len; } +/// Returns the total number of rows in the screen. +inline fn totalRows(self: Screen) usize { + return self.storage.len / self.cols; +} + /// Scroll behaviors for the scroll function. pub const Scroll = union(enum) { /// Scroll to the top of the scroll buffer. The first line of the @@ -380,13 +393,124 @@ pub fn resize(self: *Screen, alloc: Allocator, rows: usize, cols: usize) !void { // If we grew rows, then set the remaining data to zero. if (rows > old.rows) { - std.mem.set(Cell, self.storage[self.rowIndex(old.rows)..], .{ .char = 0 }); + std.mem.set(Cell, self.storage[self.viewportRowIndex(old.rows)..], .{ .char = 0 }); } // Free the old data alloc.free(old.storage); } +/// Returns the raw text associated with a selection. This will unwrap +/// soft-wrapped edges. The returned slice is owned by the caller. +pub fn selectionString(self: Screen, alloc: Allocator, sel: Selection) ![:0]const u8 { + // Get the slices for the string + const slices = self.selectionSlices(sel); + + // We can now know how much space we'll need to store the string. We loop + // over and UTF8-encode and calculate the exact size required. We will be + // off here by at most "newlines" values in the worst case that every + // single line is soft-wrapped. + const newlines = @divFloor(slices.top.len + slices.bot.len, self.cols) + 1; + const chars = chars: { + var count: usize = 0; + const arr = [_][]Cell{ slices.top, slices.bot }; + for (arr) |slice| { + for (slice) |cell| { + var buf: [4]u8 = undefined; + const char = if (cell.char > 0) cell.char else ' '; + count += try std.unicode.utf8Encode(@intCast(u21, char), &buf); + } + } + + break :chars count; + }; + const buf = try alloc.alloc(u8, chars + newlines + 1); + errdefer alloc.free(buf); + + var i: usize = 0; + for (slices.top) |cell, idx| { + // If our index cleanly divides into the col count then we're + // at a newline and we add it. + if (idx > 0 and + @mod(idx + slices.top_offset, self.cols) == 0 and + slices.top[idx - 1].attrs.wrap == 0) + { + buf[i] = '\n'; + i += 1; + } + + const char = if (cell.char > 0) cell.char else ' '; + i += try std.unicode.utf8Encode(@intCast(u21, char), buf[i..]); + } + + for (slices.bot) |cell, idx| { + // We don't use "top_offset" here because the bot by definition + // is never offset, it always starts at index 0 so we can just check + // the index directly. + if (@mod(idx, self.cols) == 0) { + // Determine if we soft-wrapped. For the bottom slice this is + // a bit unique because if we're at idx 0, we actually need to + // check the end of the top. + const wrapped = if (idx > 0) + slices.bot[idx - 1].attrs.wrap == 1 + else + slices.top[slices.top.len - 1].attrs.wrap == 1; + + if (!wrapped) { + buf[i] = '\n'; + i += 1; + } + } + + const char = if (cell.char > 0) cell.char else ' '; + i += try std.unicode.utf8Encode(@intCast(u21, char), buf[i..]); + } + + // Add null termination + buf[i] = 0; + return buf[0..i :0]; +} + +/// Returns the slices that make up the selection, in order. There are at most +/// two parts to handle the ring buffer. If the selection fits in one contiguous +/// slice, then the second slice will have a length of zero. +fn selectionSlices(self: Screen, sel: Selection) struct { + // Top offset can be used to determine if a newline is required by + // seeing if the cell index plus the offset cleanly divides by screen cols. + top_offset: usize, + top: []Cell, + bot: []Cell, +} { + // TODO: test + assert(sel.start.y < self.totalRows()); + assert(sel.end.y < self.totalRows()); + assert(sel.start.x < self.cols); + assert(sel.end.x < self.cols); + + // Get the true "top" and "bottom" + const sel_top = sel.topLeft(); + const sel_bot = sel.bottomRight(); + const top = self.rowIndex(sel_top.y); + const bot = self.rowIndex(sel_bot.y); + + // The bottom and top are available in one contiguous slice. + if (bot >= top) { + return .{ + .top_offset = sel_top.x, + .top = self.storage[top + sel_top.x .. bot + sel_bot.x + 1], + .bot = self.storage[0..0], // just so its a valid slice, but zero length + }; + } + + // The bottom and top are split into two slices, so we slice to the + // bottom of the storage, then from the top. + return .{ + .top_offset = sel_top.x, + .top = self.storage[top + sel_top.x .. self.storage.len], + .bot = self.storage[0 .. bot + sel_bot.x + 1], + }; +} + /// Turns the screen into a string. pub fn testString(self: Screen, alloc: Allocator) ![]const u8 { const buf = try alloc.alloc(u8, self.storage.len + self.rows); @@ -416,12 +540,14 @@ pub fn testString(self: Screen, alloc: Allocator) ![]const u8 { } /// Writes a basic string into the screen for testing. Newlines (\n) separate -/// each row. +/// each row. If a line is longer than the available columns, soft-wrapping +/// will occur. fn testWriteString(self: *Screen, text: []const u8) void { var y: usize = 0; var x: usize = 0; var row = self.getRow(y); for (text) |c| { + // Explicit newline forces a new row if (c == '\n') { y += 1; x = 0; @@ -429,7 +555,14 @@ fn testWriteString(self: *Screen, text: []const u8) void { continue; } - assert(x < self.cols); + // If we're writing past the end, we need to soft wrap. + if (x == self.cols) { + row[x - 1].attrs.wrap = 1; + y += 1; + x = 0; + row = self.getRow(y); + } + row[x].char = @intCast(u32, c); x += 1; } @@ -478,9 +611,9 @@ test "Screen: scrolling" { try testing.expect(s.viewportIsBottom()); // Test our row index - try testing.expectEqual(@as(usize, 5), s.rowIndex(0)); - try testing.expectEqual(@as(usize, 10), s.rowIndex(1)); - try testing.expectEqual(@as(usize, 0), s.rowIndex(2)); + try testing.expectEqual(@as(usize, 5), s.viewportRowIndex(0)); + try testing.expectEqual(@as(usize, 10), s.viewportRowIndex(1)); + try testing.expectEqual(@as(usize, 0), s.viewportRowIndex(2)); { // Test our contents rotated @@ -516,9 +649,9 @@ test "Screen: scrolling" { // try testing.expect(s.viewportIsBottom()); // // // Test our row index -// try testing.expectEqual(@as(usize, 5), s.rowIndex(0)); -// try testing.expectEqual(@as(usize, 10), s.rowIndex(1)); -// try testing.expectEqual(@as(usize, 15), s.rowIndex(2)); +// try testing.expectEqual(@as(usize, 5), s.viewportRowIndex(0)); +// try testing.expectEqual(@as(usize, 10), s.viewportRowIndex(1)); +// try testing.expectEqual(@as(usize, 15), s.viewportRowIndex(2)); // } test "Screen: scroll down from 0" { @@ -549,9 +682,9 @@ test "Screen: scrollback" { s.scroll(.{ .delta = 1 }); // Test our row index - try testing.expectEqual(@as(usize, 5), s.rowIndex(0)); - try testing.expectEqual(@as(usize, 10), s.rowIndex(1)); - try testing.expectEqual(@as(usize, 15), s.rowIndex(2)); + try testing.expectEqual(@as(usize, 5), s.viewportRowIndex(0)); + try testing.expectEqual(@as(usize, 10), s.viewportRowIndex(1)); + try testing.expectEqual(@as(usize, 15), s.viewportRowIndex(2)); { // Test our contents rotated @@ -726,3 +859,70 @@ test "Screen: resize less cols" { try testing.expectEqualStrings(expected, contents); } } + +test "Screen: selectionString" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 0); + defer s.deinit(alloc); + const str = "1ABCD\n2EFGH\n3IJKL"; + s.testWriteString(str); + + { + var contents = try s.selectionString(alloc, .{ + .start = .{ .x = 0, .y = 1 }, + .end = .{ .x = 2, .y = 2 }, + }); + defer alloc.free(contents); + const expected = "2EFGH\n3IJ"; + try testing.expectEqualStrings(expected, contents); + } +} + +test "Screen: selectionString soft wrap" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 0); + defer s.deinit(alloc); + const str = "1ABCD2EFGH3IJKL"; + s.testWriteString(str); + + { + var contents = try s.selectionString(alloc, .{ + .start = .{ .x = 0, .y = 1 }, + .end = .{ .x = 2, .y = 2 }, + }); + defer alloc.free(contents); + const expected = "2EFGH3IJ"; + try testing.expectEqualStrings(expected, contents); + } +} + +test "Screen: selectionString wrap around" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 0); + defer s.deinit(alloc); + s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + try testing.expect(s.viewportIsBottom()); + + // Scroll down, should still be bottom, but should wrap because + // we're out of space. + s.scroll(.{ .delta = 1 }); + try testing.expect(s.viewportIsBottom()); + try testing.expectEqual(@as(usize, 0), s.viewportRowIndex(2)); + s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + + { + var contents = try s.selectionString(alloc, .{ + .start = .{ .x = 0, .y = 1 }, + .end = .{ .x = 2, .y = 2 }, + }); + defer alloc.free(contents); + const expected = "2EFGH\n3IJ"; + try testing.expectEqualStrings(expected, contents); + } +}