screen2: selection

This commit is contained in:
Mitchell Hashimoto
2022-08-31 14:58:43 -07:00
parent f6f8fee804
commit c7b7e3cb44

View File

@ -23,6 +23,7 @@ const Allocator = std.mem.Allocator;
const utf8proc = @import("utf8proc"); const utf8proc = @import("utf8proc");
const color = @import("color.zig"); const color = @import("color.zig");
const CircBuf = @import("circ_buf.zig").CircBuf; const CircBuf = @import("circ_buf.zig").CircBuf;
const Selection = @import("Selection.zig");
const log = std.log.scoped(.screen); const log = std.log.scoped(.screen);
@ -54,7 +55,7 @@ const StorageCell = extern union {
/// The row header is at the start of every row within the storage buffer. /// The row header is at the start of every row within the storage buffer.
/// It can store row-specific data. /// It can store row-specific data.
const RowHeader = struct { pub const RowHeader = struct {
dirty: bool, dirty: bool,
/// If true, this row is soft-wrapped. The first cell of the next /// If true, this row is soft-wrapped. The first cell of the next
@ -131,6 +132,11 @@ pub const Row = struct {
self.storage[0].header.wrap = v; self.storage[0].header.wrap = v;
} }
/// Retrieve the header for this row.
pub fn header(self: Row) RowHeader {
return self.storage[0].header;
}
/// Clear the row, making all cells empty. /// Clear the row, making all cells empty.
pub fn clear(self: Row) void { pub fn clear(self: Row) void {
self.fill(.{}); self.fill(.{});
@ -141,6 +147,12 @@ pub const Row = struct {
std.mem.set(StorageCell, self.storage[1..], .{ .cell = cell }); std.mem.set(StorageCell, self.storage[1..], .{ .cell = cell });
} }
/// Get a single immutable cell.
pub fn getCell(self: Row, x: usize) Cell {
assert(x < self.storage.len - 1);
return self.storage[x + 1].cell;
}
/// Get a pointr to the cell at column x (0-indexed). This always /// Get a pointr to the cell at column x (0-indexed). This always
/// assumes that the cell was modified, notifying the renderer on the /// assumes that the cell was modified, notifying the renderer on the
/// next call to re-render this cell. Any change detection to avoid /// next call to re-render this cell. Any change detection to avoid
@ -150,6 +162,11 @@ pub const Row = struct {
return &self.storage[x + 1].cell; return &self.storage[x + 1].cell;
} }
/// Copy the row src into this row.
pub fn copyRow(self: Row, src: Row) void {
std.mem.copy(StorageCell, self.storage[1..], src.storage[1..]);
}
/// Read-only iterator for the cells in the row. /// Read-only iterator for the cells in the row.
pub fn cellIterator(self: Row) CellIterator { pub fn cellIterator(self: Row) CellIterator {
return .{ .row = self }; return .{ .row = self };
@ -352,6 +369,15 @@ pub fn getRow(self: *Screen, index: RowIndex) Row {
return .{ .storage = slices[0] }; return .{ .storage = slices[0] };
} }
/// Copy the row at src to dst.
pub fn copyRow(self: *Screen, dst: RowIndex, src: RowIndex) void {
// One day we can make this more efficient but for now
// we do the easy thing.
const dst_row = self.getRow(dst);
const src_row = self.getRow(src);
dst_row.copyRow(src_row);
}
/// Returns the offset into the storage buffer that the given row can /// Returns the offset into the storage buffer that the given row can
/// be found. This assumes valid input and will crash if the input is /// be found. This assumes valid input and will crash if the input is
/// invalid. /// invalid.
@ -483,6 +509,166 @@ fn scrollDelta(self: *Screen, delta: isize, grow: bool) void {
assert(self.viewportIsBottom()); assert(self.viewportIsBottom());
} }
/// Returns the raw text associated with a selection. This will unwrap
/// soft-wrapped edges. The returned slice is owned by the caller and allocated
/// using alloc, not the allocator associated with the screen (unless they match).
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 chars = chars: {
var count: usize = 0;
const arr = [_][]StorageCell{ slices.top, slices.bot };
for (arr) |slice| {
for (slice) |cell, i| {
// detect row headers
if (@mod(i, self.cols + 1) == 0) {
// We use each row header as an opportunity to "count"
// a new row, and therefore count a possible newline.
count += 1;
continue;
}
var buf: [4]u8 = undefined;
const char = if (cell.cell.char > 0) cell.cell.char else ' ';
count += try std.unicode.utf8Encode(@intCast(u21, char), &buf);
}
}
break :chars count;
};
const buf = try alloc.alloc(u8, chars + 1);
errdefer alloc.free(buf);
// Connect the text from the two slices
const arr = [_][]StorageCell{ slices.top, slices.bot };
var buf_i: usize = 0;
var row_count: usize = 0;
for (arr) |slice| {
var row_start: usize = row_count;
while (row_count < slices.rows) : (row_count += 1) {
const row_i = row_count - row_start;
// Calculate our start index. If we are beyond the length
// of this slice, then its time to move on (we exhausted top).
const start_idx = row_i * (self.cols + 1);
if (start_idx >= slice.len) break;
// Our end index is usually a full row, but if we're the final
// row then we just use the length.
const end_idx = @minimum(slice.len, start_idx + self.cols + 1);
// We may have to skip some cells from the beginning if we're
// the first row.
var skip: usize = if (row_count == 0) slices.top_offset else 0;
const row: Row = .{ .storage = slice[start_idx..end_idx] };
var it = row.cellIterator();
while (it.next()) |cell| {
if (skip > 0) {
skip -= 1;
continue;
}
// Skip spacers
if (cell.attrs.wide_spacer_head or
cell.attrs.wide_spacer_tail) continue;
const char = if (cell.char > 0) cell.char else ' ';
buf_i += try std.unicode.utf8Encode(@intCast(u21, char), buf[buf_i..]);
}
// If this row is not soft-wrapped, add a newline
if (!row.header().wrap) {
buf[buf_i] = '\n';
buf_i += 1;
}
}
}
// Remove our trailing newline, its never correct.
if (buf[buf_i - 1] == '\n') buf_i -= 1;
// Add null termination
buf[buf_i] = 0;
// Realloc so our free length is exactly correct
const result = try alloc.realloc(buf, buf_i + 1);
return result[0..buf_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_raw: Selection) struct {
rows: usize,
// 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: []StorageCell,
bot: []StorageCell,
} {
// Note: this function is tested via selectionString
assert(sel_raw.start.y < self.rowsWritten());
assert(sel_raw.end.y < self.rowsWritten());
assert(sel_raw.start.x < self.cols);
assert(sel_raw.end.x < self.cols);
const sel = sel: {
var sel = sel_raw;
// If the end of our selection is a wide char leader, include the
// first part of the next line.
if (sel.end.x == self.cols - 1) {
const row = self.getRow(.{ .screen = sel.end.y });
const cell = row.getCell(sel.end.x);
if (cell.attrs.wide_spacer_head) {
sel.end.y += 1;
sel.end.x = 0;
}
}
// If the start of our selection is a wide char spacer, include the
// wide char.
if (sel.start.x > 0) {
const row = self.getRow(.{ .screen = sel.start.y });
const cell = row.getCell(sel.start.x);
if (cell.attrs.wide_spacer_tail) {
sel.end.x -= 1;
}
}
break :sel sel;
};
// Get the true "top" and "bottom"
const sel_top = sel.topLeft();
const sel_bot = sel.bottomRight();
// We get the slices for the full top and bottom (inclusive).
const sel_top_offset = self.rowOffset(.{ .screen = sel_top.y });
const sel_bot_offset = self.rowOffset(.{ .screen = sel_bot.y });
const slices = self.storage.getPtrSlice(
sel_top_offset,
(sel_bot_offset - sel_top_offset) + (sel_bot.x + 2),
);
// The bottom and top are split into two slices, so we slice to the
// bottom of the storage, then from the top.
return .{
.rows = sel_bot.y - sel_top.y + 1,
.top_offset = sel_top.x,
.top = slices[0],
.bot = slices[1],
};
}
/// Writes a basic string into the screen for testing. Newlines (\n) separate /// Writes a basic string into the screen for testing. Newlines (\n) separate
/// each row. If a line is longer than the available columns, soft-wrapping /// each row. If a line is longer than the available columns, soft-wrapping
/// will occur. This will automatically handle basic wide chars. /// will occur. This will automatically handle basic wide chars.
@ -867,3 +1053,146 @@ test "Screen: history region with scrollback" {
try testing.expectEqualStrings(expected, contents); try testing.expectEqualStrings(expected, contents);
} }
} }
test "Screen: row copy" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 0);
defer s.deinit();
s.testWriteString("1ABCD\n2EFGH\n3IJKL");
// Copy
s.scroll(.{ .delta = 1 });
s.copyRow(.{ .active = 2 }, .{ .active = 0 });
// Test our contents
var contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("2EFGH\n3IJKL\n2EFGH", contents);
}
test "Screen: selectionString" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 0);
defer s.deinit();
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();
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();
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());
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);
}
}
test "Screen: selectionString wide char" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 0);
defer s.deinit();
const str = "1A⚡";
s.testWriteString(str);
{
var contents = try s.selectionString(alloc, .{
.start = .{ .x = 0, .y = 0 },
.end = .{ .x = 3, .y = 0 },
});
defer alloc.free(contents);
const expected = str;
try testing.expectEqualStrings(expected, contents);
}
{
var contents = try s.selectionString(alloc, .{
.start = .{ .x = 0, .y = 0 },
.end = .{ .x = 2, .y = 0 },
});
defer alloc.free(contents);
const expected = str;
try testing.expectEqualStrings(expected, contents);
}
{
var contents = try s.selectionString(alloc, .{
.start = .{ .x = 3, .y = 0 },
.end = .{ .x = 3, .y = 0 },
});
defer alloc.free(contents);
const expected = "";
try testing.expectEqualStrings(expected, contents);
}
}
test "Screen: selectionString wide char with header" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 0);
defer s.deinit();
const str = "1ABC⚡";
s.testWriteString(str);
{
var contents = try s.selectionString(alloc, .{
.start = .{ .x = 0, .y = 0 },
.end = .{ .x = 4, .y = 0 },
});
defer alloc.free(contents);
const expected = str;
try testing.expectEqualStrings(expected, contents);
}
}