mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-04 05:08:39 +03:00
Rework mouse selection logic (#7444)
This PR fixes the problem discussed in #5058 and #7434 by reworking the selection logic in a way that better handles edge cases as well as being generally cleaner. This rework does change how selection behaves slightly, especially rectangular selection, but the new behavior of rectangular selection is more in line with other terminals I tested (Terminal.app, Kitty). There are some TODO comments for adding unit tests- I ran out of steam tonight, but if this PR is still open tomorrow I'll go ahead and add them.
This commit is contained in:
722
src/Surface.zig
722
src/Surface.zig
@ -3676,165 +3676,162 @@ fn dragLeftClickTriple(
|
||||
fn dragLeftClickSingle(
|
||||
self: *Surface,
|
||||
drag_pin: terminal.Pin,
|
||||
xpos: f64,
|
||||
drag_x: f64,
|
||||
) !void {
|
||||
// NOTE(mitchellh): This logic super sucks. There has to be an easier way
|
||||
// to calculate this, but this is good for a v1. Selection isn't THAT
|
||||
// common so its not like this performance heavy code is running that
|
||||
// often.
|
||||
// TODO: unit test this, this logic sucks
|
||||
|
||||
// If we were selecting, and we switched directions, then we restart
|
||||
// calculations because it forces us to reconsider if the first cell is
|
||||
// selected.
|
||||
self.checkResetSelSwitch(drag_pin);
|
||||
|
||||
// Our logic for determining if the starting cell is selected:
|
||||
//
|
||||
// - The "xboundary" is 60% the width of a cell from the left. We choose
|
||||
// 60% somewhat arbitrarily based on feeling.
|
||||
// - If we started our click left of xboundary, backwards selections
|
||||
// can NEVER select the current char.
|
||||
// - If we started our click right of xboundary, backwards selections
|
||||
// ALWAYS selected the current char, but we must move the cursor
|
||||
// left of the xboundary.
|
||||
// - Inverted logic for forwards selections.
|
||||
//
|
||||
|
||||
// Our clicking point
|
||||
const click_pin = self.mouse.left_click_pin.?.*;
|
||||
|
||||
// the boundary point at which we consider selection or non-selection
|
||||
const cell_width_f64: f64 = @floatFromInt(self.size.cell.width);
|
||||
const cell_xboundary = cell_width_f64 * 0.6;
|
||||
|
||||
// first xpos of the clicked cell adjusted for padding
|
||||
const left_padding_f64: f64 = @as(f64, @floatFromInt(self.size.padding.left));
|
||||
const cell_xstart = @as(f64, @floatFromInt(click_pin.x)) * cell_width_f64;
|
||||
const cell_start_xpos = self.mouse.left_click_xpos - cell_xstart - left_padding_f64;
|
||||
|
||||
// If this is the same cell, then we only start the selection if weve
|
||||
// moved past the boundary point the opposite direction from where we
|
||||
// started.
|
||||
if (click_pin.eql(drag_pin)) {
|
||||
// Ensuring to adjusting the cursor position for padding
|
||||
const cell_xpos = xpos - cell_xstart - left_padding_f64;
|
||||
const selected: bool = if (cell_start_xpos < cell_xboundary)
|
||||
cell_xpos >= cell_xboundary
|
||||
else
|
||||
cell_xpos < cell_xboundary;
|
||||
|
||||
try self.setSelection(if (selected) terminal.Selection.init(
|
||||
// This logic is in a separate function so that it can be unit tested.
|
||||
try self.setSelection(mouseSelection(
|
||||
self.mouse.left_click_pin.?.*,
|
||||
drag_pin,
|
||||
drag_pin,
|
||||
SurfaceMouse.isRectangleSelectState(self.mouse.mods),
|
||||
) else null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// If this is a different cell and we haven't started selection,
|
||||
// we determine the starting cell first.
|
||||
if (self.io.terminal.screen.selection == null) {
|
||||
// - If we're moving to a point before the start, then we select
|
||||
// the starting cell if we started after the boundary, else
|
||||
// we start selection of the prior cell.
|
||||
// - Inverse logic for a point after the start.
|
||||
const start: terminal.Pin = if (dragLeftClickBefore(
|
||||
drag_pin,
|
||||
click_pin,
|
||||
@intFromFloat(@max(0.0, self.mouse.left_click_xpos)),
|
||||
@intFromFloat(@max(0.0, drag_x)),
|
||||
self.mouse.mods,
|
||||
)) start: {
|
||||
if (cell_start_xpos >= cell_xboundary) break :start click_pin;
|
||||
if (click_pin.x > 0) break :start click_pin.left(1);
|
||||
var start = click_pin.up(1) orelse click_pin;
|
||||
start.x = self.io.terminal.screen.pages.cols - 1;
|
||||
break :start start;
|
||||
} else start: {
|
||||
if (cell_start_xpos < cell_xboundary) break :start click_pin;
|
||||
if (click_pin.x < self.io.terminal.screen.pages.cols - 1)
|
||||
break :start click_pin.right(1);
|
||||
var start = click_pin.down(1) orelse click_pin;
|
||||
start.x = 0;
|
||||
break :start start;
|
||||
};
|
||||
|
||||
try self.setSelection(terminal.Selection.init(
|
||||
start,
|
||||
drag_pin,
|
||||
SurfaceMouse.isRectangleSelectState(self.mouse.mods),
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: detect if selection point is passed the point where we've
|
||||
// actually written data before and disallow it.
|
||||
|
||||
// We moved! Set the selection end point. The start point should be
|
||||
// set earlier.
|
||||
assert(self.io.terminal.screen.selection != null);
|
||||
const sel = self.io.terminal.screen.selection.?;
|
||||
try self.setSelection(terminal.Selection.init(
|
||||
sel.start(),
|
||||
drag_pin,
|
||||
sel.rectangle,
|
||||
self.size,
|
||||
));
|
||||
}
|
||||
|
||||
// Resets the selection if we switched directions, depending on the select
|
||||
// mode. See dragLeftClickSingle for more details.
|
||||
fn checkResetSelSwitch(
|
||||
self: *Surface,
|
||||
drag_pin: terminal.Pin,
|
||||
) void {
|
||||
const screen = &self.io.terminal.screen;
|
||||
const sel = screen.selection orelse return;
|
||||
const sel_start = sel.start();
|
||||
const sel_end = sel.end();
|
||||
|
||||
var reset: bool = false;
|
||||
if (sel.rectangle) {
|
||||
// When we're in rectangle mode, we reset the selection relative to
|
||||
// the click point depending on the selection mode we're in, with
|
||||
// the exception of single-column selections, which we always reset
|
||||
// on if we drift.
|
||||
if (sel_start.x == sel_end.x) {
|
||||
reset = drag_pin.x != sel_start.x;
|
||||
} else {
|
||||
reset = switch (sel.order(screen)) {
|
||||
.forward => drag_pin.x < sel_start.x or drag_pin.before(sel_start),
|
||||
.reverse => drag_pin.x > sel_start.x or sel_start.before(drag_pin),
|
||||
.mirrored_forward => drag_pin.x > sel_start.x or drag_pin.before(sel_start),
|
||||
.mirrored_reverse => drag_pin.x < sel_start.x or sel_start.before(drag_pin),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Normal select uses simpler logic that is just based on the
|
||||
// selection start/end.
|
||||
reset = if (sel_end.before(sel_start))
|
||||
sel_start.before(drag_pin)
|
||||
else
|
||||
drag_pin.before(sel_start);
|
||||
}
|
||||
|
||||
// Nullifying a selection can't fail.
|
||||
if (reset) self.setSelection(null) catch unreachable;
|
||||
}
|
||||
|
||||
// Handles how whether or not the drag screen point is before the click point.
|
||||
// When we are in rectangle select, we only interpret the x axis to determine
|
||||
// where to start the selection (before or after the click point). See
|
||||
// dragLeftClickSingle for more details.
|
||||
fn dragLeftClickBefore(
|
||||
drag_pin: terminal.Pin,
|
||||
/// Calculates the appropriate selection given pins and pixel x positions for
|
||||
/// the click point and the drag point, as well as mouse mods and screen size.
|
||||
fn mouseSelection(
|
||||
click_pin: terminal.Pin,
|
||||
drag_pin: terminal.Pin,
|
||||
click_x: u32,
|
||||
drag_x: u32,
|
||||
mods: input.Mods,
|
||||
) bool {
|
||||
if (mods.ctrlOrSuper() and mods.alt) {
|
||||
return drag_pin.x < click_pin.x;
|
||||
size: rendererpkg.Size,
|
||||
) ?terminal.Selection {
|
||||
// Explanation:
|
||||
//
|
||||
// # Normal selections
|
||||
//
|
||||
// ## Left-to-right selections
|
||||
// - The clicked cell is included if it was clicked to the left of its
|
||||
// threshold point and the drag location is right of the threshold point.
|
||||
// - The cell under the cursor (the "drag cell") is included if the drag
|
||||
// location is right of its threshold point.
|
||||
//
|
||||
// ## Right-to-left selections
|
||||
// - The clicked cell is included if it was clicked to the right of its
|
||||
// threshold point and the drag location is left of the threshold point.
|
||||
// - The cell under the cursor (the "drag cell") is included if the drag
|
||||
// location is left of its threshold point.
|
||||
//
|
||||
// # Rectangular selections
|
||||
//
|
||||
// Rectangular selections are handled similarly, except that
|
||||
// entire columns are considered rather than individual cells.
|
||||
|
||||
// We only include cells in the selection if the threshold point lies
|
||||
// between the start and end points of the selection. A threshold of
|
||||
// 60% of the cell width was chosen empirically because it felt good.
|
||||
const threshold_point: u32 = @intFromFloat(@round(
|
||||
@as(f64, @floatFromInt(size.cell.width)) * 0.6,
|
||||
));
|
||||
|
||||
// We use this to clamp the pixel positions below.
|
||||
const max_x = size.grid().columns * size.cell.width - 1;
|
||||
|
||||
// We need to know how far across in the cell the drag pos is, so
|
||||
// we subtract the padding and then take it modulo the cell width.
|
||||
const drag_x_frac = @min(max_x, drag_x -| size.padding.left) % size.cell.width;
|
||||
|
||||
// We figure out the fractional part of the click x position similarly.
|
||||
const click_x_frac = @min(max_x, click_x -| size.padding.left) % size.cell.width;
|
||||
|
||||
// Whether or not this is a rectangular selection.
|
||||
const rectangle_selection = SurfaceMouse.isRectangleSelectState(mods);
|
||||
|
||||
// Whether the click pin and drag pin are equal.
|
||||
const same_pin = drag_pin.eql(click_pin);
|
||||
|
||||
// Whether or not the end point of our selection is before the start point.
|
||||
const end_before_start = ebs: {
|
||||
if (same_pin) {
|
||||
break :ebs drag_x_frac < click_x_frac;
|
||||
}
|
||||
|
||||
return drag_pin.before(click_pin);
|
||||
// Special handling for rectangular selections, we only use x position.
|
||||
if (rectangle_selection) {
|
||||
break :ebs switch (std.math.order(drag_pin.x, click_pin.x)) {
|
||||
.eq => drag_x_frac < click_x_frac,
|
||||
.lt => true,
|
||||
.gt => false,
|
||||
};
|
||||
}
|
||||
|
||||
break :ebs drag_pin.before(click_pin);
|
||||
};
|
||||
|
||||
// Whether or not the the click pin cell
|
||||
// should be included in the selection.
|
||||
const include_click_cell = if (end_before_start)
|
||||
click_x_frac >= threshold_point
|
||||
else
|
||||
click_x_frac < threshold_point;
|
||||
|
||||
// Whether or not the the drag pin cell
|
||||
// should be included in the selection.
|
||||
const include_drag_cell = if (end_before_start)
|
||||
drag_x_frac < threshold_point
|
||||
else
|
||||
drag_x_frac >= threshold_point;
|
||||
|
||||
// If the click cell should be included in the selection then it's the
|
||||
// start, otherwise we get the previous or next cell to it depending on
|
||||
// the type and direction of the selection.
|
||||
const start_pin =
|
||||
if (include_click_cell)
|
||||
click_pin
|
||||
else if (end_before_start)
|
||||
if (rectangle_selection)
|
||||
click_pin.leftClamp(1)
|
||||
else
|
||||
click_pin.leftWrap(1) orelse click_pin
|
||||
else if (rectangle_selection)
|
||||
click_pin.rightClamp(1)
|
||||
else
|
||||
click_pin.rightWrap(1) orelse click_pin;
|
||||
|
||||
// Likewise for the end pin with the drag cell.
|
||||
const end_pin =
|
||||
if (include_drag_cell)
|
||||
drag_pin
|
||||
else if (end_before_start)
|
||||
if (rectangle_selection)
|
||||
drag_pin.rightClamp(1)
|
||||
else
|
||||
drag_pin.rightWrap(1) orelse drag_pin
|
||||
else if (rectangle_selection)
|
||||
drag_pin.leftClamp(1)
|
||||
else
|
||||
drag_pin.leftWrap(1) orelse drag_pin;
|
||||
|
||||
// If the click cell is the same as the drag cell and the click cell
|
||||
// shouldn't be included, or if the cells are adjacent such that the
|
||||
// start or end pin becomes the other cell, and that cell should not
|
||||
// be included, then we have no selection, so we set it to null.
|
||||
//
|
||||
// If in rectangular selection mode, we compare columns as well.
|
||||
//
|
||||
// TODO(qwerasd): this can/should probably be refactored, it's a bit
|
||||
// repetitive and does excess work in rectangle mode.
|
||||
if ((!include_click_cell and same_pin) or
|
||||
(!include_click_cell and rectangle_selection and click_pin.x == drag_pin.x) or
|
||||
(!include_click_cell and end_pin.eql(click_pin)) or
|
||||
(!include_click_cell and rectangle_selection and end_pin.x == click_pin.x) or
|
||||
(!include_drag_cell and start_pin.eql(drag_pin)) or
|
||||
(!include_drag_cell and rectangle_selection and start_pin.x == drag_pin.x))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO: Clamp selection to the screen area, don't
|
||||
// let it extend past the last written row.
|
||||
|
||||
return .init(
|
||||
start_pin,
|
||||
end_pin,
|
||||
rectangle_selection,
|
||||
);
|
||||
}
|
||||
|
||||
/// Call to notify Ghostty that the color scheme for the terminal has
|
||||
@ -4819,3 +4816,430 @@ fn presentSurface(self: *Surface) !void {
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
/// Utility function for the unit tests for mouse selection logic.
|
||||
///
|
||||
/// Tests a click and drag on a 10x5 cell grid, x positions are given in
|
||||
/// fractional cells, e.g. 3.1 would be 10% through the cell at x = 3.
|
||||
///
|
||||
/// NOTE: The size tested with has 10px wide cells, meaning only one digit
|
||||
/// after the decimal place has any meaning, e.g. 3.14 is equal to 3.1.
|
||||
///
|
||||
/// The provided start_x/y and end_x/y are the expected start and end points
|
||||
/// of the resulting selection.
|
||||
fn testMouseSelection(
|
||||
click_x: f64,
|
||||
click_y: u32,
|
||||
drag_x: f64,
|
||||
drag_y: u32,
|
||||
start_x: terminal.size.CellCountInt,
|
||||
start_y: u32,
|
||||
end_x: terminal.size.CellCountInt,
|
||||
end_y: u32,
|
||||
rect: bool,
|
||||
) !void {
|
||||
assert(builtin.is_test);
|
||||
|
||||
// Our screen size is 10x5 cells that are
|
||||
// 10x20 px, with 5px padding on all sides.
|
||||
const size: rendererpkg.Size = .{
|
||||
.cell = .{ .width = 10, .height = 20 },
|
||||
.padding = .{ .left = 5, .top = 5, .right = 5, .bottom = 5 },
|
||||
.screen = .{ .width = 110, .height = 110 },
|
||||
};
|
||||
var screen = try terminal.Screen.init(std.testing.allocator, 10, 5, 0);
|
||||
defer screen.deinit();
|
||||
|
||||
// We hold both ctrl and alt for rectangular
|
||||
// select so that this test is platform agnostic.
|
||||
const mods: input.Mods = .{
|
||||
.ctrl = rect,
|
||||
.alt = rect,
|
||||
};
|
||||
|
||||
try std.testing.expectEqual(rect, SurfaceMouse.isRectangleSelectState(mods));
|
||||
|
||||
const click_pin = screen.pages.pin(.{
|
||||
.viewport = .{ .x = @intFromFloat(@floor(click_x)), .y = click_y },
|
||||
}) orelse unreachable;
|
||||
const drag_pin = screen.pages.pin(.{
|
||||
.viewport = .{ .x = @intFromFloat(@floor(drag_x)), .y = drag_y },
|
||||
}) orelse unreachable;
|
||||
|
||||
const cell_width_f64: f64 = @floatFromInt(size.cell.width);
|
||||
const click_x_pos: u32 =
|
||||
@as(u32, @intFromFloat(@floor(click_x * cell_width_f64))) +
|
||||
size.padding.left;
|
||||
const drag_x_pos: u32 =
|
||||
@as(u32, @intFromFloat(@floor(drag_x * cell_width_f64))) +
|
||||
size.padding.left;
|
||||
|
||||
const start_pin = screen.pages.pin(.{
|
||||
.viewport = .{ .x = start_x, .y = start_y },
|
||||
}) orelse unreachable;
|
||||
const end_pin = screen.pages.pin(.{
|
||||
.viewport = .{ .x = end_x, .y = end_y },
|
||||
}) orelse unreachable;
|
||||
|
||||
try std.testing.expectEqualDeep(terminal.Selection{
|
||||
.bounds = .{ .untracked = .{
|
||||
.start = start_pin,
|
||||
.end = end_pin,
|
||||
} },
|
||||
.rectangle = rect,
|
||||
}, mouseSelection(
|
||||
click_pin,
|
||||
drag_pin,
|
||||
click_x_pos,
|
||||
drag_x_pos,
|
||||
mods,
|
||||
size,
|
||||
));
|
||||
}
|
||||
|
||||
/// Like `testMouseSelection` but checks that the resulting selection is null.
|
||||
///
|
||||
/// See `testMouseSelection` for more details.
|
||||
fn testMouseSelectionIsNull(
|
||||
click_x: f64,
|
||||
click_y: u32,
|
||||
drag_x: f64,
|
||||
drag_y: u32,
|
||||
rect: bool,
|
||||
) !void {
|
||||
assert(builtin.is_test);
|
||||
|
||||
// Our screen size is 10x5 cells that are
|
||||
// 10x20 px, with 5px padding on all sides.
|
||||
const size: rendererpkg.Size = .{
|
||||
.cell = .{ .width = 10, .height = 20 },
|
||||
.padding = .{ .left = 5, .top = 5, .right = 5, .bottom = 5 },
|
||||
.screen = .{ .width = 110, .height = 110 },
|
||||
};
|
||||
var screen = try terminal.Screen.init(std.testing.allocator, 10, 5, 0);
|
||||
defer screen.deinit();
|
||||
|
||||
// We hold both ctrl and alt for rectangular
|
||||
// select so that this test is platform agnostic.
|
||||
const mods: input.Mods = .{
|
||||
.ctrl = rect,
|
||||
.alt = rect,
|
||||
};
|
||||
|
||||
try std.testing.expectEqual(rect, SurfaceMouse.isRectangleSelectState(mods));
|
||||
|
||||
const click_pin = screen.pages.pin(.{
|
||||
.viewport = .{ .x = @intFromFloat(@floor(click_x)), .y = click_y },
|
||||
}) orelse unreachable;
|
||||
const drag_pin = screen.pages.pin(.{
|
||||
.viewport = .{ .x = @intFromFloat(@floor(drag_x)), .y = drag_y },
|
||||
}) orelse unreachable;
|
||||
|
||||
const cell_width_f64: f64 = @floatFromInt(size.cell.width);
|
||||
const click_x_pos: u32 =
|
||||
@as(u32, @intFromFloat(@floor(click_x * cell_width_f64))) +
|
||||
size.padding.left;
|
||||
const drag_x_pos: u32 =
|
||||
@as(u32, @intFromFloat(@floor(drag_x * cell_width_f64))) +
|
||||
size.padding.left;
|
||||
|
||||
try std.testing.expectEqual(
|
||||
null,
|
||||
mouseSelection(
|
||||
click_pin,
|
||||
drag_pin,
|
||||
click_x_pos,
|
||||
drag_x_pos,
|
||||
mods,
|
||||
size,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
test "Surface: selection logic" {
|
||||
// We disable format to make these easier to
|
||||
// read by pairing sets of coordinates per line.
|
||||
// zig fmt: off
|
||||
|
||||
// -- LTR
|
||||
// single cell selection
|
||||
try testMouseSelection(
|
||||
3.0, 3, // click
|
||||
3.9, 3, // drag
|
||||
3, 3, // expected start
|
||||
3, 3, // expected end
|
||||
false, // regular selection
|
||||
);
|
||||
// including click and drag pin cells
|
||||
try testMouseSelection(
|
||||
3.0, 3, // click
|
||||
5.9, 3, // drag
|
||||
3, 3, // expected start
|
||||
5, 3, // expected end
|
||||
false, // regular selection
|
||||
);
|
||||
// including click pin cell but not drag pin cell
|
||||
try testMouseSelection(
|
||||
3.0, 3, // click
|
||||
5.0, 3, // drag
|
||||
3, 3, // expected start
|
||||
4, 3, // expected end
|
||||
false, // regular selection
|
||||
);
|
||||
// including drag pin cell but not click pin cell
|
||||
try testMouseSelection(
|
||||
3.9, 3, // click
|
||||
5.9, 3, // drag
|
||||
4, 3, // expected start
|
||||
5, 3, // expected end
|
||||
false, // regular selection
|
||||
);
|
||||
// including neither click nor drag pin cells
|
||||
try testMouseSelection(
|
||||
3.9, 3, // click
|
||||
5.0, 3, // drag
|
||||
4, 3, // expected start
|
||||
4, 3, // expected end
|
||||
false, // regular selection
|
||||
);
|
||||
// empty selection (single cell on only left half)
|
||||
try testMouseSelectionIsNull(
|
||||
3.0, 3, // click
|
||||
3.1, 3, // drag
|
||||
false, // regular selection
|
||||
);
|
||||
// empty selection (single cell on only right half)
|
||||
try testMouseSelectionIsNull(
|
||||
3.8, 3, // click
|
||||
3.9, 3, // drag
|
||||
false, // regular selection
|
||||
);
|
||||
// empty selection (between two cells, not crossing threshold)
|
||||
try testMouseSelectionIsNull(
|
||||
3.9, 3, // click
|
||||
4.0, 3, // drag
|
||||
false, // regular selection
|
||||
);
|
||||
|
||||
// -- RTL
|
||||
// single cell selection
|
||||
try testMouseSelection(
|
||||
3.9, 3, // click
|
||||
3.0, 3, // drag
|
||||
3, 3, // expected start
|
||||
3, 3, // expected end
|
||||
false, // regular selection
|
||||
);
|
||||
// including click and drag pin cells
|
||||
try testMouseSelection(
|
||||
5.9, 3, // click
|
||||
3.0, 3, // drag
|
||||
5, 3, // expected start
|
||||
3, 3, // expected end
|
||||
false, // regular selection
|
||||
);
|
||||
// including click pin cell but not drag pin cell
|
||||
try testMouseSelection(
|
||||
5.9, 3, // click
|
||||
3.9, 3, // drag
|
||||
5, 3, // expected start
|
||||
4, 3, // expected end
|
||||
false, // regular selection
|
||||
);
|
||||
// including drag pin cell but not click pin cell
|
||||
try testMouseSelection(
|
||||
5.0, 3, // click
|
||||
3.0, 3, // drag
|
||||
4, 3, // expected start
|
||||
3, 3, // expected end
|
||||
false, // regular selection
|
||||
);
|
||||
// including neither click nor drag pin cells
|
||||
try testMouseSelection(
|
||||
5.0, 3, // click
|
||||
3.9, 3, // drag
|
||||
4, 3, // expected start
|
||||
4, 3, // expected end
|
||||
false, // regular selection
|
||||
);
|
||||
// empty selection (single cell on only left half)
|
||||
try testMouseSelectionIsNull(
|
||||
3.1, 3, // click
|
||||
3.0, 3, // drag
|
||||
false, // regular selection
|
||||
);
|
||||
// empty selection (single cell on only right half)
|
||||
try testMouseSelectionIsNull(
|
||||
3.9, 3, // click
|
||||
3.8, 3, // drag
|
||||
false, // regular selection
|
||||
);
|
||||
// empty selection (between two cells, not crossing threshold)
|
||||
try testMouseSelectionIsNull(
|
||||
4.0, 3, // click
|
||||
3.9, 3, // drag
|
||||
false, // regular selection
|
||||
);
|
||||
|
||||
// -- Wrapping
|
||||
// LTR, wrap excluded cells
|
||||
try testMouseSelection(
|
||||
9.9, 2, // click
|
||||
0.0, 4, // drag
|
||||
0, 3, // expected start
|
||||
9, 3, // expected end
|
||||
false, // regular selection
|
||||
);
|
||||
// RTL, wrap excluded cells
|
||||
try testMouseSelection(
|
||||
0.0, 4, // click
|
||||
9.9, 2, // drag
|
||||
9, 3, // expected start
|
||||
0, 3, // expected end
|
||||
false, // regular selection
|
||||
);
|
||||
}
|
||||
|
||||
test "Surface: rectangle selection logic" {
|
||||
// We disable format to make these easier to
|
||||
// read by pairing sets of coordinates per line.
|
||||
// zig fmt: off
|
||||
|
||||
// -- LTR
|
||||
// single column selection
|
||||
try testMouseSelection(
|
||||
3.0, 2, // click
|
||||
3.9, 4, // drag
|
||||
3, 2, // expected start
|
||||
3, 4, // expected end
|
||||
true, //rectangle selection
|
||||
);
|
||||
// including click and drag pin columns
|
||||
try testMouseSelection(
|
||||
3.0, 2, // click
|
||||
5.9, 4, // drag
|
||||
3, 2, // expected start
|
||||
5, 4, // expected end
|
||||
true, //rectangle selection
|
||||
);
|
||||
// including click pin column but not drag pin column
|
||||
try testMouseSelection(
|
||||
3.0, 2, // click
|
||||
5.0, 4, // drag
|
||||
3, 2, // expected start
|
||||
4, 4, // expected end
|
||||
true, //rectangle selection
|
||||
);
|
||||
// including drag pin column but not click pin column
|
||||
try testMouseSelection(
|
||||
3.9, 2, // click
|
||||
5.9, 4, // drag
|
||||
4, 2, // expected start
|
||||
5, 4, // expected end
|
||||
true, //rectangle selection
|
||||
);
|
||||
// including neither click nor drag pin columns
|
||||
try testMouseSelection(
|
||||
3.9, 2, // click
|
||||
5.0, 4, // drag
|
||||
4, 2, // expected start
|
||||
4, 4, // expected end
|
||||
true, //rectangle selection
|
||||
);
|
||||
// empty selection (single column on only left half)
|
||||
try testMouseSelectionIsNull(
|
||||
3.0, 2, // click
|
||||
3.1, 4, // drag
|
||||
true, //rectangle selection
|
||||
);
|
||||
// empty selection (single column on only right half)
|
||||
try testMouseSelectionIsNull(
|
||||
3.8, 2, // click
|
||||
3.9, 4, // drag
|
||||
true, //rectangle selection
|
||||
);
|
||||
// empty selection (between two columns, not crossing threshold)
|
||||
try testMouseSelectionIsNull(
|
||||
3.9, 2, // click
|
||||
4.0, 4, // drag
|
||||
true, //rectangle selection
|
||||
);
|
||||
|
||||
// -- RTL
|
||||
// single column selection
|
||||
try testMouseSelection(
|
||||
3.9, 2, // click
|
||||
3.0, 4, // drag
|
||||
3, 2, // expected start
|
||||
3, 4, // expected end
|
||||
true, //rectangle selection
|
||||
);
|
||||
// including click and drag pin columns
|
||||
try testMouseSelection(
|
||||
5.9, 2, // click
|
||||
3.0, 4, // drag
|
||||
5, 2, // expected start
|
||||
3, 4, // expected end
|
||||
true, //rectangle selection
|
||||
);
|
||||
// including click pin column but not drag pin column
|
||||
try testMouseSelection(
|
||||
5.9, 2, // click
|
||||
3.9, 4, // drag
|
||||
5, 2, // expected start
|
||||
4, 4, // expected end
|
||||
true, //rectangle selection
|
||||
);
|
||||
// including drag pin column but not click pin column
|
||||
try testMouseSelection(
|
||||
5.0, 2, // click
|
||||
3.0, 4, // drag
|
||||
4, 2, // expected start
|
||||
3, 4, // expected end
|
||||
true, //rectangle selection
|
||||
);
|
||||
// including neither click nor drag pin columns
|
||||
try testMouseSelection(
|
||||
5.0, 2, // click
|
||||
3.9, 4, // drag
|
||||
4, 2, // expected start
|
||||
4, 4, // expected end
|
||||
true, //rectangle selection
|
||||
);
|
||||
// empty selection (single column on only left half)
|
||||
try testMouseSelectionIsNull(
|
||||
3.1, 2, // click
|
||||
3.0, 4, // drag
|
||||
true, //rectangle selection
|
||||
);
|
||||
// empty selection (single column on only right half)
|
||||
try testMouseSelectionIsNull(
|
||||
3.9, 2, // click
|
||||
3.8, 4, // drag
|
||||
true, //rectangle selection
|
||||
);
|
||||
// empty selection (between two columns, not crossing threshold)
|
||||
try testMouseSelectionIsNull(
|
||||
4.0, 2, // click
|
||||
3.9, 4, // drag
|
||||
true, //rectangle selection
|
||||
);
|
||||
|
||||
// -- Wrapping
|
||||
// LTR, do not wrap
|
||||
try testMouseSelection(
|
||||
9.9, 2, // click
|
||||
0.0, 4, // drag
|
||||
9, 2, // expected start
|
||||
0, 4, // expected end
|
||||
true, //rectangle selection
|
||||
);
|
||||
// RTL, do not wrap
|
||||
try testMouseSelection(
|
||||
0.0, 4, // click
|
||||
9.9, 2, // drag
|
||||
0, 4, // expected start
|
||||
9, 2, // expected end
|
||||
true, //rectangle selection
|
||||
);
|
||||
}
|
||||
|
@ -1563,7 +1563,7 @@ fn gtkMouseMotion(
|
||||
const scaled = self.scaledCoordinates(x, y);
|
||||
|
||||
const pos: apprt.CursorPos = .{
|
||||
.x = @floatCast(@max(0, scaled.x)),
|
||||
.x = @floatCast(scaled.x),
|
||||
.y = @floatCast(scaled.y),
|
||||
};
|
||||
|
||||
|
@ -3572,6 +3572,74 @@ pub const Pin = struct {
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Move the pin left n columns, stopping at the start of the row.
|
||||
pub fn leftClamp(self: Pin, n: size.CellCountInt) Pin {
|
||||
var result = self;
|
||||
result.x -|= n;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Move the pin right n columns, stopping at the end of the row.
|
||||
pub fn rightClamp(self: Pin, n: size.CellCountInt) Pin {
|
||||
var result = self;
|
||||
result.x = @min(self.x +| n, self.node.data.size.cols - 1);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Move the pin left n cells, wrapping to the previous row as needed.
|
||||
///
|
||||
/// If the offset goes beyond the top of the screen, returns null.
|
||||
///
|
||||
/// TODO: Unit tests.
|
||||
pub fn leftWrap(self: Pin, n: usize) ?Pin {
|
||||
// NOTE: This assumes that all pages have the same width, which may
|
||||
// be violated under certain circumstances by incomplete reflow.
|
||||
const cols = self.node.data.size.cols;
|
||||
const remaining_in_row = self.x;
|
||||
|
||||
if (n <= remaining_in_row) return self.left(n);
|
||||
|
||||
const extra_after_remaining = n - remaining_in_row;
|
||||
|
||||
const rows_off = 1 + extra_after_remaining / cols;
|
||||
|
||||
switch (self.upOverflow(rows_off)) {
|
||||
.offset => |v| {
|
||||
var result = v;
|
||||
result.x = @intCast(cols - extra_after_remaining % cols);
|
||||
return result;
|
||||
},
|
||||
.overflow => return null,
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the pin right n cells, wrapping to the next row as needed.
|
||||
///
|
||||
/// If the offset goes beyond the bottom of the screen, returns null.
|
||||
///
|
||||
/// TODO: Unit tests.
|
||||
pub fn rightWrap(self: Pin, n: usize) ?Pin {
|
||||
// NOTE: This assumes that all pages have the same width, which may
|
||||
// be violated under certain circumstances by incomplete reflow.
|
||||
const cols = self.node.data.size.cols;
|
||||
const remaining_in_row = cols - self.x - 1;
|
||||
|
||||
if (n <= remaining_in_row) return self.right(n);
|
||||
|
||||
const extra_after_remaining = n - remaining_in_row;
|
||||
|
||||
const rows_off = 1 + extra_after_remaining / cols;
|
||||
|
||||
switch (self.downOverflow(rows_off)) {
|
||||
.offset => |v| {
|
||||
var result = v;
|
||||
result.x = @intCast(extra_after_remaining % cols - 1);
|
||||
return result;
|
||||
},
|
||||
.overflow => return null,
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the pin down a certain number of rows, or return null if
|
||||
/// the pin goes beyond the end of the screen.
|
||||
pub fn down(self: Pin, n: usize) ?Pin {
|
||||
|
@ -3,10 +3,12 @@ const Allocator = std.mem.Allocator;
|
||||
const assert = std.debug.assert;
|
||||
const size = @import("size.zig");
|
||||
|
||||
/// The possible reference locations for a point. When someone says "(42, 80)" in the context of a terminal, that could mean multiple
|
||||
/// things: it is in the current visible viewport? the current active
|
||||
/// area of the screen where the cursor is? the entire scrollback history?
|
||||
/// etc. This tag is used to differentiate those cases.
|
||||
/// The possible reference locations for a point. When someone says "(42, 80)"
|
||||
/// in the context of a terminal, that could mean multiple things: it is in the
|
||||
/// current visible viewport? the current active area of the screen where the
|
||||
/// cursor is? the entire scrollback history? etc.
|
||||
///
|
||||
/// This tag is used to differentiate those cases.
|
||||
pub const Tag = enum {
|
||||
/// Top-left is part of the active area where a running program can
|
||||
/// jump the cursor and make changes. The active area is the "editable"
|
||||
|
Reference in New Issue
Block a user