From 804d4534cd7cdb676ef1ccd9a3245bc50b54b65e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 21 Nov 2022 20:42:29 -0800 Subject: [PATCH 1/7] window detects double and triple clicks --- src/Window.zig | 47 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/src/Window.zig b/src/Window.zig index ca1ff1b72..8dbd1b9f0 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -75,6 +75,7 @@ renderer_thr: std.Thread, /// Mouse state. mouse: Mouse, +mouse_interval: u64, /// The terminal IO handler. io: termio.Impl, @@ -117,6 +118,12 @@ const Mouse = struct { left_click_xpos: f64 = 0, left_click_ypos: f64 = 0, + /// The count of clicks to count double and triple clicks and so on. + /// The left click time was the last time the left click was done. This + /// is always set on the first left click. + left_click_count: u8 = 0, + left_click_time: std.time.Instant = undefined, + /// The last x/y sent for mouse reports. event_point: terminal.point.Viewport = .{}, }; @@ -388,6 +395,7 @@ pub fn create(alloc: Allocator, app: *App, config: *const Config) !*Window { }, .renderer_thr = undefined, .mouse = .{}, + .mouse_interval = 500 * 1_000_000, // 500ms .io = io, .io_thread = io_thread, .io_thr = undefined, @@ -1525,11 +1533,40 @@ fn mouseButtonCallback( win.mouse.left_click_xpos = pos.xpos; win.mouse.left_click_ypos = pos.ypos; - // Selection is always cleared - if (win.io.terminal.selection != null) { - win.io.terminal.selection = null; - win.queueRender() catch |err| - log.err("error scheduling render in mouseButtinCallback err={}", .{err}); + // Setup our click counter and timer + if (std.time.Instant.now()) |now| { + // If we have mouse clicks, then we check if the time elapsed + // is less than and our interval and if so, increase the count. + if (win.mouse.left_click_count > 0) { + const since = now.since(win.mouse.left_click_time); + if (since > win.mouse_interval) { + win.mouse.left_click_count = 0; + } + } + + win.mouse.left_click_time = now; + win.mouse.left_click_count += 1; + + // We only support up to triple-clicks. + if (win.mouse.left_click_count > 3) win.mouse.left_click_count = 1; + } else |err| { + win.mouse.left_click_count = 1; + log.err("error reading time, mouse multi-click won't work err={}", .{err}); + } + + switch (win.mouse.left_click_count) { + // First mouse click, clear selection + 1 => if (win.io.terminal.selection != null) { + win.io.terminal.selection = null; + win.queueRender() catch |err| + log.err("error scheduling render in mouseButtinCallback err={}", .{err}); + }, + + 2 => log.info("DoublE CLICk", .{}), + 3 => log.info("TRIPLE CLICK", .{}), + + // We should be bounded by 1 to 3 + else => unreachable, } } } From f7c6ea63e11acd323f1d2aa461feec2fbbf448ee Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 21 Nov 2022 21:34:38 -0800 Subject: [PATCH 2/7] screen selectWord starting functionality, not done at all --- src/terminal/Screen.zig | 154 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 271a410e7..91a27cafe 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1096,6 +1096,101 @@ pub fn clearHistory(self: *Screen) void { self.viewport = 0; } +/// Select the word under the given point. A word is any consecutive series +/// of characters that are exclusively whitespace or exclusively non-whitespace. +/// A selection can span multiple physical lines. +pub fn selectWord(self: *Screen, pt: point.ScreenPoint) ?Selection { + // Impossible to select anything outside of the area we've written. + const y_max = self.rowsWritten() - 1; + if (pt.y > y_max) return null; + + // Get our row + const row = self.getRow(.{ .screen = pt.y }); + const start_cell = row.getCell(pt.x); + + // If our cell is empty we can't select a word, because we can't select + // areas where the screen is not yet written. + if (start_cell.empty()) return null; + + // Determine if we are whitespace or not to determine what our boundary is. + const whitespace = &[_]u32{ 0, ' ', '\t' }; + const expect_whitespace = std.mem.indexOfAny(u32, whitespace, &[_]u32{start_cell.char}) != null; + + // Go forwards to find our end boundary + const end: point.ScreenPoint = boundary: { + //var y: usize = pt.y; + var x: usize = pt.x; + var prev: point.ScreenPoint = pt; + while (x < self.cols) : (x += 1) { + const cell = row.getCell(x); + + // If we reached an empty cell its always a boundary + if (cell.empty()) break :boundary prev; + + // If we do not match our expected set, we hit a boundary + const this_whitespace = std.mem.indexOfAny(u32, whitespace, &[_]u32{cell.char}) != null; + if (this_whitespace != expect_whitespace) break :boundary prev; + + // Increase our prev + prev.x = x; + } + + break :boundary .{ .x = self.cols - 1, .y = y_max }; + }; + + // Go backwards to find our start boundary + const start: point.ScreenPoint = boundary: { + var current_row = row; + var prev: point.ScreenPoint = pt; + + var y: usize = pt.y; + while (true) { + var x: usize = pt.x; + while (x > 0) : (x -= 1) { + const cell = current_row.getCell(x - 1); + + // If we reached an empty cell its always a boundary + if (cell.empty()) break :boundary prev; + + // If we do not match our expected set, we hit a boundary + const this_whitespace = std.mem.indexOfAny( + u32, + whitespace, + &[_]u32{cell.char}, + ) != null; + if (this_whitespace != expect_whitespace) break :boundary prev; + + // Update our prev + prev.x = x - 1; + } + + // If we're at the start, we need to check if the previous line wrapped. + // If we are wrapped, we continue searching. If we are not wrapped, + // then we've hit a boundary. + assert(prev.x == 0); + + // If we're at the end, we're done! + if (y == 0) break; + + // Update our prev y + prev.y = y; + + // If the previous row did not wrap, then we're done. Otherwise + // we keep searching. + y -= 1; + current_row = self.getRow(.{ .screen = y }); + if (!current_row.header().flags.wrap) break :boundary prev; + } + + break :boundary .{ .x = 0, .y = 0 }; + }; + + return Selection{ + .start = start, + .end = end, + }; +} + /// Scroll behaviors for the scroll function. pub const Scroll = union(enum) { /// Scroll to the top of the scroll buffer. The first line of the @@ -2438,6 +2533,65 @@ test "Screen: clone one line viewport" { } } +test "Screen: selectWord" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + try s.testWriteString("ABC DEF\n 123\n456"); + + // Outside of active area + try testing.expect(s.selectWord(.{ .x = 0, .y = 5 }) == null); + + // Going forward + { + const sel = s.selectWord(.{ .x = 0, .y = 0 }).?; + try testing.expectEqual(@as(usize, 0), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 2), sel.end.x); + try testing.expectEqual(@as(usize, 0), sel.end.y); + } + + // Going backward + { + const sel = s.selectWord(.{ .x = 2, .y = 0 }).?; + try testing.expectEqual(@as(usize, 0), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 2), sel.end.x); + try testing.expectEqual(@as(usize, 0), sel.end.y); + } + + // Going forward and backward + { + const sel = s.selectWord(.{ .x = 1, .y = 0 }).?; + try testing.expectEqual(@as(usize, 0), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 2), sel.end.x); + try testing.expectEqual(@as(usize, 0), sel.end.y); + } + + // Whitespace + { + const sel = s.selectWord(.{ .x = 3, .y = 0 }).?; + try testing.expectEqual(@as(usize, 3), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 4), sel.end.x); + try testing.expectEqual(@as(usize, 0), sel.end.y); + } + + // Whitespace single char + { + const sel = s.selectWord(.{ .x = 0, .y = 1 }).?; + try testing.expectEqual(@as(usize, 0), sel.start.x); + try testing.expectEqual(@as(usize, 1), sel.start.y); + try testing.expectEqual(@as(usize, 0), sel.end.x); + try testing.expectEqual(@as(usize, 1), sel.end.y); + } + + // TODO: test going backwards up a line +} + test "Screen: scrollRegionUp single" { const testing = std.testing; const alloc = testing.allocator; From a63815c5baa599d279622e933269625d94b5310f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 22 Nov 2022 07:56:11 -0800 Subject: [PATCH 3/7] selectWord goes across soft-wrapped lines --- src/Window.zig | 11 ++- src/terminal/Screen.zig | 167 ++++++++++++++++++++++++++++++++-------- 2 files changed, 144 insertions(+), 34 deletions(-) diff --git a/src/Window.zig b/src/Window.zig index 8dbd1b9f0..455edbe54 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -1562,7 +1562,16 @@ fn mouseButtonCallback( log.err("error scheduling render in mouseButtinCallback err={}", .{err}); }, - 2 => log.info("DoublE CLICk", .{}), + // Double click, select the word under our mouse + 2 => { + const sel_ = win.io.terminal.screen.selectWord(win.mouse.left_click_point); + if (sel_) |sel| { + win.io.terminal.selection = sel; + win.queueRender() catch |err| + log.err("error scheduling render in mouseButtinCallback err={}", .{err}); + } + }, + 3 => log.info("TRIPLE CLICK", .{}), // We should be bounded by 1 to 3 diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 91a27cafe..6855aaa32 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1098,7 +1098,10 @@ pub fn clearHistory(self: *Screen) void { /// Select the word under the given point. A word is any consecutive series /// of characters that are exclusively whitespace or exclusively non-whitespace. -/// A selection can span multiple physical lines. +/// A selection can span multiple physical lines if they are soft-wrapped. +/// +/// This will return null if a selection is impossible. The only scenario +/// this happens is if the point pt is outside of the written screen space. pub fn selectWord(self: *Screen, pt: point.ScreenPoint) ?Selection { // Impossible to select anything outside of the area we've written. const y_max = self.rowsWritten() - 1; @@ -1118,36 +1121,16 @@ pub fn selectWord(self: *Screen, pt: point.ScreenPoint) ?Selection { // Go forwards to find our end boundary const end: point.ScreenPoint = boundary: { - //var y: usize = pt.y; - var x: usize = pt.x; var prev: point.ScreenPoint = pt; - while (x < self.cols) : (x += 1) { - const cell = row.getCell(x); - - // If we reached an empty cell its always a boundary - if (cell.empty()) break :boundary prev; - - // If we do not match our expected set, we hit a boundary - const this_whitespace = std.mem.indexOfAny(u32, whitespace, &[_]u32{cell.char}) != null; - if (this_whitespace != expect_whitespace) break :boundary prev; - - // Increase our prev - prev.x = x; - } - - break :boundary .{ .x = self.cols - 1, .y = y_max }; - }; - - // Go backwards to find our start boundary - const start: point.ScreenPoint = boundary: { - var current_row = row; - var prev: point.ScreenPoint = pt; - var y: usize = pt.y; - while (true) { - var x: usize = pt.x; - while (x > 0) : (x -= 1) { - const cell = current_row.getCell(x - 1); + var x: usize = pt.x; + while (y < y_max) : (y += 1) { + const current_row = self.getRow(.{ .screen = y }); + + // Go through all the remainining cells on this row until + // we reach a boundary condition. + while (x < self.cols) : (x += 1) { + const cell = current_row.getCell(x); // If we reached an empty cell its always a boundary if (cell.empty()) break :boundary prev; @@ -1160,8 +1143,43 @@ pub fn selectWord(self: *Screen, pt: point.ScreenPoint) ?Selection { ) != null; if (this_whitespace != expect_whitespace) break :boundary prev; + // Increase our prev + prev.x = x; + prev.y = y; + } + + // If we aren't wrapping, then we're done this is a boundary. + if (!current_row.header().flags.wrap) break :boundary prev; + + // If we are wrapping, reset some values and search the next line. + x = 0; + } + + break :boundary .{ .x = self.cols - 1, .y = y_max }; + }; + + // Go backwards to find our start boundary + const start: point.ScreenPoint = boundary: { + var current_row = row; + var prev: point.ScreenPoint = pt; + + var y: usize = pt.y; + var x: usize = pt.x; + while (true) { + // Go through all the remainining cells on this row until + // we reach a boundary condition. + while (x > 0) : (x -= 1) { + const cell = current_row.getCell(x - 1); + const this_whitespace = std.mem.indexOfAny( + u32, + whitespace, + &[_]u32{cell.char}, + ) != null; + if (this_whitespace != expect_whitespace) break :boundary prev; + // Update our prev prev.x = x - 1; + prev.y = y; } // If we're at the start, we need to check if the previous line wrapped. @@ -1172,14 +1190,17 @@ pub fn selectWord(self: *Screen, pt: point.ScreenPoint) ?Selection { // If we're at the end, we're done! if (y == 0) break; - // Update our prev y - prev.y = y; - // If the previous row did not wrap, then we're done. Otherwise // we keep searching. y -= 1; current_row = self.getRow(.{ .screen = y }); if (!current_row.header().flags.wrap) break :boundary prev; + + // Set x to start at the first non-empty cell + x = self.cols; + while (x > 0) : (x -= 1) { + if (!current_row.getCell(x - 1).empty()) break; + } } break :boundary .{ .x = 0, .y = 0 }; @@ -2542,6 +2563,7 @@ test "Screen: selectWord" { try s.testWriteString("ABC DEF\n 123\n456"); // Outside of active area + try testing.expect(s.selectWord(.{ .x = 9, .y = 0 }) == null); try testing.expect(s.selectWord(.{ .x = 0, .y = 5 }) == null); // Going forward @@ -2589,7 +2611,86 @@ test "Screen: selectWord" { try testing.expectEqual(@as(usize, 1), sel.end.y); } - // TODO: test going backwards up a line + // End of screen + { + const sel = s.selectWord(.{ .x = 1, .y = 2 }).?; + try testing.expectEqual(@as(usize, 0), sel.start.x); + try testing.expectEqual(@as(usize, 2), sel.start.y); + try testing.expectEqual(@as(usize, 2), sel.end.x); + try testing.expectEqual(@as(usize, 2), sel.end.y); + } +} + +test "Screen: selectWord across soft-wrap" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 5, 0); + defer s.deinit(); + try s.testWriteString(" 1234012\n 123"); + + // Going forward + { + const sel = s.selectWord(.{ .x = 1, .y = 0 }).?; + try testing.expectEqual(@as(usize, 1), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 2), sel.end.x); + try testing.expectEqual(@as(usize, 1), sel.end.y); + } + + // Going backward + { + const sel = s.selectWord(.{ .x = 1, .y = 1 }).?; + try testing.expectEqual(@as(usize, 1), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 2), sel.end.x); + try testing.expectEqual(@as(usize, 1), sel.end.y); + } + + // Going forward and backward + { + const sel = s.selectWord(.{ .x = 3, .y = 0 }).?; + try testing.expectEqual(@as(usize, 1), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 2), sel.end.x); + try testing.expectEqual(@as(usize, 1), sel.end.y); + } +} + +test "Screen: selectWord whitespace across soft-wrap" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 5, 0); + defer s.deinit(); + try s.testWriteString("1 1\n 123"); + + // Going forward + { + const sel = s.selectWord(.{ .x = 1, .y = 0 }).?; + try testing.expectEqual(@as(usize, 1), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 2), sel.end.x); + try testing.expectEqual(@as(usize, 1), sel.end.y); + } + + // Going backward + { + const sel = s.selectWord(.{ .x = 1, .y = 1 }).?; + try testing.expectEqual(@as(usize, 1), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 2), sel.end.x); + try testing.expectEqual(@as(usize, 1), sel.end.y); + } + + // Going forward and backward + { + const sel = s.selectWord(.{ .x = 3, .y = 0 }).?; + try testing.expectEqual(@as(usize, 1), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 2), sel.end.x); + try testing.expectEqual(@as(usize, 1), sel.end.y); + } } test "Screen: scrollRegionUp single" { From f9fed1f3a8ced77c48a96f34bcfb2e9e2a8adf47 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 22 Nov 2022 09:15:39 -0800 Subject: [PATCH 4/7] double-click drag selects word by word --- src/Window.zig | 71 ++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 55 insertions(+), 16 deletions(-) diff --git a/src/Window.zig b/src/Window.zig index 455edbe54..0059e5986 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -1644,6 +1644,45 @@ fn cursorPosCallback( const viewport_point = win.posToViewport(xpos, ypos); const screen_point = viewport_point.toScreen(&win.io.terminal.screen); + // Handle dragging depending on click count + switch (win.mouse.left_click_count) { + 1 => win.dragLeftClickSingle(screen_point, xpos), + 2 => win.dragLeftClickDouble(screen_point), + 3 => {}, // TODO + else => unreachable, + } +} + +/// Double-click dragging moves the selection one "word" at a time. +fn dragLeftClickDouble( + self: *Window, + screen_point: terminal.point.ScreenPoint, +) void { + // Get the word under our current point. If there isn't a word, do nothing. + const word = self.io.terminal.screen.selectWord(screen_point) orelse return; + + // Get our selection to grow it. If we don't have a selection, start it now. + // We may not have a selection if we started our dbl-click in an area + // that had no data, then we dragged our mouse into an area with data. + var sel = self.io.terminal.screen.selectWord(self.mouse.left_click_point) orelse { + self.io.terminal.selection = word; + return; + }; + + // Grow our selection + if (screen_point.before(self.mouse.left_click_point)) { + sel.start = word.start; + } else { + sel.end = word.end; + } + self.io.terminal.selection = sel; +} + +fn dragLeftClickSingle( + self: *Window, + screen_point: terminal.point.ScreenPoint, + xpos: 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 @@ -1653,13 +1692,13 @@ fn cursorPosCallback( // If we were selecting, and we switched directions, then we restart // calculations because it forces us to reconsider if the first cell is // selected. - if (win.io.terminal.selection) |sel| { + if (self.io.terminal.selection) |sel| { const reset: bool = if (sel.end.before(sel.start)) sel.start.before(screen_point) else screen_point.before(sel.start); - if (reset) win.io.terminal.selection = null; + if (reset) self.io.terminal.selection = null; } // Our logic for determing if the starting cell is selected: @@ -1675,23 +1714,23 @@ fn cursorPosCallback( // // the boundary point at which we consider selection or non-selection - const cell_xboundary = win.cell_size.width * 0.6; + const cell_xboundary = self.cell_size.width * 0.6; // first xpos of the clicked cell - const cell_xstart = @intToFloat(f32, win.mouse.left_click_point.x) * win.cell_size.width; - const cell_start_xpos = win.mouse.left_click_xpos - cell_xstart; + const cell_xstart = @intToFloat(f32, self.mouse.left_click_point.x) * self.cell_size.width; + const cell_start_xpos = self.mouse.left_click_xpos - cell_xstart; // 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 (std.meta.eql(screen_point, win.mouse.left_click_point)) { + if (std.meta.eql(screen_point, self.mouse.left_click_point)) { const cell_xpos = xpos - cell_xstart; const selected: bool = if (cell_start_xpos < cell_xboundary) cell_xpos >= cell_xboundary else cell_xpos < cell_xboundary; - win.io.terminal.selection = if (selected) .{ + self.io.terminal.selection = if (selected) .{ .start = screen_point, .end = screen_point, } else null; @@ -1701,29 +1740,29 @@ fn cursorPosCallback( // If this is a different cell and we haven't started selection, // we determine the starting cell first. - if (win.io.terminal.selection == null) { + if (self.io.terminal.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 click_point = win.mouse.left_click_point; + const click_point = self.mouse.left_click_point; const start: terminal.point.ScreenPoint = if (screen_point.before(click_point)) start: { - if (win.mouse.left_click_xpos > cell_xboundary) { + if (self.mouse.left_click_xpos > cell_xboundary) { break :start click_point; } else { break :start if (click_point.x > 0) terminal.point.ScreenPoint{ .y = click_point.y, .x = click_point.x - 1, } else terminal.point.ScreenPoint{ - .x = win.io.terminal.screen.cols - 1, + .x = self.io.terminal.screen.cols - 1, .y = click_point.y -| 1, }; } } else start: { - if (win.mouse.left_click_xpos < cell_xboundary) { + if (self.mouse.left_click_xpos < cell_xboundary) { break :start click_point; } else { - break :start if (click_point.x < win.io.terminal.screen.cols - 1) terminal.point.ScreenPoint{ + break :start if (click_point.x < self.io.terminal.screen.cols - 1) terminal.point.ScreenPoint{ .y = click_point.y, .x = click_point.x + 1, } else terminal.point.ScreenPoint{ @@ -1733,7 +1772,7 @@ fn cursorPosCallback( } }; - win.io.terminal.selection = .{ .start = start, .end = screen_point }; + self.io.terminal.selection = .{ .start = start, .end = screen_point }; return; } @@ -1742,8 +1781,8 @@ fn cursorPosCallback( // We moved! Set the selection end point. The start point should be // set earlier. - assert(win.io.terminal.selection != null); - win.io.terminal.selection.?.end = screen_point; + assert(self.io.terminal.selection != null); + self.io.terminal.selection.?.end = screen_point; } fn posToViewport(self: Window, xpos: f64, ypos: f64) terminal.point.Viewport { From 04088abe5a9c06be71e41bfd8e54c26d50f33047 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 22 Nov 2022 09:41:07 -0800 Subject: [PATCH 5/7] fix regression around selecting word at end of screen --- src/terminal/Screen.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 6855aaa32..71702222c 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1124,7 +1124,7 @@ pub fn selectWord(self: *Screen, pt: point.ScreenPoint) ?Selection { var prev: point.ScreenPoint = pt; var y: usize = pt.y; var x: usize = pt.x; - while (y < y_max) : (y += 1) { + while (y <= y_max) : (y += 1) { const current_row = self.getRow(.{ .screen = y }); // Go through all the remainining cells on this row until From 8b839ef4b6558969aeb459aa3cb9d4516d825020 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 22 Nov 2022 10:03:20 -0800 Subject: [PATCH 6/7] screen selectLine and tests --- src/terminal/Screen.zig | 205 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 204 insertions(+), 1 deletion(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 71702222c..9c5e4bc17 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -64,6 +64,9 @@ const fastmem = @import("../fastmem.zig"); const log = std.log.scoped(.screen); +/// Whitespace characters for selection purposes +const whitespace = &[_]u32{ 0, ' ', '\t' }; + /// Cursor represents the cursor state. pub const Cursor = struct { // x, y where the cursor currently exists (0-indexed). This x/y is @@ -1096,6 +1099,104 @@ pub fn clearHistory(self: *Screen) void { self.viewport = 0; } +/// Select the line under the given point. This will select across soft-wrapped +/// lines and will omit the leading and trailing whitespace. If the point is +/// over whitespace but the line has non-whitespace characters elsewhere, the +/// line will be selected. +pub fn selectLine(self: *Screen, pt: point.ScreenPoint) ?Selection { + // Impossible to select anything outside of the area we've written. + const y_max = self.rowsWritten() - 1; + if (pt.y > y_max or pt.x >= self.cols) return null; + + // The real start of the row is the first row in the soft-wrap. + const start_row: usize = start_row: { + if (pt.y == 0) break :start_row 0; + + var y: usize = pt.y - 1; + while (true) { + const current = self.getRow(.{ .screen = y }); + if (!current.header().flags.wrap) break :start_row y + 1; + if (y == 0) break :start_row y; + } + unreachable; + }; + + // The real end of the row is the final row in the soft-wrap. + const end_row: usize = end_row: { + var y: usize = pt.y; + while (y < y_max) : (y += 1) { + const current = self.getRow(.{ .screen = y }); + if (y == y_max or !current.header().flags.wrap) break :end_row y; + } + unreachable; + }; + + // Go forward from the start to find the first non-whitespace character. + const start: point.ScreenPoint = start: { + var y: usize = start_row; + while (y <= y_max) : (y += 1) { + const current_row = self.getRow(.{ .screen = y }); + var x: usize = 0; + while (x < self.cols) : (x += 1) { + const cell = current_row.getCell(x); + + // Empty is whitespace + if (cell.empty()) continue; + + // Non-empty means we found it. + const this_whitespace = std.mem.indexOfAny( + u32, + whitespace, + &[_]u32{cell.char}, + ) != null; + if (this_whitespace) continue; + + break :start .{ .x = x, .y = y }; + } + } + + // There is no start point and therefore no line that can be selected. + return null; + }; + + // Go backward from the end to find the first non-whitespace character. + const end: point.ScreenPoint = end: { + var y: usize = end_row; + while (true) { + const current_row = self.getRow(.{ .screen = y }); + + var x: usize = 0; + while (x < self.cols) : (x += 1) { + const real_x = self.cols - x - 1; + const cell = current_row.getCell(real_x); + + // Empty or whitespace, ignore. + if (cell.empty()) continue; + const this_whitespace = std.mem.indexOfAny( + u32, + whitespace, + &[_]u32{cell.char}, + ) != null; + if (this_whitespace) continue; + + // Got it + break :end .{ .x = real_x, .y = y }; + } + + if (y == 0) break; + y -= 1; + } + + // There is no start point and therefore no line that can be selected. + return null; + }; + + return Selection{ + .start = start, + .end = end, + }; +} + /// Select the word under the given point. A word is any consecutive series /// of characters that are exclusively whitespace or exclusively non-whitespace. /// A selection can span multiple physical lines if they are soft-wrapped. @@ -1116,7 +1217,6 @@ pub fn selectWord(self: *Screen, pt: point.ScreenPoint) ?Selection { if (start_cell.empty()) return null; // Determine if we are whitespace or not to determine what our boundary is. - const whitespace = &[_]u32{ 0, ' ', '\t' }; const expect_whitespace = std.mem.indexOfAny(u32, whitespace, &[_]u32{start_cell.char}) != null; // Go forwards to find our end boundary @@ -2554,6 +2654,109 @@ test "Screen: clone one line viewport" { } } +test "Screen: selectLine" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + try s.testWriteString("ABC DEF\n 123\n456"); + + // Outside of active area + try testing.expect(s.selectLine(.{ .x = 13, .y = 0 }) == null); + try testing.expect(s.selectLine(.{ .x = 0, .y = 5 }) == null); + + // Going forward + { + const sel = s.selectLine(.{ .x = 0, .y = 0 }).?; + try testing.expectEqual(@as(usize, 0), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 7), sel.end.x); + try testing.expectEqual(@as(usize, 0), sel.end.y); + } + + // Going backward + { + const sel = s.selectLine(.{ .x = 7, .y = 0 }).?; + try testing.expectEqual(@as(usize, 0), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 7), sel.end.x); + try testing.expectEqual(@as(usize, 0), sel.end.y); + } + + // Going forward and backward + { + const sel = s.selectLine(.{ .x = 3, .y = 0 }).?; + try testing.expectEqual(@as(usize, 0), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 7), sel.end.x); + try testing.expectEqual(@as(usize, 0), sel.end.y); + } + + // Outside active area + { + const sel = s.selectLine(.{ .x = 9, .y = 0 }).?; + try testing.expectEqual(@as(usize, 0), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 7), sel.end.x); + try testing.expectEqual(@as(usize, 0), sel.end.y); + } +} + +test "Screen: selectLine across soft-wrap" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 5, 0); + defer s.deinit(); + try s.testWriteString(" 12 34012 \n 123"); + + // Going forward + { + const sel = s.selectLine(.{ .x = 1, .y = 0 }).?; + try testing.expectEqual(@as(usize, 1), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 3), sel.end.x); + try testing.expectEqual(@as(usize, 1), sel.end.y); + } +} + +test "Screen: selectLine across soft-wrap ignores blank lines" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 5, 0); + defer s.deinit(); + try s.testWriteString(" 12 34012 \n 123"); + + // Going forward + { + const sel = s.selectLine(.{ .x = 1, .y = 0 }).?; + try testing.expectEqual(@as(usize, 1), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 3), sel.end.x); + try testing.expectEqual(@as(usize, 1), sel.end.y); + } + + // Going backward + { + const sel = s.selectLine(.{ .x = 1, .y = 1 }).?; + try testing.expectEqual(@as(usize, 1), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 3), sel.end.x); + try testing.expectEqual(@as(usize, 1), sel.end.y); + } + + // Going forward and backward + { + const sel = s.selectLine(.{ .x = 3, .y = 0 }).?; + try testing.expectEqual(@as(usize, 1), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 3), sel.end.x); + try testing.expectEqual(@as(usize, 1), sel.end.y); + } +} + test "Screen: selectWord" { const testing = std.testing; const alloc = testing.allocator; From d1b565f4d406c5aa8acb0a87984f103adce072a9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 22 Nov 2022 10:07:52 -0800 Subject: [PATCH 7/7] hook up triple-click in UI to select lines --- src/Window.zig | 39 ++++++++++++++++++++++++++++++++++++--- src/terminal/Screen.zig | 1 + 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/Window.zig b/src/Window.zig index 0059e5986..fe46cba66 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -1562,7 +1562,7 @@ fn mouseButtonCallback( log.err("error scheduling render in mouseButtinCallback err={}", .{err}); }, - // Double click, select the word under our mouse + // Double click, select the word under our mouse 2 => { const sel_ = win.io.terminal.screen.selectWord(win.mouse.left_click_point); if (sel_) |sel| { @@ -1572,7 +1572,15 @@ fn mouseButtonCallback( } }, - 3 => log.info("TRIPLE CLICK", .{}), + // Triple click, select the line under our mouse + 3 => { + const sel_ = win.io.terminal.screen.selectLine(win.mouse.left_click_point); + if (sel_) |sel| { + win.io.terminal.selection = sel; + win.queueRender() catch |err| + log.err("error scheduling render in mouseButtinCallback err={}", .{err}); + } + }, // We should be bounded by 1 to 3 else => unreachable, @@ -1648,7 +1656,7 @@ fn cursorPosCallback( switch (win.mouse.left_click_count) { 1 => win.dragLeftClickSingle(screen_point, xpos), 2 => win.dragLeftClickDouble(screen_point), - 3 => {}, // TODO + 3 => win.dragLeftClickTriple(screen_point), else => unreachable, } } @@ -1678,6 +1686,31 @@ fn dragLeftClickDouble( self.io.terminal.selection = sel; } +/// Triple-click dragging moves the selection one "line" at a time. +fn dragLeftClickTriple( + self: *Window, + screen_point: terminal.point.ScreenPoint, +) void { + // Get the word under our current point. If there isn't a word, do nothing. + const word = self.io.terminal.screen.selectLine(screen_point) orelse return; + + // Get our selection to grow it. If we don't have a selection, start it now. + // We may not have a selection if we started our dbl-click in an area + // that had no data, then we dragged our mouse into an area with data. + var sel = self.io.terminal.screen.selectLine(self.mouse.left_click_point) orelse { + self.io.terminal.selection = word; + return; + }; + + // Grow our selection + if (screen_point.before(self.mouse.left_click_point)) { + sel.start = word.start; + } else { + sel.end = word.end; + } + self.io.terminal.selection = sel; +} + fn dragLeftClickSingle( self: *Window, screen_point: terminal.point.ScreenPoint, diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 9c5e4bc17..9dcb3ae14 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1117,6 +1117,7 @@ pub fn selectLine(self: *Screen, pt: point.ScreenPoint) ?Selection { const current = self.getRow(.{ .screen = y }); if (!current.header().flags.wrap) break :start_row y + 1; if (y == 0) break :start_row y; + y -= 1; } unreachable; };