From b84fb25e5590e3e5442d2295e90aedf2ca918649 Mon Sep 17 00:00:00 2001 From: Chris Marchesi Date: Tue, 28 Nov 2023 11:00:06 -0800 Subject: [PATCH] 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. --- src/Surface.zig | 20 +++++- src/terminal/Screen.zig | 131 ++++++++++++++++++++++++++++++++++--- src/terminal/Selection.zig | 128 +++++++++++++++++++++++++++++++++++- 3 files changed, 267 insertions(+), 12 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 43556d53c..e4984ccbd 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -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; } diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 08af2b9f2..87e8c7f5d 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -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; diff --git a/src/terminal/Selection.zig b/src/terminal/Selection.zig index 628833372..61c8e4c9f 100644 --- a/src/terminal/Selection.zig +++ b/src/terminal/Selection.zig @@ -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 = .{