Files
ghostty/src/terminal/point.zig
Mitchell Hashimoto af6cc66369 core: Fix various double-click word selection bugs
Fixes #741

This completely reimplements double-click-and-drag logic for selecting
by word. The previous implementation was horribly broken. See #741 for
all the details.

The implemented logic now is:

* A double-click initiates a select-by-word selection mechanism.
  - A double-click may start on a word or whitespace
  - If the initial double-click is on a word, that word is immediately selected.
  - If the initial double-click is on whitespace, the whitespace is not selected.
* A "word" is determined by a non-boundary character meeting a boundary character.
  - A boundary character is `NUL` ` ` (space) `\t` `'` `"`
  - This list is somewhat arbitrary to make the terminal "feel" good.
  - Cell SGR states (fg/bg, bold, italic, etc.) have no effect on boundary determination or selection logic.
* As the user drags _on the same line_:
  - No selection change occurs until the cursor is over a new word. Whitespace change does nothing.
  - When selection is over a new word, that entire word added to the selection.
* When the user drags _up_ one or more lines:
  - If the cursor is over whitespace, all lines from the selection point up to but not including the cursor line are selected.
    * This selection is done in accordance to the previous rules.
  - If the cursor is over a word, the word becomes the beginning of the selection.
  - The end of the selection in all cases is the first word at or before the initial double-click point.
* When the user drags _down_ one or more lines:
  - The same logic as _up_ but swap the "beginning" and "end" of selection terminology.
* With this logic, the behavior of Ghostty has the following invariants:
  - Whitespace is never selected unless it is between two selected words
  - Selection implies at least one word is highlighted
  - The initial double-click point marks the beginning or end of a selection, never the middle.
2023-11-11 22:45:31 -08:00

227 lines
7.3 KiB
Zig

