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. /// Double-click dragging moves the selection one "word" at a time.
fn dragLeftClickDouble( fn dragLeftClickDouble(
self: *Surface, self: *Surface,
@ -2238,11 +2249,13 @@ fn dragLeftClickDouble(
self.setSelection(.{ self.setSelection(.{
.start = word_current.start, .start = word_current.start,
.end = word_start.end, .end = word_start.end,
.rectangle = ctrlOrSuper(self.mouse.mods) and self.mouse.mods.alt,
}); });
} else { } else {
self.setSelection(.{ self.setSelection(.{
.start = word_start.start, .start = word_start.start,
.end = word_current.end, .end = word_current.end,
.rectangle = ctrlOrSuper(self.mouse.mods) and self.mouse.mods.alt,
}); });
} }
} }
@ -2330,6 +2343,7 @@ fn dragLeftClickSingle(
self.setSelection(if (selected) .{ self.setSelection(if (selected) .{
.start = screen_point, .start = screen_point,
.end = screen_point, .end = screen_point,
.rectangle = ctrlOrSuper(self.mouse.mods) and self.mouse.mods.alt,
} else null); } else null);
return; 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; return;
} }

View File

@ -2278,13 +2278,17 @@ fn selectionSliceString(
const start_idx = row_i * (self.cols + 1); const start_idx = row_i * (self.cols + 1);
if (start_idx >= slice.len) break; if (start_idx >= slice.len) break;
// Our end index is usually a full row, but if we're the final const end_idx = if (slices.sel.rectangle)
// row then we just use the length. // Rectangle select: calculate end with bottom offset.
const end_idx = @min(slice.len, start_idx + self.cols + 1); 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 // We may have to skip some cells from the beginning if we're the
// the first row. // first row, of if we're using rectangle select.
var skip: usize = if (row_count == 0) slices.top_offset else 0; 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 // If we have runtime safety we need to initialize the row
// so that the proper union tag is set. In release modes we // 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 this row is not soft-wrapped or if we're using rectangle
if (!row.header().flags.wrap) { // select, add a newline
if (!row.header().flags.wrap or slices.sel.rectangle) {
try strbuilder.append('\n'); try strbuilder.append('\n');
if (mapbuilder) |b| { if (mapbuilder) |b| {
try b.append(.{ try b.append(.{
@ -2373,6 +2378,12 @@ const SelectionSlices = struct {
// Top offset can be used to determine if a newline is required by // 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. // seeing if the cell index plus the offset cleanly divides by screen cols.
top_offset: usize, 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, top: []StorageCell,
bot: []StorageCell, bot: []StorageCell,
}; };
@ -2388,6 +2399,7 @@ fn selectionSlices(self: *Screen, sel_raw: Selection) SelectionSlices {
.rows = 0, .rows = 0,
.sel = sel_raw, .sel = sel_raw,
.top_offset = 0, .top_offset = 0,
.bot_offset = 0,
.top = self.storage.storage[0..0], .top = self.storage.storage[0..0],
.bot = 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" // Get the true "top" and "bottom"
const sel_top = sel.topLeft(); const sel_top = sel.topLeft();
const sel_bot = sel.bottomRight(); const sel_bot = sel.bottomRight();
const sel_isRect = sel.rectangle;
// We get the slices for the full top and bottom (inclusive). // We get the slices for the full top and bottom (inclusive).
const sel_top_offset = self.rowOffset(.{ .screen = sel_top.y }); 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. // bottom of the storage, then from the top.
return .{ return .{
.rows = sel_bot.y - sel_top.y + 1, .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, .top_offset = sel_top.x,
.bot_offset = sel_bot.x,
.top = slices[0], .top = slices[0],
.bot = slices[1], .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" { test "Screen: dirty with getCellPtr" {
const testing = std.testing; const testing = std.testing;
const alloc = testing.allocator; const alloc = testing.allocator;

View File

@ -15,6 +15,11 @@ const ScreenPoint = point.ScreenPoint;
start: ScreenPoint, start: ScreenPoint,
end: 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 /// Converts a selection screen points to viewport points (still typed
/// as ScreenPoints) if the selection is present within the viewport /// as ScreenPoints) if the selection is present within the viewport
/// of the screen. /// of the screen.
@ -31,6 +36,7 @@ pub fn toViewport(self: Selection, screen: *const Screen) ?Selection {
return Selection{ return Selection{
.start = .{ .x = start.x, .y = start.y }, .start = .{ .x = start.x, .y = start.y },
.end = .{ .x = end.x, .y = end.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. // Honestly there is probably way more efficient boolean logic here.
// Look back at this in the future... // 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/br are same line
if (tl.y == br.y) return p.y == tl.y and if (tl.y == br.y) return p.y == tl.y and
p.x >= tl.x 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(); const br = self.bottomRight();
if (p.y < tl.y or p.y > br.y) return null; 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 (p.y == tl.y) {
// If the selection is JUST this line, return it as-is. // If the selection is JUST this line, return it as-is.
if (p.y == br.y) { if (p.y == br.y) {
@ -154,8 +173,8 @@ pub fn ordered(self: Selection, desired: Order) Selection {
const tl = self.topLeft(); const tl = self.topLeft();
const br = self.bottomRight(); const br = self.bottomRight();
return switch (desired) { return switch (desired) {
.forward => .{ .start = tl, .end = br }, .forward => .{ .start = tl, .end = br, .rectangle = self.rectangle },
.reverse => .{ .start = br, .end = tl }, .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" { test "Selection: containedRow" {
const testing = std.testing; const testing = std.testing;
var screen = try Screen.init(testing.allocator, 5, 10, 0); var screen = try Screen.init(testing.allocator, 5, 10, 0);
@ -245,6 +336,39 @@ test "Selection: containedRow" {
}, sel.containedRow(&screen, .{ .x = 2, .y = 2 }).?); }, 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 // Single-line selection
{ {
const sel: Selection = .{ const sel: Selection = .{