From dfb40426a01ba5c9be5c0e38fe2abf2d16dd1d4c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 21 Mar 2023 10:43:50 -0700 Subject: [PATCH 1/2] move selection to screen --- src/Surface.zig | 36 ++++++++++++++++++------------------ src/renderer/Metal.zig | 2 +- src/renderer/OpenGL.zig | 2 +- src/terminal/Screen.zig | 3 +++ src/terminal/Terminal.zig | 10 +++------- 5 files changed, 26 insertions(+), 27 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 422d34a36..342cd6d5d 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -822,8 +822,8 @@ pub fn charCallback(self: *Surface, codepoint: u21) !void { defer self.renderer_state.mutex.unlock(); // Clear the selction if we have one. - if (self.io.terminal.selection != null) { - self.io.terminal.selection = null; + if (self.io.terminal.screen.selection != null) { + self.io.terminal.screen.selection = null; try self.queueRender(); } @@ -941,7 +941,7 @@ pub fn keyCallback( .copy_to_clipboard => { // We can read from the renderer state without holding // the lock because only we will write to this field. - if (self.io.terminal.selection) |sel| { + if (self.io.terminal.screen.selection) |sel| { var buf = self.io.terminal.screen.selectionString( self.alloc, sel, @@ -1584,8 +1584,8 @@ pub fn mouseButtonCallback( switch (self.mouse.left_click_count) { // First mouse click, clear selection - 1 => if (self.io.terminal.selection != null) { - self.io.terminal.selection = null; + 1 => if (self.io.terminal.screen.selection != null) { + self.io.terminal.screen.selection = null; try self.queueRender(); }, @@ -1593,7 +1593,7 @@ pub fn mouseButtonCallback( 2 => { const sel_ = self.io.terminal.screen.selectWord(self.mouse.left_click_point); if (sel_) |sel| { - self.io.terminal.selection = sel; + self.io.terminal.screen.selection = sel; try self.queueRender(); } }, @@ -1602,7 +1602,7 @@ pub fn mouseButtonCallback( 3 => { const sel_ = self.io.terminal.screen.selectLine(self.mouse.left_click_point); if (sel_) |sel| { - self.io.terminal.selection = sel; + self.io.terminal.screen.selection = sel; try self.queueRender(); } }, @@ -1686,7 +1686,7 @@ fn dragLeftClickDouble( // 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; + self.io.terminal.screen.selection = word; return; }; @@ -1696,7 +1696,7 @@ fn dragLeftClickDouble( } else { sel.end = word.end; } - self.io.terminal.selection = sel; + self.io.terminal.screen.selection = sel; } /// Triple-click dragging moves the selection one "line" at a time. @@ -1711,7 +1711,7 @@ fn dragLeftClickTriple( // 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; + self.io.terminal.screen.selection = word; return; }; @@ -1721,7 +1721,7 @@ fn dragLeftClickTriple( } else { sel.end = word.end; } - self.io.terminal.selection = sel; + self.io.terminal.screen.selection = sel; } fn dragLeftClickSingle( @@ -1738,13 +1738,13 @@ fn dragLeftClickSingle( // 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 (self.io.terminal.selection) |sel| { + if (self.io.terminal.screen.selection) |sel| { const reset: bool = if (sel.end.before(sel.start)) sel.start.before(screen_point) else screen_point.before(sel.start); - if (reset) self.io.terminal.selection = null; + if (reset) self.io.terminal.screen.selection = null; } // Our logic for determing if the starting cell is selected: @@ -1776,7 +1776,7 @@ fn dragLeftClickSingle( else cell_xpos < cell_xboundary; - self.io.terminal.selection = if (selected) .{ + self.io.terminal.screen.selection = if (selected) .{ .start = screen_point, .end = screen_point, } else null; @@ -1786,7 +1786,7 @@ fn dragLeftClickSingle( // If this is a different cell and we haven't started selection, // we determine the starting cell first. - if (self.io.terminal.selection == null) { + 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. @@ -1818,7 +1818,7 @@ fn dragLeftClickSingle( } }; - self.io.terminal.selection = .{ .start = start, .end = screen_point }; + self.io.terminal.screen.selection = .{ .start = start, .end = screen_point }; return; } @@ -1827,8 +1827,8 @@ fn dragLeftClickSingle( // We moved! Set the selection end point. The start point should be // set earlier. - assert(self.io.terminal.selection != null); - self.io.terminal.selection.?.end = screen_point; + assert(self.io.terminal.screen.selection != null); + self.io.terminal.screen.selection.?.end = screen_point; } fn posToViewport(self: Surface, xpos: f64, ypos: f64) terminal.point.Viewport { diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index ff55c1e59..a7aaaeee9 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -537,7 +537,7 @@ pub fn render( // Convert our selection to viewport points because we copy only // the viewport above. - const selection: ?terminal.Selection = if (state.terminal.selection) |sel| + const selection: ?terminal.Selection = if (state.terminal.screen.selection) |sel| sel.toViewport(&state.terminal.screen) else null; diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index e44b317e2..9db367c94 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -745,7 +745,7 @@ pub fn render( // Convert our selection to viewport points because we copy only // the viewport above. - const selection: ?terminal.Selection = if (state.terminal.selection) |sel| + const selection: ?terminal.Selection = if (state.terminal.screen.selection) |sel| sel.toViewport(&state.terminal.screen) else null; diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index f50d4dfa6..5f356385a 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -774,6 +774,9 @@ cursor: Cursor = .{}, /// Saved cursor saved with DECSC (ESC 7). saved_cursor: Cursor = .{}, +/// The selection for this screen (if any). +selection: ?Selection = null, + /// Initialize a new screen. pub fn init( alloc: Allocator, diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index db801a15c..526a388d5 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -15,7 +15,6 @@ const ansi = @import("ansi.zig"); const charsets = @import("charsets.zig"); const csi = @import("csi.zig"); const sgr = @import("sgr.zig"); -const Selection = @import("Selection.zig"); const Tabstops = @import("Tabstops.zig"); const trace = @import("tracy").trace; const color = @import("color.zig"); @@ -39,9 +38,6 @@ active_screen: ScreenType, screen: Screen, secondary_screen: Screen, -/// The current selection (if any). -selection: ?Selection = null, - /// Whether we're currently writing to the status line (DECSASD and DECSSDT). /// We don't support a status line currently so we just black hole this /// data so that it doesn't mess up our main display. @@ -204,7 +200,7 @@ pub fn alternateScreen(self: *Terminal, options: AlternateScreenOptions) void { self.screen.cursor = .{}; // Clear our selection - self.selection = null; + self.screen.selection = null; if (options.clear_on_enter) { self.eraseDisplay(.complete); @@ -231,7 +227,7 @@ pub fn primaryScreen(self: *Terminal, options: AlternateScreenOptions) void { self.active_screen = .primary; // Clear our selection - self.selection = null; + self.screen.selection = null; // Restore the cursor from the primary screen if (options.cursor_save) self.restoreCursor(); @@ -1393,7 +1389,6 @@ pub fn setScrollingRegion(self: *Terminal, top: usize, bottom: usize) void { /// Full reset pub fn fullReset(self: *Terminal) void { self.primaryScreen(.{ .clear_on_exit = true, .cursor_save = true }); - self.selection = null; self.charset = .{}; self.eraseDisplay(.scrollback); self.eraseDisplay(.complete); @@ -1401,6 +1396,7 @@ pub fn fullReset(self: *Terminal) void { self.tabstops.reset(0); self.screen.cursor = .{}; self.screen.saved_cursor = .{}; + self.screen.selection = null; self.scrolling_region = .{ .top = 0, .bottom = self.rows - 1 }; self.previous_char = null; } From 70236ebc33420418ed940ab81df69d59609ab1d5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 21 Mar 2023 10:59:44 -0700 Subject: [PATCH 2/2] terminal: screen scroll with full scrollback modifies selection --- src/terminal/Screen.zig | 255 ++++++++++++++++++++++++++++--------- src/terminal/Selection.zig | 5 + 2 files changed, 202 insertions(+), 58 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 5f356385a..8e90ca995 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1440,68 +1440,85 @@ fn scrollDelta(self: *Screen, delta: isize, grow: bool) !void { // then we expand out to there. const rows_written = self.rowsWritten(); const viewport_bottom = self.viewport + self.rows; - if (viewport_bottom > rows_written) { - // The number of new rows we need is the number of rows off our - // previous bottom we are growing. - const new_rows_needed = viewport_bottom - rows_written; + if (viewport_bottom <= rows_written) return; - // If we can't fit into our capacity but we have space, resize the - // buffer to allocate more scrollback. - const rows_final = rows_written + new_rows_needed; - if (rows_final > self.rowsCapacity()) { - const max_capacity = self.maxCapacity(); - if (self.storage.capacity() < max_capacity) { - // The capacity we want to allocate. We take whatever is greater - // of what we actually need and two pages. We don't want to - // allocate one row at a time (common for scrolling) so we do this - // to chunk it. - const needed_capacity = @max( - rows_final * (self.cols + 1), - @min(self.storage.capacity() * 2, max_capacity), - ); + // The number of new rows we need is the number of rows off our + // previous bottom we are growing. + const new_rows_needed = viewport_bottom - rows_written; - // Allocate what we can. - try self.storage.resize( - self.alloc, - @min(max_capacity, needed_capacity), - ); - } + // If we can't fit into our capacity but we have space, resize the + // buffer to allocate more scrollback. + const rows_final = rows_written + new_rows_needed; + if (rows_final > self.rowsCapacity()) { + const max_capacity = self.maxCapacity(); + if (self.storage.capacity() < max_capacity) { + // The capacity we want to allocate. We take whatever is greater + // of what we actually need and two pages. We don't want to + // allocate one row at a time (common for scrolling) so we do this + // to chunk it. + const needed_capacity = @max( + rows_final * (self.cols + 1), + @min(self.storage.capacity() * 2, max_capacity), + ); + + // Allocate what we can. + try self.storage.resize( + self.alloc, + @min(max_capacity, needed_capacity), + ); } - - // If we can't fit our rows into our capacity, we delete some scrollback. - const rows_deleted = if (rows_final > self.rowsCapacity()) deleted: { - const rows_to_delete = rows_final - self.rowsCapacity(); - - // Fast-path: we have no graphemes. - // Slow-path: we have graphemes, we have to check each row - // we're going to delete to see if they contain graphemes and - // clear the ones that do so we clear memory properly. - if (self.graphemes.count() > 0) { - var y: usize = 0; - while (y < rows_to_delete) : (y += 1) { - const row = self.getRow(.{ .active = y }); - if (row.storage[0].header.flags.grapheme) row.clear(.{}); - } - } - - self.viewport -= rows_to_delete; - self.storage.deleteOldest(rows_to_delete * (self.cols + 1)); - break :deleted rows_to_delete; - } else 0; - - // If we have more rows than what shows on our screen, we have a - // history boundary. - const rows_written_final = rows_final - rows_deleted; - if (rows_written_final > self.rows) { - self.history = rows_written_final - self.rows; - } - - // Ensure we have "written" our last row so that it shows up - _ = self.storage.getPtrSlice( - (rows_written_final - 1) * (self.cols + 1), - self.cols + 1, - ); } + + // If we can't fit our rows into our capacity, we delete some scrollback. + const rows_deleted = if (rows_final > self.rowsCapacity()) deleted: { + const rows_to_delete = rows_final - self.rowsCapacity(); + + // Fast-path: we have no graphemes. + // Slow-path: we have graphemes, we have to check each row + // we're going to delete to see if they contain graphemes and + // clear the ones that do so we clear memory properly. + if (self.graphemes.count() > 0) { + var y: usize = 0; + while (y < rows_to_delete) : (y += 1) { + const row = self.getRow(.{ .active = y }); + if (row.storage[0].header.flags.grapheme) row.clear(.{}); + } + } + + self.viewport -= rows_to_delete; + self.storage.deleteOldest(rows_to_delete * (self.cols + 1)); + break :deleted rows_to_delete; + } else 0; + + // If we are deleting rows and have a selection, then we need to offset + // the selection by the rows we're deleting. + if (self.selection) |*sel| { + // If we're deleting more rows than our Y values, we also move + // the X over to 0 because we're in the middle of the selection now. + if (rows_deleted > sel.start.y) sel.start.x = 0; + if (rows_deleted > sel.end.y) sel.end.x = 0; + + // Remove the deleted rows from both y values. We use saturating + // subtraction so that we can detect when we're at zero. + sel.start.y -|= rows_deleted; + sel.end.y -|= rows_deleted; + + // If the selection is now empty, just clear it. + if (sel.empty()) self.selection = null; + } + + // If we have more rows than what shows on our screen, we have a + // history boundary. + const rows_written_final = rows_final - rows_deleted; + if (rows_written_final > self.rows) { + self.history = rows_written_final - self.rows; + } + + // Ensure we have "written" our last row so that it shows up + _ = self.storage.getPtrSlice( + (rows_written_final - 1) * (self.cols + 1), + self.cols + 1, + ); } /// Returns the raw text associated with a selection. This will unwrap @@ -2776,6 +2793,128 @@ test "Screen: scrollback empty" { } } +test "Screen: scrolling moves selection" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 0); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + try testing.expect(s.viewportIsBottom()); + + // Select a single line + s.selection = .{ + .start = .{ .x = 0, .y = 1 }, + .end = .{ .x = s.cols - 1, .y = 1 }, + }; + + // Scroll down, should still be bottom + try s.scroll(.{ .delta = 1 }); + try testing.expect(s.viewportIsBottom()); + + // Our selection should've moved up + try testing.expectEqual(Selection{ + .start = .{ .x = 0, .y = 0 }, + .end = .{ .x = s.cols - 1, .y = 0 }, + }, s.selection.?); + + { + // Test our contents rotated + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + } + + // Scrolling to the bottom does nothing + try s.scroll(.{ .bottom = {} }); + + // Our selection should've stayed the same + try testing.expectEqual(Selection{ + .start = .{ .x = 0, .y = 0 }, + .end = .{ .x = s.cols - 1, .y = 0 }, + }, s.selection.?); + + { + // Test our contents rotated + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + } + + // Scroll up again + try s.scroll(.{ .delta = 1 }); + + // Our selection should be null because it left the screen. + try testing.expect(s.selection == null); +} + +test "Screen: scrolling with scrollback available doesn't move selection" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 1); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + try testing.expect(s.viewportIsBottom()); + + // Select a single line + s.selection = .{ + .start = .{ .x = 0, .y = 1 }, + .end = .{ .x = s.cols - 1, .y = 1 }, + }; + + // Scroll down, should still be bottom + try s.scroll(.{ .delta = 1 }); + try testing.expect(s.viewportIsBottom()); + + // Our selection should NOT move since we have scrollback + try testing.expectEqual(Selection{ + .start = .{ .x = 0, .y = 1 }, + .end = .{ .x = s.cols - 1, .y = 1 }, + }, s.selection.?); + + { + // Test our contents rotated + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + } + + // Scrolling back should make it visible again + try s.scroll(.{ .delta = -1 }); + try testing.expect(!s.viewportIsBottom()); + + // Our selection should NOT move since we have scrollback + try testing.expectEqual(Selection{ + .start = .{ .x = 0, .y = 1 }, + .end = .{ .x = s.cols - 1, .y = 1 }, + }, s.selection.?); + + { + // Test our contents rotated + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); + } + + // Scroll down, this sends us off the scrollback + try s.scroll(.{ .delta = 2 }); + try testing.expect(s.viewportIsBottom()); + + // Selection should move since we went down 2 + try testing.expectEqual(Selection{ + .start = .{ .x = 0, .y = 0 }, + .end = .{ .x = s.cols - 1, .y = 0 }, + }, s.selection.?); + + { + // Test our contents rotated + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("3IJKL", contents); + } +} + test "Screen: history region with no scrollback" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/Selection.zig b/src/terminal/Selection.zig index 973e1ab67..66853d3be 100644 --- a/src/terminal/Selection.zig +++ b/src/terminal/Selection.zig @@ -33,6 +33,11 @@ pub fn toViewport(self: Selection, screen: *const Screen) ?Selection { }; } +/// Returns true if the selection is empty. +pub fn empty(self: Selection) bool { + return self.start.x == self.end.x and self.start.y == self.end.y; +} + /// Returns true if the selection contains the given point. /// /// This recalculates top left and bottom right each call. If you have