Add rectangle select

This adds rectangle select mode; when dragging with ctrl+alt (or
super+alt on MacOS), this allows you to select a rectangular region of
the terminal instead of the full start-end points of the buffer.
This commit is contained in:
Chris Marchesi
2023-11-28 11:00:06 -08:00
parent e27eb727c4
commit b84fb25e55
3 changed files with 267 additions and 12 deletions

View File

@ -2209,6 +2209,17 @@ pub fn cursorPosCallback(
}
}
// Checks to see if super is on in mods (MacOS) or ctrl. We use this for
// rectangle select along with alt.
//
// Not to be confused with ctrlOrSuper in Config.
fn ctrlOrSuper(mods: input.Mods) bool {
if (comptime builtin.target.isDarwin()) {
return mods.super;
}
return mods.ctrl;
}
/// Double-click dragging moves the selection one "word" at a time.
fn dragLeftClickDouble(
self: *Surface,
@ -2238,11 +2249,13 @@ fn dragLeftClickDouble(
self.setSelection(.{
.start = word_current.start,
.end = word_start.end,
.rectangle = ctrlOrSuper(self.mouse.mods) and self.mouse.mods.alt,
});
} else {
self.setSelection(.{
.start = word_start.start,
.end = word_current.end,
.rectangle = ctrlOrSuper(self.mouse.mods) and self.mouse.mods.alt,
});
}
}
@ -2330,6 +2343,7 @@ fn dragLeftClickSingle(
self.setSelection(if (selected) .{
.start = screen_point,
.end = screen_point,
.rectangle = ctrlOrSuper(self.mouse.mods) and self.mouse.mods.alt,
} else null);
return;
@ -2369,7 +2383,11 @@ fn dragLeftClickSingle(
}
};
self.setSelection(.{ .start = start, .end = screen_point });
self.setSelection(.{
.start = start,
.end = screen_point,
.rectangle = ctrlOrSuper(self.mouse.mods) and self.mouse.mods.alt,
});
return;
}

View File

