mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-15 08:16:13 +03:00
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:
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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 = .{
|
||||
|
Reference in New Issue
Block a user