const std = @import("std");
const terminal = @import("main.zig");
const Screen = terminal.Screen;
// This file contains various types to represent x/y coordinates. We
// use different types so that we can lean on type-safety to get the
// exact expected type of point.
/// Viewport is a point within the viewport of the screen.
pub const Viewport = struct {
x: usize = 0,
y: usize = 0,
pub fn toScreen(self: Viewport, screen: *const Screen) ScreenPoint {
// x is unchanged, y we have to add the visible offset to
// get the full offset from the top.
return .{
.x = self.x,
.y = screen.viewport + self.y,
};
}
pub fn eql(self: Viewport, other: Viewport) bool {
return self.x == other.x and self.y == other.y;
}
test "toScreen with no scrollback" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try Screen.init(alloc, 3, 5, 0);
defer s.deinit();
try testing.expectEqual(ScreenPoint{
.x = 1,
.y = 1,
}, (Viewport{ .x = 1, .y = 1 }).toScreen(&s));
}
test "toScreen with scrollback" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try Screen.init(alloc, 3, 5, 3);
defer s.deinit();
// At the bottom
try s.scroll(.{ .screen = 6 });
try testing.expectEqual(ScreenPoint{
.x = 0,
.y = 3,
}, (Viewport{ .x = 0, .y = 0 }).toScreen(&s));
// Move the viewport a bit up
try s.scroll(.{ .screen = -1 });
try testing.expectEqual(ScreenPoint{
.x = 0,
.y = 2,
}, (Viewport{ .x = 0, .y = 0 }).toScreen(&s));
// Move the viewport to top
try s.scroll(.{ .top = {} });
try testing.expectEqual(ScreenPoint{
.x = 0,
.y = 0,
}, (Viewport{ .x = 0, .y = 0 }).toScreen(&s));
}
};
/// A screen point. This is offset from the top of the scrollback
/// buffer. If the screen is scrolled or resized, this will have to
/// be recomputed.
pub const ScreenPoint = struct {
x: usize = 0,
y: usize = 0,
/// Returns if this point is before another point.
pub fn before(self: ScreenPoint, other: ScreenPoint) bool {
return self.y < other.y or
(self.y == other.y and self.x < other.x);
}
/// Returns if two points are equal.
pub fn eql(self: ScreenPoint, other: ScreenPoint) bool {
return self.x == other.x and self.y == other.y;
}
/// Returns true if this screen point is currently in the active viewport.
pub fn inViewport(self: ScreenPoint, screen: *const Screen) bool {
return self.y >= screen.viewport and
self.y < screen.viewport + screen.rows;
}
/// Converts this to a viewport point. If the point is above the
/// viewport this will move the point to (0, 0) and if it is below
/// the viewport it'll move it to (cols - 1, rows - 1).
pub fn toViewport(self: ScreenPoint, screen: *const Screen) Viewport {
// TODO: test
// Before viewport
if (self.y < screen.viewport) return .{ .x = 0, .y = 0 };
// After viewport
if (self.y > screen.viewport + screen.rows) return .{
.x = screen.cols - 1,
.y = screen.rows - 1,
};
return .{ .x = self.x, .y = self.y - screen.viewport };
}
/// Returns a screen point iterator. This will iterate over all of
/// of the points in a screen in a given direction one by one.
///
/// The iterator is only valid as long as the screen is not resized.
pub fn iterator(
self: ScreenPoint,
screen: *const Screen,
dir: Direction,
) Iterator {
return .{ .screen = screen, .current = self, .direction = dir };
}
pub const Iterator = struct {
screen: *const Screen,
current: ?ScreenPoint,
direction: Direction,
pub fn next(self: *Iterator) ?ScreenPoint {
const current = self.current orelse return null;
self.current = switch (self.direction) {
.left_up => left_up: {
if (current.x == 0) {
if (current.y == 0) break :left_up null;
break :left_up .{
.x = self.screen.cols - 1,
.y = current.y - 1,
};
}
break :left_up .{
.x = current.x - 1,
.y = current.y,
};
},
.right_down => right_down: {
if (current.x == self.screen.cols - 1) {
const max = self.screen.rows + self.screen.max_scrollback;
if (current.y == max - 1) break :right_down null;
break :right_down .{
.x = 0,
.y = current.y + 1,
};
}
break :right_down .{
.x = current.x + 1,
.y = current.y,
};
},
};
return current;
}
};
test "before" {
const testing = std.testing;
const p: ScreenPoint = .{ .x = 5, .y = 2 };
try testing.expect(p.before(.{ .x = 6, .y = 2 }));
try testing.expect(p.before(.{ .x = 3, .y = 3 }));
}
test "iterator" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try Screen.init(alloc, 5, 5, 0);
defer s.deinit();
// Back from the first line
{
var pt: ScreenPoint = .{ .x = 1, .y = 0 };
var it = pt.iterator(&s, .left_up);
try testing.expectEqual(ScreenPoint{ .x = 1, .y = 0 }, it.next().?);
try testing.expectEqual(ScreenPoint{ .x = 0, .y = 0 }, it.next().?);
try testing.expect(it.next() == null);
}
// Back from second line
{
var pt: ScreenPoint = .{ .x = 1, .y = 1 };
var it = pt.iterator(&s, .left_up);
try testing.expectEqual(ScreenPoint{ .x = 1, .y = 1 }, it.next().?);
try testing.expectEqual(ScreenPoint{ .x = 0, .y = 1 }, it.next().?);
try testing.expectEqual(ScreenPoint{ .x = 4, .y = 0 }, it.next().?);
}
// Forward last line
{
var pt: ScreenPoint = .{ .x = 3, .y = 4 };
var it = pt.iterator(&s, .right_down);
try testing.expectEqual(ScreenPoint{ .x = 3, .y = 4 }, it.next().?);
try testing.expectEqual(ScreenPoint{ .x = 4, .y = 4 }, it.next().?);
try testing.expect(it.next() == null);
}
// Forward not last line
{
var pt: ScreenPoint = .{ .x = 3, .y = 3 };
var it = pt.iterator(&s, .right_down);
try testing.expectEqual(ScreenPoint{ .x = 3, .y = 3 }, it.next().?);
try testing.expectEqual(ScreenPoint{ .x = 4, .y = 3 }, it.next().?);
try testing.expectEqual(ScreenPoint{ .x = 0, .y = 4 }, it.next().?);
}
}
};
/// Direction that points can go.
pub const Direction = enum { left_up, right_down };
test {
std.testing.refAllDecls(@This());
}