@ -2278,13 +2278,17 @@ fn selectionSliceString(
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 = @min(slice.len, start_idx + self.cols + 1);
const end_idx = if (slices.sel.rectangle)
// Rectangle select: calculate end with bottom offset.
start_idx + slices.bot_offset + 2 // think "column count" + 1
else
// Normal select: our end index is usually a full row, but if
// we're the final row then we just use the length.
@min(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;
// We may have to skip some cells from the beginning if we're the
// first row, of if we're using rectangle select.
var skip: usize = if (row_count == 0 or slices.sel.rectangle) slices.top_offset else 0;
// If we have runtime safety we need to initialize the row
// so that the proper union tag is set. In release modes we
@ -2334,8 +2338,9 @@ fn selectionSliceString(
}
}
// If this row is not soft-wrapped, add a newline
if (!row.header().flags.wrap) {
// If this row is not soft-wrapped or if we're using rectangle
// select, add a newline
if (!row.header().flags.wrap or slices.sel.rectangle) {
try strbuilder.append('\n');
if (mapbuilder) |b| {
try b.append(.{
@ -2373,6 +2378,12 @@ const SelectionSlices = 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,
// Our bottom offset is used in rectangle select to always determine the
// maximum cell in a given row.
bot_offset: usize,
// Our selection storage cell chunks.
top: []StorageCell,
bot: []StorageCell,
};
@ -2388,6 +2399,7 @@ fn selectionSlices(self: *Screen, sel_raw: Selection) SelectionSlices {
.rows = 0,
.sel = sel_raw,
.top_offset = 0,
.bot_offset = 0,
.top = self.storage.storage[0..0],
.bot = self.storage.storage[0..0],
};
@ -2428,6 +2440,7 @@ fn selectionSlices(self: *Screen, sel_raw: Selection) SelectionSlices {
// Get the true "top" and "bottom"
const sel_top = sel.topLeft();
const sel_bot = sel.bottomRight();
const sel_isRect = sel.rectangle;
// We get the slices for the full top and bottom (inclusive).
const sel_top_offset = self.rowOffset(.{ .screen = sel_top.y });
@ -2441,8 +2454,9 @@ fn selectionSlices(self: *Screen, sel_raw: Selection) SelectionSlices {
// bottom of the storage, then from the top.
return .{
.rows = sel_bot.y - sel_top.y + 1,
.sel = .{ .start = sel_top, .end = sel_bot },
.sel = .{ .start = sel_top, .end = sel_bot, .rectangle = sel_isRect },
.top_offset = sel_top.x,
.bot_offset = sel_bot.x,
.top = slices[0],
.bot = slices[1],
};
@ -5349,6 +5363,105 @@ test "Screen: selectionString with zero width joiner" {
}
}
test "Screen: selectionString, rectangle, basic" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 30, 0);
defer s.deinit();
const str =
\\Lorem ipsum dolor
\\sit amet, consectetur
\\adipiscing elit, sed do
\\eiusmod tempor incididunt
\\ut labore et dolore
;
const sel = Selection{
.start = .{ .x = 2, .y = 1 },
.end = .{ .x = 6, .y = 3 },
.rectangle = true,
};
const expected =
\\t ame
\\ipisc
\\usmod
;
try s.testWriteString(str);
const contents = try s.selectionString(alloc, sel, true);
defer alloc.free(contents);
try testing.expectEqualStrings(expected, contents);
}
test "Screen: selectionString, rectangle, w/EOL" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 30, 0);
defer s.deinit();
const str =
\\Lorem ipsum dolor
\\sit amet, consectetur
\\adipiscing elit, sed do
\\eiusmod tempor incididunt
\\ut labore et dolore
;
const sel = Selection{
.start = .{ .x = 12, .y = 0 },
.end = .{ .x = 26, .y = 4 },
.rectangle = true,
};
const expected =
\\dolor
\\nsectetur
\\lit, sed do
\\or incididunt
\\ dolore
;
try s.testWriteString(str);
const contents = try s.selectionString(alloc, sel, true);
defer alloc.free(contents);
try testing.expectEqualStrings(expected, contents);
}
test "Screen: selectionString, rectangle, more complex w/breaks" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 8, 30, 0);
defer s.deinit();
const str =
\\Lorem ipsum dolor
\\sit amet, consectetur
\\adipiscing elit, sed do
\\eiusmod tempor incididunt
\\ut labore et dolore
\\
\\magna aliqua. Ut enim
\\ad minim veniam, quis
;
const sel = Selection{
.start = .{ .x = 11, .y = 2 },
.end = .{ .x = 26, .y = 7 },
.rectangle = true,
};
const expected =
\\elit, sed do
\\por incididunt
\\t dolore
\\
\\a. Ut enim
\\niam, quis
;
try s.testWriteString(str);
const contents = try s.selectionString(alloc, sel, true);
defer alloc.free(contents);
try testing.expectEqualStrings(expected, contents);
}
test "Screen: dirty with getCellPtr" {
const testing = std.testing;
const alloc = testing.allocator;

View File

@ -15,6 +15,11 @@ const ScreenPoint = point.ScreenPoint;
start: ScreenPoint,
end: ScreenPoint,
/// Whether or not this selection refers to a rectangle, rather than whole
/// lines of a buffer. In this mode, start and end refer to the top left and
/// bottom right of the rectangle, or vice versa if the selection is backwards.
rectangle: bool = false,
/// Converts a selection screen points to viewport points (still typed
/// as ScreenPoints) if the selection is present within the viewport
/// of the screen.
@ -31,6 +36,7 @@ pub fn toViewport(self: Selection, screen: *const Screen) ?Selection {
return Selection{
.start = .{ .x = start.x, .y = start.y },
.end = .{ .x = end.x, .y = end.y },
.rectangle = self.rectangle,
};
}
@ -51,6 +57,11 @@ pub fn contains(self: Selection, p: ScreenPoint) bool {
// Honestly there is probably way more efficient boolean logic here.
// Look back at this in the future...
// If we're in rectangle select, we can short-circuit with an easy check
// here
if (self.rectangle)
return p.y >= tl.y and p.y <= br.y and p.x >= tl.x and p.x <= br.x;
// If tl/br are same line
if (tl.y == br.y) return p.y == tl.y and
p.x >= tl.x and
@ -101,6 +112,14 @@ pub fn containedRow(self: Selection, screen: *const Screen, p: ScreenPoint) ?Sel
const br = self.bottomRight();
if (p.y < tl.y or p.y > br.y) return null;
// Rectangle case: we can return early as the x range will always be the
// same. We've already validated that the row is in the selection.
if (self.rectangle) return .{
.start = .{ .y = p.y, .x = tl.x },
.end = .{ .y = p.y, .x = br.x },
.rectangle = true,
};
if (p.y == tl.y) {
// If the selection is JUST this line, return it as-is.
if (p.y == br.y) {
@ -154,8 +173,8 @@ pub fn ordered(self: Selection, desired: Order) Selection {
const tl = self.topLeft();
const br = self.bottomRight();
return switch (desired) {
.forward => .{ .start = tl, .end = br },
.reverse => .{ .start = br, .end = tl },
.forward => .{ .start = tl, .end = br, .rectangle = self.rectangle },
.reverse => .{ .start = br, .end = tl, .rectangle = self.rectangle },
};
}
@ -212,6 +231,78 @@ test "Selection: contains" {
}
}
test "Selection: contains, rectangle" {
const testing = std.testing;
{
const sel: Selection = .{
.start = .{ .x = 3, .y = 3 },
.end = .{ .x = 7, .y = 9 },
.rectangle = true,
};
try testing.expect(sel.contains(.{ .x = 5, .y = 6 })); // Center
try testing.expect(sel.contains(.{ .x = 3, .y = 6 })); // Left border
try testing.expect(sel.contains(.{ .x = 7, .y = 6 })); // Right border
try testing.expect(sel.contains(.{ .x = 5, .y = 3 })); // Top border
try testing.expect(sel.contains(.{ .x = 5, .y = 9 })); // Bottom border
try testing.expect(!sel.contains(.{ .x = 5, .y = 2 })); // Above center
try testing.expect(!sel.contains(.{ .x = 5, .y = 10 })); // Below center
try testing.expect(!sel.contains(.{ .x = 2, .y = 6 })); // Left center
try testing.expect(!sel.contains(.{ .x = 8, .y = 6 })); // Right center
try testing.expect(!sel.contains(.{ .x = 8, .y = 3 })); // Just right of top right
try testing.expect(!sel.contains(.{ .x = 2, .y = 9 })); // Just left of bottom left
try testing.expect(!sel.containsRow(.{ .x = 1, .y = 1 }));
try testing.expect(sel.containsRow(.{ .x = 1, .y = 3 })); // x does not matter
try testing.expect(sel.containsRow(.{ .x = 1, .y = 6 }));
try testing.expect(sel.containsRow(.{ .x = 5, .y = 9 }));
try testing.expect(!sel.containsRow(.{ .x = 5, .y = 10 }));
}
// Reverse
{
const sel: Selection = .{
.start = .{ .x = 7, .y = 9 },
.end = .{ .x = 3, .y = 3 },
.rectangle = true,
};
try testing.expect(sel.contains(.{ .x = 5, .y = 6 })); // Center
try testing.expect(sel.contains(.{ .x = 3, .y = 6 })); // Left border
try testing.expect(sel.contains(.{ .x = 7, .y = 6 })); // Right border
try testing.expect(sel.contains(.{ .x = 5, .y = 3 })); // Top border
try testing.expect(sel.contains(.{ .x = 5, .y = 9 })); // Bottom border
try testing.expect(!sel.contains(.{ .x = 5, .y = 2 })); // Above center
try testing.expect(!sel.contains(.{ .x = 5, .y = 10 })); // Below center
try testing.expect(!sel.contains(.{ .x = 2, .y = 6 })); // Left center
try testing.expect(!sel.contains(.{ .x = 8, .y = 6 })); // Right center
try testing.expect(!sel.contains(.{ .x = 8, .y = 3 })); // Just right of top right
try testing.expect(!sel.contains(.{ .x = 2, .y = 9 })); // Just left of bottom left
try testing.expect(!sel.containsRow(.{ .x = 1, .y = 1 }));
try testing.expect(sel.containsRow(.{ .x = 1, .y = 3 })); // x does not matter
try testing.expect(sel.containsRow(.{ .x = 1, .y = 6 }));
try testing.expect(sel.containsRow(.{ .x = 5, .y = 9 }));
try testing.expect(!sel.containsRow(.{ .x = 5, .y = 10 }));
}
// Single line
// NOTE: This is the same as normal selection but we just do it for brevity
{
const sel: Selection = .{
.start = .{ .x = 5, .y = 1 },
.end = .{ .x = 10, .y = 1 },
.rectangle = true,
};
try testing.expect(sel.contains(.{ .x = 6, .y = 1 }));
try testing.expect(!sel.contains(.{ .x = 2, .y = 1 }));
try testing.expect(!sel.contains(.{ .x = 12, .y = 1 }));
}
}
test "Selection: containedRow" {
const testing = std.testing;
var screen = try Screen.init(testing.allocator, 5, 10, 0);
@ -245,6 +336,39 @@ test "Selection: containedRow" {
}, sel.containedRow(&screen, .{ .x = 2, .y = 2 }).?);
}
// Rectangle
{
const sel: Selection = .{
.start = .{ .x = 3, .y = 1 },
.end = .{ .x = 6, .y = 3 },
.rectangle = true,
};
// Not contained
try testing.expect(sel.containedRow(&screen, .{ .x = 1, .y = 4 }) == null);
// Start line
try testing.expectEqual(Selection{
.start = .{ .x = 3, .y = 1 },
.end = .{ .x = 6, .y = 1 },
.rectangle = true,
}, sel.containedRow(&screen, .{ .x = 1, .y = 1 }).?);
// End line
try testing.expectEqual(Selection{
.start = .{ .x = 3, .y = 3 },
.end = .{ .x = 6, .y = 3 },
.rectangle = true,
}, sel.containedRow(&screen, .{ .x = 2, .y = 3 }).?);
// Middle line
try testing.expectEqual(Selection{
.start = .{ .x = 3, .y = 2 },
.end = .{ .x = 6, .y = 2 },
.rectangle = true,
}, sel.containedRow(&screen, .{ .x = 2, .y = 2 }).?);
}
// Single-line selection
{
const sel: Selection = .{