diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml new file mode 100644 index 0000000000..f7b26e5b93 --- /dev/null +++ b/.github/workflows/release-pr.yml @@ -0,0 +1,151 @@ +on: + pull_request: + types: [opened, reopened, synchronize] + + workflow_dispatch: {} + +name: Release PR + +jobs: + build-macos: + runs-on: namespace-profile-ghostty-macos + timeout-minutes: 90 + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + # Important so that build number generation works + fetch-depth: 0 + + # Install Nix and use that to run our tests so our environment matches exactly. + - uses: cachix/install-nix-action@v26 + with: + nix_path: nixpkgs=channel:nixos-unstable + - uses: cachix/cachix-action@v14 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + # Setup Sparkle + - name: Setup Sparkle + env: + SPARKLE_VERSION: 2.5.1 + run: | + mkdir -p .action/sparkle + cd .action/sparkle + curl -L https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-for-Swift-Package-Manager.zip > sparkle.zip + unzip sparkle.zip + echo "$(pwd)/bin" >> $GITHUB_PATH + + # Load Build Number + - name: Build Number + run: | + echo "GHOSTTY_BUILD=$(git rev-list --count head)" >> $GITHUB_ENV + echo "GHOSTTY_COMMIT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV + + # GhosttyKit is the framework that is built from Zig for our native + # Mac app to access. Build this in release mode. + - name: Build GhosttyKit + run: nix develop -c zig build -Dstatic=true -Doptimize=ReleaseSafe + + # The native app is built with native XCode tooling. This also does + # codesigning. IMPORTANT: this must NOT run in a Nix environment. + # Nix breaks xcodebuild so this has to be run outside. + - name: Build Ghostty.app + run: cd macos && xcodebuild -target Ghostty -configuration Release + + # We inject the "build number" as simply the number of commits since HEAD. + # This will be a monotonically always increasing build number that we use. + - name: Update Info.plist + env: + SPARKLE_KEY_PUB: ${{ secrets.PROD_MACOS_SPARKLE_KEY_PUB }} + run: | + # Version Info + /usr/libexec/PlistBuddy -c "Set :GhosttyCommit $GHOSTTY_COMMIT" "macos/build/Release/Ghostty.app/Contents/Info.plist" + /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $GHOSTTY_BUILD" "macos/build/Release/Ghostty.app/Contents/Info.plist" + /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $GHOSTTY_COMMIT" "macos/build/Release/Ghostty.app/Contents/Info.plist" + + # Updater + /usr/libexec/PlistBuddy -c "Set :SUPublicEDKey $SPARKLE_KEY_PUB" "macos/build/Release/Ghostty.app/Contents/Info.plist" + + - name: Codesign app bundle + env: + MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PWD: ${{ secrets.PROD_MACOS_CERTIFICATE_PWD }} + MACOS_CERTIFICATE_NAME: ${{ secrets.PROD_MACOS_CERTIFICATE_NAME }} + MACOS_CI_KEYCHAIN_PWD: ${{ secrets.PROD_MACOS_CI_KEYCHAIN_PWD }} + run: | + # Turn our base64-encoded certificate back to a regular .p12 file + echo $MACOS_CERTIFICATE | base64 --decode > certificate.p12 + + # We need to create a new keychain, otherwise using the certificate will prompt + # with a UI dialog asking for the certificate password, which we can't + # use in a headless CI environment + security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain + security import certificate.p12 -k build.keychain -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MACOS_CI_KEYCHAIN_PWD" build.keychain + + # Codesign Sparkle. Some notes here: + # - The XPC services aren't used since we don't sandbox Ghostty, + # but since they're part of the build, they still need to be + # codesigned. + # - The binaries in the "Versions" folders need to NOT be symlinks. + /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/XPCServices/Downloader.xpc" + /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/XPCServices/Installer.xpc" + /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/Autoupdate" + /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/Updater.app" + /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework" + + # Codesign the app bundle + /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime --entitlements "macos/Ghostty.entitlements" macos/build/Release/Ghostty.app + + - name: "Notarize app bundle" + env: + PROD_MACOS_NOTARIZATION_APPLE_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_APPLE_ID }} + PROD_MACOS_NOTARIZATION_TEAM_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_TEAM_ID }} + PROD_MACOS_NOTARIZATION_PWD: ${{ secrets.PROD_MACOS_NOTARIZATION_PWD }} + run: | + # Store the notarization credentials so that we can prevent a UI password dialog + # from blocking the CI + echo "Create keychain profile" + xcrun notarytool store-credentials "notarytool-profile" --apple-id "$PROD_MACOS_NOTARIZATION_APPLE_ID" --team-id "$PROD_MACOS_NOTARIZATION_TEAM_ID" --password "$PROD_MACOS_NOTARIZATION_PWD" + + # We can't notarize an app bundle directly, but we need to compress it as an archive. + # Therefore, we create a zip file containing our app bundle, so that we can send it to the + # notarization service + echo "Creating temp notarization archive" + ditto -c -k --keepParent "macos/build/Release/Ghostty.app" "notarization.zip" + + # Here we send the notarization request to the Apple's Notarization service, waiting for the result. + # This typically takes a few seconds inside a CI environment, but it might take more depending on the App + # characteristics. Visit the Notarization docs for more information and strategies on how to optimize it if + # you're curious + echo "Notarize app" + xcrun notarytool submit "notarization.zip" --keychain-profile "notarytool-profile" --wait + + # Finally, we need to "attach the staple" to our executable, which will allow our app to be + # validated by macOS even when an internet connection is not available. + echo "Attach staple" + xcrun stapler staple "macos/build/Release/Ghostty.app" + + # Zip up the app + - name: Zip App + run: cd macos/build/Release && zip -9 -r --symlinks ../../../ghostty-macos-universal.zip Ghostty.app + + # Update Blob Storage + - name: Prep R2 Storage + run: | + mkdir blob + mkdir -p blob/${GHOSTTY_BUILD} + cp ghostty-macos-universal.zip blob/${GHOSTTY_BUILD}/ghostty-macos-universal.zip + - name: Upload to R2 + uses: ryand56/r2-upload-action@latest + with: + r2-account-id: ${{ secrets.CF_R2_PR_ACCOUNT_ID }} + r2-access-key-id: ${{ secrets.CF_R2_PR_AWS_KEY }} + r2-secret-access-key: ${{ secrets.CF_R2_PR_SECRET_KEY }} + r2-bucket: ghostty-pr + source-dir: blob + destination-dir: ./ diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index 837a9c7eb9..a8723d4285 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -32,11 +32,8 @@ jobs: ) }} - runs-on: ghcr.io/cirruslabs/macos-ventura-xcode:latest + runs-on: namespace-profile-ghostty-macos timeout-minutes: 90 - env: - # Needed for macos SDK - AGREE: "true" steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/TODO.md b/TODO.md index 893233dea2..8e7d958242 100644 --- a/TODO.md +++ b/TODO.md @@ -1,9 +1,6 @@ Performance: -- for scrollback, investigate using segmented list for sufficiently large - scrollback scenarios. - Loading fonts on startups should probably happen in multiple threads -- `deleteLines` is very, very slow which makes scroll region benchmarks terrible Correctness: @@ -15,10 +12,6 @@ Correctness: - can effect a crash using `vttest` menu `3 10` since it tries to parse ASCII as UTF-8. -Improvements: - -- scrollback: configurable - Mac: - Preferences window diff --git a/flake.nix b/flake.nix index 0536b52470..ad7d4da173 100644 --- a/flake.nix +++ b/flake.nix @@ -53,10 +53,22 @@ }; packages.${system} = rec { - ghostty = pkgs-stable.callPackage ./nix/package.nix { + ghostty-debug = pkgs-stable.callPackage ./nix/package.nix { inherit (pkgs-zig-0-12) zig_0_12; revision = self.shortRev or self.dirtyShortRev or "dirty"; + optimize = "Debug"; }; + ghostty-releasesafe = pkgs-stable.callPackage ./nix/package.nix { + inherit (pkgs-zig-0-12) zig_0_12; + revision = self.shortRev or self.dirtyShortRev or "dirty"; + optimize = "ReleaseSafe"; + }; + ghostty-releasefast = pkgs-stable.callPackage ./nix/package.nix { + inherit (pkgs-zig-0-12) zig_0_12; + revision = self.shortRev or self.dirtyShortRev or "dirty"; + optimize = "ReleaseFast"; + }; + ghostty = ghostty-releasesafe; default = ghostty; }; diff --git a/nix/package.nix b/nix/package.nix index b7a96da015..b638831da4 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -25,6 +25,7 @@ zig_0_12, pandoc, revision ? "dirty", + optimize ? "Debug", }: let # The Zig hook has no way to select the release type without actual # overriding of the default flags. @@ -34,7 +35,7 @@ # ultimately acted on and has made its way to a nixpkgs implementation, this # can probably be removed in favor of that. zig012Hook = zig_0_12.hook.overrideAttrs { - zig_default_flags = "-Dcpu=baseline -Doptimize=ReleaseFast"; + zig_default_flags = "-Dcpu=baseline -Doptimize=${optimize}"; }; # This hash is the computation of the zigCache fixed-output derivation. This diff --git a/src/Surface.zig b/src/Surface.zig index e61977d539..a5ecb3eba5 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -156,7 +156,8 @@ const Mouse = struct { /// The point at which the left mouse click happened. This is in screen /// coordinates so that scrolling preserves the location. - left_click_point: terminal.point.ScreenPoint = .{}, + left_click_pin: ?*terminal.Pin = null, + left_click_screen: terminal.ScreenType = .primary, /// The starting xpos/ypos of the left click. Note that if scrolling occurs, /// these will point to different "cells", but the xpos/ypos will stay @@ -171,7 +172,7 @@ const Mouse = struct { left_click_time: std.time.Instant = undefined, /// The last x/y sent for mouse reports. - event_point: ?terminal.point.Viewport = null, + event_point: ?terminal.point.Coordinate = null, /// Pending scroll amounts for high-precision scrolls pending_scroll_x: f64 = 0, @@ -185,7 +186,7 @@ const Mouse = struct { /// The last x/y in the cursor position for links. We use this to /// only process link hover events when the mouse actually moves cells. - link_point: ?terminal.point.Viewport = null, + link_point: ?terminal.point.Coordinate = null, }; /// The configuration that a surface has, this is copied from the main @@ -945,7 +946,10 @@ pub fn selectionString(self: *Surface, alloc: Allocator) !?[]const u8 { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); const sel = self.io.terminal.screen.selection orelse return null; - return try self.io.terminal.screen.selectionString(alloc, sel, false); + return try self.io.terminal.screen.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); } /// Returns the pwd of the terminal, if any. This is always copied because @@ -1048,9 +1052,9 @@ fn clipboardWrite(self: *const Surface, data: []const u8, loc: apprt.Clipboard) /// Set the selection contents. /// /// This must be called with the renderer mutex held. -fn setSelection(self: *Surface, sel_: ?terminal.Selection) void { +fn setSelection(self: *Surface, sel_: ?terminal.Selection) !void { const prev_ = self.io.terminal.screen.selection; - self.io.terminal.screen.selection = sel_; + try self.io.terminal.screen.select(sel_); // Determine the clipboard we want to copy selection to, if it is enabled. const clipboard: apprt.Clipboard = switch (self.config.copy_on_select) { @@ -1064,7 +1068,7 @@ fn setSelection(self: *Surface, sel_: ?terminal.Selection) void { // again if it changed, since setting the clipboard can be an expensive // operation. const sel = sel_ orelse return; - if (prev_) |prev| if (std.meta.eql(sel, prev)) return; + if (prev_) |prev| if (sel.eql(prev)) return; // Check if our runtime supports the selection clipboard at all. // We can save a lot of work if it doesn't. @@ -1074,11 +1078,10 @@ fn setSelection(self: *Surface, sel_: ?terminal.Selection) void { } } - const buf = self.io.terminal.screen.selectionString( - self.alloc, - sel, - self.config.clipboard_trim_trailing_spaces, - ) catch |err| { + const buf = self.io.terminal.screen.selectionString(self.alloc, .{ + .sel = sel, + .trim = self.config.clipboard_trim_trailing_spaces, + }) catch |err| { log.err("error reading selection string err={}", .{err}); return; }; @@ -1357,42 +1360,46 @@ pub fn keyCallback( if (event.mods.shift) adjust_selection: { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - var screen = self.io.terminal.screen; - const sel = sel: { - const old_sel = screen.selection orelse break :adjust_selection; - break :sel old_sel.adjust(&screen, switch (event.key) { - .left => .left, - .right => .right, - .up => .up, - .down => .down, - .page_up => .page_up, - .page_down => .page_down, - .home => .home, - .end => .end, - else => break :adjust_selection, - }); - }; + var screen = &self.io.terminal.screen; + const sel = if (screen.selection) |*sel| sel else break :adjust_selection; - // Silently consume key releases. + // Silently consume key releases. We only want to process selection + // adjust on press. if (event.action != .press and event.action != .repeat) return .consumed; + sel.adjust(screen, switch (event.key) { + .left => .left, + .right => .right, + .up => .up, + .down => .down, + .page_up => .page_up, + .page_down => .page_down, + .home => .home, + .end => .end, + else => break :adjust_selection, + }); + // If the selection endpoint is outside of the current viewpoint, - // scroll it in to view. + // scroll it in to view. Note we always specifically use sel.end + // because that is what adjust modifies. scroll: { - const viewport_max = terminal.Screen.RowIndexTag.viewport.maxLen(&screen) - 1; - const viewport_end = screen.viewport + viewport_max; - const delta: isize = if (sel.end.y < screen.viewport) - @intCast(screen.viewport) - else if (sel.end.y > viewport_end) - @intCast(viewport_end) - else + const viewport_tl = screen.pages.getTopLeft(.viewport); + const viewport_br = screen.pages.getBottomRight(.viewport).?; + if (sel.end().isBetween(viewport_tl, viewport_br)) break :scroll; - const start_y: isize = @intCast(sel.end.y); - try self.io.terminal.scrollViewport(.{ .delta = start_y - delta }); + + // Our end point is not within the viewport. If the end + // point is after the br then we need to adjust the end so + // that it is at the bottom right of the viewport. + const target = if (sel.end().before(viewport_tl)) + sel.end() + else + sel.end().up(screen.pages.rows - 1) orelse sel.end(); + + screen.scroll(.{ .pin = target }); } - // Change our selection and queue a render so its shown. - self.setSelection(sel); + // Queue a render so its shown try self.queueRender(); return .consumed; } @@ -1542,7 +1549,7 @@ pub fn keyCallback( if (!event.key.modifier()) { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - self.setSelection(null); + try self.setSelection(null); try self.io.terminal.scrollViewport(.{ .bottom = {} }); try self.queueRender(); } @@ -1749,7 +1756,7 @@ pub fn scrollCallback( // The selection can occur if the user uses the shift mod key to // override mouse grabbing from the window. if (self.io.terminal.flags.mouse_event != .none) { - self.setSelection(null); + try self.setSelection(null); } // If we're in alternate screen with alternate scroll enabled, then @@ -1763,7 +1770,7 @@ pub fn scrollCallback( if (y.delta_unsigned > 0) { // When we send mouse events as cursor keys we always // clear the selection. - self.setSelection(null); + try self.setSelection(null); const seq = if (self.io.terminal.modes.get(.cursor_keys)) seq: { // cursor key: application mode @@ -2138,17 +2145,20 @@ pub fn mouseButtonCallback( { const pos = try self.rt_surface.getCursorPos(); const point = self.posToViewport(pos.x, pos.y); - const cell = self.renderer_state.terminal.screen.getCell( - .viewport, - point.y, - point.x, - ); + const screen = &self.renderer_state.terminal.screen; + const p = screen.pages.pin(.{ .active = point }) orelse { + log.warn("failed to get pin for clicked point", .{}); + return; + }; - insp.cell = .{ .selected = .{ - .row = point.y, - .col = point.x, - .cell = cell, - } }; + insp.cell.select( + self.alloc, + p, + point.x, + point.y, + ) catch |err| { + log.warn("error selecting cell for inspector err={}", .{err}); + }; return; } } @@ -2217,7 +2227,7 @@ pub fn mouseButtonCallback( // In any other mouse button scenario without shift pressed we // clear the selection since the underlying application can handle // that in any way (i.e. "scrolling"). - self.setSelection(null); + try self.setSelection(null); // We also set the left click count to 0 so that if mouse reporting // is disabled in the middle of press (before release) we don't @@ -2245,25 +2255,44 @@ pub fn mouseButtonCallback( } // For left button click release we check if we are moving our cursor. - if (button == .left and action == .release and mods.alt) { + if (button == .left and action == .release and mods.alt) click_move: { // Moving always resets the click count so that we don't highlight. self.mouse.left_click_count = 0; - + const pin = self.mouse.left_click_pin orelse break :click_move; self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - try self.clickMoveCursor(self.mouse.left_click_point); + try self.clickMoveCursor(pin.*); return; } // For left button clicks we always record some information for // selection/highlighting purposes. - if (button == .left and action == .press) { + if (button == .left and action == .press) click: { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); + const t: *terminal.Terminal = self.renderer_state.terminal; + const screen = &self.renderer_state.terminal.screen; const pos = try self.rt_surface.getCursorPos(); - const pt_viewport = self.posToViewport(pos.x, pos.y); - const pt_screen = pt_viewport.toScreen(&self.io.terminal.screen); + const pin = pin: { + const pt_viewport = self.posToViewport(pos.x, pos.y); + const pin = screen.pages.pin(.{ + .viewport = .{ + .x = pt_viewport.x, + .y = pt_viewport.y, + }, + }) orelse { + // Weird... our viewport x/y that we just converted isn't + // found in our pages. This is probably a bug but we don't + // want to crash in releases because its harmless. So, we + // only assert in debug mode. + if (comptime std.debug.runtime_safety) unreachable; + break :click; + }; + + break :pin try screen.pages.trackPin(pin); + }; + errdefer screen.pages.untrackPin(pin); // If we move our cursor too much between clicks then we reset // the multi-click state. @@ -2277,8 +2306,15 @@ pub fn mouseButtonCallback( if (distance > max_distance) self.mouse.left_click_count = 0; } + if (self.mouse.left_click_pin) |prev| { + const pin_screen = t.getScreen(self.mouse.left_click_screen); + pin_screen.pages.untrackPin(prev); + self.mouse.left_click_pin = null; + } + // Store it - self.mouse.left_click_point = pt_screen; + self.mouse.left_click_pin = pin; + self.mouse.left_click_screen = t.active_screen; self.mouse.left_click_xpos = pos.x; self.mouse.left_click_ypos = pos.y; @@ -2308,16 +2344,16 @@ pub fn mouseButtonCallback( 1 => { // If we have a selection, clear it. This always happens. if (self.io.terminal.screen.selection != null) { - self.setSelection(null); + try self.setSelection(null); try self.queueRender(); } }, // Double click, select the word under our mouse 2 => { - const sel_ = self.io.terminal.screen.selectWord(self.mouse.left_click_point); + const sel_ = self.io.terminal.screen.selectWord(pin.*); if (sel_) |sel| { - self.setSelection(sel); + try self.setSelection(sel); try self.queueRender(); } }, @@ -2325,11 +2361,11 @@ pub fn mouseButtonCallback( // Triple click, select the line under our mouse 3 => { const sel_ = if (mods.ctrl) - self.io.terminal.screen.selectOutput(self.mouse.left_click_point) + self.io.terminal.screen.selectOutput(pin.*) else - self.io.terminal.screen.selectLine(self.mouse.left_click_point); + self.io.terminal.screen.selectLine(.{ .pin = pin.* }); if (sel_) |sel| { - self.setSelection(sel); + try self.setSelection(sel); try self.queueRender(); } }, @@ -2360,7 +2396,7 @@ pub fn mouseButtonCallback( /// Performs the "click-to-move" logic to move the cursor to the given /// screen point if possible. This works by converting the path to the /// given point into a series of arrow key inputs. -fn clickMoveCursor(self: *Surface, to: terminal.point.ScreenPoint) !void { +fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void { // If click-to-move is disabled then we're done. if (!self.config.cursor_click_to_move) return; @@ -2377,10 +2413,7 @@ fn clickMoveCursor(self: *Surface, to: terminal.point.ScreenPoint) !void { if (!t.flags.shell_redraws_prompt) return; // Get our path - const from = (terminal.point.Viewport{ - .x = t.screen.cursor.x, - .y = t.screen.cursor.y, - }).toScreen(&t.screen); + const from = t.screen.cursor.page_pin.*; const path = t.screen.promptPath(from, to); log.debug("click-to-move-cursor from={} to={} path={}", .{ from, to, path }); @@ -2432,18 +2465,32 @@ fn linkAtPos( if (self.config.links.len == 0) return null; // Convert our cursor position to a screen point. - const mouse_pt = mouse_pt: { - const viewport_point = self.posToViewport(pos.x, pos.y); - break :mouse_pt viewport_point.toScreen(&self.io.terminal.screen); + const screen = &self.renderer_state.terminal.screen; + const mouse_pin: terminal.Pin = mouse_pin: { + const point = self.posToViewport(pos.x, pos.y); + const pin = screen.pages.pin(.{ .viewport = point }) orelse { + log.warn("failed to get pin for clicked point", .{}); + return null; + }; + break :mouse_pin pin; }; // Get our comparison mods const mouse_mods = self.mouseModsWithCapture(self.mouse.mods); // Get the line we're hovering over. - const line = self.io.terminal.screen.getLine(mouse_pt) orelse - return null; - const strmap = try line.stringMap(self.alloc); + const line = screen.selectLine(.{ + .pin = mouse_pin, + .whitespace = null, + .semantic_prompt_boundary = false, + }) orelse return null; + + var strmap: terminal.StringMap = undefined; + self.alloc.free(try screen.selectionString(self.alloc, .{ + .sel = line, + .trim = false, + .map = &strmap, + })); defer strmap.deinit(self.alloc); // Go through each link and see if we clicked it @@ -2458,7 +2505,7 @@ fn linkAtPos( var match = (try it.next()) orelse break; defer match.deinit(); const sel = match.selection(); - if (!sel.contains(mouse_pt)) continue; + if (!sel.contains(screen, mouse_pin)) continue; return .{ link, sel }; } } @@ -2493,11 +2540,10 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool { const link, const sel = try self.linkAtPos(pos) orelse return false; switch (link.action) { .open => { - const str = try self.io.terminal.screen.selectionString( - self.alloc, - sel, - false, - ); + const str = try self.io.terminal.screen.selectionString(self.alloc, .{ + .sel = sel, + .trim = false, + }); defer self.alloc.free(str); try internal_os.open(self.alloc, str); }, @@ -2535,7 +2581,12 @@ pub fn cursorPosCallback( if (self.inspector) |insp| { insp.mouse.last_xpos = pos.x; insp.mouse.last_ypos = pos.y; - insp.mouse.last_point = pos_vp.toScreen(&self.io.terminal.screen); + + const screen = &self.renderer_state.terminal.screen; + insp.mouse.last_point = screen.pages.pin(.{ .viewport = .{ + .x = pos_vp.x, + .y = pos_vp.y, + } }); try self.queueRender(); } @@ -2589,13 +2640,22 @@ pub fn cursorPosCallback( } // Convert to points - const screen_point = pos_vp.toScreen(&self.io.terminal.screen); + const screen = &self.renderer_state.terminal.screen; + const pin = screen.pages.pin(.{ + .viewport = .{ + .x = pos_vp.x, + .y = pos_vp.y, + }, + }) orelse { + if (comptime std.debug.runtime_safety) unreachable; + return; + }; // Handle dragging depending on click count switch (self.mouse.left_click_count) { - 1 => self.dragLeftClickSingle(screen_point, pos.x), - 2 => self.dragLeftClickDouble(screen_point), - 3 => self.dragLeftClickTriple(screen_point), + 1 => try self.dragLeftClickSingle(pin, pos.x), + 2 => try self.dragLeftClickDouble(pin), + 3 => try self.dragLeftClickTriple(pin), 0 => unreachable, // handled above else => unreachable, } @@ -2634,71 +2694,76 @@ pub fn cursorPosCallback( /// Double-click dragging moves the selection one "word" at a time. fn dragLeftClickDouble( self: *Surface, - screen_point: terminal.point.ScreenPoint, -) void { + drag_pin: terminal.Pin, +) !void { + const screen = &self.io.terminal.screen; + const click_pin = self.mouse.left_click_pin.?.*; + // Get the word closest to our starting click. - const word_start = self.io.terminal.screen.selectWordBetween( - self.mouse.left_click_point, - screen_point, - ) orelse { - self.setSelection(null); + const word_start = screen.selectWordBetween(click_pin, drag_pin) orelse { + try self.setSelection(null); return; }; // Get the word closest to our current point. - const word_current = self.io.terminal.screen.selectWordBetween( - screen_point, - self.mouse.left_click_point, + const word_current = screen.selectWordBetween( + drag_pin, + click_pin, ) orelse { - self.setSelection(null); + try self.setSelection(null); return; }; // If our current mouse position is before the starting position, // then the seletion start is the word nearest our current position. - if (screen_point.before(self.mouse.left_click_point)) { - self.setSelection(.{ - .start = word_current.start, - .end = word_start.end, - }); + if (drag_pin.before(click_pin)) { + try self.setSelection(terminal.Selection.init( + word_current.start(), + word_start.end(), + false, + )); } else { - self.setSelection(.{ - .start = word_start.start, - .end = word_current.end, - }); + try self.setSelection(terminal.Selection.init( + word_start.start(), + word_current.end(), + false, + )); } } /// Triple-click dragging moves the selection one "line" at a time. fn dragLeftClickTriple( self: *Surface, - screen_point: terminal.point.ScreenPoint, -) void { + drag_pin: terminal.Pin, +) !void { + const screen = &self.io.terminal.screen; + const click_pin = self.mouse.left_click_pin.?.*; + // 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; + const word = screen.selectLine(.{ .pin = drag_pin }) 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.setSelection(word); + var sel = screen.selectLine(.{ .pin = click_pin }) orelse { + try self.setSelection(word); return; }; // Grow our selection - if (screen_point.before(self.mouse.left_click_point)) { - sel.start = word.start; + if (drag_pin.before(click_pin)) { + sel.startPtr().* = word.start(); } else { - sel.end = word.end; + sel.endPtr().* = word.end(); } - self.setSelection(sel); + try self.setSelection(sel); } fn dragLeftClickSingle( self: *Surface, - screen_point: terminal.point.ScreenPoint, + drag_pin: terminal.Pin, xpos: f64, -) void { +) !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 @@ -2708,7 +2773,7 @@ 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. - self.checkResetSelSwitch(screen_point); + self.checkResetSelSwitch(drag_pin); // Our logic for determining if the starting cell is selected: // @@ -2722,19 +2787,22 @@ fn dragLeftClickSingle( // - 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.cell_size.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.padding.left)); - const cell_xstart = @as(f64, @floatFromInt(self.mouse.left_click_point.x)) * cell_width_f64; + 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 (std.meta.eql(screen_point, self.mouse.left_click_point)) { + 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) @@ -2742,11 +2810,11 @@ fn dragLeftClickSingle( else cell_xpos < cell_xboundary; - self.setSelection(if (selected) .{ - .start = screen_point, - .end = screen_point, - .rectangle = self.mouse.mods.ctrlOrSuper() and self.mouse.mods.alt, - } else null); + try self.setSelection(if (selected) terminal.Selection.init( + drag_pin, + drag_pin, + self.mouse.mods.ctrlOrSuper() and self.mouse.mods.alt, + ) else null); return; } @@ -2758,42 +2826,30 @@ fn dragLeftClickSingle( // 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 = self.mouse.left_click_point; - const start: terminal.point.ScreenPoint = if (dragLeftClickBefore( - screen_point, - click_point, + const start: terminal.Pin = if (dragLeftClickBefore( + drag_pin, + click_pin, self.mouse.mods, )) start: { - if (cell_start_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 = self.io.terminal.screen.cols - 1, - .y = click_point.y -| 1, - }; - } + 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_point; - } else { - 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{ - .y = click_point.y + 1, - .x = 0, - }; - } + 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; }; - self.setSelection(.{ - .start = start, - .end = screen_point, - .rectangle = self.mouse.mods.ctrlOrSuper() and self.mouse.mods.alt, - }); + try self.setSelection(terminal.Selection.init( + start, + drag_pin, + self.mouse.mods.ctrlOrSuper() and self.mouse.mods.alt, + )); return; } @@ -2803,15 +2859,24 @@ fn dragLeftClickSingle( // We moved! Set the selection end point. The start point should be // set earlier. assert(self.io.terminal.screen.selection != null); - var sel = self.io.terminal.screen.selection.?; - sel.end = screen_point; - self.setSelection(sel); + const sel = self.io.terminal.screen.selection.?; + try self.setSelection(terminal.Selection.init( + sel.start(), + drag_pin, + sel.rectangle, + )); } // Resets the selection if we switched directions, depending on the select // mode. See dragLeftClickSingle for more details. -fn checkResetSelSwitch(self: *Surface, screen_point: terminal.point.ScreenPoint) void { - const sel = self.io.terminal.screen.selection orelse return; +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) { @@ -2819,26 +2884,27 @@ fn checkResetSelSwitch(self: *Surface, screen_point: terminal.point.ScreenPoint) // 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 = screen_point.x != sel.start.x; + if (sel_start.x == sel_end.x) { + reset = drag_pin.x != sel_start.x; } else { - reset = switch (sel.order()) { - .forward => screen_point.x < sel.start.x or screen_point.y < sel.start.y, - .reverse => screen_point.x > sel.start.x or screen_point.y > sel.start.y, - .mirrored_forward => screen_point.x > sel.start.x or screen_point.y < sel.start.y, - .mirrored_reverse => screen_point.x < sel.start.x or screen_point.y > sel.start.y, + 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(screen_point) + reset = if (sel_end.before(sel_start)) + sel_start.before(drag_pin) else - screen_point.before(sel.start); + drag_pin.before(sel_start); } - if (reset) self.setSelection(null); + // 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. @@ -2846,15 +2912,15 @@ fn checkResetSelSwitch(self: *Surface, screen_point: terminal.point.ScreenPoint) // where to start the selection (before or after the click point). See // dragLeftClickSingle for more details. fn dragLeftClickBefore( - screen_point: terminal.point.ScreenPoint, - click_point: terminal.point.ScreenPoint, + drag_pin: terminal.Pin, + click_pin: terminal.Pin, mods: input.Mods, ) bool { if (mods.ctrlOrSuper() and mods.alt) { - return screen_point.x < click_point.x; + return drag_pin.x < click_pin.x; } - return screen_point.before(click_point); + return drag_pin.before(click_pin); } /// Call to notify Ghostty that the color scheme for the terminal has @@ -2875,7 +2941,7 @@ pub fn colorSchemeCallback(self: *Surface, scheme: apprt.ColorScheme) !void { if (report) try self.reportColorScheme(); } -fn posToViewport(self: Surface, xpos: f64, ypos: f64) terminal.point.Viewport { +fn posToViewport(self: Surface, xpos: f64, ypos: f64) terminal.point.Coordinate { // xpos/ypos need to be adjusted for window padding // (i.e. "window-padding-*" settings. const pad = if (self.config.window_padding_balance) @@ -3034,18 +3100,17 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .reset => { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - self.renderer_state.terminal.fullReset(self.alloc); + self.renderer_state.terminal.fullReset(); }, .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.screen.selection) |sel| { - const buf = self.io.terminal.screen.selectionString( - self.alloc, - sel, - self.config.clipboard_trim_trailing_spaces, - ) catch |err| { + const buf = self.io.terminal.screen.selectionString(self.alloc, .{ + .sel = sel, + .trim = self.config.clipboard_trim_trailing_spaces, + }) catch |err| { log.err("error reading selection string err={}", .{err}); return true; }; @@ -3184,19 +3249,16 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool break :write_scrollback_file; } - const history_max = terminal.Screen.RowIndexTag.history.maxLen( - &self.io.terminal.screen, - ); - // We only dump history if we have history. We still keep // the file and write the empty file to the pty so that this // command always works on the primary screen. - if (history_max > 0) { - try self.io.terminal.screen.dumpString(file.writer(), .{ - .start = .{ .history = 0 }, - .end = .{ .history = history_max -| 1 }, - .unwrap = true, - }); + const pages = &self.io.terminal.screen.pages; + if (pages.getBottomRight(.history)) |br| { + const tl = pages.getTopLeft(.history); + try self.io.terminal.screen.dumpString( + file.writer(), + .{ .tl = tl, .br = br, .unwrap = true }, + ); } } @@ -3299,7 +3361,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .select_all => { const sel = self.io.terminal.screen.selectAll(); if (sel) |s| { - self.setSelection(s); + try self.setSelection(s); try self.queueRender(); } }, diff --git a/src/bench/page-init.sh b/src/bench/page-init.sh new file mode 100755 index 0000000000..54712250bd --- /dev/null +++ b/src/bench/page-init.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# +# This is a trivial helper script to help run the page init benchmark. +# You probably want to tweak this script depending on what you're +# trying to measure. + +# Uncomment to test with an active terminal state. +# ARGS=" --terminal" + +hyperfine \ + --warmup 10 \ + -n alloc \ + "./zig-out/bin/bench-page-init --mode=alloc${ARGS} try benchAlloc(args.count), + .pool => try benchPool(alloc, args.count), + } +} + +noinline fn benchAlloc(count: usize) !void { + for (0..count) |_| { + _ = try terminal_new.Page.init(terminal_new.page.std_capacity); + } +} + +noinline fn benchPool(alloc: Allocator, count: usize) !void { + var list = try terminal_new.PageList.init( + alloc, + terminal_new.page.std_capacity.cols, + terminal_new.page.std_capacity.rows, + 0, + ); + defer list.deinit(); + + for (0..count) |_| { + _ = try list.grow(); + } +} diff --git a/src/bench/stream.sh b/src/bench/stream.sh index 5f2e4d311b..38d4c37cda 100755 --- a/src/bench/stream.sh +++ b/src/bench/stream.sh @@ -8,7 +8,7 @@ # - "ascii", uniform random ASCII bytes # - "utf8", uniform random unicode characters, encoded as utf8 # - "rand", pure random data, will contain many invalid code sequences. -DATA="utf8" +DATA="ascii" SIZE="25000000" # Uncomment to test with an active terminal state. diff --git a/src/bench/stream.zig b/src/bench/stream.zig index 5312a3d0ec..a7abb37cc0 100644 --- a/src/bench/stream.zig +++ b/src/bench/stream.zig @@ -26,7 +26,7 @@ const Args = struct { /// Process input with a real terminal. This will be MUCH slower than /// the other modes because it has to maintain terminal state but will /// help get more realistic numbers. - terminal: bool = false, + terminal: Terminal = .none, @"terminal-rows": usize = 80, @"terminal-cols": usize = 120, @@ -42,6 +42,8 @@ const Args = struct { if (self._arena) |arena| arena.deinit(); self.* = undefined; } + + const Terminal = enum { none, new }; }; const Mode = enum { @@ -91,8 +93,7 @@ pub fn main() !void { const writer = std.io.getStdOut().writer(); const buf = try alloc.alloc(u8, args.@"buffer-size"); - const seed: u64 = if (args.seed >= 0) @bitCast(args.seed) - else @truncate(@as(u128, @bitCast(std.time.nanoTimestamp()))); + const seed: u64 = if (args.seed >= 0) @bitCast(args.seed) else @truncate(@as(u128, @bitCast(std.time.nanoTimestamp()))); // Handle the modes that do not depend on terminal state first. switch (args.mode) { @@ -104,14 +105,13 @@ pub fn main() !void { // Handle the ones that depend on terminal state next inline .scalar, .simd, - => |tag| { - if (args.terminal) { + => |tag| switch (args.terminal) { + .new => { const TerminalStream = terminal.Stream(*TerminalHandler); - var t = try terminal.Terminal.init( - alloc, - args.@"terminal-cols", - args.@"terminal-rows", - ); + var t = try terminal.Terminal.init(alloc, .{ + .cols = @intCast(args.@"terminal-cols"), + .rows = @intCast(args.@"terminal-rows"), + }); var handler: TerminalHandler = .{ .t = &t }; var stream: TerminalStream = .{ .handler = &handler }; switch (tag) { @@ -119,14 +119,16 @@ pub fn main() !void { .simd => try benchSimd(reader, &stream, buf), else => @compileError("missing case"), } - } else { + }, + + .none => { var stream: terminal.Stream(NoopHandler) = .{ .handler = .{} }; switch (tag) { .scalar => try benchScalar(reader, &stream, buf), .simd => try benchSimd(reader, &stream, buf), else => @compileError("missing case"), } - } + }, }, } } @@ -163,11 +165,11 @@ fn genUtf8(writer: anytype, seed: u64) !void { while (true) { var i: usize = 0; while (i <= buf.len - 4) { - const cp: u18 = while(true) { + const cp: u18 = while (true) { const cp = rnd.int(u18); if (ziglyph.isPrint(cp)) break cp; }; - + i += try std.unicode.utf8Encode(cp, buf[i..]); } diff --git a/src/build_config.zig b/src/build_config.zig index 803e3ee4d2..742a2b692b 100644 --- a/src/build_config.zig +++ b/src/build_config.zig @@ -143,4 +143,5 @@ pub const ExeEntrypoint = enum { bench_stream, bench_codepoint_width, bench_grapheme_break, + bench_page_init, }; diff --git a/src/cli/args.zig b/src/cli/args.zig index 0363ba9be3..49c5152ac4 100644 --- a/src/cli/args.zig +++ b/src/cli/args.zig @@ -234,20 +234,18 @@ fn parseIntoField( bool => try parseBool(value orelse "t"), - u8 => std.fmt.parseInt( - u8, - value orelse return error.ValueRequired, - 0, - ) catch return error.InvalidValue, - - u32 => std.fmt.parseInt( - u32, - value orelse return error.ValueRequired, - 0, - ) catch return error.InvalidValue, - - u64 => std.fmt.parseInt( - u64, + inline u8, + u16, + u32, + u64, + usize, + i8, + i16, + i32, + i64, + isize, + => |Int| std.fmt.parseInt( + Int, value orelse return error.ValueRequired, 0, ) catch return error.InvalidValue, diff --git a/src/config/Config.zig b/src/config/Config.zig index 4a0ba75ec1..af69260dd7 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -291,7 +291,7 @@ palette: Palette = .{}, /// a prompt, regardless of this configuration. You can disable that behavior /// by specifying `shell-integration-features = no-cursor` or disabling shell /// integration entirely. -@"cursor-style": terminal.Cursor.Style = .block, +@"cursor-style": terminal.CursorStyle = .block, /// Sets the default blinking state of the cursor. This is just the default /// state; running programs may override the cursor style using `DECSCUSR` (`CSI @@ -427,6 +427,27 @@ command: ?[]const u8 = null, /// command. @"abnormal-command-exit-runtime": u32 = 250, +/// The size of the scrollback buffer in bytes. This also includes the active +/// screen. No matter what this is set to, enough memory will always be +/// allocated for the visible screen and anything leftover is the limit for +/// the scrollback. +/// +/// When this limit is reached, the oldest lines are removed from the +/// scrollback. +/// +/// Scrollback currently exists completely in memory. This means that the +/// larger this value, the larger potential memory usage. Scrollback is +/// allocated lazily up to this limit, so if you set this to a very large +/// value, it will not immediately consume a lot of memory. +/// +/// This size is per terminal surface, not for the entire application. +/// +/// It is not currently possible to set an unlimited scrollback buffer. +/// This is a future planned feature. +/// +/// This can be changed at runtime but will only affect new terminal surfaces. +@"scrollback-limit": u32 = 10_000_000, // 10MB + /// Match a regular expression against the terminal text and associate clicking /// it with an action. This can be used to match URLs, file paths, etc. Actions /// can be opening using the system opener (i.e. `open` or `xdg-open`) or diff --git a/src/fastmem.zig b/src/fastmem.zig index 0e9a444ee6..8f32bc3c88 100644 --- a/src/fastmem.zig +++ b/src/fastmem.zig @@ -12,7 +12,7 @@ pub inline fn move(comptime T: type, dest: []T, source: []const T) void { } } -/// Same as std.mem.copyForwards but prefers libc memcpy if it is available +/// Same as @memcpy but prefers libc memcpy if it is available /// because it is generally much faster. pub inline fn copy(comptime T: type, dest: []T, source: []const T) void { if (builtin.link_libc) { @@ -22,5 +22,13 @@ pub inline fn copy(comptime T: type, dest: []T, source: []const T) void { } } +/// Same as std.mem.rotate(T, items, 1) but more efficient by using memmove +/// and a tmp var for the single rotated item instead of 3 calls to reverse. +pub inline fn rotateOnce(comptime T: type, items: []T) void { + const tmp = items[0]; + move(T, items[0..items.len - 1], items[1..items.len]); + items[items.len - 1] = tmp; +} + extern "c" fn memcpy(*anyopaque, *const anyopaque, usize) *anyopaque; extern "c" fn memmove(*anyopaque, *const anyopaque, usize) *anyopaque; diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index 601b642fe2..04143c090d 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -84,13 +84,15 @@ pub const Shaper = struct { pub fn runIterator( self: *Shaper, group: *GroupCache, - row: terminal.Screen.Row, + screen: *const terminal.Screen, + row: terminal.Pin, selection: ?terminal.Selection, cursor_x: ?usize, ) font.shape.RunIterator { return .{ .hooks = .{ .shaper = self }, .group = group, + .screen = screen, .row = row, .selection = selection, .cursor_x = cursor_x, @@ -242,13 +244,19 @@ test "run iterator" { { // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 3, 5, 0); + var screen = try terminal.Screen.init(alloc, 5, 3, 0); defer screen.deinit(); try screen.testWriteString("ABCD"); // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; try testing.expectEqual(@as(usize, 1), count); @@ -256,12 +264,18 @@ test "run iterator" { // Spaces should be part of a run { - var screen = try terminal.Screen.init(alloc, 3, 10, 0); + var screen = try terminal.Screen.init(alloc, 10, 3, 0); defer screen.deinit(); try screen.testWriteString("ABCD EFG"); var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; try testing.expectEqual(@as(usize, 1), count); @@ -269,13 +283,19 @@ test "run iterator" { { // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 3, 5, 0); + var screen = try terminal.Screen.init(alloc, 5, 3, 0); defer screen.deinit(); try screen.testWriteString("A😃D"); // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); var count: usize = 0; while (try it.next(alloc)) |_| { count += 1; @@ -296,30 +316,51 @@ test "run iterator: empty cells with background set" { { // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 3, 5, 0); + var screen = try terminal.Screen.init(alloc, 5, 3, 0); defer screen.deinit(); - screen.cursor.pen.bg = .{ .rgb = try terminal.color.Name.cyan.default() }; + try screen.setAttribute(.{ .direct_color_bg = .{ .r = 0xFF, .g = 0, .b = 0 } }); try screen.testWriteString("A"); // Get our first row - const row = screen.getRow(.{ .active = 0 }); - row.getCellPtr(1).* = screen.cursor.pen; - row.getCellPtr(2).* = screen.cursor.pen; + { + const list_cell = screen.pages.getCell(.{ .active = .{ .x = 1 } }).?; + const cell = list_cell.cell; + cell.* = .{ + .content_tag = .bg_color_rgb, + .content = .{ .color_rgb = .{ .r = 0xFF, .g = 0, .b = 0 } }, + }; + } + { + const list_cell = screen.pages.getCell(.{ .active = .{ .x = 2 } }).?; + const cell = list_cell.cell; + cell.* = .{ + .content_tag = .bg_color_rgb, + .content = .{ .color_rgb = .{ .r = 0xFF, .g = 0, .b = 0 } }, + }; + } // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - - // The run should have length 3 because of the two background - // cells. - try testing.expectEqual(@as(u32, 3), shaper.hb_buf.getLength()); + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); + { + const run = (try it.next(alloc)).?; + try testing.expectEqual(@as(u32, 1), shaper.hb_buf.getLength()); const cells = try shaper.shape(run); - try testing.expectEqual(@as(usize, 3), cells.len); + try testing.expectEqual(@as(usize, 1), cells.len); } - try testing.expectEqual(@as(usize, 1), count); + { + const run = (try it.next(alloc)).?; + try testing.expectEqual(@as(u32, 2), shaper.hb_buf.getLength()); + const cells = try shaper.shape(run); + try testing.expectEqual(@as(usize, 2), cells.len); + } + try testing.expect(try it.next(alloc) == null); } } @@ -337,13 +378,19 @@ test "shape" { buf_idx += try std.unicode.utf8Encode(0x1F3FD, buf[buf_idx..]); // Medium skin tone // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 3, 10, 0); + var screen = try terminal.Screen.init(alloc, 10, 3, 0); defer screen.deinit(); try screen.testWriteString(buf[0..buf_idx]); // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -361,12 +408,18 @@ test "shape inconsolata ligs" { defer testdata.deinit(); { - var screen = try terminal.Screen.init(alloc, 3, 5, 0); + var screen = try terminal.Screen.init(alloc, 5, 3, 0); defer screen.deinit(); try screen.testWriteString(">="); var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -380,12 +433,18 @@ test "shape inconsolata ligs" { } { - var screen = try terminal.Screen.init(alloc, 3, 5, 0); + var screen = try terminal.Screen.init(alloc, 5, 3, 0); defer screen.deinit(); try screen.testWriteString("==="); var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -408,12 +467,18 @@ test "shape monaspace ligs" { defer testdata.deinit(); { - var screen = try terminal.Screen.init(alloc, 3, 5, 0); + var screen = try terminal.Screen.init(alloc, 5, 3, 0); defer screen.deinit(); try screen.testWriteString("==="); var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -436,12 +501,18 @@ test "shape emoji width" { defer testdata.deinit(); { - var screen = try terminal.Screen.init(alloc, 3, 5, 0); + var screen = try terminal.Screen.init(alloc, 5, 3, 0); defer screen.deinit(); try screen.testWriteString("👍"); var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -469,13 +540,19 @@ test "shape emoji width long" { buf_idx += try std.unicode.utf8Encode(0xFE0F, buf[buf_idx..]); // emoji representation // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 3, 30, 0); + var screen = try terminal.Screen.init(alloc, 30, 3, 0); defer screen.deinit(); try screen.testWriteString(buf[0..buf_idx]); // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -502,13 +579,19 @@ test "shape variation selector VS15" { buf_idx += try std.unicode.utf8Encode(0xFE0E, buf[buf_idx..]); // ZWJ to force text // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 3, 10, 0); + var screen = try terminal.Screen.init(alloc, 10, 3, 0); defer screen.deinit(); try screen.testWriteString(buf[0..buf_idx]); // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -533,13 +616,19 @@ test "shape variation selector VS16" { buf_idx += try std.unicode.utf8Encode(0xFE0F, buf[buf_idx..]); // ZWJ to force color // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 3, 10, 0); + var screen = try terminal.Screen.init(alloc, 10, 3, 0); defer screen.deinit(); try screen.testWriteString(buf[0..buf_idx]); // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -559,23 +648,29 @@ test "shape with empty cells in between" { defer testdata.deinit(); // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 3, 30, 0); + var screen = try terminal.Screen.init(alloc, 30, 3, 0); defer screen.deinit(); try screen.testWriteString("A"); - screen.cursor.x += 5; + screen.cursorRight(5); try screen.testWriteString("B"); // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; const cells = try shaper.shape(run); + try testing.expectEqual(@as(usize, 1), count); try testing.expectEqual(@as(usize, 7), cells.len); } - try testing.expectEqual(@as(usize, 1), count); } test "shape Chinese characters" { @@ -593,13 +688,19 @@ test "shape Chinese characters" { buf_idx += try std.unicode.utf8Encode('a', buf[buf_idx..]); // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 3, 30, 0); + var screen = try terminal.Screen.init(alloc, 30, 3, 0); defer screen.deinit(); try screen.testWriteString(buf[0..buf_idx]); // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -634,13 +735,19 @@ test "shape box glyphs" { buf_idx += try std.unicode.utf8Encode(0x2501, buf[buf_idx..]); // // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 3, 10, 0); + var screen = try terminal.Screen.init(alloc, 10, 3, 0); defer screen.deinit(); try screen.testWriteString(buf[0..buf_idx]); // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -663,7 +770,7 @@ test "shape selection boundary" { defer testdata.deinit(); // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 3, 10, 0); + var screen = try terminal.Screen.init(alloc, 10, 3, 0); defer screen.deinit(); try screen.testWriteString("a1b2c3d4e5"); @@ -671,10 +778,17 @@ test "shape selection boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), .{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = screen.cols - 1, .y = 0 }, - }, null); + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + terminal.Selection.init( + screen.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, + screen.pages.pin(.{ .active = .{ .x = screen.pages.cols - 1, .y = 0 } }).?, + false, + ), + null, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -687,10 +801,17 @@ test "shape selection boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), .{ - .start = .{ .x = 2, .y = 0 }, - .end = .{ .x = screen.cols - 1, .y = 0 }, - }, null); + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + terminal.Selection.init( + screen.pages.pin(.{ .active = .{ .x = 2, .y = 0 } }).?, + screen.pages.pin(.{ .active = .{ .x = screen.pages.cols - 1, .y = 0 } }).?, + false, + ), + null, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -703,10 +824,17 @@ test "shape selection boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), .{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = 3, .y = 0 }, - }, null); + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + terminal.Selection.init( + screen.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, + screen.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?, + false, + ), + null, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -719,10 +847,17 @@ test "shape selection boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), .{ - .start = .{ .x = 1, .y = 0 }, - .end = .{ .x = 3, .y = 0 }, - }, null); + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + terminal.Selection.init( + screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?, + screen.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?, + false, + ), + null, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -735,10 +870,17 @@ test "shape selection boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), .{ - .start = .{ .x = 1, .y = 0 }, - .end = .{ .x = 1, .y = 0 }, - }, null); + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + terminal.Selection.init( + screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?, + screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?, + false, + ), + null, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -756,7 +898,7 @@ test "shape cursor boundary" { defer testdata.deinit(); // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 3, 10, 0); + var screen = try terminal.Screen.init(alloc, 10, 3, 0); defer screen.deinit(); try screen.testWriteString("a1b2c3d4e5"); @@ -764,7 +906,13 @@ test "shape cursor boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -777,7 +925,13 @@ test "shape cursor boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, 0); + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + 0, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -790,7 +944,13 @@ test "shape cursor boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, 1); + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + 1, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -803,7 +963,13 @@ test "shape cursor boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, 9); + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + 9, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -829,7 +995,13 @@ test "shape cursor boundary and colored emoji" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -842,7 +1014,13 @@ test "shape cursor boundary and colored emoji" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, 0); + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + 0, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -853,7 +1031,13 @@ test "shape cursor boundary and colored emoji" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, 1); + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + 1, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -872,12 +1056,18 @@ test "shape cell attribute change" { // Plain >= should shape into 1 run { - var screen = try terminal.Screen.init(alloc, 3, 10, 0); + var screen = try terminal.Screen.init(alloc, 10, 3, 0); defer screen.deinit(); try screen.testWriteString(">="); var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -891,11 +1081,17 @@ test "shape cell attribute change" { var screen = try terminal.Screen.init(alloc, 3, 10, 0); defer screen.deinit(); try screen.testWriteString(">"); - screen.cursor.pen.attrs.bold = true; + try screen.setAttribute(.{ .bold = {} }); try screen.testWriteString("="); var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -908,13 +1104,19 @@ test "shape cell attribute change" { { var screen = try terminal.Screen.init(alloc, 3, 10, 0); defer screen.deinit(); - screen.cursor.pen.fg = .{ .rgb = .{ .r = 1, .g = 2, .b = 3 } }; + try screen.setAttribute(.{ .direct_color_fg = .{ .r = 1, .g = 2, .b = 3 } }); try screen.testWriteString(">"); - screen.cursor.pen.fg = .{ .rgb = .{ .r = 3, .g = 2, .b = 1 } }; + try screen.setAttribute(.{ .direct_color_fg = .{ .r = 3, .g = 2, .b = 1 } }); try screen.testWriteString("="); var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -927,13 +1129,19 @@ test "shape cell attribute change" { { var screen = try terminal.Screen.init(alloc, 3, 10, 0); defer screen.deinit(); - screen.cursor.pen.bg = .{ .rgb = .{ .r = 1, .g = 2, .b = 3 } }; + try screen.setAttribute(.{ .direct_color_bg = .{ .r = 1, .g = 2, .b = 3 } }); try screen.testWriteString(">"); - screen.cursor.pen.bg = .{ .rgb = .{ .r = 3, .g = 2, .b = 1 } }; + try screen.setAttribute(.{ .direct_color_bg = .{ .r = 3, .g = 2, .b = 1 } }); try screen.testWriteString("="); var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -946,12 +1154,18 @@ test "shape cell attribute change" { { var screen = try terminal.Screen.init(alloc, 3, 10, 0); defer screen.deinit(); - screen.cursor.pen.bg = .{ .rgb = .{ .r = 1, .g = 2, .b = 3 } }; + try screen.setAttribute(.{ .direct_color_bg = .{ .r = 1, .g = 2, .b = 3 } }); try screen.testWriteString(">"); try screen.testWriteString("="); var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; diff --git a/src/font/shaper/run.zig b/src/font/shaper/run.zig index 7b75b574d6..b5c29ec3f6 100644 --- a/src/font/shaper/run.zig +++ b/src/font/shaper/run.zig @@ -26,17 +26,23 @@ pub const TextRun = struct { pub const RunIterator = struct { hooks: font.Shaper.RunIteratorHook, group: *font.GroupCache, - row: terminal.Screen.Row, + screen: *const terminal.Screen, + row: terminal.Pin, selection: ?terminal.Selection = null, cursor_x: ?usize = null, i: usize = 0, pub fn next(self: *RunIterator, alloc: Allocator) !?TextRun { + const cells = self.row.cells(.all); + // Trim the right side of a row that might be empty const max: usize = max: { - var j: usize = self.row.lenCells(); - while (j > 0) : (j -= 1) if (!self.row.getCell(j - 1).empty()) break; - break :max j; + for (0..cells.len) |i| { + const rev_i = cells.len - i - 1; + if (!cells[rev_i].isEmpty()) break :max rev_i + 1; + } + + break :max 0; }; // We're over at the max @@ -48,67 +54,65 @@ pub const RunIterator = struct { // Allow the hook to prepare try self.hooks.prepare(); + // Let's get our style that we'll expect for the run. + const style = self.row.style(&cells[self.i]); + // Go through cell by cell and accumulate while we build our run. var j: usize = self.i; while (j < max) : (j += 1) { const cluster = j; - const cell = self.row.getCell(j); + const cell = &cells[j]; // If we have a selection and we're at a boundary point, then // we break the run here. if (self.selection) |unordered_sel| { if (j > self.i) { - const sel = unordered_sel.ordered(.forward); + const sel = unordered_sel.ordered(self.screen, .forward); + const start_x = sel.start().x; + const end_x = sel.end().x; - if (sel.start.x > 0 and - j == sel.start.x and - self.row.graphemeBreak(sel.start.x)) break; + if (start_x > 0 and + j == start_x) break; - if (sel.end.x > 0 and - j == sel.end.x + 1 and - self.row.graphemeBreak(sel.end.x)) break; + if (end_x > 0 and + j == end_x + 1) break; } } // If we're a spacer, then we ignore it - if (cell.attrs.wide_spacer_tail) continue; + switch (cell.wide) { + .narrow, .wide => {}, + .spacer_head, .spacer_tail => continue, + } // If our cell attributes are changing, then we split the run. // This prevents a single glyph for ">=" to be rendered with // one color when the two components have different styling. if (j > self.i) { - const prev_cell = self.row.getCell(j - 1); - const Attrs = @TypeOf(cell.attrs); - const Int = @typeInfo(Attrs).Struct.backing_integer.?; - const prev_attrs: Int = @bitCast(prev_cell.attrs.styleAttrs()); - const attrs: Int = @bitCast(cell.attrs.styleAttrs()); - if (prev_attrs != attrs) break; - if (!cell.bg.eql(prev_cell.bg)) break; - if (!cell.fg.eql(prev_cell.fg)) break; + const prev_cell = cells[j - 1]; + if (prev_cell.style_id != cell.style_id) break; } // Text runs break when font styles change so we need to get // the proper style. - const style: font.Style = style: { - if (cell.attrs.bold) { - if (cell.attrs.italic) break :style .bold_italic; + const font_style: font.Style = style: { + if (style.flags.bold) { + if (style.flags.italic) break :style .bold_italic; break :style .bold; } - if (cell.attrs.italic) break :style .italic; + if (style.flags.italic) break :style .italic; break :style .regular; }; // Determine the presentation format for this glyph. - const presentation: ?font.Presentation = if (cell.attrs.grapheme) p: { + const presentation: ?font.Presentation = if (cell.hasGrapheme()) p: { // We only check the FIRST codepoint because I believe the // presentation format must be directly adjacent to the codepoint. - var it = self.row.codepointIterator(j); - if (it.next()) |cp| { - if (cp == 0xFE0E) break :p .text; - if (cp == 0xFE0F) break :p .emoji; - } - + const cps = self.row.grapheme(cell) orelse break :p null; + assert(cps.len > 0); + if (cps[0] == 0xFE0E) break :p .text; + if (cps[0] == 0xFE0F) break :p .emoji; break :p null; } else emoji: { // If we're not a grapheme, our individual char could be @@ -128,7 +132,7 @@ pub const RunIterator = struct { // such as a skin-tone emoji is fine, but hovering over the // joiners will show the joiners allowing you to modify the // emoji. - if (!cell.attrs.grapheme) { + if (!cell.hasGrapheme()) { if (self.cursor_x) |cursor_x| { // Exactly: self.i is the cursor and we iterated once. This // means that we started exactly at the cursor and did at @@ -163,9 +167,8 @@ pub const RunIterator = struct { // then we use that. if (try self.indexForCell( alloc, - j, cell, - style, + font_style, presentation, )) |idx| break :font_info .{ .idx = idx }; @@ -174,7 +177,7 @@ pub const RunIterator = struct { if (try self.group.indexForCodepoint( alloc, 0xFFFD, // replacement char - style, + font_style, presentation, )) |idx| break :font_info .{ .idx = idx, .fallback = 0xFFFD }; @@ -182,7 +185,7 @@ pub const RunIterator = struct { if (try self.group.indexForCodepoint( alloc, ' ', - style, + font_style, presentation, )) |idx| break :font_info .{ .idx = idx, .fallback = ' ' }; @@ -206,12 +209,12 @@ pub const RunIterator = struct { // Add all the codepoints for our grapheme try self.hooks.addCodepoint( - if (cell.char == 0) ' ' else cell.char, + if (cell.codepoint() == 0) ' ' else cell.codepoint(), @intCast(cluster), ); - if (cell.attrs.grapheme) { - var it = self.row.codepointIterator(j); - while (it.next()) |cp| { + if (cell.hasGrapheme()) { + const cps = self.row.grapheme(cell).?; + for (cps) |cp| { // Do not send presentation modifiers if (cp == 0xFE0E or cp == 0xFE0F) continue; try self.hooks.addCodepoint(cp, @intCast(cluster)); @@ -242,13 +245,12 @@ pub const RunIterator = struct { fn indexForCell( self: *RunIterator, alloc: Allocator, - j: usize, - cell: terminal.Screen.Cell, + cell: *terminal.Cell, style: font.Style, presentation: ?font.Presentation, ) !?font.Group.FontIndex { // Get the font index for the primary codepoint. - const primary_cp: u32 = if (cell.empty() or cell.char == 0) ' ' else cell.char; + const primary_cp: u32 = if (cell.isEmpty() or cell.codepoint() == 0) ' ' else cell.codepoint(); const primary = try self.group.indexForCodepoint( alloc, primary_cp, @@ -258,16 +260,16 @@ pub const RunIterator = struct { // Easy, and common: we aren't a multi-codepoint grapheme, so // we just return whatever index for the cell codepoint. - if (!cell.attrs.grapheme) return primary; + if (!cell.hasGrapheme()) return primary; // If this is a grapheme, we need to find a font that supports // all of the codepoints in the grapheme. - var it = self.row.codepointIterator(j); - var candidates = try std.ArrayList(font.Group.FontIndex).initCapacity(alloc, it.len() + 1); + const cps = self.row.grapheme(cell) orelse return primary; + var candidates = try std.ArrayList(font.Group.FontIndex).initCapacity(alloc, cps.len + 1); defer candidates.deinit(); candidates.appendAssumeCapacity(primary); - while (it.next()) |cp| { + for (cps) |cp| { // Ignore Emoji ZWJs if (cp == 0xFE0E or cp == 0xFE0F or cp == 0x200D) continue; @@ -285,8 +287,7 @@ pub const RunIterator = struct { // We need to find a candidate that has ALL of our codepoints for (candidates.items) |idx| { if (!self.group.group.hasCodepoint(idx, primary_cp, presentation)) continue; - it.reset(); - while (it.next()) |cp| { + for (cps) |cp| { // Ignore Emoji ZWJs if (cp == 0xFE0E or cp == 0xFE0F or cp == 0x200D) continue; if (!self.group.group.hasCodepoint(idx, cp, presentation)) break; diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index 6a4235a7ea..4f30318fc9 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -4,6 +4,7 @@ const Inspector = @This(); const std = @import("std"); +const assert = std.debug.assert; const Allocator = std.mem.Allocator; const builtin = @import("builtin"); const cimgui = @import("cimgui"); @@ -35,8 +36,8 @@ mouse: struct { last_xpos: f64 = 0, last_ypos: f64 = 0, - /// Last hovered screen point - last_point: terminal.point.ScreenPoint = .{}, + // Last hovered screen point + last_point: ?terminal.Pin = null, } = .{}, /// A selected cell. @@ -61,17 +62,47 @@ const CellInspect = union(enum) { selected: Selected, const Selected = struct { + alloc: Allocator, row: usize, col: usize, - cell: terminal.Screen.Cell, + cell: inspector.Cell, }; + pub fn deinit(self: *CellInspect) void { + switch (self.*) { + .idle, .requested => {}, + .selected => |*v| v.cell.deinit(v.alloc), + } + } + pub fn request(self: *CellInspect) void { switch (self.*) { - .idle, .selected => self.* = .requested, + .idle => self.* = .requested, + .selected => |*v| { + v.cell.deinit(v.alloc); + self.* = .requested; + }, .requested => {}, } } + + pub fn select( + self: *CellInspect, + alloc: Allocator, + pin: terminal.Pin, + x: usize, + y: usize, + ) !void { + assert(self.* == .requested); + const cell = try inspector.Cell.init(alloc, pin); + errdefer cell.deinit(alloc); + self.* = .{ .selected = .{ + .alloc = alloc, + .row = y, + .col = x, + .cell = cell, + } }; + } }; /// Setup the ImGui state. This requires an ImGui context to be set. @@ -134,6 +165,8 @@ pub fn init(surface: *Surface) !Inspector { } pub fn deinit(self: *Inspector) void { + self.cell.deinit(); + { var it = self.key_events.iterator(.forward); while (it.next()) |v| v.deinit(self.surface.alloc); @@ -298,9 +331,10 @@ fn renderScreenWindow(self: *Inspector) void { 0, ); defer cimgui.c.igEndTable(); - - const palette = self.surface.io.terminal.color_palette.colors; - inspector.cursor.renderInTable(&screen.cursor, &palette); + inspector.cursor.renderInTable( + self.surface.renderer_state.terminal, + &screen.cursor, + ); } // table cimgui.c.igTextDisabled("(Any styles not shown are not currently set)"); @@ -457,6 +491,67 @@ fn renderScreenWindow(self: *Inspector) void { } } // table } // kitty graphics + + if (cimgui.c.igCollapsingHeader_TreeNodeFlags( + "Internal Terminal State", + cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, + )) { + const pages = &screen.pages; + + { + _ = cimgui.c.igBeginTable( + "##terminal_state", + 2, + cimgui.c.ImGuiTableFlags_None, + .{ .x = 0, .y = 0 }, + 0, + ); + defer cimgui.c.igEndTable(); + + { + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + { + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Memory Usage"); + } + { + _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.igText("%d bytes", pages.page_size); + } + } + + { + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + { + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Memory Limit"); + } + { + _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.igText("%d bytes", pages.maxSize()); + } + } + + { + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + { + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Viewport Location"); + } + { + _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.igText("%s", @tagName(pages.viewport).ptr); + } + } + } // table + // + if (cimgui.c.igCollapsingHeader_TreeNodeFlags( + "Active Page", + cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, + )) { + inspector.page.render(&pages.pages.last.?.data); + } + } // terminal state } /// The modes window shows the currently active terminal modes and allows @@ -664,7 +759,15 @@ fn renderSizeWindow(self: *Inspector) void { const t = self.surface.renderer_state.terminal; { - const hover_point = self.mouse.last_point.toViewport(&t.screen); + const hover_point: terminal.point.Coordinate = pt: { + const p = self.mouse.last_point orelse break :pt .{}; + const pt = t.screen.pages.pointFromPin( + .active, + p, + ) orelse break :pt .{}; + break :pt pt.coord(); + }; + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); { _ = cimgui.c.igTableSetColumnIndex(0); @@ -736,7 +839,15 @@ fn renderSizeWindow(self: *Inspector) void { } { - const left_click_point = mouse.left_click_point.toViewport(&t.screen); + const left_click_point: terminal.point.Coordinate = pt: { + const p = mouse.left_click_pin orelse break :pt .{}; + const pt = t.screen.pages.pointFromPin( + .active, + p.*, + ) orelse break :pt .{}; + break :pt pt.coord(); + }; + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); { _ = cimgui.c.igTableSetColumnIndex(0); @@ -825,136 +936,11 @@ fn renderCellWindow(self: *Inspector) void { } const selected = self.cell.selected; - - { - // We have a selected cell, show information about it. - _ = cimgui.c.igBeginTable( - "table_cursor", - 2, - cimgui.c.ImGuiTableFlags_None, - .{ .x = 0, .y = 0 }, - 0, - ); - defer cimgui.c.igEndTable(); - - { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); - { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Grid Position"); - } - { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("row=%d col=%d", selected.row, selected.col); - } - } - - // NOTE: we don't currently write the character itself because - // we haven't hooked up imgui to our font system. That's hard! We - // can/should instead hook up our renderer to imgui and just render - // the single glyph in an image view so it looks _identical_ to the - // terminal. - codepoint: { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); - { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Codepoint"); - } - { - _ = cimgui.c.igTableSetColumnIndex(1); - if (selected.cell.char == 0) { - cimgui.c.igTextDisabled("(empty)"); - break :codepoint; - } - - cimgui.c.igText("U+%X", selected.cell.char); - } - } - - // If we have a color then we show the color - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Foreground Color"); - _ = cimgui.c.igTableSetColumnIndex(1); - switch (selected.cell.fg) { - .none => cimgui.c.igText("default"), - else => { - const rgb = switch (selected.cell.fg) { - .none => unreachable, - .indexed => |idx| self.surface.io.terminal.color_palette.colors[idx], - .rgb => |rgb| rgb, - }; - - if (selected.cell.fg == .indexed) { - cimgui.c.igValue_Int("Palette", selected.cell.fg.indexed); - } - - var color: [3]f32 = .{ - @as(f32, @floatFromInt(rgb.r)) / 255, - @as(f32, @floatFromInt(rgb.g)) / 255, - @as(f32, @floatFromInt(rgb.b)) / 255, - }; - _ = cimgui.c.igColorEdit3( - "color_fg", - &color, - cimgui.c.ImGuiColorEditFlags_NoPicker | - cimgui.c.ImGuiColorEditFlags_NoLabel, - ); - }, - } - - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Background Color"); - _ = cimgui.c.igTableSetColumnIndex(1); - switch (selected.cell.bg) { - .none => cimgui.c.igText("default"), - else => { - const rgb = switch (selected.cell.bg) { - .none => unreachable, - .indexed => |idx| self.surface.io.terminal.color_palette.colors[idx], - .rgb => |rgb| rgb, - }; - - if (selected.cell.bg == .indexed) { - cimgui.c.igValue_Int("Palette", selected.cell.bg.indexed); - } - - var color: [3]f32 = .{ - @as(f32, @floatFromInt(rgb.r)) / 255, - @as(f32, @floatFromInt(rgb.g)) / 255, - @as(f32, @floatFromInt(rgb.b)) / 255, - }; - _ = cimgui.c.igColorEdit3( - "color_bg", - &color, - cimgui.c.ImGuiColorEditFlags_NoPicker | - cimgui.c.ImGuiColorEditFlags_NoLabel, - ); - }, - } - - // Boolean styles - const styles = .{ - "bold", "italic", "faint", "blink", - "inverse", "invisible", "protected", "strikethrough", - }; - inline for (styles) |style| style: { - if (!@field(selected.cell.attrs, style)) break :style; - - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); - { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText(style.ptr); - } - { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("true"); - } - } - } // table - - cimgui.c.igTextDisabled("(Any styles not shown are not currently set)"); + selected.cell.renderTable( + self.surface.renderer_state.terminal, + selected.col, + selected.row, + ); } fn renderKeyboardWindow(self: *Inspector) void { @@ -1127,8 +1113,10 @@ fn renderTermioWindow(self: *Inspector) void { 0, ); defer cimgui.c.igEndTable(); - const palette = self.surface.io.terminal.color_palette.colors; - inspector.cursor.renderInTable(&ev.cursor, &palette); + inspector.cursor.renderInTable( + self.surface.renderer_state.terminal, + &ev.cursor, + ); { cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); diff --git a/src/inspector/cell.zig b/src/inspector/cell.zig new file mode 100644 index 0000000000..36069eee0c --- /dev/null +++ b/src/inspector/cell.zig @@ -0,0 +1,193 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const cimgui = @import("cimgui"); +const terminal = @import("../terminal/main.zig"); + +/// A cell being inspected. This duplicates much of the data in +/// the terminal data structure because we want the inspector to +/// not have a reference to the terminal state or to grab any +/// locks. +pub const Cell = struct { + /// The main codepoint for this cell. + codepoint: u21, + + /// Codepoints for this cell to produce a single grapheme cluster. + /// This is only non-empty if the cell is part of a multi-codepoint + /// grapheme cluster. This does NOT include the primary codepoint. + cps: []const u21, + + /// The style of this cell. + style: terminal.Style, + + pub fn init( + alloc: Allocator, + pin: terminal.Pin, + ) !Cell { + const cell = pin.rowAndCell().cell; + const style = pin.style(cell); + const cps: []const u21 = if (cell.hasGrapheme()) cps: { + const src = pin.grapheme(cell).?; + assert(src.len > 0); + break :cps try alloc.dupe(u21, src); + } else &.{}; + errdefer if (cps.len > 0) alloc.free(cps); + + return .{ + .codepoint = cell.codepoint(), + .cps = cps, + .style = style, + }; + } + + pub fn deinit(self: *Cell, alloc: Allocator) void { + if (self.cps.len > 0) alloc.free(self.cps); + } + + pub fn renderTable( + self: *const Cell, + t: *const terminal.Terminal, + x: usize, + y: usize, + ) void { + // We have a selected cell, show information about it. + _ = cimgui.c.igBeginTable( + "table_cursor", + 2, + cimgui.c.ImGuiTableFlags_None, + .{ .x = 0, .y = 0 }, + 0, + ); + defer cimgui.c.igEndTable(); + + { + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + { + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Grid Position"); + } + { + _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.igText("row=%d col=%d", y, x); + } + } + + // NOTE: we don't currently write the character itself because + // we haven't hooked up imgui to our font system. That's hard! We + // can/should instead hook up our renderer to imgui and just render + // the single glyph in an image view so it looks _identical_ to the + // terminal. + codepoint: { + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + { + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Codepoint"); + } + { + _ = cimgui.c.igTableSetColumnIndex(1); + if (self.codepoint == 0) { + cimgui.c.igTextDisabled("(empty)"); + break :codepoint; + } + + cimgui.c.igText("U+%X", @as(u32, @intCast(self.codepoint))); + } + } + + // If we have a color then we show the color + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Foreground Color"); + _ = cimgui.c.igTableSetColumnIndex(1); + switch (self.style.fg_color) { + .none => cimgui.c.igText("default"), + .palette => |idx| { + const rgb = t.color_palette.colors[idx]; + cimgui.c.igValue_Int("Palette", idx); + var color: [3]f32 = .{ + @as(f32, @floatFromInt(rgb.r)) / 255, + @as(f32, @floatFromInt(rgb.g)) / 255, + @as(f32, @floatFromInt(rgb.b)) / 255, + }; + _ = cimgui.c.igColorEdit3( + "color_fg", + &color, + cimgui.c.ImGuiColorEditFlags_NoPicker | + cimgui.c.ImGuiColorEditFlags_NoLabel, + ); + }, + + .rgb => |rgb| { + var color: [3]f32 = .{ + @as(f32, @floatFromInt(rgb.r)) / 255, + @as(f32, @floatFromInt(rgb.g)) / 255, + @as(f32, @floatFromInt(rgb.b)) / 255, + }; + _ = cimgui.c.igColorEdit3( + "color_fg", + &color, + cimgui.c.ImGuiColorEditFlags_NoPicker | + cimgui.c.ImGuiColorEditFlags_NoLabel, + ); + }, + } + + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Background Color"); + _ = cimgui.c.igTableSetColumnIndex(1); + switch (self.style.bg_color) { + .none => cimgui.c.igText("default"), + .palette => |idx| { + const rgb = t.color_palette.colors[idx]; + cimgui.c.igValue_Int("Palette", idx); + var color: [3]f32 = .{ + @as(f32, @floatFromInt(rgb.r)) / 255, + @as(f32, @floatFromInt(rgb.g)) / 255, + @as(f32, @floatFromInt(rgb.b)) / 255, + }; + _ = cimgui.c.igColorEdit3( + "color_bg", + &color, + cimgui.c.ImGuiColorEditFlags_NoPicker | + cimgui.c.ImGuiColorEditFlags_NoLabel, + ); + }, + + .rgb => |rgb| { + var color: [3]f32 = .{ + @as(f32, @floatFromInt(rgb.r)) / 255, + @as(f32, @floatFromInt(rgb.g)) / 255, + @as(f32, @floatFromInt(rgb.b)) / 255, + }; + _ = cimgui.c.igColorEdit3( + "color_bg", + &color, + cimgui.c.ImGuiColorEditFlags_NoPicker | + cimgui.c.ImGuiColorEditFlags_NoLabel, + ); + }, + } + + // Boolean styles + const styles = .{ + "bold", "italic", "faint", "blink", + "inverse", "invisible", "strikethrough", + }; + inline for (styles) |style| style: { + if (!@field(self.style.flags, style)) break :style; + + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + { + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText(style.ptr); + } + { + _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.igText("true"); + } + } + + cimgui.c.igTextDisabled("(Any styles not shown are not currently set)"); + } +}; diff --git a/src/inspector/cursor.zig b/src/inspector/cursor.zig index c1098a7c7e..c2491b2581 100644 --- a/src/inspector/cursor.zig +++ b/src/inspector/cursor.zig @@ -4,8 +4,8 @@ const terminal = @import("../terminal/main.zig"); /// Render cursor information with a table already open. pub fn renderInTable( + t: *const terminal.Terminal, cursor: *const terminal.Screen.Cursor, - palette: *const terminal.color.Palette, ) void { { cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); @@ -27,7 +27,7 @@ pub fn renderInTable( } { _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%s", @tagName(cursor.style).ptr); + cimgui.c.igText("%s", @tagName(cursor.cursor_style).ptr); } } @@ -48,19 +48,25 @@ pub fn renderInTable( _ = cimgui.c.igTableSetColumnIndex(0); cimgui.c.igText("Foreground Color"); _ = cimgui.c.igTableSetColumnIndex(1); - switch (cursor.pen.fg) { + switch (cursor.style.fg_color) { .none => cimgui.c.igText("default"), - else => { - const rgb = switch (cursor.pen.fg) { - .none => unreachable, - .indexed => |idx| palette[idx], - .rgb => |rgb| rgb, + .palette => |idx| { + const rgb = t.color_palette.colors[idx]; + cimgui.c.igValue_Int("Palette", idx); + var color: [3]f32 = .{ + @as(f32, @floatFromInt(rgb.r)) / 255, + @as(f32, @floatFromInt(rgb.g)) / 255, + @as(f32, @floatFromInt(rgb.b)) / 255, }; + _ = cimgui.c.igColorEdit3( + "color_fg", + &color, + cimgui.c.ImGuiColorEditFlags_NoPicker | + cimgui.c.ImGuiColorEditFlags_NoLabel, + ); + }, - if (cursor.pen.fg == .indexed) { - cimgui.c.igValue_Int("Palette", cursor.pen.fg.indexed); - } - + .rgb => |rgb| { var color: [3]f32 = .{ @as(f32, @floatFromInt(rgb.r)) / 255, @as(f32, @floatFromInt(rgb.g)) / 255, @@ -79,19 +85,25 @@ pub fn renderInTable( _ = cimgui.c.igTableSetColumnIndex(0); cimgui.c.igText("Background Color"); _ = cimgui.c.igTableSetColumnIndex(1); - switch (cursor.pen.bg) { + switch (cursor.style.bg_color) { .none => cimgui.c.igText("default"), - else => { - const rgb = switch (cursor.pen.bg) { - .none => unreachable, - .indexed => |idx| palette[idx], - .rgb => |rgb| rgb, + .palette => |idx| { + const rgb = t.color_palette.colors[idx]; + cimgui.c.igValue_Int("Palette", idx); + var color: [3]f32 = .{ + @as(f32, @floatFromInt(rgb.r)) / 255, + @as(f32, @floatFromInt(rgb.g)) / 255, + @as(f32, @floatFromInt(rgb.b)) / 255, }; + _ = cimgui.c.igColorEdit3( + "color_bg", + &color, + cimgui.c.ImGuiColorEditFlags_NoPicker | + cimgui.c.ImGuiColorEditFlags_NoLabel, + ); + }, - if (cursor.pen.bg == .indexed) { - cimgui.c.igValue_Int("Palette", cursor.pen.bg.indexed); - } - + .rgb => |rgb| { var color: [3]f32 = .{ @as(f32, @floatFromInt(rgb.r)) / 255, @as(f32, @floatFromInt(rgb.g)) / 255, @@ -108,11 +120,11 @@ pub fn renderInTable( // Boolean styles const styles = .{ - "bold", "italic", "faint", "blink", - "inverse", "invisible", "protected", "strikethrough", + "bold", "italic", "faint", "blink", + "inverse", "invisible", "strikethrough", }; inline for (styles) |style| style: { - if (!@field(cursor.pen.attrs, style)) break :style; + if (!@field(cursor.style.flags, style)) break :style; cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); { diff --git a/src/inspector/main.zig b/src/inspector/main.zig index 920491dd8b..ee871f2002 100644 --- a/src/inspector/main.zig +++ b/src/inspector/main.zig @@ -1,7 +1,11 @@ const std = @import("std"); +pub const cell = @import("cell.zig"); pub const cursor = @import("cursor.zig"); pub const key = @import("key.zig"); +pub const page = @import("page.zig"); pub const termio = @import("termio.zig"); + +pub const Cell = cell.Cell; pub const Inspector = @import("Inspector.zig"); test { diff --git a/src/inspector/page.zig b/src/inspector/page.zig new file mode 100644 index 0000000000..29e76be785 --- /dev/null +++ b/src/inspector/page.zig @@ -0,0 +1,169 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const cimgui = @import("cimgui"); +const terminal = @import("../terminal/main.zig"); + +pub fn render(page: *const terminal.Page) void { + cimgui.c.igPushID_Ptr(page); + defer cimgui.c.igPopID(); + + _ = cimgui.c.igBeginTable( + "##page_state", + 2, + cimgui.c.ImGuiTableFlags_None, + .{ .x = 0, .y = 0 }, + 0, + ); + defer cimgui.c.igEndTable(); + + { + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + { + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Memory Size"); + } + { + _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.igText("%d bytes", page.memory.len); + cimgui.c.igText("%d VM pages", page.memory.len / std.mem.page_size); + } + } + { + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + { + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Unique Styles"); + } + { + _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.igText("%d", page.styles.count(page.memory)); + } + } + { + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + { + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Grapheme Entries"); + } + { + _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.igText("%d", page.graphemeCount()); + } + } + { + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + { + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Capacity"); + } + { + _ = cimgui.c.igTableSetColumnIndex(1); + _ = cimgui.c.igBeginTable( + "##capacity", + 2, + cimgui.c.ImGuiTableFlags_None, + .{ .x = 0, .y = 0 }, + 0, + ); + defer cimgui.c.igEndTable(); + + const cap = page.capacity; + { + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + { + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Columns"); + } + + { + _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.igText("%d", @as(u32, @intCast(cap.cols))); + } + } + + { + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + { + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Rows"); + } + + { + _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.igText("%d", @as(u32, @intCast(cap.rows))); + } + } + + { + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + { + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Unique Styles"); + } + + { + _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.igText("%d", @as(u32, @intCast(cap.styles))); + } + } + + { + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + { + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Grapheme Bytes"); + } + + { + _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.igText("%d", cap.grapheme_bytes); + } + } + } + } + { + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + { + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Size"); + } + { + _ = cimgui.c.igTableSetColumnIndex(1); + _ = cimgui.c.igBeginTable( + "##size", + 2, + cimgui.c.ImGuiTableFlags_None, + .{ .x = 0, .y = 0 }, + 0, + ); + defer cimgui.c.igEndTable(); + + const size = page.size; + { + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + { + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Columns"); + } + + { + _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.igText("%d", @as(u32, @intCast(size.cols))); + } + } + { + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + { + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Rows"); + } + + { + _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.igText("%d", @as(u32, @intCast(size.rows))); + } + } + } + } // size table +} diff --git a/src/main.zig b/src/main.zig index 8cad7ec9fe..3a5357471b 100644 --- a/src/main.zig +++ b/src/main.zig @@ -10,4 +10,5 @@ pub usingnamespace switch (build_config.exe_entrypoint) { .bench_stream => @import("bench/stream.zig"), .bench_codepoint_width => @import("bench/codepoint-width.zig"), .bench_grapheme_break => @import("bench/grapheme-break.zig"), + .bench_page_init => @import("bench/page-init.zig"), }; diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 02ba2f93f7..e89454f9d9 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -623,7 +623,6 @@ pub fn updateFrame( // Data we extract out of the critical area. const Critical = struct { bg: terminal.color.RGB, - selection: ?terminal.Selection, screen: terminal.Screen, mouse: renderer.State.Mouse, preedit: ?renderer.State.Preedit, @@ -657,25 +656,13 @@ pub fn updateFrame( // We used to share terminal state, but we've since learned through // analysis that it is faster to copy the terminal state than to // hold the lock while rebuilding GPU cells. - const viewport_bottom = state.terminal.screen.viewportIsBottom(); - var screen_copy = if (viewport_bottom) try state.terminal.screen.clone( + var screen_copy = try state.terminal.screen.clone( self.alloc, - .{ .active = 0 }, - .{ .active = state.terminal.rows - 1 }, - ) else try state.terminal.screen.clone( - self.alloc, - .{ .viewport = 0 }, - .{ .viewport = state.terminal.rows - 1 }, + .{ .viewport = .{} }, + null, ); errdefer screen_copy.deinit(); - // Convert our selection to viewport points because we copy only - // the viewport above. - const selection: ?terminal.Selection = if (state.terminal.screen.selection) |sel| - sel.toViewport(&state.terminal.screen) - else - null; - // Whether to draw our cursor or not. const cursor_style = renderer.cursorStyle( state, @@ -700,7 +687,6 @@ pub fn updateFrame( break :critical .{ .bg = self.background_color, - .selection = selection, .screen = screen_copy, .mouse = state.mouse, .preedit = preedit, @@ -715,7 +701,6 @@ pub fn updateFrame( // Build our GPU cells try self.rebuildCells( - critical.selection, &critical.screen, critical.mouse, critical.preedit, @@ -1243,11 +1228,8 @@ fn prepKittyGraphics( // The top-left and bottom-right corners of our viewport in screen // points. This lets us determine offsets and containment of placements. - const top = (terminal.point.Viewport{}).toScreen(&t.screen); - const bot = (terminal.point.Viewport{ - .x = t.screen.cols - 1, - .y = t.screen.rows - 1, - }).toScreen(&t.screen); + const top = t.screen.pages.getTopLeft(.viewport); + const bot = t.screen.pages.getBottomRight(.viewport).?; // Go through the placements and ensure the image is loaded on the GPU. var it = storage.placements.iterator(); @@ -1264,13 +1246,15 @@ fn prepKittyGraphics( // If the selection isn't within our viewport then skip it. const rect = p.rect(image, t); - if (rect.top_left.y > bot.y) continue; - if (rect.bottom_right.y < top.y) continue; + if (bot.before(rect.top_left)) continue; + if (rect.bottom_right.before(top)) continue; // If the top left is outside the viewport we need to calc an offset // so that we render (0, 0) with some offset for the texture. - const offset_y: u32 = if (rect.top_left.y < t.screen.viewport) offset_y: { - const offset_cells = t.screen.viewport - rect.top_left.y; + const offset_y: u32 = if (rect.top_left.before(top)) offset_y: { + const vp_y = t.screen.pages.pointFromPin(.screen, top).?.screen.y; + const img_y = t.screen.pages.pointFromPin(.screen, rect.top_left).?.screen.y; + const offset_cells = vp_y - img_y; const offset_pixels = offset_cells * self.grid_metrics.cell_height; break :offset_y @intCast(offset_pixels); } else 0; @@ -1316,7 +1300,10 @@ fn prepKittyGraphics( } // Convert our screen point to a viewport point - const viewport = p.point.toViewport(&t.screen); + const viewport: terminal.point.Point = t.screen.pages.pointFromPin( + .viewport, + p.pin.*, + ) orelse .{ .viewport = .{} }; // Calculate the source rectangle const source_x = @min(image.width, p.source_x); @@ -1338,8 +1325,8 @@ fn prepKittyGraphics( if (image.width > 0 and image.height > 0) { try self.image_placements.append(self.alloc, .{ .image_id = kv.key_ptr.image_id, - .x = @intCast(p.point.x), - .y = @intCast(viewport.y), + .x = @intCast(p.pin.x), + .y = @intCast(viewport.viewport.y), .z = p.z, .width = dest_width, .height = dest_height, @@ -1536,16 +1523,21 @@ pub fn setScreenSize( /// down to the GPU yet. fn rebuildCells( self: *Metal, - term_selection: ?terminal.Selection, screen: *terminal.Screen, mouse: renderer.State.Mouse, preedit: ?renderer.State.Preedit, cursor_style_: ?renderer.CursorStyle, color_palette: *const terminal.color.Palette, ) !void { + const rows_usize: usize = @intCast(screen.pages.rows); + const cols_usize: usize = @intCast(screen.pages.cols); + // Bg cells at most will need space for the visible screen size self.cells_bg.clearRetainingCapacity(); - try self.cells_bg.ensureTotalCapacity(self.alloc, screen.rows * screen.cols); + try self.cells_bg.ensureTotalCapacity( + self.alloc, + rows_usize * cols_usize, + ); // Over-allocate just to ensure we don't allocate again during loops. self.cells.clearRetainingCapacity(); @@ -1554,7 +1546,7 @@ fn rebuildCells( // * 3 for glyph + underline + strikethrough for each cell // + 1 for cursor - (screen.rows * screen.cols * 3) + 1, + (rows_usize * cols_usize * 3) + 1, ); // Create an arena for all our temporary allocations while rebuilding @@ -1577,7 +1569,7 @@ fn rebuildCells( x: [2]usize, cp_offset: usize, } = if (preedit) |preedit_v| preedit: { - const range = preedit_v.range(screen.cursor.x, screen.cols - 1); + const range = preedit_v.range(screen.cursor.x, screen.pages.cols - 1); break :preedit .{ .y = screen.cursor.y, .x = .{ range.start, range.end }, @@ -1591,9 +1583,9 @@ fn rebuildCells( var cursor_cell: ?mtl_shaders.Cell = null; // Build each cell - var rowIter = screen.rowIterator(.viewport); + var row_it = screen.pages.rowIterator(.right_down, .{ .viewport = .{} }, null); var y: usize = 0; - while (rowIter.next()) |row| { + while (row_it.next()) |row| { defer y += 1; // True if this is the row with our cursor. There are a lot of conditions @@ -1628,8 +1620,8 @@ fn rebuildCells( defer if (cursor_row) { // If we're on a wide spacer tail, then we want to look for // the previous cell. - const screen_cell = row.getCell(screen.cursor.x); - const x = screen.cursor.x - @intFromBool(screen_cell.attrs.wide_spacer_tail); + const screen_cell = row.cells(.all)[screen.cursor.x]; + const x = screen.cursor.x - @intFromBool(screen_cell.wide == .spacer_tail); for (self.cells.items[start_i..]) |cell| { if (cell.grid_pos[0] == @as(f32, @floatFromInt(x)) and (cell.mode == .fg or cell.mode == .fg_color)) @@ -1643,22 +1635,16 @@ fn rebuildCells( // We need to get this row's selection if there is one for proper // run splitting. const row_selection = sel: { - if (term_selection) |sel| { - const screen_point = (terminal.point.Viewport{ - .x = 0, - .y = y, - }).toScreen(screen); - if (sel.containedRow(screen, screen_point)) |row_sel| { - break :sel row_sel; - } - } - - break :sel null; + const sel = screen.selection orelse break :sel null; + const pin = screen.pages.pin(.{ .viewport = .{ .y = y } }) orelse + break :sel null; + break :sel sel.containedRow(screen, pin) orelse null; }; // Split our row into runs and shape each one. var iter = self.font_shaper.runIterator( self.font_group, + screen, row, row_selection, if (shape_cursor) screen.cursor.x else null, @@ -1679,25 +1665,19 @@ fn rebuildCells( // It this cell is within our hint range then we need to // underline it. - const cell: terminal.Screen.Cell = cell: { - var cell = row.getCell(shaper_cell.x); - - // If our links contain this cell then we want to - // underline it. - if (link_match_set.orderedContains(.{ - .x = shaper_cell.x, - .y = y, - })) { - cell.attrs.underline = .single; - } - - break :cell cell; + const cell: terminal.Pin = cell: { + var copy = row; + copy.x = shaper_cell.x; + break :cell copy; }; if (self.updateCell( - term_selection, screen, cell, + if (link_match_set.orderedContains(screen, cell)) + .single + else + null, color_palette, shaper_cell, run, @@ -1714,9 +1694,6 @@ fn rebuildCells( } } } - - // Set row is not dirty anymore - row.setDirty(false); } // Add the cursor at the end so that it overlays everything. If we have @@ -1766,9 +1743,9 @@ fn rebuildCells( fn updateCell( self: *Metal, - selection: ?terminal.Selection, - screen: *terminal.Screen, - cell: terminal.Screen.Cell, + screen: *const terminal.Screen, + cell_pin: terminal.Pin, + cell_underline: ?terminal.Attribute.Underline, palette: *const terminal.color.Palette, shaper_cell: font.shape.Cell, shaper_run: font.shape.TextRun, @@ -1788,47 +1765,30 @@ fn updateCell( }; // True if this cell is selected - // TODO(perf): we can check in advance if selection is in - // our viewport at all and not run this on every point. - const selected: bool = if (selection) |sel| selected: { - const screen_point = (terminal.point.Viewport{ - .x = x, - .y = y, - }).toScreen(screen); + const selected: bool = if (screen.selection) |sel| + sel.contains(screen, cell_pin) + else + false; - break :selected sel.contains(screen_point); - } else false; + const rac = cell_pin.rowAndCell(); + const cell = rac.cell; + const style = cell_pin.style(cell); + const underline = cell_underline orelse style.flags.underline; // The colors for the cell. const colors: BgFg = colors: { // The normal cell result - const cell_res: BgFg = if (!cell.attrs.inverse) .{ + const cell_res: BgFg = if (!style.flags.inverse) .{ // In normal mode, background and fg match the cell. We // un-optionalize the fg by defaulting to our fg color. - .bg = switch (cell.bg) { - .none => null, - .indexed => |i| palette[i], - .rgb => |rgb| rgb, - }, - .fg = switch (cell.fg) { - .none => self.foreground_color, - .indexed => |i| palette[i], - .rgb => |rgb| rgb, - }, + .bg = style.bg(cell, palette), + .fg = style.fg(palette) orelse self.foreground_color, } else .{ // In inverted mode, the background MUST be set to something // (is never null) so it is either the fg or default fg. The // fg is either the bg or default background. - .bg = switch (cell.fg) { - .none => self.foreground_color, - .indexed => |i| palette[i], - .rgb => |rgb| rgb, - }, - .fg = switch (cell.bg) { - .none => self.background_color, - .indexed => |i| palette[i], - .rgb => |rgb| rgb, - }, + .bg = style.fg(palette) orelse self.foreground_color, + .fg = style.bg(cell, palette) orelse self.background_color, }; // If we are selected, we our colors are just inverted fg/bg @@ -1846,7 +1806,7 @@ fn updateCell( // If the cell is "invisible" then we just make fg = bg so that // the cell is transparent but still copy-able. const res: BgFg = selection_res orelse cell_res; - if (cell.attrs.invisible) { + if (style.flags.invisible) { break :colors BgFg{ .bg = res.bg, .fg = res.bg orelse self.background_color, @@ -1857,7 +1817,7 @@ fn updateCell( }; // Alpha multiplier - const alpha: u8 = if (cell.attrs.faint) 175 else 255; + const alpha: u8 = if (style.flags.faint) 175 else 255; // If the cell has a background, we always draw it. const bg: [4]u8 = if (colors.bg) |rgb| bg: { @@ -1874,11 +1834,11 @@ fn updateCell( if (selected) break :bg_alpha default; // If we're reversed, do not apply background opacity - if (cell.attrs.inverse) break :bg_alpha default; + if (style.flags.inverse) break :bg_alpha default; // If we have a background and its not the default background // then we apply background opacity - if (cell.bg != .none and !rgb.eql(self.background_color)) { + if (style.bg(cell, palette) != null and !rgb.eql(self.background_color)) { break :bg_alpha default; } @@ -1892,7 +1852,7 @@ fn updateCell( self.cells_bg.appendAssumeCapacity(.{ .mode = .bg, .grid_pos = .{ @as(f32, @floatFromInt(x)), @as(f32, @floatFromInt(y)) }, - .cell_width = cell.widthLegacy(), + .cell_width = cell.gridWidth(), .color = .{ rgb.r, rgb.g, rgb.b, bg_alpha }, .bg_color = .{ 0, 0, 0, 0 }, }); @@ -1906,7 +1866,7 @@ fn updateCell( }; // If the cell has a character, draw it - if (cell.char > 0) fg: { + if (cell.hasText()) fg: { // Render const glyph = try self.font_group.renderGlyph( self.alloc, @@ -1920,11 +1880,8 @@ fn updateCell( const mode: mtl_shaders.Cell.Mode = switch (try fgMode( &self.font_group.group, - screen, - cell, + cell_pin, shaper_run, - x, - y, )) { .normal => .fg, .color => .fg_color, @@ -1934,7 +1891,7 @@ fn updateCell( self.cells.appendAssumeCapacity(.{ .mode = mode, .grid_pos = .{ @as(f32, @floatFromInt(x)), @as(f32, @floatFromInt(y)) }, - .cell_width = cell.widthLegacy(), + .cell_width = cell.gridWidth(), .color = .{ colors.fg.r, colors.fg.g, colors.fg.b, alpha }, .bg_color = bg, .glyph_pos = .{ glyph.atlas_x, glyph.atlas_y }, @@ -1946,8 +1903,8 @@ fn updateCell( }); } - if (cell.attrs.underline != .none) { - const sprite: font.Sprite = switch (cell.attrs.underline) { + if (underline != .none) { + const sprite: font.Sprite = switch (underline) { .none => unreachable, .single => .underline, .double => .underline_double, @@ -1961,17 +1918,17 @@ fn updateCell( font.sprite_index, @intFromEnum(sprite), .{ - .cell_width = if (cell.attrs.wide) 2 else 1, + .cell_width = if (cell.wide == .wide) 2 else 1, .grid_metrics = self.grid_metrics, }, ); - const color = if (cell.attrs.underline_color) cell.underline_fg else colors.fg; + const color = style.underlineColor(palette) orelse colors.fg; self.cells.appendAssumeCapacity(.{ .mode = .fg, .grid_pos = .{ @as(f32, @floatFromInt(x)), @as(f32, @floatFromInt(y)) }, - .cell_width = cell.widthLegacy(), + .cell_width = cell.gridWidth(), .color = .{ color.r, color.g, color.b, alpha }, .bg_color = bg, .glyph_pos = .{ glyph.atlas_x, glyph.atlas_y }, @@ -1980,11 +1937,11 @@ fn updateCell( }); } - if (cell.attrs.strikethrough) { + if (style.flags.strikethrough) { self.cells.appendAssumeCapacity(.{ .mode = .strikethrough, .grid_pos = .{ @as(f32, @floatFromInt(x)), @as(f32, @floatFromInt(y)) }, - .cell_width = cell.widthLegacy(), + .cell_width = cell.gridWidth(), .color = .{ colors.fg.r, colors.fg.g, colors.fg.b, alpha }, .bg_color = bg, }); @@ -2002,21 +1959,14 @@ fn addCursor( // we're on the wide characer tail. const wide, const x = cell: { // The cursor goes over the screen cursor position. - const cell = screen.getCell( - .active, - screen.cursor.y, - screen.cursor.x, - ); - if (!cell.attrs.wide_spacer_tail or screen.cursor.x == 0) - break :cell .{ cell.attrs.wide, screen.cursor.x }; + const cell = screen.cursor.page_cell; + if (cell.wide != .spacer_tail or screen.cursor.x == 0) + break :cell .{ cell.wide == .wide, screen.cursor.x }; // If we're part of a wide character, we move the cursor back to // the actual character. - break :cell .{ screen.getCell( - .active, - screen.cursor.y, - screen.cursor.x - 1, - ).attrs.wide, screen.cursor.x - 1 }; + const prev_cell = screen.cursorCellLeft(1); + break :cell .{ prev_cell.wide == .wide, screen.cursor.x - 1 }; }; const color = self.cursor_color orelse self.foreground_color; diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 77d42dc8f7..47d660b346 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -657,7 +657,6 @@ pub fn updateFrame( // Data we extract out of the critical area. const Critical = struct { gl_bg: terminal.color.RGB, - selection: ?terminal.Selection, screen: terminal.Screen, mouse: renderer.State.Mouse, preedit: ?renderer.State.Preedit, @@ -691,25 +690,13 @@ pub fn updateFrame( // We used to share terminal state, but we've since learned through // analysis that it is faster to copy the terminal state than to // hold the lock wile rebuilding GPU cells. - const viewport_bottom = state.terminal.screen.viewportIsBottom(); - var screen_copy = if (viewport_bottom) try state.terminal.screen.clone( + var screen_copy = try state.terminal.screen.clone( self.alloc, - .{ .active = 0 }, - .{ .active = state.terminal.rows - 1 }, - ) else try state.terminal.screen.clone( - self.alloc, - .{ .viewport = 0 }, - .{ .viewport = state.terminal.rows - 1 }, + .{ .viewport = .{} }, + null, ); errdefer screen_copy.deinit(); - // Convert our selection to viewport points because we copy only - // the viewport above. - const selection: ?terminal.Selection = if (state.terminal.screen.selection) |sel| - sel.toViewport(&state.terminal.screen) - else - null; - // Whether to draw our cursor or not. const cursor_style = renderer.cursorStyle( state, @@ -739,7 +726,6 @@ pub fn updateFrame( break :critical .{ .gl_bg = self.background_color, - .selection = selection, .screen = screen_copy, .mouse = state.mouse, .preedit = preedit, @@ -762,7 +748,6 @@ pub fn updateFrame( // Build our GPU cells try self.rebuildCells( - critical.selection, &critical.screen, critical.mouse, critical.preedit, @@ -802,11 +787,8 @@ fn prepKittyGraphics( // The top-left and bottom-right corners of our viewport in screen // points. This lets us determine offsets and containment of placements. - const top = (terminal.point.Viewport{}).toScreen(&t.screen); - const bot = (terminal.point.Viewport{ - .x = t.screen.cols - 1, - .y = t.screen.rows - 1, - }).toScreen(&t.screen); + const top = t.screen.pages.getTopLeft(.viewport); + const bot = t.screen.pages.getBottomRight(.viewport).?; // Go through the placements and ensure the image is loaded on the GPU. var it = storage.placements.iterator(); @@ -823,13 +805,15 @@ fn prepKittyGraphics( // If the selection isn't within our viewport then skip it. const rect = p.rect(image, t); - if (rect.top_left.y > bot.y) continue; - if (rect.bottom_right.y < top.y) continue; + if (bot.before(rect.top_left)) continue; + if (rect.bottom_right.before(top)) continue; // If the top left is outside the viewport we need to calc an offset // so that we render (0, 0) with some offset for the texture. - const offset_y: u32 = if (rect.top_left.y < t.screen.viewport) offset_y: { - const offset_cells = t.screen.viewport - rect.top_left.y; + const offset_y: u32 = if (rect.top_left.before(top)) offset_y: { + const vp_y = t.screen.pages.pointFromPin(.screen, top).?.screen.y; + const img_y = t.screen.pages.pointFromPin(.screen, rect.top_left).?.screen.y; + const offset_cells = vp_y - img_y; const offset_pixels = offset_cells * self.grid_metrics.cell_height; break :offset_y @intCast(offset_pixels); } else 0; @@ -875,7 +859,10 @@ fn prepKittyGraphics( } // Convert our screen point to a viewport point - const viewport = p.point.toViewport(&t.screen); + const viewport: terminal.point.Point = t.screen.pages.pointFromPin( + .viewport, + p.pin.*, + ) orelse .{ .viewport = .{} }; // Calculate the source rectangle const source_x = @min(image.width, p.source_x); @@ -897,8 +884,8 @@ fn prepKittyGraphics( if (image.width > 0 and image.height > 0) { try self.image_placements.append(self.alloc, .{ .image_id = kv.key_ptr.image_id, - .x = @intCast(p.point.x), - .y = @intCast(viewport.y), + .x = @intCast(p.pin.x), + .y = @intCast(viewport.viewport.y), .z = p.z, .width = dest_width, .height = dest_height, @@ -955,16 +942,21 @@ fn prepKittyGraphics( /// the renderer will do this when it needs more memory space. pub fn rebuildCells( self: *OpenGL, - term_selection: ?terminal.Selection, screen: *terminal.Screen, mouse: renderer.State.Mouse, preedit: ?renderer.State.Preedit, cursor_style_: ?renderer.CursorStyle, color_palette: *const terminal.color.Palette, ) !void { + const rows_usize: usize = @intCast(screen.pages.rows); + const cols_usize: usize = @intCast(screen.pages.cols); + // Bg cells at most will need space for the visible screen size self.cells_bg.clearRetainingCapacity(); - try self.cells_bg.ensureTotalCapacity(self.alloc, screen.rows * screen.cols); + try self.cells_bg.ensureTotalCapacity( + self.alloc, + rows_usize * cols_usize, + ); // For now, we just ensure that we have enough cells for all the lines // we have plus a full width. This is very likely too much but its @@ -975,7 +967,7 @@ pub fn rebuildCells( // * 3 for glyph + underline + strikethrough for each cell // + 1 for cursor - (screen.rows * screen.cols * 3) + 1, + (rows_usize * cols_usize * 3) + 1, ); // Create an arena for all our temporary allocations while rebuilding @@ -1001,7 +993,7 @@ pub fn rebuildCells( x: [2]usize, cp_offset: usize, } = if (preedit) |preedit_v| preedit: { - const range = preedit_v.range(screen.cursor.x, screen.cols - 1); + const range = preedit_v.range(screen.cursor.x, screen.pages.cols - 1); break :preedit .{ .y = screen.cursor.y, .x = .{ range.start, range.end }, @@ -1015,9 +1007,9 @@ pub fn rebuildCells( var cursor_cell: ?CellProgram.Cell = null; // Build each cell - var rowIter = screen.rowIterator(.viewport); + var row_it = screen.pages.rowIterator(.right_down, .{ .viewport = .{} }, null); var y: usize = 0; - while (rowIter.next()) |row| { + while (row_it.next()) |row| { defer y += 1; // Our selection value is only non-null if this selection happens @@ -1025,19 +1017,10 @@ pub fn rebuildCells( // the selection that contains this row. This way, if the selection // changes but not for this line, we don't invalidate the cache. const selection = sel: { - if (term_selection) |sel| { - const screen_point = (terminal.point.Viewport{ - .x = 0, - .y = y, - }).toScreen(screen); - - // If we are selected, we our colors are just inverted fg/bg. - if (sel.containedRow(screen, screen_point)) |row_sel| { - break :sel row_sel; - } - } - - break :sel null; + const sel = screen.selection orelse break :sel null; + const pin = screen.pages.pin(.{ .viewport = .{ .y = y } }) orelse + break :sel null; + break :sel sel.containedRow(screen, pin) orelse null; }; // See Metal.zig @@ -1059,8 +1042,8 @@ pub fn rebuildCells( defer if (cursor_row) { // If we're on a wide spacer tail, then we want to look for // the previous cell. - const screen_cell = row.getCell(screen.cursor.x); - const x = screen.cursor.x - @intFromBool(screen_cell.attrs.wide_spacer_tail); + const screen_cell = row.cells(.all)[screen.cursor.x]; + const x = screen.cursor.x - @intFromBool(screen_cell.wide == .spacer_tail); for (self.cells.items[start_i..]) |cell| { if (cell.grid_col == x and (cell.mode == .fg or cell.mode == .fg_color)) @@ -1074,6 +1057,7 @@ pub fn rebuildCells( // Split our row into runs and shape each one. var iter = self.font_shaper.runIterator( self.font_group, + screen, row, selection, if (shape_cursor) screen.cursor.x else null, @@ -1094,25 +1078,19 @@ pub fn rebuildCells( // It this cell is within our hint range then we need to // underline it. - const cell: terminal.Screen.Cell = cell: { - var cell = row.getCell(shaper_cell.x); - - // If our links contain this cell then we want to - // underline it. - if (link_match_set.orderedContains(.{ - .x = shaper_cell.x, - .y = y, - })) { - cell.attrs.underline = .single; - } - - break :cell cell; + const cell: terminal.Pin = cell: { + var copy = row; + copy.x = shaper_cell.x; + break :cell copy; }; if (self.updateCell( - term_selection, screen, cell, + if (link_match_set.orderedContains(screen, cell)) + .single + else + null, color_palette, shaper_cell, run, @@ -1129,9 +1107,6 @@ pub fn rebuildCells( } } } - - // Set row is not dirty anymore - row.setDirty(false); } // Add the cursor at the end so that it overlays everything. If we have @@ -1277,21 +1252,14 @@ fn addCursor( // we're on the wide characer tail. const wide, const x = cell: { // The cursor goes over the screen cursor position. - const cell = screen.getCell( - .active, - screen.cursor.y, - screen.cursor.x, - ); - if (!cell.attrs.wide_spacer_tail or screen.cursor.x == 0) - break :cell .{ cell.attrs.wide, screen.cursor.x }; + const cell = screen.cursor.page_cell; + if (cell.wide != .spacer_tail or screen.cursor.x == 0) + break :cell .{ cell.wide == .wide, screen.cursor.x }; // If we're part of a wide character, we move the cursor back to // the actual character. - break :cell .{ screen.getCell( - .active, - screen.cursor.y, - screen.cursor.x - 1, - ).attrs.wide, screen.cursor.x - 1 }; + const prev_cell = screen.cursorCellLeft(1); + break :cell .{ prev_cell.wide == .wide, screen.cursor.x - 1 }; }; const color = self.cursor_color orelse self.foreground_color; @@ -1349,9 +1317,9 @@ fn addCursor( /// needed. fn updateCell( self: *OpenGL, - selection: ?terminal.Selection, screen: *terminal.Screen, - cell: terminal.Screen.Cell, + cell_pin: terminal.Pin, + cell_underline: ?terminal.Attribute.Underline, palette: *const terminal.color.Palette, shaper_cell: font.shape.Cell, shaper_run: font.shape.TextRun, @@ -1371,47 +1339,30 @@ fn updateCell( }; // True if this cell is selected - // TODO(perf): we can check in advance if selection is in - // our viewport at all and not run this on every point. - const selected: bool = if (selection) |sel| selected: { - const screen_point = (terminal.point.Viewport{ - .x = x, - .y = y, - }).toScreen(screen); + const selected: bool = if (screen.selection) |sel| + sel.contains(screen, cell_pin) + else + false; - break :selected sel.contains(screen_point); - } else false; + const rac = cell_pin.rowAndCell(); + const cell = rac.cell; + const style = cell_pin.style(cell); + const underline = cell_underline orelse style.flags.underline; // The colors for the cell. const colors: BgFg = colors: { // The normal cell result - const cell_res: BgFg = if (!cell.attrs.inverse) .{ + const cell_res: BgFg = if (!style.flags.inverse) .{ // In normal mode, background and fg match the cell. We // un-optionalize the fg by defaulting to our fg color. - .bg = switch (cell.bg) { - .none => null, - .indexed => |i| palette[i], - .rgb => |rgb| rgb, - }, - .fg = switch (cell.fg) { - .none => self.foreground_color, - .indexed => |i| palette[i], - .rgb => |rgb| rgb, - }, + .bg = style.bg(cell, palette), + .fg = style.fg(palette) orelse self.foreground_color, } else .{ // In inverted mode, the background MUST be set to something // (is never null) so it is either the fg or default fg. The // fg is either the bg or default background. - .bg = switch (cell.fg) { - .none => self.foreground_color, - .indexed => |i| palette[i], - .rgb => |rgb| rgb, - }, - .fg = switch (cell.bg) { - .none => self.background_color, - .indexed => |i| palette[i], - .rgb => |rgb| rgb, - }, + .bg = style.fg(palette) orelse self.foreground_color, + .fg = style.bg(cell, palette) orelse self.background_color, }; // If we are selected, we our colors are just inverted fg/bg @@ -1429,7 +1380,7 @@ fn updateCell( // If the cell is "invisible" then we just make fg = bg so that // the cell is transparent but still copy-able. const res: BgFg = selection_res orelse cell_res; - if (cell.attrs.invisible) { + if (style.flags.invisible) { break :colors BgFg{ .bg = res.bg, .fg = res.bg orelse self.background_color, @@ -1439,19 +1390,8 @@ fn updateCell( break :colors res; }; - // Calculate the amount of space we need in the cells list. - const needed = needed: { - var i: usize = 0; - if (colors.bg != null) i += 1; - if (!cell.empty()) i += 1; - if (cell.attrs.underline != .none) i += 1; - if (cell.attrs.strikethrough) i += 1; - break :needed i; - }; - if (self.cells.items.len + needed > self.cells.capacity) return false; - // Alpha multiplier - const alpha: u8 = if (cell.attrs.faint) 175 else 255; + const alpha: u8 = if (style.flags.faint) 175 else 255; // If the cell has a background, we always draw it. const bg: [4]u8 = if (colors.bg) |rgb| bg: { @@ -1468,11 +1408,11 @@ fn updateCell( if (selected) break :bg_alpha default; // If we're reversed, do not apply background opacity - if (cell.attrs.inverse) break :bg_alpha default; + if (style.flags.inverse) break :bg_alpha default; // If we have a background and its not the default background // then we apply background opacity - if (cell.bg != .none and !rgb.eql(self.background_color)) { + if (style.bg(cell, palette) != null and !rgb.eql(self.background_color)) { break :bg_alpha default; } @@ -1487,7 +1427,7 @@ fn updateCell( .mode = .bg, .grid_col = @intCast(x), .grid_row = @intCast(y), - .grid_width = cell.widthLegacy(), + .grid_width = cell.gridWidth(), .glyph_x = 0, .glyph_y = 0, .glyph_width = 0, @@ -1513,7 +1453,7 @@ fn updateCell( }; // If the cell has a character, draw it - if (cell.char > 0) fg: { + if (cell.hasText()) fg: { // Render const glyph = try self.font_group.renderGlyph( self.alloc, @@ -1528,11 +1468,8 @@ fn updateCell( // If we're rendering a color font, we use the color atlas const mode: CellProgram.CellMode = switch (try fgMode( &self.font_group.group, - screen, - cell, + cell_pin, shaper_run, - x, - y, )) { .normal => .fg, .color => .fg_color, @@ -1543,7 +1480,7 @@ fn updateCell( .mode = mode, .grid_col = @intCast(x), .grid_row = @intCast(y), - .grid_width = cell.widthLegacy(), + .grid_width = cell.gridWidth(), .glyph_x = glyph.atlas_x, .glyph_y = glyph.atlas_y, .glyph_width = glyph.width, @@ -1561,8 +1498,8 @@ fn updateCell( }); } - if (cell.attrs.underline != .none) { - const sprite: font.Sprite = switch (cell.attrs.underline) { + if (underline != .none) { + const sprite: font.Sprite = switch (underline) { .none => unreachable, .single => .underline, .double => .underline_double, @@ -1577,17 +1514,17 @@ fn updateCell( @intFromEnum(sprite), .{ .grid_metrics = self.grid_metrics, - .cell_width = if (cell.attrs.wide) 2 else 1, + .cell_width = if (cell.wide == .wide) 2 else 1, }, ); - const color = if (cell.attrs.underline_color) cell.underline_fg else colors.fg; + const color = style.underlineColor(palette) orelse colors.fg; self.cells.appendAssumeCapacity(.{ .mode = .fg, .grid_col = @intCast(x), .grid_row = @intCast(y), - .grid_width = cell.widthLegacy(), + .grid_width = cell.gridWidth(), .glyph_x = underline_glyph.atlas_x, .glyph_y = underline_glyph.atlas_y, .glyph_width = underline_glyph.width, @@ -1605,12 +1542,12 @@ fn updateCell( }); } - if (cell.attrs.strikethrough) { + if (style.flags.strikethrough) { self.cells.appendAssumeCapacity(.{ .mode = .strikethrough, .grid_col = @intCast(x), .grid_row = @intCast(y), - .grid_width = cell.widthLegacy(), + .grid_width = cell.gridWidth(), .glyph_x = 0, .glyph_y = 0, .glyph_width = 0, diff --git a/src/renderer/State.zig b/src/renderer/State.zig index 6c69d30a95..6bff5b2192 100644 --- a/src/renderer/State.zig +++ b/src/renderer/State.zig @@ -34,7 +34,7 @@ pub const Mouse = struct { /// The point on the viewport where the mouse currently is. We use /// viewport points to avoid the complexity of mapping the mouse to /// the renderer state. - point: ?terminal.point.Viewport = null, + point: ?terminal.point.Coordinate = null, /// The mods that are currently active for the last mouse event. /// This could really just be mods in general and we probably will diff --git a/src/renderer/cell.zig b/src/renderer/cell.zig index 1a5ac51d9d..44087da444 100644 --- a/src/renderer/cell.zig +++ b/src/renderer/cell.zig @@ -21,11 +21,8 @@ pub const FgMode = enum { /// renderer. pub fn fgMode( group: *font.Group, - screen: *terminal.Screen, - cell: terminal.Screen.Cell, + cell_pin: terminal.Pin, shaper_run: font.shape.TextRun, - x: usize, - y: usize, ) !FgMode { const presentation = try group.presentationFromIndex(shaper_run.font_index); return switch (presentation) { @@ -41,42 +38,55 @@ pub fn fgMode( // the subsequent character is empty, then we allow it to use // the full glyph size. See #1071. .text => text: { - if (!ziglyph.general_category.isPrivateUse(@intCast(cell.char)) and - !ziglyph.blocks.isDingbats(@intCast(cell.char))) + const cell = cell_pin.rowAndCell().cell; + const cp = cell.codepoint(); + + if (!ziglyph.general_category.isPrivateUse(cp) and + !ziglyph.blocks.isDingbats(cp)) { break :text .normal; } // We exempt the Powerline range from this since they exhibit // box-drawing behavior and should not be constrained. - if (isPowerline(cell.char)) { + if (isPowerline(cp)) { break :text .normal; } // If we are at the end of the screen its definitely constrained - if (x == screen.cols - 1) break :text .constrained; + if (cell_pin.x == cell_pin.page.data.size.cols - 1) break :text .constrained; // If we have a previous cell and it was PUA then we need to // also constrain. This is so that multiple PUA glyphs align. // As an exception, we ignore powerline glyphs since they are // used for box drawing and we consider them whitespace. - if (x > 0) prev: { - const prev_cell = screen.getCell(.active, y, x - 1); + if (cell_pin.x > 0) prev: { + const prev_cp = prev_cp: { + var copy = cell_pin; + copy.x -= 1; + const prev_cell = copy.rowAndCell().cell; + break :prev_cp prev_cell.codepoint(); + }; // Powerline is whitespace - if (isPowerline(prev_cell.char)) break :prev; + if (isPowerline(prev_cp)) break :prev; - if (ziglyph.general_category.isPrivateUse(@intCast(prev_cell.char))) { + if (ziglyph.general_category.isPrivateUse(prev_cp)) { break :text .constrained; } } // If the next cell is empty, then we allow it to use the // full glyph size. - const next_cell = screen.getCell(.active, y, x + 1); - if (next_cell.char == 0 or - next_cell.char == ' ' or - isPowerline(next_cell.char)) + const next_cp = next_cp: { + var copy = cell_pin; + copy.x += 1; + const next_cell = copy.rowAndCell().cell; + break :next_cp next_cell.codepoint(); + }; + if (next_cp == 0 or + next_cp == ' ' or + isPowerline(next_cp)) { break :text .normal; } @@ -88,7 +98,7 @@ pub fn fgMode( } // Returns true if the codepoint is a part of the Powerline range. -fn isPowerline(char: u32) bool { +fn isPowerline(char: u21) bool { return switch (char) { 0xE0B0...0xE0C8, 0xE0CA, 0xE0CC...0xE0D2, 0xE0D4 => true, else => false, diff --git a/src/renderer/cursor.zig b/src/renderer/cursor.zig index c4a74e05c6..d7cb7c5bb0 100644 --- a/src/renderer/cursor.zig +++ b/src/renderer/cursor.zig @@ -12,7 +12,7 @@ pub const CursorStyle = enum { underline, /// Create a cursor style from the terminal style request. - pub fn fromTerminal(style: terminal.Cursor.Style) ?CursorStyle { + pub fn fromTerminal(style: terminal.CursorStyle) ?CursorStyle { return switch (style) { .bar => .bar, .block => .block, @@ -57,16 +57,16 @@ pub fn cursorStyle( } // Otherwise, we use whatever style the terminal wants. - return CursorStyle.fromTerminal(state.terminal.screen.cursor.style); + return CursorStyle.fromTerminal(state.terminal.screen.cursor.cursor_style); } test "cursor: default uses configured style" { const testing = std.testing; const alloc = testing.allocator; - var term = try terminal.Terminal.init(alloc, 10, 10); + var term = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 10 }); defer term.deinit(alloc); - term.screen.cursor.style = .bar; + term.screen.cursor.cursor_style = .bar; term.modes.set(.cursor_blinking, true); var state: State = .{ @@ -84,10 +84,10 @@ test "cursor: default uses configured style" { test "cursor: blinking disabled" { const testing = std.testing; const alloc = testing.allocator; - var term = try terminal.Terminal.init(alloc, 10, 10); + var term = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 10 }); defer term.deinit(alloc); - term.screen.cursor.style = .bar; + term.screen.cursor.cursor_style = .bar; term.modes.set(.cursor_blinking, false); var state: State = .{ @@ -105,10 +105,10 @@ test "cursor: blinking disabled" { test "cursor: explictly not visible" { const testing = std.testing; const alloc = testing.allocator; - var term = try terminal.Terminal.init(alloc, 10, 10); + var term = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 10 }); defer term.deinit(alloc); - term.screen.cursor.style = .bar; + term.screen.cursor.cursor_style = .bar; term.modes.set(.cursor_visible, false); term.modes.set(.cursor_blinking, false); @@ -127,7 +127,7 @@ test "cursor: explictly not visible" { test "cursor: always block with preedit" { const testing = std.testing; const alloc = testing.allocator; - var term = try terminal.Terminal.init(alloc, 10, 10); + var term = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 10 }); defer term.deinit(alloc); var state: State = .{ diff --git a/src/renderer/link.zig b/src/renderer/link.zig index ca1dc062a2..e6c7f6ba04 100644 --- a/src/renderer/link.zig +++ b/src/renderer/link.zig @@ -64,11 +64,13 @@ pub const Set = struct { self: *const Set, alloc: Allocator, screen: *Screen, - mouse_vp_pt: point.Viewport, + mouse_vp_pt: point.Coordinate, mouse_mods: inputpkg.Mods, ) !MatchSet { // Convert the viewport point to a screen point. - const mouse_pt = mouse_vp_pt.toScreen(screen); + const mouse_pin = screen.pages.pin(.{ + .viewport = mouse_vp_pt, + }) orelse return .{}; // This contains our list of matches. The matches are stored // as selections which contain the start and end points of @@ -78,14 +80,25 @@ pub const Set = struct { defer matches.deinit(); // Iterate over all the visible lines. - var lineIter = screen.lineIterator(.viewport); - while (lineIter.next()) |line| { - const strmap = line.stringMap(alloc) catch |err| { - log.warn( - "failed to build string map for link checking err={}", - .{err}, - ); - continue; + var lineIter = screen.lineIterator(screen.pages.pin(.{ + .viewport = .{}, + }) orelse return .{}); + while (lineIter.next()) |line_sel| { + const strmap: terminal.StringMap = strmap: { + var strmap: terminal.StringMap = undefined; + const str = screen.selectionString(alloc, .{ + .sel = line_sel, + .trim = false, + .map = &strmap, + }) catch |err| { + log.warn( + "failed to build string map for link checking err={}", + .{err}, + ); + continue; + }; + alloc.free(str); + break :strmap strmap; }; defer strmap.deinit(alloc); @@ -98,7 +111,7 @@ pub const Set = struct { .always => {}, .always_mods => |v| if (!mouse_mods.equal(v)) continue, inline .hover, .hover_mods => |v, tag| { - if (!line.selection().contains(mouse_pt)) continue; + if (!line_sel.contains(screen, mouse_pin)) continue; if (comptime tag == .hover_mods) { if (!mouse_mods.equal(v)) continue; } @@ -121,7 +134,7 @@ pub const Set = struct { .always, .always_mods => {}, .hover, .hover_mods, - => if (!sel.contains(mouse_pt)) continue, + => if (!sel.contains(screen, mouse_pin)) continue, } try matches.append(sel); @@ -153,19 +166,20 @@ pub const MatchSet = struct { /// results. pub fn orderedContains( self: *MatchSet, - pt: point.ScreenPoint, + screen: *const Screen, + pin: terminal.Pin, ) bool { // If we're beyond the end of our possible matches, we're done. if (self.i >= self.matches.len) return false; // If our selection ends before the point, then no point will ever // again match this selection so we move on to the next one. - while (self.matches[self.i].end.before(pt)) { + while (self.matches[self.i].end().before(pin)) { self.i += 1; if (self.i >= self.matches.len) return false; } - return self.matches[self.i].contains(pt); + return self.matches[self.i].contains(screen, pin); } }; @@ -201,12 +215,30 @@ test "matchset" { try testing.expectEqual(@as(usize, 2), match.matches.len); // Test our matches - try testing.expect(!match.orderedContains(.{ .x = 0, .y = 0 })); - try testing.expect(match.orderedContains(.{ .x = 1, .y = 0 })); - try testing.expect(match.orderedContains(.{ .x = 2, .y = 0 })); - try testing.expect(!match.orderedContains(.{ .x = 3, .y = 0 })); - try testing.expect(match.orderedContains(.{ .x = 1, .y = 1 })); - try testing.expect(!match.orderedContains(.{ .x = 1, .y = 2 })); + try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 0, + .y = 0, + } }).?)); + try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 1, + .y = 0, + } }).?)); + try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 2, + .y = 0, + } }).?)); + try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 3, + .y = 0, + } }).?)); + try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 1, + .y = 1, + } }).?)); + try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 1, + .y = 2, + } }).?)); } test "matchset hover links" { @@ -242,12 +274,30 @@ test "matchset hover links" { try testing.expectEqual(@as(usize, 1), match.matches.len); // Test our matches - try testing.expect(!match.orderedContains(.{ .x = 0, .y = 0 })); - try testing.expect(!match.orderedContains(.{ .x = 1, .y = 0 })); - try testing.expect(!match.orderedContains(.{ .x = 2, .y = 0 })); - try testing.expect(!match.orderedContains(.{ .x = 3, .y = 0 })); - try testing.expect(match.orderedContains(.{ .x = 1, .y = 1 })); - try testing.expect(!match.orderedContains(.{ .x = 1, .y = 2 })); + try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 0, + .y = 0, + } }).?)); + try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 1, + .y = 0, + } }).?)); + try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 2, + .y = 0, + } }).?)); + try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 3, + .y = 0, + } }).?)); + try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 1, + .y = 1, + } }).?)); + try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 1, + .y = 2, + } }).?)); } // Hovering over the first link @@ -257,12 +307,30 @@ test "matchset hover links" { try testing.expectEqual(@as(usize, 2), match.matches.len); // Test our matches - try testing.expect(!match.orderedContains(.{ .x = 0, .y = 0 })); - try testing.expect(match.orderedContains(.{ .x = 1, .y = 0 })); - try testing.expect(match.orderedContains(.{ .x = 2, .y = 0 })); - try testing.expect(!match.orderedContains(.{ .x = 3, .y = 0 })); - try testing.expect(match.orderedContains(.{ .x = 1, .y = 1 })); - try testing.expect(!match.orderedContains(.{ .x = 1, .y = 2 })); + try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 0, + .y = 0, + } }).?)); + try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 1, + .y = 0, + } }).?)); + try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 2, + .y = 0, + } }).?)); + try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 3, + .y = 0, + } }).?)); + try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 1, + .y = 1, + } }).?)); + try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 1, + .y = 2, + } }).?)); } } @@ -298,10 +366,28 @@ test "matchset mods no match" { try testing.expectEqual(@as(usize, 1), match.matches.len); // Test our matches - try testing.expect(!match.orderedContains(.{ .x = 0, .y = 0 })); - try testing.expect(match.orderedContains(.{ .x = 1, .y = 0 })); - try testing.expect(match.orderedContains(.{ .x = 2, .y = 0 })); - try testing.expect(!match.orderedContains(.{ .x = 3, .y = 0 })); - try testing.expect(!match.orderedContains(.{ .x = 1, .y = 1 })); - try testing.expect(!match.orderedContains(.{ .x = 1, .y = 2 })); + try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 0, + .y = 0, + } }).?)); + try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 1, + .y = 0, + } }).?)); + try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 2, + .y = 0, + } }).?)); + try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 3, + .y = 0, + } }).?)); + try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 1, + .y = 1, + } }).?)); + try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 1, + .y = 2, + } }).?)); } diff --git a/src/renderer/size.zig b/src/renderer/size.zig index d3047bb869..4f6b5fc5b3 100644 --- a/src/renderer/size.zig +++ b/src/renderer/size.zig @@ -1,6 +1,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const font = @import("../font/main.zig"); +const terminal = @import("../terminal/main.zig"); const log = std.log.scoped(.renderer_size); @@ -61,7 +62,7 @@ pub const ScreenSize = struct { /// The dimensions of the grid itself, in rows/columns units. pub const GridSize = struct { - const Unit = u32; + const Unit = terminal.size.CellCountInt; columns: Unit = 0, rows: Unit = 0, diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig new file mode 100644 index 0000000000..bacb553b0f --- /dev/null +++ b/src/terminal/PageList.zig @@ -0,0 +1,7204 @@ +//! Maintains a linked list of pages to make up a terminal screen +//! and provides higher level operations on top of those pages to +//! make it slightly easier to work with. +const PageList = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; +const fastmem = @import("../fastmem.zig"); +const point = @import("point.zig"); +const pagepkg = @import("page.zig"); +const stylepkg = @import("style.zig"); +const size = @import("size.zig"); +const Selection = @import("Selection.zig"); +const OffsetBuf = size.OffsetBuf; +const Capacity = pagepkg.Capacity; +const Page = pagepkg.Page; +const Row = pagepkg.Row; + +const log = std.log.scoped(.page_list); + +/// The number of PageList.Nodes we preheat the pool with. A node is +/// a very small struct so we can afford to preheat many, but the exact +/// number is uncertain. Any number too large is wasting memory, any number +/// too small will cause the pool to have to allocate more memory later. +/// This should be set to some reasonable minimum that we expect a terminal +/// window to scroll into quickly. +const page_preheat = 4; + +/// The list of pages in the screen. These are expected to be in order +/// where the first page is the topmost page (scrollback) and the last is +/// the bottommost page (the current active page). +const List = std.DoublyLinkedList(Page); + +/// The memory pool we get page nodes from. +const NodePool = std.heap.MemoryPool(List.Node); + +const std_capacity = pagepkg.std_capacity; +const std_size = Page.layout(std_capacity).total_size; + +/// The memory pool we use for page memory buffers. We use a separate pool +/// so we can allocate these with a page allocator. We have to use a page +/// allocator because we need memory that is zero-initialized and page-aligned. +const PagePool = std.heap.MemoryPoolAligned( + [std_size]u8, + std.mem.page_size, +); + +/// List of pins, known as "tracked" pins. These are pins that are kept +/// up to date automatically through page-modifying operations. +const PinSet = std.AutoHashMapUnmanaged(*Pin, void); +const PinPool = std.heap.MemoryPool(Pin); + +/// The pool of memory used for a pagelist. This can be shared between +/// multiple pagelists but it is not threadsafe. +pub const MemoryPool = struct { + alloc: Allocator, + nodes: NodePool, + pages: PagePool, + pins: PinPool, + + pub const ResetMode = std.heap.ArenaAllocator.ResetMode; + + pub fn init( + gen_alloc: Allocator, + page_alloc: Allocator, + preheat: usize, + ) !MemoryPool { + var pool = try NodePool.initPreheated(gen_alloc, preheat); + errdefer pool.deinit(); + var page_pool = try PagePool.initPreheated(page_alloc, preheat); + errdefer page_pool.deinit(); + var pin_pool = try PinPool.initPreheated(gen_alloc, 8); + errdefer pin_pool.deinit(); + return .{ + .alloc = gen_alloc, + .nodes = pool, + .pages = page_pool, + .pins = pin_pool, + }; + } + + pub fn deinit(self: *MemoryPool) void { + self.pages.deinit(); + self.nodes.deinit(); + self.pins.deinit(); + } + + pub fn reset(self: *MemoryPool, mode: ResetMode) void { + _ = self.pages.reset(mode); + _ = self.nodes.reset(mode); + _ = self.pins.reset(mode); + } +}; + +/// The memory pool we get page nodes, pages from. +pool: MemoryPool, +pool_owned: bool, + +/// The list of pages in the screen. +pages: List, + +/// Byte size of the total amount of allocated pages. Note this does +/// not include the total allocated amount in the pool which may be more +/// than this due to preheating. +page_size: usize, + +/// Maximum size of the page allocation in bytes. This only includes pages +/// that are used ONLY for scrollback. If the active area is still partially +/// in a page that also includes scrollback, then that page is not included. +explicit_max_size: usize, + +/// This is the minimum max size that we will respect due to the rows/cols +/// of the PageList. We must always be able to fit at least the active area +/// and at least two pages for our algorithms. +min_max_size: usize, + +/// The list of tracked pins. These are kept up to date automatically. +tracked_pins: PinSet, + +/// The top-left of certain parts of the screen that are frequently +/// accessed so we don't have to traverse the linked list to find them. +/// +/// For other tags, don't need this: +/// - screen: pages.first +/// - history: active row minus one +/// +viewport: Viewport, + +/// The pin used for when the viewport scrolls. This is always pre-allocated +/// so that scrolling doesn't have a failable memory allocation. This should +/// never be access directly; use `viewport`. +viewport_pin: *Pin, + +/// The current desired screen dimensions. I say "desired" because individual +/// pages may still be a different size and not yet reflowed since we lazily +/// reflow text. +cols: size.CellCountInt, +rows: size.CellCountInt, + +/// The viewport location. +pub const Viewport = union(enum) { + /// The viewport is pinned to the active area. By using a specific marker + /// for this instead of tracking the row offset, we eliminate a number of + /// memory writes making scrolling faster. + active, + + /// The viewport is pinned to the top of the screen, or the farthest + /// back in the scrollback history. + top, + + /// The viewport is pinned to a tracked pin. The tracked pin is ALWAYS + /// s.viewport_pin hence this has no value. We force that value to prevent + /// allocations. + pin, +}; + +/// Returns the minimum valid "max size" for a given number of rows and cols +/// such that we can fit the active area AND at least two pages. Note we +/// need the two pages for algorithms to work properly (such as grow) but +/// we don't need to fit double the active area. +/// +/// This min size may not be totally correct in the case that a large +/// number of other dimensions makes our row size in a page very small. +/// But this gives us a nice fast heuristic for determining min/max size. +/// Therefore, if the page size is violated you should always also verify +/// that we have enough space for the active area. +fn minMaxSize(cols: size.CellCountInt, rows: size.CellCountInt) !usize { + // Get our capacity to fit our rows. If the cols are too big, it may + // force less rows than we want meaning we need more than one page to + // represent a viewport. + const cap = try std_capacity.adjust(.{ .cols = cols }); + + // Calculate the number of standard sized pages we need to represent + // an active area. + const pages_exact = if (cap.rows >= rows) 1 else try std.math.divCeil( + usize, + rows, + cap.rows, + ); + + // We always need at least one page extra so that we + // can fit partial pages to spread our active area across two pages. + // Even for caps that can't fit all rows in a single page, we add one + // because the most extra space we need at any given time is only + // the partial amount of one page. + const pages = pages_exact + 1; + assert(pages >= 2); + + // log.debug("minMaxSize cols={} rows={} cap={} pages={}", .{ + // cols, + // rows, + // cap, + // pages, + // }); + + return PagePool.item_size * pages; +} + +/// Initialize the page. The top of the first page in the list is always the +/// top of the active area of the screen (important knowledge for quickly +/// setting up cursors in Screen). +/// +/// max_size is the maximum number of bytes that will be allocated for +/// pages. If this is smaller than the bytes required to show the viewport +/// then max_size will be ignored and the viewport will be shown, but no +/// scrollback will be created. max_size is always rounded down to the nearest +/// terminal page size (not virtual memory page), otherwise we would always +/// slightly exceed max_size in the limits. +/// +/// If max_size is null then there is no defined limit and the screen will +/// grow forever. In reality, the limit is set to the byte limit that your +/// computer can address in memory. If you somehow require more than that +/// (due to disk paging) then please contribute that yourself and perhaps +/// search deep within yourself to find out why you need that. +pub fn init( + alloc: Allocator, + cols: size.CellCountInt, + rows: size.CellCountInt, + max_size: ?usize, +) !PageList { + // The screen starts with a single page that is the entire viewport, + // and we'll split it thereafter if it gets too large and add more as + // necessary. + var pool = try MemoryPool.init(alloc, std.heap.page_allocator, page_preheat); + errdefer pool.deinit(); + const page_list, const page_size = try initPages(&pool, cols, rows); + + // Get our minimum max size, see doc comments for more details. + const min_max_size = try minMaxSize(cols, rows); + + // We always track our viewport pin to ensure this is never an allocation + const viewport_pin = try pool.pins.create(); + var tracked_pins: PinSet = .{}; + errdefer tracked_pins.deinit(pool.alloc); + try tracked_pins.putNoClobber(pool.alloc, viewport_pin, {}); + + return .{ + .cols = cols, + .rows = rows, + .pool = pool, + .pool_owned = true, + .pages = page_list, + .page_size = page_size, + .explicit_max_size = max_size orelse std.math.maxInt(usize), + .min_max_size = min_max_size, + .tracked_pins = tracked_pins, + .viewport = .{ .active = {} }, + .viewport_pin = viewport_pin, + }; +} + +fn initPages( + pool: *MemoryPool, + cols: size.CellCountInt, + rows: size.CellCountInt, +) !struct { List, usize } { + var page_list: List = .{}; + var page_size: usize = 0; + + // Add pages as needed to create our initial viewport. + const cap = try std_capacity.adjust(.{ .cols = cols }); + var rem = rows; + while (rem > 0) { + const page = try pool.nodes.create(); + const page_buf = try pool.pages.create(); + // no errdefer because the pool deinit will clean these up + + // In runtime safety modes we have to memset because the Zig allocator + // interface will always memset to 0xAA for undefined. In non-safe modes + // we use a page allocator and the OS guarantees zeroed memory. + if (comptime std.debug.runtime_safety) @memset(page_buf, 0); + + // Initialize the first set of pages to contain our viewport so that + // the top of the first page is always the active area. + page.* = .{ + .data = Page.initBuf( + OffsetBuf.init(page_buf), + Page.layout(cap), + ), + }; + page.data.size.rows = @min(rem, page.data.capacity.rows); + rem -= page.data.size.rows; + + // Add the page to the list + page_list.append(page); + page_size += page_buf.len; + } + + assert(page_list.first != null); + + return .{ page_list, page_size }; +} + +/// Deinit the pagelist. If you own the memory pool (used clonePool) then +/// this will reset the pool and retain capacity. +pub fn deinit(self: *PageList) void { + // Always deallocate our hashmap. + self.tracked_pins.deinit(self.pool.alloc); + + // Go through our linked list and deallocate all pages that are + // not standard size. + const page_alloc = self.pool.pages.arena.child_allocator; + var it = self.pages.first; + while (it) |node| : (it = node.next) { + if (node.data.memory.len > std_size) { + page_alloc.free(node.data.memory); + } + } + + // Deallocate all the pages. We don't need to deallocate the list or + // nodes because they all reside in the pool. + if (self.pool_owned) { + self.pool.deinit(); + } else { + self.pool.reset(.{ .retain_capacity = {} }); + } +} + +pub const Clone = struct { + /// The top and bottom (inclusive) points of the region to clone. + /// The x coordinate is ignored; the full row is always cloned. + top: point.Point, + bot: ?point.Point = null, + + /// The allocator source for the clone operation. If this is alloc + /// then the cloned pagelist will own and dealloc the memory on deinit. + /// If this is pool then the caller owns the memory. + memory: union(enum) { + alloc: Allocator, + pool: *MemoryPool, + }, + + // If this is non-null then cloning will attempt to remap the tracked + // pins into the new cloned area and will keep track of the old to + // new mapping in this map. If this is null, the cloned pagelist will + // not retain any previously tracked pins except those required for + // internal operations. + // + // Any pins not present in the map were not remapped. + tracked_pins: ?*TrackedPinsRemap = null, + + pub const TrackedPinsRemap = std.AutoHashMap(*Pin, *Pin); +}; + +/// Clone this pagelist from the top to bottom (inclusive). +/// +/// The viewport is always moved to the active area. +/// +/// The cloned pagelist must contain at least enough rows for the active +/// area. If the region specified has less rows than the active area then +/// rows will be added to the bottom of the region to make up the difference. +pub fn clone( + self: *const PageList, + opts: Clone, +) !PageList { + var it = self.pageIterator(.right_down, opts.top, opts.bot); + + // Setup our own memory pool if we have to. + var owned_pool: ?MemoryPool = switch (opts.memory) { + .pool => null, + .alloc => |alloc| alloc: { + // First, count our pages so our preheat is exactly what we need. + var it_copy = it; + const page_count: usize = page_count: { + var count: usize = 0; + while (it_copy.next()) |_| count += 1; + break :page_count count; + }; + + // Setup our pools + break :alloc try MemoryPool.init( + alloc, + std.heap.page_allocator, + page_count, + ); + }, + }; + errdefer if (owned_pool) |*pool| pool.deinit(); + + // Create our memory pool we use + const pool: *MemoryPool = switch (opts.memory) { + .pool => |v| v, + .alloc => &owned_pool.?, + }; + + // Our viewport pin is always undefined since our viewport in a clones + // goes back to the top + const viewport_pin = try pool.pins.create(); + var tracked_pins: PinSet = .{}; + errdefer tracked_pins.deinit(pool.alloc); + try tracked_pins.putNoClobber(pool.alloc, viewport_pin, {}); + + // Our list of pages + var page_list: List = .{}; + errdefer { + const page_alloc = pool.pages.arena.child_allocator; + var page_it = page_list.first; + while (page_it) |node| : (page_it = node.next) { + if (node.data.memory.len > std_size) { + page_alloc.free(node.data.memory); + } + } + } + + // Copy our pages + var total_rows: usize = 0; + var page_size: usize = 0; + while (it.next()) |chunk| { + // Clone the page. We have to use createPageExt here because + // we don't know if the source page has a standard size. + const page = try createPageExt( + pool, + chunk.page.data.capacity, + &page_size, + ); + assert(page.data.capacity.rows >= chunk.page.data.capacity.rows); + defer page.data.assertIntegrity(); + page.data.size.rows = chunk.page.data.size.rows; + try page.data.cloneFrom( + &chunk.page.data, + 0, + chunk.page.data.size.rows, + ); + + page_list.append(page); + + // If this is a full page then we're done. + if (chunk.fullPage()) { + total_rows += page.data.size.rows; + + // Updating tracked pins is easy, we just change the page + // pointer but all offsets remain the same. + if (opts.tracked_pins) |remap| { + var pin_it = self.tracked_pins.keyIterator(); + while (pin_it.next()) |p_ptr| { + const p = p_ptr.*; + if (p.page != chunk.page) continue; + const new_p = try pool.pins.create(); + new_p.* = p.*; + new_p.page = page; + try remap.putNoClobber(p, new_p); + try tracked_pins.putNoClobber(pool.alloc, new_p, {}); + } + } + + continue; + } + + // If this is just a shortened chunk off the end we can just + // shorten the size. We don't worry about clearing memory here because + // as the page grows the memory will be reclaimable because the data + // is still valid. + if (chunk.start == 0) { + page.data.size.rows = @intCast(chunk.end); + total_rows += chunk.end; + + // Updating tracked pins for the pins that are in the shortened chunk. + if (opts.tracked_pins) |remap| { + var pin_it = self.tracked_pins.keyIterator(); + while (pin_it.next()) |p_ptr| { + const p = p_ptr.*; + if (p.page != chunk.page or + p.y >= chunk.end) continue; + const new_p = try pool.pins.create(); + new_p.* = p.*; + new_p.page = page; + try remap.putNoClobber(p, new_p); + try tracked_pins.putNoClobber(pool.alloc, new_p, {}); + } + } + + continue; + } + + // Kind of slow, we want to shift the rows up in the page up to + // end and then resize down. + const rows = page.data.rows.ptr(page.data.memory); + const len = chunk.end - chunk.start; + for (0..len) |i| { + const src: *Row = &rows[i + chunk.start]; + const dst: *Row = &rows[i]; + const old_dst = dst.*; + dst.* = src.*; + src.* = old_dst; + } + page.data.size.rows = @intCast(len); + total_rows += len; + + // Updating tracked pins + if (opts.tracked_pins) |remap| { + var pin_it = self.tracked_pins.keyIterator(); + while (pin_it.next()) |p_ptr| { + const p = p_ptr.*; + if (p.page != chunk.page or + p.y < chunk.start or + p.y >= chunk.end) continue; + const new_p = try pool.pins.create(); + new_p.* = p.*; + new_p.page = page; + new_p.y -= chunk.start; + try remap.putNoClobber(p, new_p); + try tracked_pins.putNoClobber(pool.alloc, new_p, {}); + } + } + } + + var result: PageList = .{ + .pool = pool.*, + .pool_owned = switch (opts.memory) { + .pool => false, + .alloc => true, + }, + .pages = page_list, + .page_size = page_size, + .explicit_max_size = self.explicit_max_size, + .min_max_size = self.min_max_size, + .cols = self.cols, + .rows = self.rows, + .tracked_pins = tracked_pins, + .viewport = .{ .active = {} }, + .viewport_pin = viewport_pin, + }; + + // We always need to have enough rows for our viewport because this is + // a pagelist invariant that other code relies on. + if (total_rows < self.rows) { + const len = self.rows - total_rows; + for (0..len) |_| { + _ = try result.grow(); + + // Clear the row. This is not very fast but in reality right + // now we rarely clone less than the active area and if we do + // the area is by definition very small. + const last = result.pages.last.?; + const row = &last.data.rows.ptr(last.data.memory)[last.data.size.rows - 1]; + last.data.clearCells(row, 0, result.cols); + } + } + + return result; +} + +/// Resize options +pub const Resize = struct { + /// The new cols/cells of the screen. + cols: ?size.CellCountInt = null, + rows: ?size.CellCountInt = null, + + /// Whether to reflow the text. If this is false then the text will + /// be truncated if the new size is smaller than the old size. + reflow: bool = true, + + /// Set this to the current cursor position in the active area. Some + /// resize/reflow behavior depends on the cursor position. + cursor: ?Cursor = null, + + pub const Cursor = struct { + x: size.CellCountInt, + y: size.CellCountInt, + }; +}; + +/// Resize +/// TODO: docs +pub fn resize(self: *PageList, opts: Resize) !void { + if (comptime std.debug.runtime_safety) { + // Resize does not work with 0 values, this should be protected + // upstream + if (opts.cols) |v| assert(v > 0); + if (opts.rows) |v| assert(v > 0); + } + + if (!opts.reflow) return try self.resizeWithoutReflow(opts); + + // Recalculate our minimum max size. This allows grow to work properly + // when increasing beyond our initial minimum max size or explicit max + // size to fit the active area. + const old_min_max_size = self.min_max_size; + self.min_max_size = try minMaxSize( + opts.cols orelse self.cols, + opts.rows orelse self.rows, + ); + errdefer self.min_max_size = old_min_max_size; + + // On reflow, the main thing that causes reflow is column changes. If + // only rows change, reflow is impossible. So we change our behavior based + // on the change of columns. + const cols = opts.cols orelse self.cols; + switch (std.math.order(cols, self.cols)) { + .eq => try self.resizeWithoutReflow(opts), + + .gt => { + // We grow rows after cols so that we can do our unwrapping/reflow + // before we do a no-reflow grow. + try self.resizeCols(cols, opts.cursor); + try self.resizeWithoutReflow(opts); + }, + + .lt => { + // We first change our row count so that we have the proper amount + // we can use when shrinking our cols. + try self.resizeWithoutReflow(opts: { + var copy = opts; + copy.cols = self.cols; + break :opts copy; + }); + + try self.resizeCols(cols, opts.cursor); + }, + } +} + +/// Resize the pagelist with reflow by adding or removing columns. +fn resizeCols( + self: *PageList, + cols: size.CellCountInt, + cursor: ?Resize.Cursor, +) !void { + assert(cols != self.cols); + + // If we have a cursor position (x,y), then we try under any col resizing + // to keep the same number remaining active rows beneath it. This is a + // very special case if you can imagine clearing the screen (i.e. + // scrollClear), having an empty active area, and then resizing to less + // cols then we don't want the active area to "jump" to the bottom and + // pull down scrollback. + const preserved_cursor: ?struct { + tracked_pin: *Pin, + remaining_rows: usize, + } = if (cursor) |c| cursor: { + const p = self.pin(.{ .active = .{ + .x = c.x, + .y = c.y, + } }) orelse break :cursor null; + + break :cursor .{ + .tracked_pin = try self.trackPin(p), + .remaining_rows = self.rows - c.y - 1, + }; + } else null; + defer if (preserved_cursor) |c| self.untrackPin(c.tracked_pin); + + // Go page by page and shrink the columns on a per-page basis. + var it = self.pageIterator(.right_down, .{ .screen = .{} }, null); + while (it.next()) |chunk| { + // Fast-path: none of our rows are wrapped. In this case we can + // treat this like a no-reflow resize. This only applies if we + // are growing columns. + if (cols > self.cols) no_reflow: { + const page = &chunk.page.data; + const rows = page.rows.ptr(page.memory)[0..page.size.rows]; + + // If our first row is a wrap continuation, then we have to + // reflow since we're continuing a wrapped line. + if (rows[0].wrap_continuation) break :no_reflow; + + // If any row is soft-wrapped then we have to reflow + for (rows) |row| { + if (row.wrap) break :no_reflow; + } + + try self.resizeWithoutReflowGrowCols(cols, chunk); + continue; + } + + // Note: we can do a fast-path here if all of our rows in this + // page already fit within the new capacity. In that case we can + // do a non-reflow resize. + try self.reflowPage(cols, chunk.page); + } + + // If our total rows is less than our active rows, we need to grow. + // This can happen if you're growing columns such that enough active + // rows unwrap that we no longer have enough. + var node_it = self.pages.first; + var total: usize = 0; + while (node_it) |node| : (node_it = node.next) { + total += node.data.size.rows; + if (total >= self.rows) break; + } else { + for (total..self.rows) |_| _ = try self.grow(); + } + + // See preserved_cursor setup for why. + if (preserved_cursor) |c| cursor: { + const active_pt = self.pointFromPin( + .active, + c.tracked_pin.*, + ) orelse break :cursor; + + // We need to determine how many rows we wrapped from the original + // and subtract that from the remaining rows we expect because if + // we wrap down we don't want to push our original row contents into + // the scrollback. + const wrapped = wrapped: { + var wrapped: usize = 0; + + var row_it = c.tracked_pin.rowIterator(.left_up, null); + _ = row_it.next(); // skip ourselves + while (row_it.next()) |next| { + const row = next.rowAndCell().row; + if (!row.wrap) break; + wrapped += 1; + } + + break :wrapped wrapped; + }; + + // If we wrapped more than we expect, do nothing. + if (wrapped >= c.remaining_rows) break :cursor; + const desired = c.remaining_rows - wrapped; + const current = self.rows - (active_pt.active.y + 1); + if (current >= desired) break :cursor; + for (0..desired - current) |_| _ = try self.grow(); + } + + // Update our cols + self.cols = cols; +} + +// We use a cursor to track where we are in the src/dst. This is very +// similar to Screen.Cursor, so see that for docs on individual fields. +// We don't use a Screen because we don't need all the same data and we +// do our best to optimize having direct access to the page memory. +const ReflowCursor = struct { + x: size.CellCountInt, + y: size.CellCountInt, + pending_wrap: bool, + page: *pagepkg.Page, + page_row: *pagepkg.Row, + page_cell: *pagepkg.Cell, + + fn init(page: *pagepkg.Page) ReflowCursor { + const rows = page.rows.ptr(page.memory); + return .{ + .x = 0, + .y = 0, + .pending_wrap = false, + .page = page, + .page_row = &rows[0], + .page_cell = &rows[0].cells.ptr(page.memory)[0], + }; + } + + /// True if this cursor is at the bottom of the page by capacity, + /// i.e. we can't scroll anymore. + fn bottom(self: *const ReflowCursor) bool { + return self.y == self.page.capacity.rows - 1; + } + + fn cursorForward(self: *ReflowCursor) void { + if (self.x == self.page.size.cols - 1) { + self.pending_wrap = true; + } else { + const cell: [*]pagepkg.Cell = @ptrCast(self.page_cell); + self.page_cell = @ptrCast(cell + 1); + self.x += 1; + } + } + + fn cursorDown(self: *ReflowCursor) void { + assert(self.y + 1 < self.page.size.rows); + self.cursorAbsolute(self.x, self.y + 1); + } + + fn cursorScroll(self: *ReflowCursor) void { + // Scrolling requires that we're on the bottom of our page. + // We also assert that we have capacity because reflow always + // works within the capacity of the page. + assert(self.y == self.page.size.rows - 1); + assert(self.page.size.rows < self.page.capacity.rows); + + // Increase our page size + self.page.size.rows += 1; + + // With the increased page size, safely move down a row. + const rows: [*]pagepkg.Row = @ptrCast(self.page_row); + const row: *pagepkg.Row = @ptrCast(rows + 1); + self.page_row = row; + self.page_cell = &row.cells.ptr(self.page.memory)[0]; + self.pending_wrap = false; + self.x = 0; + self.y += 1; + } + + fn cursorAbsolute( + self: *ReflowCursor, + x: size.CellCountInt, + y: size.CellCountInt, + ) void { + assert(x < self.page.size.cols); + assert(y < self.page.size.rows); + + const rows: [*]pagepkg.Row = @ptrCast(self.page_row); + const row: *pagepkg.Row = switch (std.math.order(y, self.y)) { + .eq => self.page_row, + .lt => @ptrCast(rows - (self.y - y)), + .gt => @ptrCast(rows + (y - self.y)), + }; + self.page_row = row; + self.page_cell = &row.cells.ptr(self.page.memory)[x]; + self.pending_wrap = false; + self.x = x; + self.y = y; + } + + fn countTrailingEmptyCells(self: *const ReflowCursor) usize { + // If the row is wrapped, all empty cells are meaningful. + if (self.page_row.wrap) return 0; + + const cells: [*]pagepkg.Cell = @ptrCast(self.page_cell); + const len: usize = self.page.size.cols - self.x; + for (0..len) |i| { + const rev_i = len - i - 1; + if (!cells[rev_i].isEmpty()) return i; + } + + // If the row has a semantic prompt then the blank row is meaningful + // so we always return all but one so that the row is drawn. + if (self.page_row.semantic_prompt != .unknown) return len - 1; + + return len; + } + + fn copyRowMetadata(self: *ReflowCursor, other: *const Row) void { + self.page_row.semantic_prompt = other.semantic_prompt; + } +}; + +/// Reflow the given page into the new capacity. The new capacity can have +/// any number of columns and rows. This will create as many pages as +/// necessary to fit the reflowed text and will remove the old page. +/// +/// Note a couple edge cases: +/// +/// 1. All initial rows that are wrap continuations are ignored. If you +/// want to reflow these lines you must reflow the page with the +/// initially wrapped line. +/// +/// 2. If the last row is wrapped then we will traverse forward to reflow +/// all the continuation rows. This follows from #1. +/// +/// Despite the edge cases above, this will only ever remove the initial +/// node, so that this can be called within a pageIterator. This is a weird +/// detail that will surely cause bugs one day so we should look into fixing +/// it. :) +/// +/// Conceptually, this is a simple process: we're effectively traversing +/// the old page and rewriting into the new page as if it were a text editor. +/// But, due to the edge cases, cursor tracking, and attempts at efficiency, +/// the code can be convoluted so this is going to be a heavily commented +/// function. +fn reflowPage( + self: *PageList, + cols: size.CellCountInt, + initial_node: *List.Node, +) !void { + // The cursor tracks where we are in the source page. + var src_node = initial_node; + var src_cursor = ReflowCursor.init(&src_node.data); + + // Skip initially reflowed lines + if (src_cursor.page_row.wrap_continuation) { + while (src_cursor.page_row.wrap_continuation) { + // If this entire page was continuations then we can remove it. + if (src_cursor.y == src_cursor.page.size.rows - 1) { + // If this is the last page, then we need to insert an empty + // page so that erasePage works. This is a rare scenario that + // can happen in no-scrollback pages where EVERY line is + // a continuation. + if (initial_node.prev == null and initial_node.next == null) { + const cap = try std_capacity.adjust(.{ .cols = cols }); + const node = try self.createPage(cap); + self.pages.insertAfter(initial_node, node); + } + + self.erasePage(initial_node); + return; + } + + src_cursor.cursorDown(); + } + } + + // This is set to true when we're in the middle of completing a wrap + // from the initial page. If this is true, the moment we see a non-wrapped + // row we are done. + var src_completing_wrap = false; + + // This is used to count blank lines so that we don't copy those. + var blank_lines: usize = 0; + + // This is set to true when we're wrapping a line that requires a new + // writer page. + var dst_wrap = false; + + // Our new capacity when growing columns may also shrink rows. So we + // need to do a loop in order to potentially make multiple pages. + dst_loop: while (true) { + // Our cap is based on the source page cap so we can inherit + // potentially increased styles/graphemes/etc. + const cap = try src_cursor.page.capacity.adjust(.{ .cols = cols }); + + // Create our new page and our cursor restarts at 0,0 in the new page. + // The new page always starts with a size of 1 because we know we have + // at least one row to copy from the src. + const dst_node = try self.createPage(cap); + defer dst_node.data.assertIntegrity(); + dst_node.data.size.rows = 1; + var dst_cursor = ReflowCursor.init(&dst_node.data); + dst_cursor.copyRowMetadata(src_cursor.page_row); + + // Set our wrap state + if (dst_wrap) { + dst_cursor.page_row.wrap_continuation = true; + dst_wrap = false; + } + + // Our new page goes before our src node. This will append it to any + // previous pages we've created. + self.pages.insertBefore(initial_node, dst_node); + + src_loop: while (true) { + // Continue traversing the source until we're out of space in our + // destination or we've copied all our intended rows. + const started_completing_wrap = src_completing_wrap; + for (src_cursor.y..src_cursor.page.size.rows) |src_y| { + // If we started completing a wrap and our flag is no longer true + // then we completed it and we can exit the loop. + if (started_completing_wrap and !src_completing_wrap) break; + + // We are previously wrapped if we're not on the first row and + // the previous row was wrapped OR if we're on the first row + // but we're not on our initial node it means the last row of + // our previous page was wrapped. + const prev_wrap = + (src_y > 0 and src_cursor.page_row.wrap) or + (src_y == 0 and src_node != initial_node); + src_cursor.cursorAbsolute(0, @intCast(src_y)); + + // Trim trailing empty cells if the row is not wrapped. If the + // row is wrapped then we don't trim trailing empty cells because + // the empty cells can be meaningful. + const trailing_empty = src_cursor.countTrailingEmptyCells(); + const cols_len = cols_len: { + var cols_len = src_cursor.page.size.cols - trailing_empty; + + if (cols_len > 0) { + // We want to update any tracked pins that are in our + // trailing empty cells to the last col. We don't + // want to wrap blanks. + var it = self.tracked_pins.keyIterator(); + while (it.next()) |p_ptr| { + const p = p_ptr.*; + if (&p.page.data != src_cursor.page or + p.y != src_cursor.y or + p.x < cols_len) continue; + if (p.x >= cap.cols) p.x = cap.cols - 1; + } + + break :cols_len cols_len; + } + + // If a tracked pin is in this row then we need to keep it + // even if it is empty, because it is somehow meaningful + // (usually the screen cursor), but we do trim the cells + // down to the desired size. + // + // The reason we do this logic is because if you do a scroll + // clear (i.e. move all active into scrollback and reset + // the screen), the cursor is on the top line again with + // an empty active. If you resize to a smaller col size we + // don't want to "pull down" all the scrollback again. The + // user expects we just shrink the active area. + var it = self.tracked_pins.keyIterator(); + while (it.next()) |p_ptr| { + const p = p_ptr.*; + if (&p.page.data != src_cursor.page or + p.y != src_cursor.y) continue; + + // If our tracked pin is outside our resized cols, we + // trim it to the last col, we don't want to wrap blanks. + if (p.x >= cap.cols) p.x = cap.cols - 1; + + // We increase our col len to at least include this pin + cols_len = @max(cols_len, p.x + 1); + } + + if (cols_len == 0) { + // If the row is empty, we don't copy it. We count it as a + // blank line and continue to the next row. + blank_lines += 1; + continue; + } + + break :cols_len cols_len; + }; + + // We have data, if we have blank lines we need to create them first. + if (blank_lines > 0) { + // This is a dumb edge caes where if we start with blank + // lines, we're off by one because our cursor is at 0 + // on the first blank line but if its in the middle we + // haven't scrolled yet. Don't worry, this is covered by + // unit tests so if we find a better way we can remove this. + const len = blank_lines - @intFromBool(blank_lines >= src_y); + for (0..len) |i| { + // If we're at the bottom we can't fit anymore into this page, + // so we need to reloop and create a new page. + if (dst_cursor.bottom()) { + blank_lines -= i; + continue :dst_loop; + } + + // TODO: cursor in here + dst_cursor.cursorScroll(); + } + } + + if (src_y > 0) { + // We're done with this row, if this row isn't wrapped, we can + // move our destination cursor to the next row. + // + // The blank_lines == 0 condition is because if we were prefixed + // with blank lines, we handled the scroll already above. + if (!prev_wrap) { + if (dst_cursor.bottom()) continue :dst_loop; + dst_cursor.cursorScroll(); + } + + dst_cursor.copyRowMetadata(src_cursor.page_row); + } + + // Reset our blank line count since handled it all above. + blank_lines = 0; + + for (src_cursor.x..cols_len) |src_x| { + assert(src_cursor.x == src_x); + + // std.log.warn("src_y={} src_x={} dst_y={} dst_x={} dst_cols={} cp={} wide={}", .{ + // src_cursor.y, + // src_cursor.x, + // dst_cursor.y, + // dst_cursor.x, + // dst_cursor.page.size.cols, + // src_cursor.page_cell.content.codepoint, + // src_cursor.page_cell.wide, + // }); + + if (cap.cols > 1) switch (src_cursor.page_cell.wide) { + .narrow => {}, + + .wide => if (!dst_cursor.pending_wrap and + dst_cursor.x == cap.cols - 1) + { + self.reflowUpdateCursor(&src_cursor, &dst_cursor, dst_node); + dst_cursor.page_cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0 }, + .wide = .spacer_head, + }; + dst_cursor.cursorForward(); + assert(dst_cursor.pending_wrap); + }, + + .spacer_head => if (dst_cursor.pending_wrap or + dst_cursor.x != cap.cols - 1) + { + assert(src_cursor.x == src_cursor.page.size.cols - 1); + self.reflowUpdateCursor(&src_cursor, &dst_cursor, dst_node); + continue; + }, + + else => {}, + }; + + if (dst_cursor.pending_wrap) { + dst_cursor.page_row.wrap = true; + if (dst_cursor.bottom()) { + dst_wrap = true; + continue :dst_loop; + } + dst_cursor.cursorScroll(); + dst_cursor.page_row.wrap_continuation = true; + dst_cursor.copyRowMetadata(src_cursor.page_row); + } + + // A rare edge case. If we're resizing down to 1 column + // and the source is a non-narrow character, we reset the + // cell to a narrow blank and we skip to the next cell. + if (cap.cols == 1 and src_cursor.page_cell.wide != .narrow) { + switch (src_cursor.page_cell.wide) { + .narrow => unreachable, + + // Wide char, we delete it, reset it to narrow, + // and skip forward. + .wide => { + dst_cursor.page_cell.content.codepoint = 0; + dst_cursor.page_cell.wide = .narrow; + src_cursor.cursorForward(); + continue; + }, + + // Skip spacer tails since we should've already + // handled them in the previous cell. + .spacer_tail => {}, + + // TODO: test? + .spacer_head => {}, + } + } else { + switch (src_cursor.page_cell.content_tag) { + // These are guaranteed to have no styling data and no + // graphemes, a fast path. + .bg_color_palette, + .bg_color_rgb, + => { + assert(!src_cursor.page_cell.hasStyling()); + assert(!src_cursor.page_cell.hasGrapheme()); + dst_cursor.page_cell.* = src_cursor.page_cell.*; + }, + + .codepoint => { + dst_cursor.page_cell.* = src_cursor.page_cell.*; + }, + + .codepoint_grapheme => { + // We copy the cell like normal but we have to reset the + // tag because this is used for fast-path detection in + // appendGrapheme. + dst_cursor.page_cell.* = src_cursor.page_cell.*; + dst_cursor.page_cell.content_tag = .codepoint; + + // Unset the style ID so our integrity checks don't fire. + // We handle style fixups after this switch block. + if (comptime std.debug.runtime_safety) { + dst_cursor.page_cell.style_id = stylepkg.default_id; + } + + // Copy the graphemes + const src_cps = src_cursor.page.lookupGrapheme(src_cursor.page_cell).?; + for (src_cps) |cp| { + try dst_cursor.page.appendGrapheme( + dst_cursor.page_row, + dst_cursor.page_cell, + cp, + ); + } + }, + } + + // If the source cell has a style, we need to copy it. + if (src_cursor.page_cell.style_id != stylepkg.default_id) { + const src_style = src_cursor.page.styles.lookupId( + src_cursor.page.memory, + src_cursor.page_cell.style_id, + ).?.*; + + const dst_md = try dst_cursor.page.styles.upsert( + dst_cursor.page.memory, + src_style, + ); + dst_md.ref += 1; + dst_cursor.page_cell.style_id = dst_md.id; + dst_cursor.page_row.styled = true; + } + } + + // If our original cursor was on this page, this x/y then + // we need to update to the new location. + self.reflowUpdateCursor(&src_cursor, &dst_cursor, dst_node); + + // Move both our cursors forward + src_cursor.cursorForward(); + dst_cursor.cursorForward(); + } else cursor: { + // We made it through all our source columns. As a final edge + // case, if our cursor is in one of the blanks, we update it + // to the edge of this page. + + // If we are in wrap completion mode and this row is not wrapped + // then we are done and we can gracefully exit our y loop. + if (src_completing_wrap and !src_cursor.page_row.wrap) { + assert(started_completing_wrap); + src_completing_wrap = false; + } + + // If we have no trailing empty cells, it can't be in the blanks. + if (trailing_empty == 0) break :cursor; + + // Update all our tracked pins + var it = self.tracked_pins.keyIterator(); + while (it.next()) |p_ptr| { + const p = p_ptr.*; + if (&p.page.data != src_cursor.page or + p.y != src_cursor.y or + p.x < cols_len) continue; + + p.page = dst_node; + p.y = dst_cursor.y; + } + } + } + + // If we're still in a wrapped line at the end of our page, + // we traverse forward and continue reflowing until we complete + // this entire line. + if (src_cursor.page_row.wrap) wrap: { + src_completing_wrap = true; + src_node = src_node.next orelse break :wrap; + src_cursor = ReflowCursor.init(&src_node.data); + continue :src_loop; + } + + // We are not on a wrapped line, we're truly done. + self.pages.remove(initial_node); + self.destroyPage(initial_node); + return; + } + } +} + +/// This updates the cursor offset if the cursor is exactly on the cell +/// we're currently reflowing. This can then be fixed up later to an exact +/// x/y (see resizeCols). +fn reflowUpdateCursor( + self: *const PageList, + src_cursor: *const ReflowCursor, + dst_cursor: *const ReflowCursor, + dst_node: *List.Node, +) void { + // Update all our tracked pins + var it = self.tracked_pins.keyIterator(); + while (it.next()) |p_ptr| { + const p = p_ptr.*; + if (&p.page.data != src_cursor.page or + p.y != src_cursor.y or + p.x != src_cursor.x) continue; + + p.page = dst_node; + p.x = dst_cursor.x; + p.y = dst_cursor.y; + } +} + +fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { + // We only set the new min_max_size if we're not reflowing. If we are + // reflowing, then resize handles this for us. + const old_min_max_size = self.min_max_size; + self.min_max_size = if (!opts.reflow) try minMaxSize( + opts.cols orelse self.cols, + opts.rows orelse self.rows, + ) else old_min_max_size; + errdefer self.min_max_size = old_min_max_size; + + // Important! We have to do cols first because cols may cause us to + // destroy pages if we're increasing cols which will free up page_size + // so that when we call grow() in the row mods, we won't prune. + if (opts.cols) |cols| { + switch (std.math.order(cols, self.cols)) { + .eq => {}, + + // Making our columns smaller. We always have space for this + // in existing pages so we need to go through the pages, + // resize the columns, and clear any cells that are beyond + // the new size. + .lt => { + var it = self.pageIterator(.right_down, .{ .screen = .{} }, null); + while (it.next()) |chunk| { + const page = &chunk.page.data; + defer page.assertIntegrity(); + const rows = page.rows.ptr(page.memory); + for (0..page.size.rows) |i| { + const row = &rows[i]; + page.clearCells(row, cols, self.cols); + } + + page.size.cols = cols; + } + + // Update all our tracked pins. If they have an X + // beyond the edge, clamp it. + var pin_it = self.tracked_pins.keyIterator(); + while (pin_it.next()) |p_ptr| { + const p = p_ptr.*; + if (p.x >= cols) p.x = cols - 1; + } + + self.cols = cols; + }, + + // Make our columns larger. This is a bit more complicated because + // pages may not have the capacity for this. If they don't have + // the capacity we need to allocate a new page and copy the data. + .gt => { + // See the comment in the while loop when setting self.cols + const old_cols = self.cols; + + var it = self.pageIterator(.right_down, .{ .screen = .{} }, null); + while (it.next()) |chunk| { + // We need to restore our old cols after we resize because + // we have an assertion on this and we want to be able to + // call this method multiple times. + self.cols = old_cols; + try self.resizeWithoutReflowGrowCols(cols, chunk); + } + + self.cols = cols; + }, + } + } + + if (opts.rows) |rows| { + switch (std.math.order(rows, self.rows)) { + .eq => {}, + + // Making rows smaller, we simply change our rows value. Changing + // the row size doesn't affect anything else since max size and + // so on are all byte-based. + .lt => { + // If our rows are shrinking, we prefer to trim trailing + // blank lines from the active area instead of creating + // history if we can. + // + // This matches macOS Terminal.app behavior. I chose to match that + // behavior because it seemed fine in an ocean of differing behavior + // between terminal apps. I'm completely open to changing it as long + // as resize behavior isn't regressed in a user-hostile way. + _ = self.trimTrailingBlankRows(self.rows - rows); + + // If we didn't trim enough, just modify our row count and this + // will create additional history. + self.rows = rows; + }, + + // Making rows larger we adjust our row count, and then grow + // to the row count. + .gt => gt: { + // If our rows increased and our cursor is NOT at the bottom, + // we want to try to preserve the y value of the old cursor. + // In other words, we don't want to "pull down" scrollback. + // This is purely a UX feature. + if (opts.cursor) |cursor| cursor: { + if (cursor.y >= self.rows - 1) break :cursor; + + // Cursor is not at the bottom, so we just grow our + // rows and we're done. Cursor does NOT change for this + // since we're not pulling down scrollback. + for (0..rows - self.rows) |_| _ = try self.grow(); + self.rows = rows; + break :gt; + } + + // Cursor is at the bottom or we don't care about cursors. + // In this case, if we have enough rows in our pages, we + // just update our rows and we're done. This effectively + // "pulls down" scrollback. + // + // If we don't have enough scrollback, we add the difference, + // to the active area. + var count: usize = 0; + var page = self.pages.first; + while (page) |p| : (page = p.next) { + count += p.data.size.rows; + if (count >= rows) break; + } else { + assert(count < rows); + for (count..rows) |_| _ = try self.grow(); + } + + self.rows = rows; + }, + } + + if (comptime std.debug.runtime_safety) { + assert(self.totalRows() >= self.rows); + } + } +} + +fn resizeWithoutReflowGrowCols( + self: *PageList, + cols: size.CellCountInt, + chunk: PageIterator.Chunk, +) !void { + assert(cols > self.cols); + const page = &chunk.page.data; + const cap = try page.capacity.adjust(.{ .cols = cols }); + + // Update our col count + const old_cols = self.cols; + self.cols = cap.cols; + errdefer self.cols = old_cols; + + // Unlikely fast path: we have capacity in the page. This + // is only true if we resized to less cols earlier. + if (page.capacity.cols >= cap.cols) { + page.size.cols = cap.cols; + return; + } + + // Likely slow path: we don't have capacity, so we need + // to allocate a page, and copy the old data into it. + + // On error, we need to undo all the pages we've added. + const prev = chunk.page.prev; + errdefer { + var current = chunk.page.prev; + while (current) |p| { + if (current == prev) break; + current = p.prev; + self.pages.remove(p); + self.destroyPage(p); + } + } + + // Keeps track of all our copied rows. Assertions at the end is that + // we copied exactly our page size. + var copied: usize = 0; + + // This function has an unfortunate side effect in that it causes memory + // fragmentation on rows if the columns are increasing in a way that + // shrinks capacity rows. If we have pages that don't divide evenly then + // we end up creating a final page that is not using its full capacity. + // If this chunk isn't the last chunk in the page list, then we've created + // a page where we'll never reclaim that capacity. This makes our max size + // calculation incorrect since we'll throw away data even though we have + // excess capacity. To avoid this, we try to fill our previous page + // first if it has capacity. + // + // This can fail for many reasons (can't fit styles/graphemes, etc.) so + // if it fails then we give up and drop back into creating new pages. + if (prev) |prev_node| prev: { + const prev_page = &prev_node.data; + + // We only want scenarios where we have excess capacity. + if (prev_page.size.rows >= prev_page.capacity.rows) break :prev; + + // We can copy as much as we can to fill the capacity or our + // current page size. + const len = @min( + prev_page.capacity.rows - prev_page.size.rows, + page.size.rows, + ); + + const src_rows = page.rows.ptr(page.memory)[0..len]; + const dst_rows = prev_page.rows.ptr(prev_page.memory)[prev_page.size.rows..]; + for (dst_rows, src_rows) |*dst_row, *src_row| { + prev_page.size.rows += 1; + copied += 1; + prev_page.cloneRowFrom( + page, + dst_row, + src_row, + ) catch { + // If an error happens, we undo our row copy and break out + // into creating a new page. + prev_page.size.rows -= 1; + copied -= 1; + break :prev; + }; + } + + assert(copied == len); + assert(prev_page.size.rows <= prev_page.capacity.rows); + } + + // We need to loop because our col growth may force us + // to split pages. + while (copied < page.size.rows) { + const new_page = try self.createPage(cap); + defer new_page.data.assertIntegrity(); + + // The length we can copy into the new page is at most the number + // of rows in our cap. But if we can finish our source page we use that. + const len = @min(cap.rows, page.size.rows - copied); + + // Perform the copy + const y_start = copied; + const y_end = copied + len; + const src_rows = page.rows.ptr(page.memory)[y_start..y_end]; + const dst_rows = new_page.data.rows.ptr(new_page.data.memory)[0..len]; + for (dst_rows, src_rows) |*dst_row, *src_row| { + new_page.data.size.rows += 1; + errdefer new_page.data.size.rows -= 1; + try new_page.data.cloneRowFrom( + page, + dst_row, + src_row, + ); + } + copied = y_end; + + // Insert our new page + self.pages.insertBefore(chunk.page, new_page); + + // Update our tracked pins that pointed to this previous page. + var pin_it = self.tracked_pins.keyIterator(); + while (pin_it.next()) |p_ptr| { + const p = p_ptr.*; + if (p.page != chunk.page or + p.y < y_start or + p.y >= y_end) continue; + p.page = new_page; + p.y -= y_start; + } + } + assert(copied == page.size.rows); + + // Remove the old page. + // Deallocate the old page. + self.pages.remove(chunk.page); + self.destroyPage(chunk.page); +} + +/// Returns the number of trailing blank lines, not to exceed max. Max +/// is used to limit our traversal in the case of large scrollback. +fn trailingBlankLines( + self: *const PageList, + max: size.CellCountInt, +) size.CellCountInt { + var count: size.CellCountInt = 0; + + // Go through our pages backwards since we're counting trailing blanks. + var it = self.pages.last; + while (it) |page| : (it = page.prev) { + const len = page.data.size.rows; + const rows = page.data.rows.ptr(page.data.memory)[0..len]; + for (0..len) |i| { + const rev_i = len - i - 1; + const cells = rows[rev_i].cells.ptr(page.data.memory)[0..page.data.size.cols]; + + // If the row has any text then we're done. + if (pagepkg.Cell.hasTextAny(cells)) return count; + + // Inc count, if we're beyond max then we're done. + count += 1; + if (count >= max) return count; + } + } + + return count; +} + +/// Trims up to max trailing blank rows from the pagelist and returns the +/// number of rows trimmed. A blank row is any row with no text (but may +/// have styling). +fn trimTrailingBlankRows( + self: *PageList, + max: size.CellCountInt, +) size.CellCountInt { + var trimmed: size.CellCountInt = 0; + const bl_pin = self.getBottomRight(.screen).?; + var it = bl_pin.rowIterator(.left_up, null); + while (it.next()) |row_pin| { + const cells = row_pin.cells(.all); + + // If the row has any text then we're done. + if (pagepkg.Cell.hasTextAny(cells)) return trimmed; + + // If our tracked pins are in this row then we cannot trim it + // because it implies some sort of importance. If we trimmed this + // we'd invalidate this pin, as well. + var tracked_it = self.tracked_pins.keyIterator(); + while (tracked_it.next()) |p_ptr| { + const p = p_ptr.*; + if (p.page != row_pin.page or + p.y != row_pin.y) continue; + return trimmed; + } + + // No text, we can trim this row. Because it has + // no text we can also be sure it has no styling + // so we don't need to worry about memory. + row_pin.page.data.size.rows -= 1; + if (row_pin.page.data.size.rows == 0) { + self.erasePage(row_pin.page); + } else { + row_pin.page.data.assertIntegrity(); + } + + trimmed += 1; + if (trimmed >= max) return trimmed; + } + + return trimmed; +} + +/// Scroll options. +pub const Scroll = union(enum) { + /// Scroll to the active area. This is also sometimes referred to as + /// the "bottom" of the screen. This makes it so that the end of the + /// screen is fully visible since the active area is the bottom + /// rows/cols of the screen. + active, + + /// Scroll to the top of the screen, which is the farthest back in + /// the scrollback history. + top, + + /// Scroll up (negative) or down (positive) by the given number of + /// rows. This is clamped to the "top" and "active" top left. + delta_row: isize, + + /// Jump forwards (positive) or backwards (negative) a set number of + /// prompts. If the absolute value is greater than the number of prompts + /// in either direction, jump to the furthest prompt in that direction. + delta_prompt: isize, + + /// Scroll directly to a specific pin in the page. This will be set + /// as the top left of the viewport (ignoring the pin x value). + pin: Pin, +}; + +/// Scroll the viewport. This will never create new scrollback, allocate +/// pages, etc. This can only be used to move the viewport within the +/// previously allocated pages. +pub fn scroll(self: *PageList, behavior: Scroll) void { + switch (behavior) { + .active => self.viewport = .{ .active = {} }, + .top => self.viewport = .{ .top = {} }, + .pin => |p| { + if (self.pinIsActive(p)) { + self.viewport = .{ .active = {} }; + return; + } + + self.viewport_pin.* = p; + self.viewport = .{ .pin = {} }; + }, + .delta_prompt => |n| self.scrollPrompt(n), + .delta_row => |n| { + if (n == 0) return; + + const top = self.getTopLeft(.viewport); + const p: Pin = if (n < 0) switch (top.upOverflow(@intCast(-n))) { + .offset => |v| v, + .overflow => |v| v.end, + } else switch (top.downOverflow(@intCast(n))) { + .offset => |v| v, + .overflow => |v| v.end, + }; + + // If we are still within the active area, then we pin the + // viewport to active. This isn't EXACTLY the same behavior as + // other scrolling because normally when you scroll the viewport + // is pinned to _that row_ even if new scrollback is created. + // But in a terminal when you get to the bottom and back into the + // active area, you usually expect that the viewport will now + // follow the active area. + if (self.pinIsActive(p)) { + self.viewport = .{ .active = {} }; + return; + } + + // Pin is not active so we need to track it. + self.viewport_pin.* = p; + self.viewport = .{ .pin = {} }; + }, + } +} + +/// Jump the viewport forwards (positive) or backwards (negative) a set number of +/// prompts (delta). +fn scrollPrompt(self: *PageList, delta: isize) void { + // If we aren't jumping any prompts then we don't need to do anything. + if (delta == 0) return; + const delta_start: usize = @intCast(if (delta > 0) delta else -delta); + var delta_rem: usize = delta_start; + + // Iterate and count the number of prompts we see. + const viewport_pin = self.getTopLeft(.viewport); + var it = viewport_pin.rowIterator(if (delta > 0) .right_down else .left_up, null); + _ = it.next(); // skip our own row + var prompt_pin: ?Pin = null; + while (it.next()) |next| { + const row = next.rowAndCell().row; + switch (row.semantic_prompt) { + .command, .unknown => {}, + .prompt, .prompt_continuation, .input => { + delta_rem -= 1; + prompt_pin = next; + }, + } + + if (delta_rem == 0) break; + } + + // If we found a prompt, we move to it. If the prompt is in the active + // area we keep our viewport as active because we can't scroll DOWN + // into the active area. Otherwise, we scroll up to the pin. + if (prompt_pin) |p| { + if (self.pinIsActive(p)) { + self.viewport = .{ .active = {} }; + } else { + self.viewport_pin.* = p; + self.viewport = .{ .pin = {} }; + } + } +} + +/// Clear the screen by scrolling written contents up into the scrollback. +/// This will not update the viewport. +pub fn scrollClear(self: *PageList) !void { + // Go through the active area backwards to find the first non-empty + // row. We use this to determine how many rows to scroll up. + const non_empty: usize = non_empty: { + var page = self.pages.last.?; + var n: usize = 0; + while (true) { + const rows: [*]Row = page.data.rows.ptr(page.data.memory); + for (0..page.data.size.rows) |i| { + const rev_i = page.data.size.rows - i - 1; + const row = rows[rev_i]; + const cells = row.cells.ptr(page.data.memory)[0..self.cols]; + for (cells) |cell| { + if (!cell.isEmpty()) break :non_empty self.rows - n; + } + + n += 1; + if (n > self.rows) break :non_empty 0; + } + + page = page.prev orelse break :non_empty 0; + } + }; + + // Scroll + for (0..non_empty) |_| _ = try self.grow(); +} + +/// Returns the actual max size. This may be greater than the explicit +/// value if the explicit value is less than the min_max_size. +/// +/// This value is a HEURISTIC. You cannot assert on this value. We may +/// exceed this value if required to fit the active area. This may be +/// required in some cases if the active area has a large number of +/// graphemes, styles, etc. +pub fn maxSize(self: *const PageList) usize { + return @max(self.explicit_max_size, self.min_max_size); +} + +/// Returns true if we need to grow into our active area. +fn growRequiredForActive(self: *const PageList) bool { + var rows: usize = 0; + var page = self.pages.last; + while (page) |p| : (page = p.prev) { + rows += p.data.size.rows; + if (rows >= self.rows) return false; + } + + return true; +} + +/// Grow the active area by exactly one row. +/// +/// This may allocate, but also may not if our current page has more +/// capacity we can use. This will prune scrollback if necessary to +/// adhere to max_size. +/// +/// This returns the newly allocated page node if there is one. +pub fn grow(self: *PageList) !?*List.Node { + const last = self.pages.last.?; + if (last.data.capacity.rows > last.data.size.rows) { + // Fast path: we have capacity in the last page. + last.data.size.rows += 1; + last.data.assertIntegrity(); + return null; + } + + // Slower path: we have no space, we need to allocate a new page. + + // If allocation would exceed our max size, we prune the first page. + // We don't need to reallocate because we can simply reuse that first + // page. + if (self.page_size + PagePool.item_size > self.maxSize()) prune: { + // If we need to add more memory to ensure our active area is + // satisfied then we do not prune. + if (self.growRequiredForActive()) break :prune; + + const layout = Page.layout(try std_capacity.adjust(.{ .cols = self.cols })); + + // Get our first page and reset it to prepare for reuse. + const first = self.pages.popFirst().?; + assert(first != last); + const buf = first.data.memory; + @memset(buf, 0); + + // Initialize our new page and reinsert it as the last + first.data = Page.initBuf(OffsetBuf.init(buf), layout); + first.data.size.rows = 1; + self.pages.insertAfter(last, first); + + // Update any tracked pins that point to this page to point to the + // new first page to the top-left. + var it = self.tracked_pins.keyIterator(); + while (it.next()) |p_ptr| { + const p = p_ptr.*; + if (p.page != first) continue; + p.page = self.pages.first.?; + p.y = 0; + p.x = 0; + } + + // In this case we do NOT need to update page_size because + // we're reusing an existing page so nothing has changed. + + first.data.assertIntegrity(); + return first; + } + + // We need to allocate a new memory buffer. + const next_page = try self.createPage(try std_capacity.adjust(.{ .cols = self.cols })); + // we don't errdefer this because we've added it to the linked + // list and its fine to have dangling unused pages. + self.pages.append(next_page); + next_page.data.size.rows = 1; + + // We should never be more than our max size here because we've + // verified the case above. + next_page.data.assertIntegrity(); + + return next_page; +} + +/// Adjust the capacity of the given page in the list. +pub const AdjustCapacity = struct { + /// Adjust the number of styles in the page. This may be + /// rounded up if necessary to fit alignment requirements, + /// but it will never be rounded down. + styles: ?u16 = null, + + /// Adjust the number of available grapheme bytes in the page. + grapheme_bytes: ?usize = null, +}; + +/// Adjust the capcaity of the given page in the list. This should +/// be used in cases where OutOfMemory is returned by some operation +/// i.e to increase style counts, grapheme counts, etc. +/// +/// Adjustment works by increasing the capacity of the desired +/// dimension to a certain amount and increases the memory allocation +/// requirement for the backing memory of the page. We currently +/// never split pages or anything like that. Because increased allocation +/// has to happen outside our memory pool, its generally much slower +/// so pages should be sized to be large enough to handle all but +/// exceptional cases. +/// +/// This can currently only INCREASE capacity size. It cannot +/// decrease capacity size. This limitation is only because we haven't +/// yet needed that use case. If we ever do, this can be added. Currently +/// any requests to decrease will be ignored. +pub fn adjustCapacity( + self: *PageList, + page: *List.Node, + adjustment: AdjustCapacity, +) !*List.Node { + // We always start with the base capacity of the existing page. This + // ensures we never shrink from what we need. + var cap = page.data.capacity; + + if (adjustment.styles) |v| { + const aligned = try std.math.ceilPowerOfTwo(u16, v); + cap.styles = @max(cap.styles, aligned); + } + if (adjustment.grapheme_bytes) |v| { + const aligned = try std.math.ceilPowerOfTwo(usize, v); + cap.grapheme_bytes = @max(cap.grapheme_bytes, aligned); + } + + log.info("adjusting page capacity={}", .{cap}); + + // Create our new page and clone the old page into it. + const new_page = try self.createPage(cap); + errdefer self.destroyPage(new_page); + assert(new_page.data.capacity.rows >= page.data.capacity.rows); + new_page.data.size.rows = page.data.size.rows; + try new_page.data.cloneFrom(&page.data, 0, page.data.size.rows); + + // Fix up all our tracked pins to point to the new page. + var it = self.tracked_pins.keyIterator(); + while (it.next()) |p_ptr| { + const p = p_ptr.*; + if (p.page != page) continue; + p.page = new_page; + } + + // Insert this page and destroy the old page + self.pages.insertBefore(page, new_page); + self.pages.remove(page); + self.destroyPage(page); + + new_page.data.assertIntegrity(); + return new_page; +} + +/// Compact a page, reallocating to minimize the amount of memory +/// required for the page. This is useful when we've overflowed ID +/// spaces, are archiving a page, etc. +/// +/// Note today: this doesn't minimize the memory usage, but it does +/// fix style ID overflow. A future update can shrink the memory +/// allocation. +pub fn compact(self: *PageList, page: *List.Node) !*List.Node { + // Adjusting capacity with no adjustments forces a reallocation. + return try self.adjustCapacity(page, .{}); +} + +/// Create a new page node. This does not add it to the list and this +/// does not do any memory size accounting with max_size/page_size. +fn createPage( + self: *PageList, + cap: Capacity, +) !*List.Node { + return try createPageExt(&self.pool, cap, &self.page_size); +} + +fn createPageExt( + pool: *MemoryPool, + cap: Capacity, + total_size: ?*usize, +) !*List.Node { + var page = try pool.nodes.create(); + errdefer pool.nodes.destroy(page); + + const layout = Page.layout(cap); + const pooled = layout.total_size <= std_size; + const page_alloc = pool.pages.arena.child_allocator; + + // Our page buffer comes from our standard memory pool if it + // is within our standard size since this is what the pool + // dispenses. Otherwise, we use the heap allocator to allocate. + const page_buf = if (pooled) + try pool.pages.create() + else + try page_alloc.alignedAlloc( + u8, + std.mem.page_size, + layout.total_size, + ); + errdefer if (pooled) + pool.pages.destroy(page_buf) + else + page_alloc.free(page_buf); + + // Required only with runtime safety because allocators initialize + // to undefined, 0xAA. + if (comptime std.debug.runtime_safety) @memset(page_buf, 0); + + page.* = .{ .data = Page.initBuf(OffsetBuf.init(page_buf), layout) }; + page.data.size.rows = 0; + + if (total_size) |v| { + // Accumulate page size now. We don't assert or check max size + // because we may exceed it here temporarily as we are allocating + // pages before destroy. + v.* += page_buf.len; + } + + return page; +} + +/// Destroy the memory of the given page and return it to the pool. The +/// page is assumed to already be removed from the linked list. +fn destroyPage(self: *PageList, page: *List.Node) void { + destroyPageExt(&self.pool, page, &self.page_size); +} + +fn destroyPageExt( + pool: *MemoryPool, + page: *List.Node, + total_size: ?*usize, +) void { + // Update our accounting for page size + if (total_size) |v| v.* -= page.data.memory.len; + + if (page.data.memory.len <= std_size) { + // Reset the memory to zero so it can be reused + @memset(page.data.memory, 0); + pool.pages.destroy(@ptrCast(page.data.memory.ptr)); + } else { + const page_alloc = pool.pages.arena.child_allocator; + page_alloc.free(page.data.memory); + } + + pool.nodes.destroy(page); +} + +/// Fast-path function to erase exactly 1 row. Erasing means that the row +/// is completely REMOVED, not just cleared. All rows following the removed +/// row will be shifted up by 1 to fill the empty space. +/// +/// Unlike eraseRows, eraseRow does not change the size of any pages. The +/// caller is responsible for adjusting the row count of the final page if +/// that behavior is required. +pub fn eraseRow( + self: *PageList, + pt: point.Point, +) !void { + const pn = self.pin(pt).?; + + var page = pn.page; + var rows = page.data.rows.ptr(page.data.memory.ptr); + + // In order to move the following rows up we rotate the rows array by 1. + // The rotate operation turns e.g. [ 0 1 2 3 ] in to [ 1 2 3 0 ], which + // works perfectly to move all of our elements where they belong. + fastmem.rotateOnce(Row, rows[pn.y..page.data.size.rows]); + + // We adjust the tracked pins in this page, moving up any that were below + // the removed row. + { + var pin_it = self.tracked_pins.keyIterator(); + while (pin_it.next()) |p_ptr| { + const p = p_ptr.*; + if (p.page == page and p.y > pn.y) p.y -= 1; + } + } + + // We iterate through all of the following pages in order to move their + // rows up by 1 as well. + while (page.next) |next| { + const next_rows = next.data.rows.ptr(next.data.memory.ptr); + + // We take the top row of the page and clone it in to the bottom + // row of the previous page, which gets rid of the top row that was + // rotated down in the previous page, and accounts for the row in + // this page that will be rotated down as well. + // + // rotate -> clone --> rotate -> result + // 0 -. 1 1 1 + // 1 | 2 2 2 + // 2 | 3 3 3 + // 3 <' 0 <. 4 4 + // --- --- | --- --- <- page boundary + // 4 4 -' 4 -. 5 + // 5 5 5 | 6 + // 6 6 6 | 7 + // 7 7 7 <' 4 + try page.data.cloneRowFrom(&next.data, &rows[page.data.size.rows - 1], &next_rows[0]); + + page = next; + rows = next_rows; + + fastmem.rotateOnce(Row, rows[0..page.data.size.rows]); + + // Our tracked pins for this page need to be updated. + // If the pin is in row 0 that means the corresponding row has + // been moved to the previous page. Otherwise, move it up by 1. + var pin_it = self.tracked_pins.keyIterator(); + while (pin_it.next()) |p_ptr| { + const p = p_ptr.*; + if (p.page != page) continue; + if (p.y == 0) { + p.page = page.prev.?; + p.y = p.page.data.size.rows - 1; + continue; + } + p.y -= 1; + } + } + + // Clear the final row which was rotated from the top of the page. + page.data.clearCells(&rows[page.data.size.rows - 1], 0, page.data.size.cols); +} + +/// A variant of eraseRow that shifts only a bounded number of following +/// rows up, filling the space they leave behind with blank rows. +/// +/// `limit` is exclusive of the erased row. A limit of 1 will erase the target +/// row and shift the row below in to its position, leaving a blank row below. +pub fn eraseRowBounded( + self: *PageList, + pt: point.Point, + limit: usize, +) !void { + // This function has a lot of repeated code in it because it is a hot path. + // + // To get a better idea of what's happening, read eraseRow first for more + // in-depth explanatory comments. To avoid repetition, the only comments for + // this function are for where it differs from eraseRow. + + const pn = self.pin(pt).?; + + var page = pn.page; + var rows = page.data.rows.ptr(page.data.memory.ptr); + + // If the row limit is less than the remaining rows before the end of the + // page, then we clear the row, rotate it to the end of the boundary limit + // and update our pins. + if (page.data.size.rows - pn.y > limit) { + page.data.clearCells(&rows[pn.y], 0, page.data.size.cols); + fastmem.rotateOnce(Row, rows[pn.y..][0..limit + 1]); + + // Update pins in the shifted region. + var pin_it = self.tracked_pins.keyIterator(); + while (pin_it.next()) |p_ptr| { + const p = p_ptr.*; + if (p.page == page and p.y > pn.y and p.y < pn.y + limit) p.y -= 1; + } + + return; + } + + fastmem.rotateOnce(Row, rows[pn.y..page.data.size.rows]); + + // We need to keep track of how many rows we've shifted so that we can + // determine at what point we need to do a partial shift on subsequent + // pages. + var shifted: usize = page.data.size.rows - pn.y; + + // Update tracked pins. + { + var pin_it = self.tracked_pins.keyIterator(); + while (pin_it.next()) |p_ptr| { + const p = p_ptr.*; + if (p.page == page and p.y > pn.y) p.y -= 1; + } + } + + while (page.next) |next| { + const next_rows = next.data.rows.ptr(next.data.memory.ptr); + + try page.data.cloneRowFrom(&next.data, &rows[page.data.size.rows - 1], &next_rows[0]); + + page = next; + rows = next_rows; + + // We check to see if this page contains enough rows to satisfy the + // specified limit, accounting for rows we've already shifted in prior + // pages. + // + // The logic here is very similar to the one before the loop. + const shifted_limit = limit - shifted; + if (page.data.size.rows > shifted_limit) { + page.data.clearCells(&rows[0], 0, page.data.size.cols); + fastmem.rotateOnce(Row, rows[0..shifted_limit + 1]); + + // Update pins in the shifted region. + var pin_it = self.tracked_pins.keyIterator(); + while (pin_it.next()) |p_ptr| { + const p = p_ptr.*; + if (p.page != page or p.y > shifted_limit) continue; + if (p.y == 0) { + p.page = page.prev.?; + p.y = p.page.data.size.rows - 1; + continue; + } + p.y -= 1; + } + + return; + } + + fastmem.rotateOnce(Row, rows[0..page.data.size.rows]); + + // Account for the rows shifted in this page. + shifted += page.data.size.rows; + + // Update tracked pins. + var pin_it = self.tracked_pins.keyIterator(); + while (pin_it.next()) |p_ptr| { + const p = p_ptr.*; + if (p.page != page) continue; + if (p.y == 0) { + p.page = page.prev.?; + p.y = p.page.data.size.rows - 1; + continue; + } + p.y -= 1; + } + } + + // We reached the end of the page list before the limit, so we clear + // the final row since it was rotated down from the top of this page. + page.data.clearCells(&rows[page.data.size.rows - 1], 0, page.data.size.cols); +} + +/// Erase the rows from the given top to bottom (inclusive). Erasing +/// the rows doesn't clear them but actually physically REMOVES the rows. +/// If the top or bottom point is in the middle of a page, the other +/// contents in the page will be preserved but the page itself will be +/// underutilized (size < capacity). +pub fn eraseRows( + self: *PageList, + tl_pt: point.Point, + bl_pt: ?point.Point, +) void { + // The count of rows that was erased. + var erased: usize = 0; + + // A pageIterator iterates one page at a time from the back forward. + // "back" here is in terms of scrollback, but actually the front of the + // linked list. + var it = self.pageIterator(.right_down, tl_pt, bl_pt); + while (it.next()) |chunk| { + // If the chunk is a full page, deinit thit page and remove it from + // the linked list. + if (chunk.fullPage()) { + // A rare special case is that we're deleting everything + // in our linked list. erasePage requires at least one other + // page so to handle this we reinit this page, set it to zero + // size which will let us grow our active area back. + if (chunk.page.next == null and chunk.page.prev == null) { + const page = &chunk.page.data; + erased += page.size.rows; + page.reinit(); + page.size.rows = 0; + break; + } + + self.erasePage(chunk.page); + erased += chunk.page.data.size.rows; + continue; + } + + // We are modifying our chunk so make sure it is in a good state. + defer chunk.page.data.assertIntegrity(); + + // The chunk is not a full page so we need to move the rows. + // This is a cheap operation because we're just moving cell offsets, + // not the actual cell contents. + assert(chunk.start == 0); + const rows = chunk.page.data.rows.ptr(chunk.page.data.memory); + const scroll_amount = chunk.page.data.size.rows - chunk.end; + for (0..scroll_amount) |i| { + const src: *Row = &rows[i + chunk.end]; + const dst: *Row = &rows[i]; + const old_dst = dst.*; + dst.* = src.*; + src.* = old_dst; + } + + // Clear our remaining cells that we didn't shift or swapped + // in case we grow back into them. + for (scroll_amount..chunk.page.data.size.rows) |i| { + const row: *Row = &rows[i]; + chunk.page.data.clearCells( + row, + 0, + chunk.page.data.size.cols, + ); + } + + // Update any tracked pins to shift their y. If it was in the erased + // row then we move it to the top of this page. + var pin_it = self.tracked_pins.keyIterator(); + while (pin_it.next()) |p_ptr| { + const p = p_ptr.*; + if (p.page != chunk.page) continue; + if (p.y >= chunk.end) { + p.y -= chunk.end; + } else { + p.y = 0; + p.x = 0; + } + } + + // Our new size is the amount we scrolled + chunk.page.data.size.rows = @intCast(scroll_amount); + erased += chunk.end; + } + + // If we deleted active, we need to regrow because one of our invariants + // is that we always have full active space. + if (tl_pt == .active) { + for (0..erased) |_| _ = self.grow() catch |err| { + // If this fails its a pretty big issue actually... but I don't + // want to turn this function into an error-returning function + // because erasing active is so rare and even if it happens failing + // is even more rare... + log.err("failed to regrow active area after erase err={}", .{err}); + return; + }; + } + + // If we have a pinned viewport, we need to adjust for active area. + switch (self.viewport) { + .active => {}, + + // For pin, we check if our pin is now in the active area and if so + // we move our viewport back to the active area. + .pin => if (self.pinIsActive(self.viewport_pin.*)) { + self.viewport = .{ .active = {} }; + }, + + // For top, we move back to active if our erasing moved our + // top page into the active area. + .top => if (self.pinIsActive(.{ .page = self.pages.first.? })) { + self.viewport = .{ .active = {} }; + }, + } +} + +/// Erase a single page, freeing all its resources. The page can be +/// anywhere in the linked list but must NOT be the final page in the +/// entire list (i.e. must not make the list empty). +fn erasePage(self: *PageList, page: *List.Node) void { + assert(page.next != null or page.prev != null); + + // Update any tracked pins to move to the next page. + var it = self.tracked_pins.keyIterator(); + while (it.next()) |p_ptr| { + const p = p_ptr.*; + if (p.page != page) continue; + p.page = page.next orelse page.prev orelse unreachable; + p.y = 0; + p.x = 0; + } + + // Remove the page from the linked list + self.pages.remove(page); + self.destroyPage(page); +} + +/// Returns the pin for the given point. The pin is NOT tracked so it +/// is only valid as long as the pagelist isn't modified. +pub fn pin(self: *const PageList, pt: point.Point) ?Pin { + var p = self.getTopLeft(pt).down(pt.coord().y) orelse return null; + p.x = pt.coord().x; + return p; +} + +/// Convert the given pin to a tracked pin. A tracked pin will always be +/// automatically updated as the pagelist is modified. If the point the +/// pin points to is removed completely, the tracked pin will be updated +/// to the top-left of the screen. +pub fn trackPin(self: *PageList, p: Pin) !*Pin { + if (comptime std.debug.runtime_safety) assert(self.pinIsValid(p)); + + // Create our tracked pin + const tracked = try self.pool.pins.create(); + errdefer self.pool.pins.destroy(tracked); + tracked.* = p; + + // Add it to the tracked list + try self.tracked_pins.putNoClobber(self.pool.alloc, tracked, {}); + errdefer _ = self.tracked_pins.remove(tracked); + + return tracked; +} + +/// Untrack a previously tracked pin. This will deallocate the pin. +pub fn untrackPin(self: *PageList, p: *Pin) void { + assert(p != self.viewport_pin); + if (self.tracked_pins.remove(p)) { + self.pool.pins.destroy(p); + } +} + +pub fn countTrackedPins(self: *const PageList) usize { + return self.tracked_pins.count(); +} + +/// Checks if a pin is valid for this pagelist. This is a very slow and +/// expensive operation since we traverse the entire linked list in the +/// worst case. Only for runtime safety/debug. +fn pinIsValid(self: *const PageList, p: Pin) bool { + var it = self.pages.first; + while (it) |page| : (it = page.next) { + if (page != p.page) continue; + return p.y < page.data.size.rows and + p.x < page.data.size.cols; + } + + return false; +} + +/// Returns the viewport for the given pin, prefering to pin to +/// "active" if the pin is within the active area. +fn pinIsActive(self: *const PageList, p: Pin) bool { + // If the pin is in the active page, then we can quickly determine + // if we're beyond the end. + const active = self.getTopLeft(.active); + if (p.page == active.page) return p.y >= active.y; + + var page_ = active.page.next; + while (page_) |page| { + // This loop is pretty fast because the active area is + // never that large so this is at most one, two pages for + // reasonable terminals (including very large real world + // ones). + + // A page forward in the active area is our page, so we're + // definitely in the active area. + if (page == p.page) return true; + page_ = page.next; + } + + return false; +} + +/// Convert a pin to a point in the given context. If the pin can't fit +/// within the given tag (i.e. its in the history but you requested active), +/// then this will return null. +/// +/// Note that this can be a very expensive operation depending on the tag and +/// the location of the pin. This works by traversing the linked list of pages +/// in the tagged region. +/// +/// Therefore, this is recommended only very rarely. +pub fn pointFromPin(self: *const PageList, tag: point.Tag, p: Pin) ?point.Point { + const tl = self.getTopLeft(tag); + + // Count our first page which is special because it may be partial. + var coord: point.Coordinate = .{ .x = p.x }; + if (p.page == tl.page) { + // If our top-left is after our y then we're outside the range. + if (tl.y > p.y) return null; + coord.y = p.y - tl.y; + } else { + coord.y += tl.page.data.size.rows - tl.y; + var page_ = tl.page.next; + while (page_) |page| : (page_ = page.next) { + if (page == p.page) { + coord.y += p.y; + break; + } + + coord.y += page.data.size.rows; + } else { + // We never saw our page, meaning we're outside the range. + return null; + } + } + + return switch (tag) { + inline else => |comptime_tag| @unionInit( + point.Point, + @tagName(comptime_tag), + coord, + ), + }; +} + +/// Get the cell at the given point, or null if the cell does not +/// exist or is out of bounds. +/// +/// Warning: this is slow and should not be used in performance critical paths +pub fn getCell(self: *const PageList, pt: point.Point) ?Cell { + const pt_pin = self.pin(pt) orelse return null; + const rac = pt_pin.page.data.getRowAndCell(pt_pin.x, pt_pin.y); + return .{ + .page = pt_pin.page, + .row = rac.row, + .cell = rac.cell, + .row_idx = pt_pin.y, + .col_idx = pt_pin.x, + }; +} + +/// Direction that iterators can move. +pub const Direction = enum { left_up, right_down }; + +pub const CellIterator = struct { + row_it: RowIterator, + cell: ?Pin = null, + + pub fn next(self: *CellIterator) ?Pin { + const cell = self.cell orelse return null; + + switch (self.row_it.page_it.direction) { + .right_down => { + if (cell.x + 1 < cell.page.data.size.cols) { + // We still have cells in this row, increase x. + var copy = cell; + copy.x += 1; + self.cell = copy; + } else { + // We need to move to the next row. + self.cell = self.row_it.next(); + } + }, + + .left_up => { + if (cell.x > 0) { + // We still have cells in this row, decrease x. + var copy = cell; + copy.x -= 1; + self.cell = copy; + } else { + // We need to move to the previous row and last col + if (self.row_it.next()) |next_cell| { + var copy = next_cell; + copy.x = next_cell.page.data.size.cols - 1; + self.cell = copy; + } else { + self.cell = null; + } + } + }, + } + + return cell; + } +}; + +pub fn cellIterator( + self: *const PageList, + direction: Direction, + tl_pt: point.Point, + bl_pt: ?point.Point, +) CellIterator { + const tl_pin = self.pin(tl_pt).?; + const bl_pin = if (bl_pt) |pt| + self.pin(pt).? + else + self.getBottomRight(tl_pt) orelse + return .{ .row_it = undefined }; + + return switch (direction) { + .right_down => tl_pin.cellIterator(.right_down, bl_pin), + .left_up => bl_pin.cellIterator(.left_up, tl_pin), + }; +} + +pub const RowIterator = struct { + page_it: PageIterator, + chunk: ?PageIterator.Chunk = null, + offset: usize = 0, + + pub fn next(self: *RowIterator) ?Pin { + const chunk = self.chunk orelse return null; + const row: Pin = .{ .page = chunk.page, .y = self.offset }; + + switch (self.page_it.direction) { + .right_down => { + // Increase our offset in the chunk + self.offset += 1; + + // If we are beyond the chunk end, we need to move to the next chunk. + if (self.offset >= chunk.end) { + self.chunk = self.page_it.next(); + if (self.chunk) |c| self.offset = c.start; + } + }, + + .left_up => { + // If we are at the start of the chunk, we need to move to the + // previous chunk. + if (self.offset == 0) { + self.chunk = self.page_it.next(); + if (self.chunk) |c| self.offset = c.end - 1; + } else { + // If we're at the start of the chunk and its a non-zero + // offset then we've reached a limit. + if (self.offset == chunk.start) { + self.chunk = null; + } else { + self.offset -= 1; + } + } + }, + } + + return row; + } +}; + +/// Create an interator that can be used to iterate all the rows in +/// a region of the screen from the given top-left. The tag of the +/// top-left point will also determine the end of the iteration, +/// so convert from one reference point to another to change the +/// iteration bounds. +pub fn rowIterator( + self: *const PageList, + direction: Direction, + tl_pt: point.Point, + bl_pt: ?point.Point, +) RowIterator { + const tl_pin = self.pin(tl_pt).?; + const bl_pin = if (bl_pt) |pt| + self.pin(pt).? + else + self.getBottomRight(tl_pt) orelse + return .{ .page_it = undefined }; + + return switch (direction) { + .right_down => tl_pin.rowIterator(.right_down, bl_pin), + .left_up => bl_pin.rowIterator(.left_up, tl_pin), + }; +} + +pub const PageIterator = struct { + row: ?Pin = null, + limit: Limit = .none, + direction: Direction = .right_down, + + const Limit = union(enum) { + none, + count: usize, + row: Pin, + }; + + pub fn next(self: *PageIterator) ?Chunk { + return switch (self.direction) { + .left_up => self.nextUp(), + .right_down => self.nextDown(), + }; + } + + fn nextDown(self: *PageIterator) ?Chunk { + // Get our current row location + const row = self.row orelse return null; + + return switch (self.limit) { + .none => none: { + // If we have no limit, then we consume this entire page. Our + // next row is the next page. + self.row = next: { + const next_page = row.page.next orelse break :next null; + break :next .{ .page = next_page }; + }; + + break :none .{ + .page = row.page, + .start = row.y, + .end = row.page.data.size.rows, + }; + }, + + .count => |*limit| count: { + assert(limit.* > 0); // should be handled already + const len = @min(row.page.data.size.rows - row.y, limit.*); + if (len > limit.*) { + self.row = row.down(len); + limit.* -= len; + } else { + self.row = null; + } + + break :count .{ + .page = row.page, + .start = row.y, + .end = row.y + len, + }; + }, + + .row => |limit_row| row: { + // If this is not the same page as our limit then we + // can consume the entire page. + if (limit_row.page != row.page) { + self.row = next: { + const next_page = row.page.next orelse break :next null; + break :next .{ .page = next_page }; + }; + + break :row .{ + .page = row.page, + .start = row.y, + .end = row.page.data.size.rows, + }; + } + + // If this is the same page then we only consume up to + // the limit row. + self.row = null; + if (row.y > limit_row.y) return null; + break :row .{ + .page = row.page, + .start = row.y, + .end = limit_row.y + 1, + }; + }, + }; + } + + fn nextUp(self: *PageIterator) ?Chunk { + // Get our current row location + const row = self.row orelse return null; + + return switch (self.limit) { + .none => none: { + // If we have no limit, then we consume this entire page. Our + // next row is the next page. + self.row = next: { + const next_page = row.page.prev orelse break :next null; + break :next .{ + .page = next_page, + .y = next_page.data.size.rows - 1, + }; + }; + + break :none .{ + .page = row.page, + .start = 0, + .end = row.y + 1, + }; + }, + + .count => |*limit| count: { + assert(limit.* > 0); // should be handled already + const len = @min(row.y, limit.*); + if (len > limit.*) { + self.row = row.up(len); + limit.* -= len; + } else { + self.row = null; + } + + break :count .{ + .page = row.page, + .start = row.y - len, + .end = row.y - 1, + }; + }, + + .row => |limit_row| row: { + // If this is not the same page as our limit then we + // can consume the entire page. + if (limit_row.page != row.page) { + self.row = next: { + const next_page = row.page.prev orelse break :next null; + break :next .{ + .page = next_page, + .y = next_page.data.size.rows - 1, + }; + }; + + break :row .{ + .page = row.page, + .start = 0, + .end = row.y + 1, + }; + } + + // If this is the same page then we only consume up to + // the limit row. + self.row = null; + if (row.y < limit_row.y) return null; + break :row .{ + .page = row.page, + .start = limit_row.y, + .end = row.y + 1, + }; + }, + }; + } + + pub const Chunk = struct { + page: *List.Node, + start: usize, + end: usize, + + pub fn rows(self: Chunk) []Row { + const rows_ptr = self.page.data.rows.ptr(self.page.data.memory); + return rows_ptr[self.start..self.end]; + } + + /// Returns true if this chunk represents every row in the page. + pub fn fullPage(self: Chunk) bool { + return self.start == 0 and self.end == self.page.data.size.rows; + } + }; +}; + +/// Return an iterator that iterates through the rows in the tagged area +/// of the point. The iterator returns row "chunks", which are the largest +/// contiguous set of rows in a single backing page for a given portion of +/// the point region. +/// +/// This is a more efficient way to iterate through the data in a region, +/// since you can do simple pointer math and so on. +/// +/// If bl_pt is non-null, iteration will stop at the bottom left point +/// (inclusive). If bl_pt is null, the entire region specified by the point +/// tag will be iterated over. tl_pt and bl_pt must be the same tag, and +/// bl_pt must be greater than or equal to tl_pt. +/// +/// If direction is left_up, iteration will go from bl_pt to tl_pt. If +/// direction is right_down, iteration will go from tl_pt to bl_pt. +/// Both inclusive. +pub fn pageIterator( + self: *const PageList, + direction: Direction, + tl_pt: point.Point, + bl_pt: ?point.Point, +) PageIterator { + const tl_pin = self.pin(tl_pt).?; + const bl_pin = if (bl_pt) |pt| + self.pin(pt).? + else + self.getBottomRight(tl_pt) orelse return .{ .row = null }; + + if (comptime std.debug.runtime_safety) { + assert(tl_pin.eql(bl_pin) or tl_pin.before(bl_pin)); + } + + return switch (direction) { + .right_down => tl_pin.pageIterator(.right_down, bl_pin), + .left_up => bl_pin.pageIterator(.left_up, tl_pin), + }; +} + +/// Get the top-left of the screen for the given tag. +pub fn getTopLeft(self: *const PageList, tag: point.Tag) Pin { + return switch (tag) { + // The full screen or history is always just the first page. + .screen, .history => .{ .page = self.pages.first.? }, + + .viewport => switch (self.viewport) { + .active => self.getTopLeft(.active), + .top => self.getTopLeft(.screen), + .pin => self.viewport_pin.*, + }, + + // The active area is calculated backwards from the last page. + // This makes getting the active top left slower but makes scrolling + // much faster because we don't need to update the top left. Under + // heavy load this makes a measurable difference. + .active => active: { + var rem = self.rows; + var it = self.pages.last; + while (it) |page| : (it = page.prev) { + if (rem <= page.data.size.rows) break :active .{ + .page = page, + .y = page.data.size.rows - rem, + }; + + rem -= page.data.size.rows; + } + + unreachable; // assertion: we always have enough rows for active + }, + }; +} + +/// Returns the bottom right of the screen for the given tag. This can +/// return null because it is possible that a tag is not in the screen +/// (e.g. history does not yet exist). +pub fn getBottomRight(self: *const PageList, tag: point.Tag) ?Pin { + return switch (tag) { + .screen, .active => last: { + const page = self.pages.last.?; + break :last .{ + .page = page, + .y = page.data.size.rows - 1, + .x = page.data.size.cols - 1, + }; + }, + + .viewport => viewport: { + const tl = self.getTopLeft(.viewport); + break :viewport tl.down(self.rows - 1).?; + }, + + .history => active: { + const tl = self.getTopLeft(.active); + break :active tl.up(1); + }, + }; +} + +/// The total rows in the screen. This is the actual row count currently +/// and not a capacity or maximum. +/// +/// This is very slow, it traverses the full list of pages to count the +/// rows, so it is not pub. This is only used for testing/debugging. +fn totalRows(self: *const PageList) usize { + var rows: usize = 0; + var page = self.pages.first; + while (page) |p| { + rows += p.data.size.rows; + page = p.next; + } + + return rows; +} + +/// The total number of pages in this list. +fn totalPages(self: *const PageList) usize { + var pages: usize = 0; + var page = self.pages.first; + while (page) |p| { + pages += 1; + page = p.next; + } + + return pages; +} + +/// Grow the number of rows available in the page list by n. +/// This is only used for testing so it isn't optimized. +fn growRows(self: *PageList, n: usize) !void { + var page = self.pages.last.?; + var n_rem: usize = n; + if (page.data.size.rows < page.data.capacity.rows) { + const add = @min(n_rem, page.data.capacity.rows - page.data.size.rows); + page.data.size.rows += add; + if (n_rem == add) return; + n_rem -= add; + } + + while (n_rem > 0) { + page = (try self.grow()).?; + const add = @min(n_rem, page.data.capacity.rows); + page.data.size.rows = add; + n_rem -= add; + } +} + +/// Represents an exact x/y coordinate within the screen. This is called +/// a "pin" because it is a fixed point within the pagelist direct to +/// a specific page pointer and memory offset. The benefit is that this +/// point remains valid even through scrolling without any additional work. +/// +/// A downside is that the pin is only valid until the pagelist is modified +/// in a way that may invalid page pointers or shuffle rows, such as resizing, +/// erasing rows, etc. +/// +/// A pin can also be "tracked" which means that it will be updated as the +/// PageList is modified. +/// +/// The PageList maintains a list of active pin references and keeps them +/// all up to date as the pagelist is modified. This isn't cheap so callers +/// should limit the number of active pins as much as possible. +pub const Pin = struct { + page: *List.Node, + y: usize = 0, + x: usize = 0, + + pub fn rowAndCell(self: Pin) struct { + row: *pagepkg.Row, + cell: *pagepkg.Cell, + } { + const rac = self.page.data.getRowAndCell(self.x, self.y); + return .{ .row = rac.row, .cell = rac.cell }; + } + + pub const CellSubset = enum { all, left, right }; + + /// Returns the cells for the row that this pin is on. The subset determines + /// what subset of the cells are returned. The "left/right" subsets are + /// inclusive of the x coordinate of the pin. + pub fn cells(self: Pin, subset: CellSubset) []pagepkg.Cell { + const rac = self.rowAndCell(); + const all = self.page.data.getCells(rac.row); + return switch (subset) { + .all => all, + .left => all[0 .. self.x + 1], + .right => all[self.x..], + }; + } + + /// Returns the grapheme codepoints for the given cell. These are only + /// the EXTRA codepoints and not the first codepoint. + pub fn grapheme(self: Pin, cell: *pagepkg.Cell) ?[]u21 { + return self.page.data.lookupGrapheme(cell); + } + + /// Returns the style for the given cell in this pin. + pub fn style(self: Pin, cell: *pagepkg.Cell) stylepkg.Style { + if (cell.style_id == stylepkg.default_id) return .{}; + return self.page.data.styles.lookupId( + self.page.data.memory, + cell.style_id, + ).?.*; + } + + /// Iterators. These are the same as PageList iterator funcs but operate + /// on pins rather than points. This is MUCH more efficient than calling + /// pointFromPin and building up the iterator from points. + /// + /// The limit pin is inclusive. + pub fn pageIterator( + self: Pin, + direction: Direction, + limit: ?Pin, + ) PageIterator { + return .{ + .row = self, + .limit = if (limit) |p| .{ .row = p } else .{ .none = {} }, + .direction = direction, + }; + } + + pub fn rowIterator( + self: Pin, + direction: Direction, + limit: ?Pin, + ) RowIterator { + var page_it = self.pageIterator(direction, limit); + const chunk = page_it.next() orelse return .{ .page_it = page_it }; + return .{ + .page_it = page_it, + .chunk = chunk, + .offset = switch (direction) { + .right_down => chunk.start, + .left_up => chunk.end - 1, + }, + }; + } + + pub fn cellIterator( + self: Pin, + direction: Direction, + limit: ?Pin, + ) CellIterator { + var row_it = self.rowIterator(direction, limit); + var cell = row_it.next() orelse return .{ .row_it = row_it }; + cell.x = self.x; + return .{ .row_it = row_it, .cell = cell }; + } + + /// Returns true if this pin is between the top and bottom, inclusive. + // + // Note: this is primarily unit tested as part of the Kitty + // graphics deletion code. + pub fn isBetween(self: Pin, top: Pin, bottom: Pin) bool { + if (comptime std.debug.runtime_safety) { + if (top.page == bottom.page) { + // If top is bottom, must be ordered. + assert(top.y <= bottom.y); + if (top.y == bottom.y) { + assert(top.x <= bottom.x); + } + } else { + // If top is not bottom, top must be before bottom. + var page = top.page.next; + while (page) |p| : (page = p.next) { + if (p == bottom.page) break; + } else assert(false); + } + } + + if (self.page == top.page) { + if (self.y < top.y) return false; + if (self.y > top.y) { + return if (self.page == bottom.page) + self.y <= bottom.y + else + true; + } + return self.x >= top.x; + } + if (self.page == bottom.page) { + if (self.y > bottom.y) return false; + if (self.y < bottom.y) return true; + return self.x <= bottom.x; + } + + var page = top.page.next; + while (page) |p| : (page = p.next) { + if (p == bottom.page) break; + if (p == self.page) return true; + } + + return false; + } + + /// Returns true if self is before other. This is very expensive since + /// it requires traversing the linked list of pages. This should not + /// be called in performance critical paths. + pub fn before(self: Pin, other: Pin) bool { + if (self.page == other.page) { + if (self.y < other.y) return true; + if (self.y > other.y) return false; + return self.x < other.x; + } + + var page = self.page.next; + while (page) |p| : (page = p.next) { + if (p == other.page) return true; + } + + return false; + } + + pub fn eql(self: Pin, other: Pin) bool { + return self.page == other.page and + self.y == other.y and + self.x == other.x; + } + + /// Move the pin left n columns. n must fit within the size. + pub fn left(self: Pin, n: usize) Pin { + assert(n <= self.x); + var result = self; + result.x -= n; + return result; + } + + /// Move the pin right n columns. n must fit within the size. + pub fn right(self: Pin, n: usize) Pin { + assert(self.x + n < self.page.data.size.cols); + var result = self; + result.x += n; + return result; + } + + /// 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 { + return switch (self.downOverflow(n)) { + .offset => |v| v, + .overflow => null, + }; + } + + /// Move the pin up a certain number of rows, or return null if + /// the pin goes beyond the start of the screen. + pub fn up(self: Pin, n: usize) ?Pin { + return switch (self.upOverflow(n)) { + .offset => |v| v, + .overflow => null, + }; + } + + /// Move the offset down n rows. If the offset goes beyond the + /// end of the screen, return the overflow amount. + pub fn downOverflow(self: Pin, n: usize) union(enum) { + offset: Pin, + overflow: struct { + end: Pin, + remaining: usize, + }, + } { + // Index fits within this page + const rows = self.page.data.size.rows - (self.y + 1); + if (n <= rows) return .{ .offset = .{ + .page = self.page, + .y = n + self.y, + .x = self.x, + } }; + + // Need to traverse page links to find the page + var page: *List.Node = self.page; + var n_left: usize = n - rows; + while (true) { + page = page.next orelse return .{ .overflow = .{ + .end = .{ + .page = page, + .y = page.data.size.rows - 1, + .x = self.x, + }, + .remaining = n_left, + } }; + if (n_left <= page.data.size.rows) return .{ .offset = .{ + .page = page, + .y = n_left - 1, + .x = self.x, + } }; + n_left -= page.data.size.rows; + } + } + + /// Move the offset up n rows. If the offset goes beyond the + /// start of the screen, return the overflow amount. + pub fn upOverflow(self: Pin, n: usize) union(enum) { + offset: Pin, + overflow: struct { + end: Pin, + remaining: usize, + }, + } { + // Index fits within this page + if (n <= self.y) return .{ .offset = .{ + .page = self.page, + .y = self.y - n, + .x = self.x, + } }; + + // Need to traverse page links to find the page + var page: *List.Node = self.page; + var n_left: usize = n - self.y; + while (true) { + page = page.prev orelse return .{ .overflow = .{ + .end = .{ .page = page, .y = 0, .x = self.x }, + .remaining = n_left, + } }; + if (n_left <= page.data.size.rows) return .{ .offset = .{ + .page = page, + .y = page.data.size.rows - n_left, + .x = self.x, + } }; + n_left -= page.data.size.rows; + } + } +}; + +const Cell = struct { + page: *List.Node, + row: *pagepkg.Row, + cell: *pagepkg.Cell, + row_idx: usize, + col_idx: usize, + + /// Get the cell style. + /// + /// Not meant for non-test usage since this is inefficient. + pub fn style(self: Cell) stylepkg.Style { + if (self.cell.style_id == stylepkg.default_id) return .{}; + return self.page.data.styles.lookupId( + self.page.data.memory, + self.cell.style_id, + ).?.*; + } + + /// Gets the screen point for the given cell. + /// + /// This is REALLY expensive/slow so it isn't pub. This was built + /// for debugging and tests. If you have a need for this outside of + /// this file then consider a different approach and ask yourself very + /// carefully if you really need this. + pub fn screenPoint(self: Cell) point.Point { + var y: usize = self.row_idx; + var page = self.page; + while (page.prev) |prev| { + y += prev.data.size.rows; + page = prev; + } + + return .{ .screen = .{ + .x = self.col_idx, + .y = y, + } }; + } +}; + +test "PageList" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try testing.expect(s.viewport == .active); + try testing.expect(s.pages.first != null); + try testing.expectEqual(@as(usize, s.rows), s.totalRows()); + + // Active area should be the top + try testing.expectEqual(Pin{ + .page = s.pages.first.?, + .y = 0, + .x = 0, + }, s.getTopLeft(.active)); +} + +test "PageList init rows across two pages" { + const testing = std.testing; + const alloc = testing.allocator; + + // Find a cap that makes it so that rows don't fit on one page. + const rows = 100; + const cap = cap: { + var cap = try std_capacity.adjust(.{ .cols = 50 }); + while (cap.rows >= rows) cap = try std_capacity.adjust(.{ + .cols = cap.cols + 50, + }); + + break :cap cap; + }; + + // Init + var s = try init(alloc, cap.cols, rows, null); + defer s.deinit(); + try testing.expect(s.viewport == .active); + try testing.expect(s.pages.first != null); + try testing.expectEqual(@as(usize, s.rows), s.totalRows()); +} + +test "PageList pointFromPin active no history" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + { + try testing.expectEqual(point.Point{ + .active = .{ + .y = 0, + .x = 0, + }, + }, s.pointFromPin(.active, .{ + .page = s.pages.first.?, + .y = 0, + .x = 0, + }).?); + } + { + try testing.expectEqual(point.Point{ + .active = .{ + .y = 2, + .x = 4, + }, + }, s.pointFromPin(.active, .{ + .page = s.pages.first.?, + .y = 2, + .x = 4, + }).?); + } +} + +test "PageList pointFromPin active with history" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(30); + + { + try testing.expectEqual(point.Point{ + .active = .{ + .y = 0, + .x = 2, + }, + }, s.pointFromPin(.active, .{ + .page = s.pages.first.?, + .y = 30, + .x = 2, + }).?); + } + + // In history, invalid + { + try testing.expect(s.pointFromPin(.active, .{ + .page = s.pages.first.?, + .y = 21, + .x = 2, + }) == null); + } +} + +test "PageList pointFromPin active from prior page" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + const page = &s.pages.last.?.data; + for (0..page.capacity.rows * 5) |_| { + _ = try s.grow(); + } + + { + try testing.expectEqual(point.Point{ + .active = .{ + .y = 0, + .x = 2, + }, + }, s.pointFromPin(.active, .{ + .page = s.pages.last.?, + .y = 0, + .x = 2, + }).?); + } + + // Prior page + { + try testing.expect(s.pointFromPin(.active, .{ + .page = s.pages.first.?, + .y = 0, + .x = 0, + }) == null); + } +} + +test "PageList pointFromPin traverse pages" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + const page = &s.pages.last.?.data; + for (0..page.capacity.rows * 2) |_| { + _ = try s.grow(); + } + + { + const pages = s.totalPages(); + const page_cap = page.capacity.rows; + const expected_y = page_cap * (pages - 2) + 5; + + try testing.expectEqual(point.Point{ + .screen = .{ + .y = expected_y, + .x = 2, + }, + }, s.pointFromPin(.screen, .{ + .page = s.pages.last.?.prev.?, + .y = 5, + .x = 2, + }).?); + } + + // Prior page + { + try testing.expect(s.pointFromPin(.active, .{ + .page = s.pages.first.?, + .y = 0, + .x = 0, + }) == null); + } +} +test "PageList active after grow" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try testing.expectEqual(@as(usize, s.rows), s.totalRows()); + + try s.growRows(10); + try testing.expectEqual(@as(usize, s.rows + 10), s.totalRows()); + + // Make sure all points make sense + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 10, + } }, pt); + } + { + const pt = s.getCell(.{ .screen = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, pt); + } + { + const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 10, + } }, pt); + } +} + +test "PageList grow allows exceeding max size for active area" { + const testing = std.testing; + const alloc = testing.allocator; + + // Setup our initial page so that we fully take up one page. + const cap = try std_capacity.adjust(.{ .cols = 5 }); + var s = try init(alloc, 5, cap.rows, 0); + defer s.deinit(); + try testing.expectEqual(@as(usize, s.rows), s.totalRows()); + + // Grow once because we guarantee at least two pages of + // capacity so we want to get to that. + _ = try s.grow(); + const start_pages = s.totalPages(); + try testing.expect(start_pages >= 2); + + // Surgically modify our pages so that they have a smaller size. + { + var it = s.pages.first; + while (it) |page| : (it = page.next) { + page.data.size.rows = 1; + page.data.capacity.rows = 1; + } + } + + // Grow our row and ensure we don't prune pages because we need + // enough for the active area. + _ = try s.grow(); + try testing.expectEqual(start_pages + 1, s.totalPages()); +} + +test "PageList scroll top" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(10); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 10, + } }, pt); + } + + s.scroll(.{ .top = {} }); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, pt); + } + + try s.growRows(10); + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, pt); + } + + s.scroll(.{ .active = {} }); + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 20, + } }, pt); + } +} + +test "PageList scroll delta row back" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(10); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 10, + } }, pt); + } + + s.scroll(.{ .delta_row = -1 }); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 9, + } }, pt); + } + + try s.growRows(10); + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 9, + } }, pt); + } +} + +test "PageList scroll delta row back overflow" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(10); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 10, + } }, pt); + } + + s.scroll(.{ .delta_row = -100 }); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, pt); + } + + try s.growRows(10); + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, pt); + } +} + +test "PageList scroll delta row forward" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(10); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 10, + } }, pt); + } + + s.scroll(.{ .top = {} }); + s.scroll(.{ .delta_row = 2 }); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, pt); + } + + try s.growRows(10); + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, pt); + } +} + +test "PageList scroll delta row forward into active" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + s.scroll(.{ .delta_row = 2 }); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, pt); + } +} + +test "PageList scroll delta row back without space preserves active" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + s.scroll(.{ .delta_row = -1 }); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, pt); + } + + try testing.expect(s.viewport == .active); +} + +test "PageList scroll clear" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + { + const cell = s.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + cell.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + } + { + const cell = s.getCell(.{ .active = .{ .x = 0, .y = 1 } }).?; + cell.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + } + + try s.scrollClear(); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, pt); + } +} + +test "PageList: jump zero" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, null); + defer s.deinit(); + try s.growRows(3); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + { + const rac = page.getRowAndCell(0, 1); + rac.row.semantic_prompt = .prompt; + } + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + } + + s.scroll(.{ .delta_prompt = 0 }); + try testing.expect(s.viewport == .active); +} + +test "Screen: jump to prompt" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, null); + defer s.deinit(); + try s.growRows(3); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + { + const rac = page.getRowAndCell(0, 1); + rac.row.semantic_prompt = .prompt; + } + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + } + + // Jump back + { + s.scroll(.{ .delta_prompt = -1 }); + try testing.expect(s.viewport == .pin); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 1, + } }, s.pointFromPin(.screen, s.pin(.{ .viewport = .{} }).?).?); + } + { + s.scroll(.{ .delta_prompt = -1 }); + try testing.expect(s.viewport == .pin); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 1, + } }, s.pointFromPin(.screen, s.pin(.{ .viewport = .{} }).?).?); + } + + // Jump forward + { + s.scroll(.{ .delta_prompt = 1 }); + try testing.expect(s.viewport == .active); + } + { + s.scroll(.{ .delta_prompt = 1 }); + try testing.expect(s.viewport == .active); + } +} + +test "PageList grow fit in capacity" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // So we know we're using capacity to grow + const last = &s.pages.last.?.data; + try testing.expect(last.size.rows < last.capacity.rows); + + // Grow + try testing.expect(try s.grow() == null); + { + const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 1, + } }, pt); + } +} + +test "PageList grow allocate" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Grow to capacity + const last_node = s.pages.last.?; + const last = &s.pages.last.?.data; + for (0..last.capacity.rows - last.size.rows) |_| { + try testing.expect(try s.grow() == null); + } + + // Grow, should allocate + const new = (try s.grow()).?; + try testing.expect(s.pages.last.? == new); + try testing.expect(last_node.next.? == new); + { + const cell = s.getCell(.{ .active = .{ .y = s.rows - 1 } }).?; + try testing.expect(cell.page == new); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = last.capacity.rows, + } }, cell.screenPoint()); + } +} + +test "PageList grow prune scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + // Zero here forces minimum max size to effectively two pages. + var s = try init(alloc, 80, 24, 0); + defer s.deinit(); + + // Grow to capacity + const page1_node = s.pages.last.?; + const page1 = page1_node.data; + for (0..page1.capacity.rows - page1.size.rows) |_| { + try testing.expect(try s.grow() == null); + } + + // Grow and allocate one more page. Then fill that page up. + const page2_node = (try s.grow()).?; + const page2 = page2_node.data; + for (0..page2.capacity.rows - page2.size.rows) |_| { + try testing.expect(try s.grow() == null); + } + + // Get our page size + const old_page_size = s.page_size; + + // Create a tracked pin in the first page + const p = try s.trackPin(s.pin(.{ .screen = .{} }).?); + defer s.untrackPin(p); + try testing.expect(p.page == s.pages.first.?); + + // Next should create a new page, but it should reuse our first + // page since we're at max size. + const new = (try s.grow()).?; + try testing.expect(s.pages.last.? == new); + try testing.expectEqual(s.page_size, old_page_size); + + // Our first should now be page2 and our last should be page1 + try testing.expectEqual(page2_node, s.pages.first.?); + try testing.expectEqual(page1_node, s.pages.last.?); + + // Our tracked pin should point to the top-left of the first page + try testing.expect(p.page == s.pages.first.?); + try testing.expect(p.x == 0); + try testing.expect(p.y == 0); +} + +test "PageList adjustCapacity to increase styles" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 2, 0); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Write all our data so we can assert its the same after + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + } + + // Increase our styles + _ = try s.adjustCapacity( + s.pages.first.?, + .{ .styles = std_capacity.styles * 2 }, + ); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + try testing.expectEqual( + @as(u21, @intCast(x)), + rac.cell.content.codepoint, + ); + } + } + } +} + +test "PageList adjustCapacity to increase graphemes" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 2, 0); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Write all our data so we can assert its the same after + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + } + + // Increase our graphemes + _ = try s.adjustCapacity( + s.pages.first.?, + .{ .grapheme_bytes = std_capacity.grapheme_bytes * 2 }, + ); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + try testing.expectEqual( + @as(u21, @intCast(x)), + rac.cell.content.codepoint, + ); + } + } + } +} + +test "PageList pageIterator single page" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // The viewport should be within a single page + try testing.expect(s.pages.first.?.next == null); + + // Iterate the active area + var it = s.pageIterator(.right_down, .{ .active = .{} }, null); + { + const chunk = it.next().?; + try testing.expect(chunk.page == s.pages.first.?); + try testing.expectEqual(@as(usize, 0), chunk.start); + try testing.expectEqual(@as(usize, s.rows), chunk.end); + } + + // Should only have one chunk + try testing.expect(it.next() == null); +} + +test "PageList pageIterator two pages" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Grow to capacity + const page1_node = s.pages.last.?; + const page1 = page1_node.data; + for (0..page1.capacity.rows - page1.size.rows) |_| { + try testing.expect(try s.grow() == null); + } + try testing.expect(try s.grow() != null); + + // Iterate the active area + var it = s.pageIterator(.right_down, .{ .active = .{} }, null); + { + const chunk = it.next().?; + try testing.expect(chunk.page == s.pages.first.?); + const start = chunk.page.data.size.rows - s.rows + 1; + try testing.expectEqual(start, chunk.start); + try testing.expectEqual(chunk.page.data.size.rows, chunk.end); + } + { + const chunk = it.next().?; + try testing.expect(chunk.page == s.pages.last.?); + const start: usize = 0; + try testing.expectEqual(start, chunk.start); + try testing.expectEqual(start + 1, chunk.end); + } + try testing.expect(it.next() == null); +} + +test "PageList pageIterator history two pages" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Grow to capacity + const page1_node = s.pages.last.?; + const page1 = page1_node.data; + for (0..page1.capacity.rows - page1.size.rows) |_| { + try testing.expect(try s.grow() == null); + } + try testing.expect(try s.grow() != null); + + // Iterate the active area + var it = s.pageIterator(.right_down, .{ .history = .{} }, null); + { + const active_tl = s.getTopLeft(.active); + const chunk = it.next().?; + try testing.expect(chunk.page == s.pages.first.?); + const start: usize = 0; + try testing.expectEqual(start, chunk.start); + try testing.expectEqual(active_tl.y, chunk.end); + } + try testing.expect(it.next() == null); +} + +test "PageList pageIterator reverse single page" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // The viewport should be within a single page + try testing.expect(s.pages.first.?.next == null); + + // Iterate the active area + var it = s.pageIterator(.left_up, .{ .active = .{} }, null); + { + const chunk = it.next().?; + try testing.expect(chunk.page == s.pages.first.?); + try testing.expectEqual(@as(usize, 0), chunk.start); + try testing.expectEqual(@as(usize, s.rows), chunk.end); + } + + // Should only have one chunk + try testing.expect(it.next() == null); +} + +test "PageList pageIterator reverse two pages" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Grow to capacity + const page1_node = s.pages.last.?; + const page1 = page1_node.data; + for (0..page1.capacity.rows - page1.size.rows) |_| { + try testing.expect(try s.grow() == null); + } + try testing.expect(try s.grow() != null); + + // Iterate the active area + var it = s.pageIterator(.left_up, .{ .active = .{} }, null); + var count: usize = 0; + { + const chunk = it.next().?; + try testing.expect(chunk.page == s.pages.last.?); + const start: usize = 0; + try testing.expectEqual(start, chunk.start); + try testing.expectEqual(start + 1, chunk.end); + count += chunk.end - chunk.start; + } + { + const chunk = it.next().?; + try testing.expect(chunk.page == s.pages.first.?); + const start = chunk.page.data.size.rows - s.rows + 1; + try testing.expectEqual(start, chunk.start); + try testing.expectEqual(chunk.page.data.size.rows, chunk.end); + count += chunk.end - chunk.start; + } + try testing.expect(it.next() == null); + try testing.expectEqual(s.rows, count); +} + +test "PageList pageIterator reverse history two pages" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Grow to capacity + const page1_node = s.pages.last.?; + const page1 = page1_node.data; + for (0..page1.capacity.rows - page1.size.rows) |_| { + try testing.expect(try s.grow() == null); + } + try testing.expect(try s.grow() != null); + + // Iterate the active area + var it = s.pageIterator(.left_up, .{ .history = .{} }, null); + { + const active_tl = s.getTopLeft(.active); + const chunk = it.next().?; + try testing.expect(chunk.page == s.pages.first.?); + const start: usize = 0; + try testing.expectEqual(start, chunk.start); + try testing.expectEqual(active_tl.y, chunk.end); + } + try testing.expect(it.next() == null); +} + +test "PageList cellIterator" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 2, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + var it = s.cellIterator(.right_down, .{ .screen = .{} }, null); + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pointFromPin(.screen, p).?); + } + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 0, + } }, s.pointFromPin(.screen, p).?); + } + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 1, + } }, s.pointFromPin(.screen, p).?); + } + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 1, + } }, s.pointFromPin(.screen, p).?); + } + try testing.expect(it.next() == null); +} + +test "PageList cellIterator reverse" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 2, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + var it = s.cellIterator(.left_up, .{ .screen = .{} }, null); + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 1, + } }, s.pointFromPin(.screen, p).?); + } + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 1, + } }, s.pointFromPin(.screen, p).?); + } + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 0, + } }, s.pointFromPin(.screen, p).?); + } + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pointFromPin(.screen, p).?); + } + try testing.expect(it.next() == null); +} + +test "PageList erase" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try testing.expectEqual(@as(usize, 1), s.totalPages()); + + // Grow so we take up at least 5 pages. + const page = &s.pages.last.?.data; + for (0..page.capacity.rows * 5) |_| { + _ = try s.grow(); + } + try testing.expectEqual(@as(usize, 6), s.totalPages()); + + // Our total rows should be large + try testing.expect(s.totalRows() > s.rows); + + // Erase the entire history, we should be back to just our active set. + s.eraseRows(.{ .history = .{} }, null); + try testing.expectEqual(s.rows, s.totalRows()); + + // We should be back to just one page + try testing.expectEqual(@as(usize, 1), s.totalPages()); + try testing.expect(s.pages.first == s.pages.last); +} + +test "PageList erase reaccounts page size" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + const start_size = s.page_size; + + // Grow so we take up at least 5 pages. + const page = &s.pages.last.?.data; + for (0..page.capacity.rows * 5) |_| { + _ = try s.grow(); + } + try testing.expect(s.page_size > start_size); + + // Erase the entire history, we should be back to just our active set. + s.eraseRows(.{ .history = .{} }, null); + try testing.expectEqual(start_size, s.page_size); +} + +test "PageList erase row with tracked pin resets to top-left" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Grow so we take up at least 5 pages. + const page = &s.pages.last.?.data; + for (0..page.capacity.rows * 5) |_| { + _ = try s.grow(); + } + + // Our total rows should be large + try testing.expect(s.totalRows() > s.rows); + + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .history = .{} }).?); + defer s.untrackPin(p); + + // Erase the entire history, we should be back to just our active set. + s.eraseRows(.{ .history = .{} }, null); + try testing.expectEqual(s.rows, s.totalRows()); + + // Our pin should move to the first page + try testing.expectEqual(s.pages.first.?, p.page); + try testing.expectEqual(@as(usize, 0), p.y); + try testing.expectEqual(@as(usize, 0), p.x); +} + +test "PageList erase row with tracked pin shifts" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .y = 4, .x = 2 } }).?); + defer s.untrackPin(p); + + // Erase only a few rows in our active + s.eraseRows(.{ .active = .{} }, .{ .active = .{ .y = 3 } }); + try testing.expectEqual(s.rows, s.totalRows()); + + // Our pin should move to the first page + try testing.expectEqual(s.pages.first.?, p.page); + try testing.expectEqual(@as(usize, 0), p.y); + try testing.expectEqual(@as(usize, 2), p.x); +} + +test "PageList erase row with tracked pin is erased" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .y = 2, .x = 2 } }).?); + defer s.untrackPin(p); + + // Erase the entire history, we should be back to just our active set. + s.eraseRows(.{ .active = .{} }, .{ .active = .{ .y = 3 } }); + try testing.expectEqual(s.rows, s.totalRows()); + + // Our pin should move to the first page + try testing.expectEqual(s.pages.first.?, p.page); + try testing.expectEqual(@as(usize, 0), p.y); + try testing.expectEqual(@as(usize, 0), p.x); +} + +test "PageList erase resets viewport to active if moves within active" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Grow so we take up at least 5 pages. + const page = &s.pages.last.?.data; + for (0..page.capacity.rows * 5) |_| { + _ = try s.grow(); + } + + // Move our viewport to the top + s.scroll(.{ .delta_row = -@as(isize, @intCast(s.totalRows())) }); + try testing.expect(s.viewport == .pin); + try testing.expect(s.viewport_pin.page == s.pages.first.?); + + // Erase the entire history, we should be back to just our active set. + s.eraseRows(.{ .history = .{} }, null); + try testing.expect(s.viewport == .active); +} + +test "PageList erase resets viewport if inside erased page but not active" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Grow so we take up at least 5 pages. + const page = &s.pages.last.?.data; + for (0..page.capacity.rows * 5) |_| { + _ = try s.grow(); + } + + // Move our viewport to the top + s.scroll(.{ .delta_row = -@as(isize, @intCast(s.totalRows())) }); + try testing.expect(s.viewport == .pin); + try testing.expect(s.viewport_pin.page == s.pages.first.?); + + // Erase the entire history, we should be back to just our active set. + s.eraseRows(.{ .history = .{} }, .{ .history = .{ .y = 2 } }); + try testing.expect(s.viewport == .pin); + try testing.expect(s.viewport_pin.page == s.pages.first.?); +} + +test "PageList erase resets viewport to active if top is inside active" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Grow so we take up at least 5 pages. + const page = &s.pages.last.?.data; + for (0..page.capacity.rows * 5) |_| { + _ = try s.grow(); + } + + // Move our viewport to the top + s.scroll(.{ .top = {} }); + + // Erase the entire history, we should be back to just our active set. + s.eraseRows(.{ .history = .{} }, null); + try testing.expect(s.viewport == .active); +} + +test "PageList erase active regrows automatically" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try testing.expect(s.totalRows() == s.rows); + s.eraseRows(.{ .active = .{} }, .{ .active = .{ .y = 10 } }); + try testing.expect(s.totalRows() == s.rows); +} + +test "PageList erase a one-row active" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 1, null); + defer s.deinit(); + try testing.expectEqual(@as(usize, 1), s.totalPages()); + + // Write our letter + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + const rac = page.getRowAndCell(0, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + } + + s.eraseRows(.{ .active = .{} }, .{ .active = .{} }); + try testing.expectEqual(s.rows, s.totalRows()); + + // The row should be empty + { + const get = s.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + try testing.expectEqual(@as(u21, 0), get.cell.content.codepoint); + } +} + +test "PageList clone" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try testing.expectEqual(@as(usize, s.rows), s.totalRows()); + + var s2 = try s.clone(.{ + .top = .{ .screen = .{} }, + .memory = .{ .alloc = alloc }, + }); + defer s2.deinit(); + try testing.expectEqual(@as(usize, s.rows), s2.totalRows()); +} + +test "PageList clone partial trimmed right" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 20, null); + defer s.deinit(); + try testing.expectEqual(@as(usize, s.rows), s.totalRows()); + try s.growRows(30); + + var s2 = try s.clone(.{ + .top = .{ .screen = .{} }, + .bot = .{ .screen = .{ .y = 39 } }, + .memory = .{ .alloc = alloc }, + }); + defer s2.deinit(); + try testing.expectEqual(@as(usize, 40), s2.totalRows()); +} + +test "PageList clone partial trimmed left" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 20, null); + defer s.deinit(); + try testing.expectEqual(@as(usize, s.rows), s.totalRows()); + try s.growRows(30); + + var s2 = try s.clone(.{ + .top = .{ .screen = .{ .y = 10 } }, + .memory = .{ .alloc = alloc }, + }); + defer s2.deinit(); + try testing.expectEqual(@as(usize, 40), s2.totalRows()); +} + +test "PageList clone partial trimmed both" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 20, null); + defer s.deinit(); + try testing.expectEqual(@as(usize, s.rows), s.totalRows()); + try s.growRows(30); + + var s2 = try s.clone(.{ + .top = .{ .screen = .{ .y = 10 } }, + .bot = .{ .screen = .{ .y = 35 } }, + .memory = .{ .alloc = alloc }, + }); + defer s2.deinit(); + try testing.expectEqual(@as(usize, 26), s2.totalRows()); +} + +test "PageList clone less than active" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try testing.expectEqual(@as(usize, s.rows), s.totalRows()); + + var s2 = try s.clone(.{ + .top = .{ .active = .{ .y = 5 } }, + .memory = .{ .alloc = alloc }, + }); + defer s2.deinit(); + try testing.expectEqual(@as(usize, s.rows), s2.totalRows()); +} + +test "PageList clone remap tracked pin" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try testing.expectEqual(@as(usize, s.rows), s.totalRows()); + + // Put a tracked pin in the screen + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 0, .y = 6 } }).?); + defer s.untrackPin(p); + + var pin_remap = Clone.TrackedPinsRemap.init(alloc); + defer pin_remap.deinit(); + var s2 = try s.clone(.{ + .top = .{ .active = .{ .y = 5 } }, + .memory = .{ .alloc = alloc }, + .tracked_pins = &pin_remap, + }); + defer s2.deinit(); + + // We should be able to find our tracked pin + const p2 = pin_remap.get(p).?; + try testing.expectEqual( + point.Point{ .active = .{ .x = 0, .y = 1 } }, + s2.pointFromPin(.active, p2.*).?, + ); +} + +test "PageList clone remap tracked pin not in cloned area" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try testing.expectEqual(@as(usize, s.rows), s.totalRows()); + + // Put a tracked pin in the screen + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 0, .y = 3 } }).?); + defer s.untrackPin(p); + + var pin_remap = Clone.TrackedPinsRemap.init(alloc); + defer pin_remap.deinit(); + var s2 = try s.clone(.{ + .top = .{ .active = .{ .y = 5 } }, + .memory = .{ .alloc = alloc }, + .tracked_pins = &pin_remap, + }); + defer s2.deinit(); + + // We should be able to find our tracked pin + try testing.expect(pin_remap.get(p) == null); +} + +test "PageList resize (no reflow) more rows" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 3, 0); + defer s.deinit(); + try testing.expectEqual(@as(usize, 3), s.totalRows()); + + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 0, .y = 2 } }).?); + defer s.untrackPin(p); + + // Resize + try s.resize(.{ .rows = 10, .reflow = false }); + try testing.expectEqual(@as(usize, 10), s.rows); + try testing.expectEqual(@as(usize, 10), s.totalRows()); + + // Our cursor should not move because we have no scrollback so + // we just grew. + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 2, + } }, s.pointFromPin(.active, p.*).?); + + { + const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, pt); + } +} + +test "PageList resize (no reflow) more rows with history" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 3, null); + defer s.deinit(); + try s.growRows(50); + { + const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 50, + } }, pt); + } + + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 0, .y = 2 } }).?); + defer s.untrackPin(p); + + // Resize + try s.resize(.{ .rows = 5, .reflow = false }); + try testing.expectEqual(@as(usize, 5), s.rows); + try testing.expectEqual(@as(usize, 53), s.totalRows()); + + // Our cursor should move since it's in the scrollback + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 4, + } }, s.pointFromPin(.active, p.*).?); + + { + const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 48, + } }, pt); + } +} + +test "PageList resize (no reflow) less rows" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + try testing.expectEqual(@as(usize, 10), s.totalRows()); + + // This is required for our writing below to work + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Write into all rows so we don't get trim behavior + for (0..s.rows) |y| { + const rac = page.getRowAndCell(0, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + } + + // Resize + try s.resize(.{ .rows = 5, .reflow = false }); + try testing.expectEqual(@as(usize, 5), s.rows); + try testing.expectEqual(@as(usize, 10), s.totalRows()); + { + const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 5, + } }, pt); + } +} + +test "PageList resize (no reflow) one rows" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + try testing.expectEqual(@as(usize, 10), s.totalRows()); + + // This is required for our writing below to work + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Write into all rows so we don't get trim behavior + for (0..s.rows) |y| { + const rac = page.getRowAndCell(0, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + } + + // Resize + try s.resize(.{ .rows = 1, .reflow = false }); + try testing.expectEqual(@as(usize, 1), s.rows); + try testing.expectEqual(@as(usize, 10), s.totalRows()); + { + const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 9, + } }, pt); + } +} + +test "PageList resize (no reflow) less rows cursor on bottom" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + try testing.expectEqual(@as(usize, 10), s.totalRows()); + + // This is required for our writing below to work + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Write into all rows so we don't get trim behavior + for (0..s.rows) |y| { + const rac = page.getRowAndCell(0, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(y) }, + }; + } + + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 0, .y = 9 } }).?); + defer s.untrackPin(p); + { + const cursor = s.pointFromPin(.active, p.*).?.active; + const get = s.getCell(.{ .active = .{ + .x = cursor.x, + .y = cursor.y, + } }).?; + try testing.expectEqual(@as(u21, 9), get.cell.content.codepoint); + } + + // Resize + try s.resize(.{ .rows = 5, .reflow = false }); + try testing.expectEqual(@as(usize, 5), s.rows); + try testing.expectEqual(@as(usize, 10), s.totalRows()); + + // Our cursor should move since it's in the scrollback + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 4, + } }, s.pointFromPin(.active, p.*).?); + + { + const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 5, + } }, pt); + } +} +test "PageList resize (no reflow) less rows cursor in scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + try testing.expectEqual(@as(usize, 10), s.totalRows()); + + // This is required for our writing below to work + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Write into all rows so we don't get trim behavior + for (0..s.rows) |y| { + const rac = page.getRowAndCell(0, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(y) }, + }; + } + + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 0, .y = 2 } }).?); + defer s.untrackPin(p); + { + const cursor = s.pointFromPin(.active, p.*).?.active; + const get = s.getCell(.{ .active = .{ + .x = cursor.x, + .y = cursor.y, + } }).?; + try testing.expectEqual(@as(u21, 2), get.cell.content.codepoint); + } + + // Resize + try s.resize(.{ .rows = 5, .reflow = false }); + try testing.expectEqual(@as(usize, 5), s.rows); + try testing.expectEqual(@as(usize, 10), s.totalRows()); + + // Our cursor should move since it's in the scrollback + try testing.expect(s.pointFromPin(.active, p.*) == null); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, s.pointFromPin(.screen, p.*).?); + + { + const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 5, + } }, pt); + } +} + +test "PageList resize (no reflow) less rows trims blank lines" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 5, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Write codepoint into first line + { + const rac = page.getRowAndCell(0, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + } + + // Fill remaining lines with a background color + for (1..s.rows) |y| { + const rac = page.getRowAndCell(0, y); + rac.cell.* = .{ + .content_tag = .bg_color_rgb, + .content = .{ .color_rgb = .{ .r = 0xFF, .g = 0, .b = 0 } }, + }; + } + + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 0, .y = 0 } }).?); + defer s.untrackPin(p); + { + const cursor = s.pointFromPin(.active, p.*).?.active; + const get = s.getCell(.{ .active = .{ + .x = cursor.x, + .y = cursor.y, + } }).?; + try testing.expectEqual(@as(u21, 'A'), get.cell.content.codepoint); + } + + // Resize + try s.resize(.{ .rows = 2, .reflow = false }); + try testing.expectEqual(@as(usize, 2), s.rows); + try testing.expectEqual(@as(usize, 2), s.totalRows()); + + // Our cursor should not move since we trimmed + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 0, + } }, s.pointFromPin(.active, p.*).?); + + { + const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, pt); + } +} + +test "PageList resize (no reflow) less rows trims blank lines cursor in blank line" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 5, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Write codepoint into first line + { + const rac = page.getRowAndCell(0, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + } + + // Fill remaining lines with a background color + for (1..s.rows) |y| { + const rac = page.getRowAndCell(0, y); + rac.cell.* = .{ + .content_tag = .bg_color_rgb, + .content = .{ .color_rgb = .{ .r = 0xFF, .g = 0, .b = 0 } }, + }; + } + + // Put a tracked pin in a blank line + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 0, .y = 3 } }).?); + defer s.untrackPin(p); + + // Resize + try s.resize(.{ .rows = 2, .reflow = false }); + try testing.expectEqual(@as(usize, 2), s.rows); + try testing.expectEqual(@as(usize, 4), s.totalRows()); + + // Our cursor should not move since we trimmed + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 1, + } }, s.pointFromPin(.active, p.*).?); +} + +test "PageList resize (no reflow) less rows trims blank lines erases pages" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 100, 5, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Resize to take up two pages + { + const rows = page.capacity.rows + 10; + try s.resize(.{ .rows = rows, .reflow = false }); + try testing.expectEqual(@as(usize, 2), s.totalPages()); + } + + // Write codepoint into first line + { + const rac = page.getRowAndCell(0, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + } + + // Resize down. Every row except the first is blank so we + // should erase the second page. + try s.resize(.{ .rows = 5, .reflow = false }); + try testing.expectEqual(@as(usize, 5), s.rows); + try testing.expectEqual(@as(usize, 5), s.totalRows()); + try testing.expectEqual(@as(usize, 1), s.totalPages()); +} + +test "PageList resize (no reflow) more rows extends blank lines" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 3, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Write codepoint into first line + { + const rac = page.getRowAndCell(0, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + } + + // Fill remaining lines with a background color + for (1..s.rows) |y| { + const rac = page.getRowAndCell(0, y); + rac.cell.* = .{ + .content_tag = .bg_color_rgb, + .content = .{ .color_rgb = .{ .r = 0xFF, .g = 0, .b = 0 } }, + }; + } + + // Resize + try s.resize(.{ .rows = 7, .reflow = false }); + try testing.expectEqual(@as(usize, 7), s.rows); + try testing.expectEqual(@as(usize, 7), s.totalRows()); + { + const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, pt); + } +} + +test "PageList resize (no reflow) less cols" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + + // Resize + try s.resize(.{ .cols = 5, .reflow = false }); + try testing.expectEqual(@as(usize, 5), s.cols); + try testing.expectEqual(@as(usize, 10), s.totalRows()); + + var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); + while (it.next()) |offset| { + const rac = offset.rowAndCell(); + const cells = offset.page.data.getCells(rac.row); + try testing.expectEqual(@as(usize, 5), cells.len); + } +} + +test "PageList resize (no reflow) less cols pin in trimmed cols" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 8, .y = 2 } }).?); + defer s.untrackPin(p); + + // Resize + try s.resize(.{ .cols = 5, .reflow = false }); + try testing.expectEqual(@as(usize, 5), s.cols); + try testing.expectEqual(@as(usize, 10), s.totalRows()); + + var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); + while (it.next()) |offset| { + const rac = offset.rowAndCell(); + const cells = offset.page.data.getCells(rac.row); + try testing.expectEqual(@as(usize, 5), cells.len); + } + + try testing.expectEqual(point.Point{ .active = .{ + .x = 4, + .y = 2, + } }, s.pointFromPin(.active, p.*).?); +} + +test "PageList resize (no reflow) less cols clears graphemes" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + + // Add a grapheme. + const page = &s.pages.first.?.data; + { + const rac = page.getRowAndCell(9, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + try page.appendGrapheme(rac.row, rac.cell, 'A'); + } + try testing.expectEqual(@as(usize, 1), page.graphemeCount()); + + // Resize + try s.resize(.{ .cols = 5, .reflow = false }); + try testing.expectEqual(@as(usize, 5), s.cols); + try testing.expectEqual(@as(usize, 10), s.totalRows()); + + var it = s.pageIterator(.right_down, .{ .screen = .{} }, null); + while (it.next()) |chunk| { + try testing.expectEqual(@as(usize, 0), chunk.page.data.graphemeCount()); + } +} + +test "PageList resize (no reflow) more cols" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, 0); + defer s.deinit(); + + // Resize + try s.resize(.{ .cols = 10, .reflow = false }); + try testing.expectEqual(@as(usize, 10), s.cols); + try testing.expectEqual(@as(usize, 3), s.totalRows()); + + var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); + while (it.next()) |offset| { + const rac = offset.rowAndCell(); + const cells = offset.page.data.getCells(rac.row); + try testing.expectEqual(@as(usize, 10), cells.len); + } +} + +test "PageList resize (no reflow) more cols with spacer head" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 3, 0); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + { + const rac = page.getRowAndCell(0, 0); + rac.row.wrap = true; + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'x' }, + }; + } + { + const rac = page.getRowAndCell(1, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = ' ' }, + .wide = .spacer_head, + }; + } + { + const rac = page.getRowAndCell(0, 1); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '😀' }, + .wide = .wide, + }; + } + { + const rac = page.getRowAndCell(1, 1); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = ' ' }, + .wide = .spacer_tail, + }; + } + } + + // Resize + try s.resize(.{ .cols = 3, .reflow = false }); + try testing.expectEqual(@as(usize, 3), s.cols); + try testing.expectEqual(@as(usize, 3), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + { + const rac = page.getRowAndCell(0, 0); + try testing.expectEqual(@as(u21, 'x'), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide); + // try testing.expect(!rac.row.wrap); + } + { + const rac = page.getRowAndCell(1, 0); + try testing.expectEqual(@as(u21, ' '), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide); + } + { + const rac = page.getRowAndCell(2, 0); + try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide); + } + } +} + +// This test is a bit convoluted so I want to explain: what we are trying +// to verify here is that when we increase cols such that our rows per page +// shrinks, we don't fragment our rows across many pages because this ends +// up wasting a lot of memory. +// +// This is particularly important for alternate screen buffers where we +// don't have scrollback so our max size is very small. If we don't do this, +// we end up pruning our pages and that causes resizes to fail! +test "PageList resize (no reflow) more cols forces less rows per page" { + const testing = std.testing; + const alloc = testing.allocator; + + // This test requires initially that our rows fit into one page. + const cols: size.CellCountInt = 5; + const rows: size.CellCountInt = 150; + try testing.expect((try std_capacity.adjust(.{ .cols = cols })).rows >= rows); + var s = try init(alloc, cols, rows, 0); + defer s.deinit(); + + // Then we need to resize our cols so that our rows per page shrinks. + // This will force our resize to split our rows across two pages. + { + const new_cols = new_cols: { + var new_cols: size.CellCountInt = 50; + var cap = try std_capacity.adjust(.{ .cols = new_cols }); + while (cap.rows >= rows) { + new_cols += 50; + cap = try std_capacity.adjust(.{ .cols = new_cols }); + } + + break :new_cols new_cols; + }; + try s.resize(.{ .cols = new_cols, .reflow = false }); + try testing.expectEqual(@as(usize, new_cols), s.cols); + try testing.expectEqual(@as(usize, rows), s.totalRows()); + } + + // Every page except the last should be full + { + var it = s.pages.first; + while (it) |page| : (it = page.next) { + if (page == s.pages.last.?) break; + try testing.expectEqual(page.data.capacity.rows, page.data.size.rows); + } + } + + // Now we need to resize again to a col size that further shrinks + // our last capacity. + { + const page = &s.pages.first.?.data; + try testing.expect(page.size.rows == page.capacity.rows); + const new_cols = new_cols: { + var new_cols = page.size.cols + 50; + var cap = try std_capacity.adjust(.{ .cols = new_cols }); + while (cap.rows >= page.size.rows) { + new_cols += 50; + cap = try std_capacity.adjust(.{ .cols = new_cols }); + } + + break :new_cols new_cols; + }; + + try s.resize(.{ .cols = new_cols, .reflow = false }); + try testing.expectEqual(@as(usize, new_cols), s.cols); + try testing.expectEqual(@as(usize, rows), s.totalRows()); + } + + // Every page except the last should be full + { + var it = s.pages.first; + while (it) |page| : (it = page.next) { + if (page == s.pages.last.?) break; + try testing.expectEqual(page.data.capacity.rows, page.data.size.rows); + } + } +} + +test "PageList resize (no reflow) less cols then more cols" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, 0); + defer s.deinit(); + + // Resize less + try s.resize(.{ .cols = 2, .reflow = false }); + try testing.expectEqual(@as(usize, 2), s.cols); + + // Resize + try s.resize(.{ .cols = 5, .reflow = false }); + try testing.expectEqual(@as(usize, 5), s.cols); + try testing.expectEqual(@as(usize, 3), s.totalRows()); + + var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); + while (it.next()) |offset| { + const rac = offset.rowAndCell(); + const cells = offset.page.data.getCells(rac.row); + try testing.expectEqual(@as(usize, 5), cells.len); + } +} + +test "PageList resize (no reflow) less rows and cols" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + + // Resize less + try s.resize(.{ .cols = 5, .rows = 7, .reflow = false }); + try testing.expectEqual(@as(usize, 5), s.cols); + try testing.expectEqual(@as(usize, 7), s.rows); + + var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); + while (it.next()) |offset| { + const rac = offset.rowAndCell(); + const cells = offset.page.data.getCells(rac.row); + try testing.expectEqual(@as(usize, 5), cells.len); + } +} + +test "PageList resize (no reflow) more rows and less cols" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + + // Resize less + try s.resize(.{ .cols = 5, .rows = 20, .reflow = false }); + try testing.expectEqual(@as(usize, 5), s.cols); + try testing.expectEqual(@as(usize, 20), s.rows); + try testing.expectEqual(@as(usize, 20), s.totalRows()); + + var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); + while (it.next()) |offset| { + const rac = offset.rowAndCell(); + const cells = offset.page.data.getCells(rac.row); + try testing.expectEqual(@as(usize, 5), cells.len); + } +} + +test "PageList resize more rows and cols doesn't fit in single std page" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + + // Resize to a size that requires more than one page to fit our rows. + const new_cols = 600; + const new_rows = 600; + const cap = try std_capacity.adjust(.{ .cols = new_cols }); + try testing.expect(cap.rows < new_rows); + + try s.resize(.{ .cols = new_cols, .rows = new_rows, .reflow = true }); + try testing.expectEqual(@as(usize, new_cols), s.cols); + try testing.expectEqual(@as(usize, new_rows), s.rows); + try testing.expectEqual(@as(usize, new_rows), s.totalRows()); +} + +test "PageList resize (no reflow) empty screen" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 5, 0); + defer s.deinit(); + + // Resize + try s.resize(.{ .cols = 10, .rows = 10, .reflow = false }); + try testing.expectEqual(@as(usize, 10), s.cols); + try testing.expectEqual(@as(usize, 10), s.rows); + try testing.expectEqual(@as(usize, 10), s.totalRows()); + + var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); + while (it.next()) |offset| { + const rac = offset.rowAndCell(); + const cells = offset.page.data.getCells(rac.row); + try testing.expectEqual(@as(usize, 10), cells.len); + } +} + +test "PageList resize (no reflow) more cols forces smaller cap" { + const testing = std.testing; + const alloc = testing.allocator; + + // We want a cap that forces us to have less rows + const cap = try std_capacity.adjust(.{ .cols = 100 }); + const cap2 = try std_capacity.adjust(.{ .cols = 500 }); + try testing.expectEqual(@as(size.CellCountInt, 500), cap2.cols); + try testing.expect(cap2.rows < cap.rows); + + // Create initial cap, fits in one page + var s = try init(alloc, cap.cols, cap.rows, null); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + } + } + + // Resize to our large cap + const rows = s.totalRows(); + try s.resize(.{ .cols = cap2.cols, .reflow = false }); + + // Our total rows should be the same, and contents should be the same. + try testing.expectEqual(rows, s.totalRows()); + var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); + while (it.next()) |offset| { + const rac = offset.rowAndCell(); + const cells = offset.page.data.getCells(rac.row); + try testing.expectEqual(@as(usize, cap2.cols), cells.len); + try testing.expectEqual(@as(u21, 'A'), cells[0].content.codepoint); + } +} + +test "PageList resize (no reflow) more rows adds blank rows if cursor at bottom" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, null); + defer s.deinit(); + + // Grow to 5 total rows, simulating 3 active + 2 scrollback + try s.growRows(2); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.totalRows()) |y| { + const rac = page.getRowAndCell(0, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(y) }, + }; + } + + // Active should be on row 3 + { + const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, pt); + } + + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 0, .y = s.rows - 2 } }).?); + defer s.untrackPin(p); + const original_cursor = s.pointFromPin(.active, p.*).?.active; + { + const get = s.getCell(.{ .active = .{ + .x = original_cursor.x, + .y = original_cursor.y, + } }).?; + try testing.expectEqual(@as(u21, 3), get.cell.content.codepoint); + } + + // Resize + try s.resizeWithoutReflow(.{ + .rows = 10, + .reflow = false, + .cursor = .{ .x = 0, .y = s.rows - 2 }, + }); + try testing.expectEqual(@as(usize, 5), s.cols); + try testing.expectEqual(@as(usize, 10), s.rows); + + // Our cursor should not change + try testing.expectEqual(original_cursor, s.pointFromPin(.active, p.*).?.active); + + // 12 because we have our 10 rows in the active + 2 in the scrollback + // because we're preserving the cursor. + try testing.expectEqual(@as(usize, 12), s.totalRows()); + + // Active should be at the same place it was. + { + const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, pt); + } + + // Go through our active, we should get only 3,4,5 + for (0..3) |y| { + const get = s.getCell(.{ .active = .{ .y = y } }).?; + const expected: u21 = @intCast(y + 2); + try testing.expectEqual(expected, get.cell.content.codepoint); + } +} + +test "PageList resize reflow more cols no wrapped rows" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + } + } + + // Resize + try s.resize(.{ .cols = 10, .reflow = true }); + try testing.expectEqual(@as(usize, 10), s.cols); + try testing.expectEqual(@as(usize, 3), s.totalRows()); + + var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); + while (it.next()) |offset| { + const rac = offset.rowAndCell(); + const cells = offset.page.data.getCells(rac.row); + try testing.expectEqual(@as(usize, 10), cells.len); + try testing.expectEqual(@as(u21, 'A'), cells[0].content.codepoint); + } +} + +test "PageList resize reflow more cols wrapped rows" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 4, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + if (y % 2 == 0) { + const rac = page.getRowAndCell(0, y); + rac.row.wrap = true; + } else { + const rac = page.getRowAndCell(0, y); + rac.row.wrap_continuation = true; + } + + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + } + } + + // Resize + try s.resize(.{ .cols = 4, .reflow = true }); + try testing.expectEqual(@as(usize, 4), s.cols); + try testing.expectEqual(@as(usize, 4), s.totalRows()); + + // Active should still be on top + { + const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, pt); + } + + var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); + { + // First row should be unwrapped + const offset = it.next().?; + const rac = offset.rowAndCell(); + const cells = offset.page.data.getCells(rac.row); + try testing.expect(!rac.row.wrap); + try testing.expectEqual(@as(usize, 4), cells.len); + try testing.expectEqual(@as(u21, 'A'), cells[0].content.codepoint); + try testing.expectEqual(@as(u21, 'A'), cells[2].content.codepoint); + } +} + +test "PageList resize reflow more cols wrap across page boundary" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 10, 0); + defer s.deinit(); + try testing.expectEqual(@as(usize, 1), s.totalPages()); + + // Grow to the capacity of the first page. + { + const page = &s.pages.first.?.data; + for (page.size.rows..page.capacity.rows) |_| { + _ = try s.grow(); + } + try testing.expectEqual(@as(usize, 1), s.totalPages()); + try s.growRows(1); + try testing.expectEqual(@as(usize, 2), s.totalPages()); + } + + // At this point, we have some rows on the first page, and some on the second. + // We can now wrap across the boundary condition. + { + const page = &s.pages.first.?.data; + const y = page.size.rows - 1; + { + const rac = page.getRowAndCell(0, y); + rac.row.wrap = true; + } + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + { + const page2 = &s.pages.last.?.data; + const y = 0; + { + const rac = page2.getRowAndCell(0, y); + rac.row.wrap_continuation = true; + } + for (0..s.cols) |x| { + const rac = page2.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + // We expect one extra row since we unwrapped a row we need to resize + // to make our active area. + const end_rows = s.totalRows(); + + // Resize + try s.resize(.{ .cols = 4, .reflow = true }); + try testing.expectEqual(@as(usize, 4), s.cols); + try testing.expectEqual(@as(usize, end_rows), s.totalRows()); + + { + const p = s.pin(.{ .active = .{ .y = 9 } }).?; + const row = p.rowAndCell().row; + try testing.expect(!row.wrap); + + const cells = p.cells(.all); + try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); + try testing.expectEqual(@as(u21, 1), cells[1].content.codepoint); + try testing.expectEqual(@as(u21, 0), cells[2].content.codepoint); + try testing.expectEqual(@as(u21, 1), cells[3].content.codepoint); + } +} + +test "PageList resize reflow more cols wrap across page boundary cursor in second page" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 10, 0); + defer s.deinit(); + try testing.expectEqual(@as(usize, 1), s.totalPages()); + + // Grow to the capacity of the first page. + { + const page = &s.pages.first.?.data; + for (page.size.rows..page.capacity.rows) |_| { + _ = try s.grow(); + } + try testing.expectEqual(@as(usize, 1), s.totalPages()); + try s.growRows(1); + try testing.expectEqual(@as(usize, 2), s.totalPages()); + } + + // At this point, we have some rows on the first page, and some on the second. + // We can now wrap across the boundary condition. + { + const page = &s.pages.first.?.data; + const y = page.size.rows - 1; + { + const rac = page.getRowAndCell(0, y); + rac.row.wrap = true; + } + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + { + const page2 = &s.pages.last.?.data; + const y = 0; + { + const rac = page2.getRowAndCell(0, y); + rac.row.wrap_continuation = true; + } + for (0..s.cols) |x| { + const rac = page2.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + // Put a tracked pin in wrapped row on the last page + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 1, .y = 9 } }).?); + defer s.untrackPin(p); + try testing.expect(p.page == s.pages.last.?); + + // We expect one extra row since we unwrapped a row we need to resize + // to make our active area. + const end_rows = s.totalRows(); + + // Resize + try s.resize(.{ .cols = 4, .reflow = true }); + try testing.expectEqual(@as(usize, 4), s.cols); + try testing.expectEqual(@as(usize, end_rows), s.totalRows()); + + // Our cursor should move to the first row + try testing.expectEqual(point.Point{ .active = .{ + .x = 3, + .y = 9, + } }, s.pointFromPin(.active, p.*).?); + + { + const p2 = s.pin(.{ .active = .{ .y = 9 } }).?; + const row = p2.rowAndCell().row; + try testing.expect(!row.wrap); + + const cells = p2.cells(.all); + try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); + try testing.expectEqual(@as(u21, 1), cells[1].content.codepoint); + try testing.expectEqual(@as(u21, 0), cells[2].content.codepoint); + try testing.expectEqual(@as(u21, 1), cells[3].content.codepoint); + } +} + +test "PageList resize reflow less cols wrap across page boundary cursor in second page" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 10, null); + defer s.deinit(); + try testing.expectEqual(@as(usize, 1), s.totalPages()); + + // Grow to the capacity of the first page. + { + const page = &s.pages.first.?.data; + for (page.size.rows..page.capacity.rows) |_| { + _ = try s.grow(); + } + try testing.expectEqual(@as(usize, 1), s.totalPages()); + try s.growRows(5); + try testing.expectEqual(@as(usize, 2), s.totalPages()); + } + + // At this point, we have some rows on the first page, and some on the second. + // We can now wrap across the boundary condition. + { + const page = &s.pages.first.?.data; + const y = page.size.rows - 1; + { + const rac = page.getRowAndCell(0, y); + rac.row.wrap = true; + } + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + { + const page2 = &s.pages.last.?.data; + const y = 0; + { + const rac = page2.getRowAndCell(0, y); + rac.row.wrap_continuation = true; + } + for (0..s.cols) |x| { + const rac = page2.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + // Put a tracked pin in wrapped row on the last page + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 2, .y = 5 } }).?); + defer s.untrackPin(p); + try testing.expect(p.page == s.pages.last.?); + try testing.expect(p.y == 0); + + // Resize + try s.resize(.{ + .cols = 4, + .reflow = true, + .cursor = .{ .x = 2, .y = 5 }, + }); + try testing.expectEqual(@as(usize, 4), s.cols); + + // Our cursor should remain on the same cell + try testing.expectEqual(point.Point{ .active = .{ + .x = 3, + .y = 6, + } }, s.pointFromPin(.active, p.*).?); + + { + const p2 = s.pin(.{ .active = .{ .y = 5 } }).?; + const row = p2.rowAndCell().row; + try testing.expect(row.wrap); + try testing.expect(!row.wrap_continuation); + + const cells = p2.cells(.all); + try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); + try testing.expectEqual(@as(u21, 1), cells[1].content.codepoint); + try testing.expectEqual(@as(u21, 2), cells[2].content.codepoint); + try testing.expectEqual(@as(u21, 3), cells[3].content.codepoint); + } + { + const p2 = s.pin(.{ .active = .{ .y = 6 } }).?; + const row = p2.rowAndCell().row; + try testing.expect(row.wrap); + try testing.expect(row.wrap_continuation); + + const cells = p2.cells(.all); + try testing.expectEqual(@as(u21, 4), cells[0].content.codepoint); + try testing.expectEqual(@as(u21, 0), cells[1].content.codepoint); + try testing.expectEqual(@as(u21, 1), cells[2].content.codepoint); + try testing.expectEqual(@as(u21, 2), cells[3].content.codepoint); + } + { + const p2 = s.pin(.{ .active = .{ .y = 7 } }).?; + const row = p2.rowAndCell().row; + try testing.expect(!row.wrap); + try testing.expect(row.wrap_continuation); + + const cells = p2.cells(.all); + try testing.expectEqual(@as(u21, 3), cells[0].content.codepoint); + try testing.expectEqual(@as(u21, 4), cells[1].content.codepoint); + } +} +test "PageList resize reflow more cols cursor in wrapped row" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 4, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + { + { + const rac = page.getRowAndCell(0, 0); + rac.row.wrap = true; + } + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + { + { + const rac = page.getRowAndCell(0, 1); + rac.row.wrap_continuation = true; + } + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, 1); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 1, .y = 1 } }).?); + defer s.untrackPin(p); + + // Resize + try s.resize(.{ .cols = 4, .reflow = true }); + try testing.expectEqual(@as(usize, 4), s.cols); + try testing.expectEqual(@as(usize, 4), s.totalRows()); + + // Our cursor should move to the first row + try testing.expectEqual(point.Point{ .active = .{ + .x = 3, + .y = 0, + } }, s.pointFromPin(.active, p.*).?); +} + +test "PageList resize reflow more cols cursor in not wrapped row" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 4, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + { + { + const rac = page.getRowAndCell(0, 0); + rac.row.wrap = true; + } + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + { + { + const rac = page.getRowAndCell(0, 1); + rac.row.wrap_continuation = true; + } + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, 1); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 1, .y = 0 } }).?); + defer s.untrackPin(p); + + // Resize + try s.resize(.{ .cols = 4, .reflow = true }); + try testing.expectEqual(@as(usize, 4), s.cols); + try testing.expectEqual(@as(usize, 4), s.totalRows()); + + // Our cursor should move to the first row + try testing.expectEqual(point.Point{ .active = .{ + .x = 1, + .y = 0, + } }, s.pointFromPin(.active, p.*).?); +} + +test "PageList resize reflow more cols cursor in wrapped row that isn't unwrapped" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 4, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + { + { + const rac = page.getRowAndCell(0, 0); + rac.row.wrap = true; + } + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + { + { + const rac = page.getRowAndCell(0, 1); + rac.row.wrap = true; + rac.row.wrap_continuation = true; + } + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, 1); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + { + { + const rac = page.getRowAndCell(0, 2); + rac.row.wrap_continuation = true; + } + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, 2); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 1, .y = 2 } }).?); + defer s.untrackPin(p); + + // Resize + try s.resize(.{ .cols = 4, .reflow = true }); + try testing.expectEqual(@as(usize, 4), s.cols); + try testing.expectEqual(@as(usize, 4), s.totalRows()); + + // Our cursor should move to the first row + try testing.expectEqual(point.Point{ .active = .{ + .x = 1, + .y = 1, + } }, s.pointFromPin(.active, p.*).?); +} + +test "PageList resize reflow more cols no reflow preserves semantic prompt" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 4, 0); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + const rac = page.getRowAndCell(0, 1); + rac.row.semantic_prompt = .prompt; + } + + // Resize + try s.resize(.{ .cols = 4, .reflow = true }); + try testing.expectEqual(@as(usize, 4), s.cols); + try testing.expectEqual(@as(usize, 4), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + const rac = page.getRowAndCell(0, 1); + try testing.expect(rac.row.semantic_prompt == .prompt); + } +} + +test "PageList resize reflow more cols unwrap wide spacer head" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 2, 0); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + { + const rac = page.getRowAndCell(0, 0); + rac.row.wrap = true; + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'x' }, + }; + } + { + const rac = page.getRowAndCell(1, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = ' ' }, + .wide = .spacer_head, + }; + } + { + const rac = page.getRowAndCell(0, 1); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '😀' }, + .wide = .wide, + }; + } + { + const rac = page.getRowAndCell(1, 1); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = ' ' }, + .wide = .spacer_tail, + }; + } + } + + // Resize + try s.resize(.{ .cols = 4, .reflow = true }); + try testing.expectEqual(@as(usize, 4), s.cols); + try testing.expectEqual(@as(usize, 2), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + { + const rac = page.getRowAndCell(0, 0); + try testing.expectEqual(@as(u21, 'x'), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide); + try testing.expect(!rac.row.wrap); + } + { + const rac = page.getRowAndCell(1, 0); + try testing.expectEqual(@as(u21, '😀'), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.wide, rac.cell.wide); + } + { + const rac = page.getRowAndCell(2, 0); + try testing.expectEqual(@as(u21, ' '), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.spacer_tail, rac.cell.wide); + } + } +} + +test "PageList resize reflow more cols unwrap wide spacer head across two rows" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 3, 0); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + { + const rac = page.getRowAndCell(0, 0); + rac.row.wrap = true; + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'x' }, + }; + } + { + const rac = page.getRowAndCell(1, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'x' }, + }; + } + { + const rac = page.getRowAndCell(0, 1); + rac.row.wrap = true; + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'x' }, + }; + } + { + const rac = page.getRowAndCell(1, 1); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = ' ' }, + .wide = .spacer_head, + }; + } + { + const rac = page.getRowAndCell(0, 2); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '😀' }, + .wide = .wide, + }; + } + { + const rac = page.getRowAndCell(1, 2); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = ' ' }, + .wide = .spacer_tail, + }; + } + } + + // Resize + try s.resize(.{ .cols = 4, .reflow = true }); + try testing.expectEqual(@as(usize, 4), s.cols); + try testing.expectEqual(@as(usize, 3), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + { + const rac = page.getRowAndCell(0, 0); + try testing.expectEqual(@as(u21, 'x'), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide); + try testing.expect(rac.row.wrap); + } + { + const rac = page.getRowAndCell(1, 0); + try testing.expectEqual(@as(u21, 'x'), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide); + } + { + const rac = page.getRowAndCell(2, 0); + try testing.expectEqual(@as(u21, 'x'), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide); + } + { + const rac = page.getRowAndCell(3, 0); + try testing.expectEqual(@as(u21, ' '), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.spacer_head, rac.cell.wide); + } + { + const rac = page.getRowAndCell(0, 1); + try testing.expectEqual(@as(u21, '😀'), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.wide, rac.cell.wide); + } + { + const rac = page.getRowAndCell(1, 1); + try testing.expectEqual(@as(u21, ' '), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.spacer_tail, rac.cell.wide); + } + } +} + +test "PageList resize reflow more cols unwrap still requires wide spacer head" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 2, 0); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + { + const rac = page.getRowAndCell(0, 0); + rac.row.wrap = true; + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'x' }, + }; + } + { + const rac = page.getRowAndCell(1, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'x' }, + }; + } + { + const rac = page.getRowAndCell(0, 1); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '😀' }, + .wide = .wide, + }; + } + { + const rac = page.getRowAndCell(1, 1); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = ' ' }, + .wide = .spacer_tail, + }; + } + } + + // Resize + try s.resize(.{ .cols = 3, .reflow = true }); + try testing.expectEqual(@as(usize, 3), s.cols); + try testing.expectEqual(@as(usize, 2), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + { + const rac = page.getRowAndCell(0, 0); + try testing.expectEqual(@as(u21, 'x'), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide); + try testing.expect(rac.row.wrap); + } + { + const rac = page.getRowAndCell(1, 0); + try testing.expectEqual(@as(u21, 'x'), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide); + } + { + const rac = page.getRowAndCell(2, 0); + try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.spacer_head, rac.cell.wide); + } + { + const rac = page.getRowAndCell(0, 1); + try testing.expectEqual(@as(u21, '😀'), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.wide, rac.cell.wide); + } + { + const rac = page.getRowAndCell(1, 1); + try testing.expectEqual(@as(u21, ' '), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.spacer_tail, rac.cell.wide); + } + } +} +test "PageList resize reflow less cols no reflow preserves semantic prompt" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 4, 0); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + { + const rac = page.getRowAndCell(0, 1); + rac.row.semantic_prompt = .prompt; + } + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, 1); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + // Resize + try s.resize(.{ .cols = 2, .reflow = true }); + try testing.expectEqual(@as(usize, 2), s.cols); + try testing.expectEqual(@as(usize, 4), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + { + const p = s.pin(.{ .active = .{ .y = 1 } }).?; + const rac = p.rowAndCell(); + try testing.expect(rac.row.wrap); + try testing.expect(rac.row.semantic_prompt == .prompt); + } + { + const p = s.pin(.{ .active = .{ .y = 2 } }).?; + const rac = p.rowAndCell(); + try testing.expect(rac.row.semantic_prompt == .prompt); + } + } +} + +test "PageList resize reflow less cols no reflow preserves semantic prompt on first line" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 4, 0); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + const rac = page.getRowAndCell(0, 0); + rac.row.semantic_prompt = .prompt; + } + + // Resize + try s.resize(.{ .cols = 2, .reflow = true }); + try testing.expectEqual(@as(usize, 2), s.cols); + try testing.expectEqual(@as(usize, 4), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + const rac = page.getRowAndCell(0, 0); + try testing.expect(rac.row.semantic_prompt == .prompt); + } +} + +test "PageList resize reflow less cols wrap preserves semantic prompt" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 4, 0); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + const rac = page.getRowAndCell(0, 0); + rac.row.semantic_prompt = .prompt; + } + + // Resize + try s.resize(.{ .cols = 2, .reflow = true }); + try testing.expectEqual(@as(usize, 2), s.cols); + try testing.expectEqual(@as(usize, 4), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + const rac = page.getRowAndCell(0, 0); + try testing.expect(rac.row.semantic_prompt == .prompt); + } +} + +test "PageList resize reflow less cols no wrapped rows" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 3, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + const end = 4; + assert(end < s.cols); + for (0..4) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + // Resize + try s.resize(.{ .cols = 5, .reflow = true }); + try testing.expectEqual(@as(usize, 5), s.cols); + try testing.expectEqual(@as(usize, 3), s.totalRows()); + + var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); + while (it.next()) |offset| { + for (0..4) |x| { + var offset_copy = offset; + offset_copy.x = x; + const rac = offset_copy.rowAndCell(); + const cells = offset.page.data.getCells(rac.row); + try testing.expectEqual(@as(usize, 5), cells.len); + try testing.expectEqual(@as(u21, @intCast(x)), cells[x].content.codepoint); + } + } +} + +test "PageList resize reflow less cols wrapped rows" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 2, null); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + // Resize + try s.resize(.{ .cols = 2, .reflow = true }); + try testing.expectEqual(@as(usize, 2), s.cols); + try testing.expectEqual(@as(usize, 4), s.totalRows()); + + // Active moves due to scrollback + { + const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, pt); + } + + var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); + { + // First row should be wrapped + const offset = it.next().?; + const rac = offset.rowAndCell(); + const cells = offset.page.data.getCells(rac.row); + try testing.expect(rac.row.wrap); + try testing.expectEqual(@as(usize, 2), cells.len); + try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); + } + { + const offset = it.next().?; + const rac = offset.rowAndCell(); + const cells = offset.page.data.getCells(rac.row); + try testing.expect(!rac.row.wrap); + try testing.expectEqual(@as(usize, 2), cells.len); + try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint); + } + { + // First row should be wrapped + const offset = it.next().?; + const rac = offset.rowAndCell(); + const cells = offset.page.data.getCells(rac.row); + try testing.expect(rac.row.wrap); + try testing.expectEqual(@as(usize, 2), cells.len); + try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); + } + { + const offset = it.next().?; + const rac = offset.rowAndCell(); + const cells = offset.page.data.getCells(rac.row); + try testing.expect(!rac.row.wrap); + try testing.expectEqual(@as(usize, 2), cells.len); + try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint); + } +} + +test "PageList resize reflow less cols wrapped rows with graphemes" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 2, null); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + + const rac = page.getRowAndCell(2, y); + try page.appendGrapheme(rac.row, rac.cell, 'A'); + } + } + + // Resize + try s.resize(.{ .cols = 2, .reflow = true }); + try testing.expectEqual(@as(usize, 2), s.cols); + try testing.expectEqual(@as(usize, 4), s.totalRows()); + + // Active moves due to scrollback + { + const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, pt); + } + + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); + { + // First row should be wrapped + const offset = it.next().?; + const rac = offset.rowAndCell(); + const cells = offset.page.data.getCells(rac.row); + try testing.expect(rac.row.wrap); + try testing.expectEqual(@as(usize, 2), cells.len); + try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); + } + { + const offset = it.next().?; + const rac = offset.rowAndCell(); + const cells = offset.page.data.getCells(rac.row); + try testing.expect(!rac.row.wrap); + try testing.expect(rac.row.grapheme); + try testing.expectEqual(@as(usize, 2), cells.len); + try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint); + + const cps = page.lookupGrapheme(rac.cell).?; + try testing.expectEqual(@as(usize, 1), cps.len); + try testing.expectEqual(@as(u21, 'A'), cps[0]); + } + { + // First row should be wrapped + const offset = it.next().?; + const rac = offset.rowAndCell(); + const cells = offset.page.data.getCells(rac.row); + try testing.expect(rac.row.wrap); + try testing.expectEqual(@as(usize, 2), cells.len); + try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); + } + { + const offset = it.next().?; + const rac = offset.rowAndCell(); + const cells = offset.page.data.getCells(rac.row); + try testing.expect(!rac.row.wrap); + try testing.expect(rac.row.grapheme); + try testing.expectEqual(@as(usize, 2), cells.len); + try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint); + + const cps = page.lookupGrapheme(rac.cell).?; + try testing.expectEqual(@as(usize, 1), cps.len); + try testing.expectEqual(@as(u21, 'A'), cps[0]); + } +} +test "PageList resize reflow less cols cursor in wrapped row" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 2, null); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 2, .y = 1 } }).?); + defer s.untrackPin(p); + + // Resize + try s.resize(.{ .cols = 2, .reflow = true }); + try testing.expectEqual(@as(usize, 2), s.cols); + try testing.expectEqual(@as(usize, 4), s.totalRows()); + + // Our cursor should move to the first row + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 1, + } }, s.pointFromPin(.active, p.*).?); +} + +test "PageList resize reflow less cols wraps spacer head" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 3, 0); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + { + const rac = page.getRowAndCell(0, 0); + rac.row.wrap = true; + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'x' }, + }; + } + { + const rac = page.getRowAndCell(1, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'x' }, + }; + } + { + const rac = page.getRowAndCell(2, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'x' }, + }; + } + { + const rac = page.getRowAndCell(3, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = ' ' }, + .wide = .spacer_head, + }; + } + { + const rac = page.getRowAndCell(0, 1); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '😀' }, + .wide = .wide, + }; + } + { + const rac = page.getRowAndCell(1, 1); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = ' ' }, + .wide = .spacer_tail, + }; + } + } + + // Resize + try s.resize(.{ .cols = 3, .reflow = true }); + try testing.expectEqual(@as(usize, 3), s.cols); + try testing.expectEqual(@as(usize, 3), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + { + const rac = page.getRowAndCell(0, 0); + try testing.expectEqual(@as(u21, 'x'), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide); + try testing.expect(rac.row.wrap); + } + { + const rac = page.getRowAndCell(1, 0); + try testing.expectEqual(@as(u21, 'x'), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide); + } + { + const rac = page.getRowAndCell(2, 0); + try testing.expectEqual(@as(u21, 'x'), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide); + } + { + const rac = page.getRowAndCell(0, 1); + try testing.expectEqual(@as(u21, '😀'), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.wide, rac.cell.wide); + } + { + const rac = page.getRowAndCell(1, 1); + try testing.expectEqual(@as(u21, ' '), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.spacer_tail, rac.cell.wide); + } + } +} +test "PageList resize reflow less cols cursor goes to scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 2, null); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 2, .y = 0 } }).?); + defer s.untrackPin(p); + + // Resize + try s.resize(.{ .cols = 2, .reflow = true }); + try testing.expectEqual(@as(usize, 2), s.cols); + try testing.expectEqual(@as(usize, 4), s.totalRows()); + + // Our cursor should move to the first row + try testing.expect(s.pointFromPin(.active, p.*) == null); +} + +test "PageList resize reflow less cols cursor in unchanged row" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 2, null); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + for (0..2) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 1, .y = 0 } }).?); + defer s.untrackPin(p); + + // Resize + try s.resize(.{ .cols = 2, .reflow = true }); + try testing.expectEqual(@as(usize, 2), s.cols); + try testing.expectEqual(@as(usize, 2), s.totalRows()); + + // Our cursor should move to the first row + try testing.expectEqual(point.Point{ .active = .{ + .x = 1, + .y = 0, + } }, s.pointFromPin(.active, p.*).?); +} + +test "PageList resize reflow less cols cursor in blank cell" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 6, 2, null); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + for (0..2) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 2, .y = 0 } }).?); + defer s.untrackPin(p); + + // Resize + try s.resize(.{ .cols = 4, .reflow = true }); + try testing.expectEqual(@as(usize, 4), s.cols); + try testing.expectEqual(@as(usize, 2), s.totalRows()); + + // Our cursor should not move + try testing.expectEqual(point.Point{ .active = .{ + .x = 2, + .y = 0, + } }, s.pointFromPin(.active, p.*).?); +} + +test "PageList resize reflow less cols cursor in final blank cell" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 6, 2, null); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + for (0..2) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 3, .y = 0 } }).?); + defer s.untrackPin(p); + + // Resize + try s.resize(.{ .cols = 4, .reflow = true }); + try testing.expectEqual(@as(usize, 4), s.cols); + try testing.expectEqual(@as(usize, 2), s.totalRows()); + + // Our cursor should move to the first row + try testing.expectEqual(point.Point{ .active = .{ + .x = 3, + .y = 0, + } }, s.pointFromPin(.active, p.*).?); +} + +test "PageList resize reflow less cols cursor in wrapped blank cell" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 6, 2, null); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + for (0..2) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 5, .y = 0 } }).?); + defer s.untrackPin(p); + + // Resize + try s.resize(.{ .cols = 4, .reflow = true }); + try testing.expectEqual(@as(usize, 4), s.cols); + try testing.expectEqual(@as(usize, 2), s.totalRows()); + + // Our cursor should move to the first row + try testing.expectEqual(point.Point{ .active = .{ + .x = 3, + .y = 0, + } }, s.pointFromPin(.active, p.*).?); +} + +test "PageList resize reflow less cols blank lines" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 3, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..1) |y| { + for (0..4) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + // Resize + try s.resize(.{ .cols = 2, .reflow = true }); + try testing.expectEqual(@as(usize, 2), s.cols); + try testing.expectEqual(@as(usize, 3), s.totalRows()); + + var it = s.rowIterator(.right_down, .{ .active = .{} }, null); + { + // First row should be wrapped + const offset = it.next().?; + const rac = offset.rowAndCell(); + const cells = offset.page.data.getCells(rac.row); + try testing.expect(rac.row.wrap); + try testing.expectEqual(@as(usize, 2), cells.len); + try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); + } + { + const offset = it.next().?; + const rac = offset.rowAndCell(); + const cells = offset.page.data.getCells(rac.row); + try testing.expect(!rac.row.wrap); + try testing.expectEqual(@as(usize, 2), cells.len); + try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint); + } +} + +test "PageList resize reflow less cols blank lines between" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 3, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + { + for (0..4) |x| { + const rac = page.getRowAndCell(x, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + { + for (0..4) |x| { + const rac = page.getRowAndCell(x, 2); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + // Resize + try s.resize(.{ .cols = 2, .reflow = true }); + try testing.expectEqual(@as(usize, 2), s.cols); + try testing.expectEqual(@as(usize, 5), s.totalRows()); + + var it = s.rowIterator(.right_down, .{ .active = .{} }, null); + { + const offset = it.next().?; + const rac = offset.rowAndCell(); + try testing.expect(!rac.row.wrap); + } + { + const offset = it.next().?; + const rac = offset.rowAndCell(); + const cells = offset.page.data.getCells(rac.row); + try testing.expect(rac.row.wrap); + try testing.expectEqual(@as(usize, 2), cells.len); + try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); + } + { + const offset = it.next().?; + const rac = offset.rowAndCell(); + const cells = offset.page.data.getCells(rac.row); + try testing.expect(!rac.row.wrap); + try testing.expectEqual(@as(usize, 2), cells.len); + try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint); + } +} + +test "PageList resize reflow less cols blank lines between no scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + { + const rac = page.getRowAndCell(0, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + } + { + const rac = page.getRowAndCell(0, 2); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'C' }, + }; + } + + // Resize + try s.resize(.{ .cols = 2, .reflow = true }); + try testing.expectEqual(@as(usize, 2), s.cols); + try testing.expectEqual(@as(usize, 3), s.totalRows()); + + var it = s.rowIterator(.right_down, .{ .active = .{} }, null); + { + const offset = it.next().?; + const rac = offset.rowAndCell(); + const cells = offset.page.data.getCells(rac.row); + try testing.expect(!rac.row.wrap); + try testing.expectEqual(@as(usize, 2), cells.len); + try testing.expectEqual(@as(u21, 'A'), cells[0].content.codepoint); + } + { + const offset = it.next().?; + const rac = offset.rowAndCell(); + const cells = offset.page.data.getCells(rac.row); + try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); + } + { + const offset = it.next().?; + const rac = offset.rowAndCell(); + const cells = offset.page.data.getCells(rac.row); + try testing.expect(!rac.row.wrap); + try testing.expectEqual(@as(usize, 2), cells.len); + try testing.expectEqual(@as(u21, 'C'), cells[0].content.codepoint); + } +} + +test "PageList resize reflow less cols cursor not on last line preserves location" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 5, 1); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + for (0..2) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + // Grow blank rows to push our rows back into scrollback + try s.growRows(5); + try testing.expectEqual(@as(usize, 10), s.totalRows()); + + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 0, .y = 0 } }).?); + defer s.untrackPin(p); + + // Resize + try s.resize(.{ + .cols = 4, + .reflow = true, + + // Important: not on last row + .cursor = .{ .x = 1, .y = 1 }, + }); + try testing.expectEqual(@as(usize, 4), s.cols); + try testing.expectEqual(@as(usize, 10), s.totalRows()); + + // Our cursor should move to the first row + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 0, + } }, s.pointFromPin(.active, p.*).?); +} + +test "PageList resize reflow less cols copy style" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 2, 0); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Create a style + const style: stylepkg.Style = .{ .flags = .{ .bold = true } }; + const style_md = try page.styles.upsert(page.memory, style); + + for (0..s.cols - 1) |x| { + const rac = page.getRowAndCell(x, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + .style_id = style_md.id, + }; + + style_md.ref += 1; + } + } + + // Resize + try s.resize(.{ .cols = 2, .reflow = true }); + try testing.expectEqual(@as(usize, 2), s.cols); + try testing.expectEqual(@as(usize, 2), s.totalRows()); + + var it = s.rowIterator(.right_down, .{ .active = .{} }, null); + while (it.next()) |offset| { + for (0..s.cols - 1) |x| { + var offset_copy = offset; + offset_copy.x = x; + const rac = offset_copy.rowAndCell(); + const style_id = rac.cell.style_id; + try testing.expect(style_id != 0); + + const style = offset.page.data.styles.lookupId( + offset.page.data.memory, + style_id, + ).?; + try testing.expect(style.flags.bold); + + const row = rac.row; + try testing.expect(row.styled); + } + } +} + +test "PageList resize reflow less cols to eliminate a wide char" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 1, 0); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + { + const rac = page.getRowAndCell(0, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '😀' }, + .wide = .wide, + }; + } + { + const rac = page.getRowAndCell(1, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = ' ' }, + .wide = .spacer_tail, + }; + } + } + + // Resize + try s.resize(.{ .cols = 1, .reflow = true }); + try testing.expectEqual(@as(usize, 1), s.cols); + try testing.expectEqual(@as(usize, 1), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + { + const rac = page.getRowAndCell(0, 0); + try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide); + } + } +} + +test "PageList resize reflow less cols to wrap a wide char" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 1, 0); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + { + const rac = page.getRowAndCell(0, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'x' }, + }; + } + { + const rac = page.getRowAndCell(1, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '😀' }, + .wide = .wide, + }; + } + { + const rac = page.getRowAndCell(2, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = ' ' }, + .wide = .spacer_tail, + }; + } + } + + // Resize + try s.resize(.{ .cols = 2, .reflow = true }); + try testing.expectEqual(@as(usize, 2), s.cols); + try testing.expectEqual(@as(usize, 2), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + { + const rac = page.getRowAndCell(0, 0); + try testing.expectEqual(@as(u21, 'x'), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide); + try testing.expect(rac.row.wrap); + } + { + const rac = page.getRowAndCell(1, 0); + try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.spacer_head, rac.cell.wide); + } + { + const rac = page.getRowAndCell(0, 1); + try testing.expectEqual(@as(u21, '😀'), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.wide, rac.cell.wide); + } + { + const rac = page.getRowAndCell(1, 1); + try testing.expectEqual(@as(u21, ' '), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.spacer_tail, rac.cell.wide); + } + } +} diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index dcef37328d..7c5d925f46 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1,79 +1,122 @@ -//! Screen represents the internal storage for a terminal screen, including -//! scrollback. This is implemented as a single continuous ring buffer. -//! -//! Definitions: -//! -//! * Screen - The full screen (active + history). -//! * Active - The area that is the current edit-able screen (the -//! bottom of the scrollback). This is "edit-able" because it is -//! the only part that escape sequences such as set cursor position -//! actually affect. -//! * History - The area that contains the lines prior to the active -//! area. This is the scrollback area. Escape sequences can no longer -//! affect this area. -//! * Viewport - The area that is currently visible to the user. This -//! can be thought of as the current window into the screen. -//! * Row - A single visible row in the screen. -//! * Line - A single line of text. This may map to multiple rows if -//! the row is soft-wrapped. -//! -//! The internal storage of the screen is stored in a circular buffer -//! with roughly the following format: -//! -//! Storage (Circular Buffer) -//! ┌─────────────────────────────────────┐ -//! │ ┌─────┐┌─────┐┌─────┐ ┌─────┐ │ -//! │ │ Hdr ││Cell ││Cell │ ... │Cell │ │ -//! │ │ ││ 0 ││ 1 │ │ N-1 │ │ -//! │ └─────┘└─────┘└─────┘ └─────┘ │ -//! │ ┌─────┐┌─────┐┌─────┐ ┌─────┐ │ -//! │ │ Hdr ││Cell ││Cell │ ... │Cell │ │ -//! │ │ ││ 0 ││ 1 │ │ N-1 │ │ -//! │ └─────┘└─────┘└─────┘ └─────┘ │ -//! │ ┌─────┐┌─────┐┌─────┐ ┌─────┐ │ -//! │ │ Hdr ││Cell ││Cell │ ... │Cell │ │ -//! │ │ ││ 0 ││ 1 │ │ N-1 │ │ -//! │ └─────┘└─────┘└─────┘ └─────┘ │ -//! └─────────────────────────────────────┘ -//! -//! There are R rows with N columns. Each row has an extra "cell" which is -//! the row header. The row header is used to track metadata about the row. -//! Each cell itself is a union (see StorageCell) of either the header or -//! the cell. -//! -//! The storage is in a circular buffer so that scrollback can be handled -//! without copying rows. The circular buffer is implemented in circ_buf.zig. -//! The top of the circular buffer (index 0) is the top of the screen, -//! i.e. the scrollback if there is a lot of data. -//! -//! The top of the active area (or end of the history area, same thing) is -//! cached in `self.history` and is an offset in rows. This could always be -//! calculated but profiling showed that caching it saves a lot of time in -//! hot loops for minimal memory cost. const Screen = @This(); const std = @import("std"); -const builtin = @import("builtin"); -const assert = std.debug.assert; const Allocator = std.mem.Allocator; - -const ziglyph = @import("ziglyph"); +const assert = std.debug.assert; const ansi = @import("ansi.zig"); -const modes = @import("modes.zig"); -const sgr = @import("sgr.zig"); -const color = @import("color.zig"); +const charsets = @import("charsets.zig"); const kitty = @import("kitty.zig"); -const point = @import("point.zig"); -const CircBuf = @import("../circ_buf.zig").CircBuf; +const sgr = @import("sgr.zig"); +const unicode = @import("../unicode/main.zig"); const Selection = @import("Selection.zig"); +const PageList = @import("PageList.zig"); const StringMap = @import("StringMap.zig"); -const fastmem = @import("../fastmem.zig"); -const charsets = @import("charsets.zig"); +const pagepkg = @import("page.zig"); +const point = @import("point.zig"); +const size = @import("size.zig"); +const style = @import("style.zig"); +const Page = pagepkg.Page; +const Row = pagepkg.Row; +const Cell = pagepkg.Cell; +const Pin = PageList.Pin; const log = std.log.scoped(.screen); +/// The general purpose allocator to use for all memory allocations. +/// Unfortunately some screen operations do require allocation. +alloc: Allocator, + +/// The list of pages in the screen. +pages: PageList, + +/// Special-case where we want no scrollback whatsoever. We have to flag +/// this because max_size 0 in PageList gets rounded up to two pages so +/// we can always have an active screen. +no_scrollback: bool = false, + +/// The current cursor position +cursor: Cursor, + +/// The saved cursor +saved_cursor: ?SavedCursor = null, + +/// The selection for this screen (if any). This MUST be a tracked selection +/// otherwise the selection will become invalid. Instead of accessing this +/// directly to set it, use the `select` function which will assert and +/// automatically setup tracking. +selection: ?Selection = null, + +/// The charset state +charset: CharsetState = .{}, + +/// The current or most recent protected mode. Once a protection mode is +/// set, this will never become "off" again until the screen is reset. +/// The current state of whether protection attributes should be set is +/// set on the Cell pen; this is only used to determine the most recent +/// protection mode since some sequences such as ECH depend on this. +protected_mode: ansi.ProtectedMode = .off, + +/// The kitty keyboard settings. +kitty_keyboard: kitty.KeyFlagStack = .{}, + +/// Kitty graphics protocol state. +kitty_images: kitty.graphics.ImageStorage = .{}, + +/// The cursor position. +pub const Cursor = struct { + // The x/y position within the viewport. + x: size.CellCountInt, + y: size.CellCountInt, + + /// The visual style of the cursor. This defaults to block because + /// it has to default to something, but users of this struct are + /// encouraged to set their own default. + cursor_style: CursorStyle = .block, + + /// The "last column flag (LCF)" as its called. If this is set then the + /// next character print will force a soft-wrap. + pending_wrap: bool = false, + + /// The protected mode state of the cursor. If this is true then + /// all new characters printed will have the protected state set. + protected: bool = false, + + /// The currently active style. This is the concrete style value + /// that should be kept up to date. The style ID to use for cell writing + /// is below. + style: style.Style = .{}, + + /// The currently active style ID. The style is page-specific so when + /// we change pages we need to ensure that we update that page with + /// our style when used. + style_id: style.Id = style.default_id, + style_ref: ?*size.CellCountInt = null, + + /// The pointers into the page list where the cursor is currently + /// located. This makes it faster to move the cursor. + page_pin: *PageList.Pin, + page_row: *pagepkg.Row, + page_cell: *pagepkg.Cell, +}; + +/// The visual style of the cursor. Whether or not it blinks +/// is determined by mode 12 (modes.zig). This mode is synchronized +/// with CSI q, the same as xterm. +pub const CursorStyle = enum { bar, block, underline }; + +/// Saved cursor state. +pub const SavedCursor = struct { + x: size.CellCountInt, + y: size.CellCountInt, + style: style.Style, + protected: bool, + pending_wrap: bool, + origin: bool, + charset: CharsetState, +}; + /// State required for all charset operations. -const CharsetState = struct { +pub const CharsetState = struct { /// The list of graphical charsets by slot charsets: CharsetArray = CharsetArray.initFill(charsets.Charset.utf8), @@ -89,1643 +132,1560 @@ const CharsetState = struct { const CharsetArray = std.EnumArray(charsets.Slots, charsets.Charset); }; -/// Cursor represents the cursor state. -pub const Cursor = struct { - /// x, y where the cursor currently exists (0-indexed). This x/y is - /// always the offset in the active area. - x: usize = 0, - y: usize = 0, - - /// The visual style of the cursor. This defaults to block because - /// it has to default to something, but users of this struct are - /// encouraged to set their own default. - style: Style = .block, - - /// pen is the current cell styling to apply to new cells. - pen: Cell = .{ .char = 0 }, +/// Initialize a new screen. +/// +/// max_scrollback is the amount of scrollback to keep in bytes. This +/// will be rounded UP to the nearest page size because our minimum allocation +/// size is that anyways. +/// +/// If max scrollback is 0, then no scrollback is kept at all. +pub fn init( + alloc: Allocator, + cols: size.CellCountInt, + rows: size.CellCountInt, + max_scrollback: usize, +) !Screen { + // Initialize our backing pages. + var pages = try PageList.init(alloc, cols, rows, max_scrollback); + errdefer pages.deinit(); - /// The last column flag (LCF) used to do soft wrapping. - pending_wrap: bool = false, + // Create our tracked pin for the cursor. + const page_pin = try pages.trackPin(.{ .page = pages.pages.first.? }); + errdefer pages.untrackPin(page_pin); + const page_rac = page_pin.rowAndCell(); - /// The visual style of the cursor. Whether or not it blinks - /// is determined by mode 12 (modes.zig). This mode is synchronized - /// with CSI q, the same as xterm. - pub const Style = enum { bar, block, underline }; - - /// Saved cursor state. This contains more than just Cursor members - /// because additional state is stored. - pub const Saved = struct { - x: usize, - y: usize, - pen: Cell, - pending_wrap: bool, - origin: bool, - charset: CharsetState, + return .{ + .alloc = alloc, + .pages = pages, + .no_scrollback = max_scrollback == 0, + .cursor = .{ + .x = 0, + .y = 0, + .page_pin = page_pin, + .page_row = page_rac.row, + .page_cell = page_rac.cell, + }, }; -}; +} -/// This is a single item within the storage buffer. We use a union to -/// have different types of data in a single contiguous buffer. -const StorageCell = union { - header: RowHeader, - cell: Cell, - - test { - // log.warn("header={}@{} cell={}@{} storage={}@{}", .{ - // @sizeOf(RowHeader), - // @alignOf(RowHeader), - // @sizeOf(Cell), - // @alignOf(Cell), - // @sizeOf(StorageCell), - // @alignOf(StorageCell), - // }); - } - - comptime { - // We only check this during ReleaseFast because safety checks - // have to be disabled to get this size. - if (!std.debug.runtime_safety) { - // We want to be at most the size of a cell always. We have WAY - // more cells than other fields, so we don't want to pay the cost - // of padding due to other fields. - assert(@sizeOf(Cell) == @sizeOf(StorageCell)); +pub fn deinit(self: *Screen) void { + self.kitty_images.deinit(self.alloc, self); + self.pages.deinit(); +} + +/// Assert that the screen is in a consistent state. This doesn't check +/// all pages in the page list because that is SO SLOW even just for +/// tests. This only asserts the screen specific data so callers should +/// ensure they're also calling page integrity checks if necessary. +pub fn assertIntegrity(self: *const Screen) void { + if (comptime std.debug.runtime_safety) { + assert(self.cursor.x < self.pages.cols); + assert(self.cursor.y < self.pages.rows); + + if (self.cursor.style_id == style.default_id) { + // If our style is default, we should have no refs. + assert(self.cursor.style.default()); + assert(self.cursor.style_ref == null); } else { - // Extra u32 for the tag for safety checks. This is subject to - // change depending on the Zig compiler... - assert((@sizeOf(Cell) + @sizeOf(u32)) == @sizeOf(StorageCell)); + // If our style is not default, we should have a ref. + assert(!self.cursor.style.default()); + assert(self.cursor.style_ref != null); + + // Further, the ref should be valid within the current page. + const page = &self.cursor.page_pin.page.data; + const page_style = page.styles.lookupId( + page.memory, + self.cursor.style_id, + ).?.*; + assert(self.cursor.style.eql(page_style)); + + // The metadata pointer should be equal to the ref. + const md = page.styles.upsert( + page.memory, + page_style, + ) catch unreachable; + assert(&md.ref == self.cursor.style_ref.?); } } -}; +} + +/// Clone the screen. +/// +/// This will copy: +/// +/// - Screen dimensions +/// - Screen data (cell state, etc.) for the region +/// +/// Anything not mentioned above is NOT copied. Some of this is for +/// very good reason: +/// +/// - Kitty images have a LOT of data. This is not efficient to copy. +/// Use a lock and access the image data. The dirty bit is there for +/// a reason. +/// - Cursor location can be expensive to calculate with respect to the +/// specified region. It is faster to grab the cursor from the old +/// screen and then move it to the new screen. +/// +/// If not mentioned above, then there isn't a specific reason right now +/// to not copy some data other than we probably didn't need it and it +/// isn't necessary for screen coherency. +/// +/// Other notes: +/// +/// - The viewport will always be set to the active area of the new +/// screen. This is the bottom "rows" rows. +/// - If the clone region is smaller than a viewport area, blanks will +/// be filled in at the bottom. +/// +pub fn clone( + self: *const Screen, + alloc: Allocator, + top: point.Point, + bot: ?point.Point, +) !Screen { + return try self.clonePool(alloc, null, top, bot); +} -/// The row header is at the start of every row within the storage buffer. -/// It can store row-specific data. -pub const RowHeader = struct { - pub const Id = u32; - - /// The ID of this row, used to uniquely identify this row. The cells - /// are also ID'd by id + cell index (0-indexed). This will wrap around - /// when it reaches the maximum value for the type. For caching purposes, - /// when wrapping happens, all rows in the screen will be marked dirty. - id: Id = 0, - - // Packed flags - flags: packed struct { - /// If true, this row is soft-wrapped. The first cell of the next - /// row is a continuous of this row. - wrap: bool = false, - - /// True if this row has had changes. It is up to the caller to - /// set this to false. See the methods on Row to see what will set - /// this to true. - dirty: bool = false, - - /// True if any cell in this row has a grapheme associated with it. - grapheme: bool = false, - - /// True if this row is an active prompt (awaiting input). This is - /// set to false when the semantic prompt events (OSC 133) are received. - /// There are scenarios where the shell may never send this event, so - /// in order to reliably test prompt status, you need to iterate - /// backwards from the cursor to check the current line status going - /// back. - semantic_prompt: SemanticPrompt = .unknown, - } = .{}, - - /// Semantic prompt type. - pub const SemanticPrompt = enum(u3) { - /// Unknown, the running application didn't tell us for this line. - unknown = 0, - - /// This is a prompt line, meaning it only contains the shell prompt. - /// For poorly behaving shells, this may also be the input. - prompt = 1, - prompt_continuation = 2, - - /// This line contains the input area. We don't currently track - /// where this actually is in the line, so we just assume it is somewhere. - input = 3, - - /// This line is the start of command output. - command = 4, - - /// True if this is a prompt or input line. - pub fn promptOrInput(self: SemanticPrompt) bool { - return self == .prompt or self == .prompt_continuation or self == .input; +/// Same as clone but you can specify a custom memory pool to use for +/// the screen. +pub fn clonePool( + self: *const Screen, + alloc: Allocator, + pool: ?*PageList.MemoryPool, + top: point.Point, + bot: ?point.Point, +) !Screen { + // Create a tracked pin remapper for our selection and cursor. Note + // that we may want to expose this generally in the future but at the + // time of doing this we don't need to. + var pin_remap = PageList.Clone.TrackedPinsRemap.init(alloc); + defer pin_remap.deinit(); + + var pages = try self.pages.clone(.{ + .top = top, + .bot = bot, + .memory = if (pool) |p| .{ + .pool = p, + } else .{ + .alloc = alloc, + }, + .tracked_pins = &pin_remap, + }); + errdefer pages.deinit(); + + // Find our cursor. If the cursor isn't in the cloned area, we move it + // to the top-left arbitrarily because a screen must have SOME cursor. + const cursor: Cursor = cursor: { + if (pin_remap.get(self.cursor.page_pin)) |p| remap: { + const page_rac = p.rowAndCell(); + const pt = pages.pointFromPin(.active, p.*) orelse break :remap; + break :cursor .{ + .x = @intCast(pt.active.x), + .y = @intCast(pt.active.y), + .page_pin = p, + .page_row = page_rac.row, + .page_cell = page_rac.cell, + }; } + + const page_pin = try pages.trackPin(.{ .page = pages.pages.first.? }); + const page_rac = page_pin.rowAndCell(); + break :cursor .{ + .x = 0, + .y = 0, + .page_pin = page_pin, + .page_row = page_rac.row, + .page_cell = page_rac.cell, + }; }; -}; -/// The color associated with a single cell's foreground or background. -const CellColor = union(enum) { - none, - indexed: u8, - rgb: color.RGB, - - pub fn eql(self: CellColor, other: CellColor) bool { - return switch (self) { - .none => other == .none, - .indexed => |i| switch (other) { - .indexed => other.indexed == i, - else => false, + // Preserve our selection if we have one. + const sel: ?Selection = if (self.selection) |sel| sel: { + assert(sel.tracked()); + + const ordered: struct { + tl: *Pin, + br: *Pin, + } = switch (sel.order(self)) { + .forward, .mirrored_forward => .{ + .tl = sel.bounds.tracked.start, + .br = sel.bounds.tracked.end, }, - .rgb => |rgb| switch (other) { - .rgb => other.rgb.eql(rgb), - else => false, + .reverse, .mirrored_reverse => .{ + .tl = sel.bounds.tracked.end, + .br = sel.bounds.tracked.start, }, }; - } -}; -/// Cell is a single cell within the screen. -pub const Cell = struct { - /// The primary unicode codepoint for this cell. Most cells (almost all) - /// contain exactly one unicode codepoint. However, it is possible for - /// cells to contain multiple if multiple codepoints are used to create - /// a single grapheme cluster. - /// - /// In the case multiple codepoints make up a single grapheme, the - /// additional codepoints can be looked up in the hash map on the - /// Screen. Since multi-codepoints graphemes are rare, we don't want to - /// waste memory for every cell, so we use a side lookup for it. - char: u32 = 0, - - /// Foreground and background color. - fg: CellColor = .none, - bg: CellColor = .none, - - /// Underline color. - /// NOTE(mitchellh): This is very rarely set so ideally we wouldn't waste - /// cell space for this. For now its on this struct because it is convenient - /// but we should consider a lookaside table for this. - underline_fg: color.RGB = .{}, - - /// On/off attributes that can be set - attrs: packed struct { - bold: bool = false, - italic: bool = false, - faint: bool = false, - blink: bool = false, - inverse: bool = false, - invisible: bool = false, - strikethrough: bool = false, - underline: sgr.Attribute.Underline = .none, - underline_color: bool = false, - protected: bool = false, - - /// True if this is a wide character. This char takes up - /// two cells. The following cell ALWAYS is a space. - wide: bool = false, - - /// Notes that this only exists to be blank for a preceding - /// wide character (tail) or following (head). - wide_spacer_tail: bool = false, - wide_spacer_head: bool = false, - - /// True if this cell has additional codepoints to form a complete - /// grapheme cluster. If this is true, then the row grapheme flag must - /// also be true. The grapheme code points can be looked up in the - /// screen grapheme map. - grapheme: bool = false, - - /// Returns only the attributes related to style. - pub fn styleAttrs(self: @This()) @This() { - var copy = self; - copy.wide = false; - copy.wide_spacer_tail = false; - copy.wide_spacer_head = false; - copy.grapheme = false; - return copy; - } - } = .{}, - - /// True if the cell should be skipped for drawing - pub fn empty(self: Cell) bool { - // Get our backing integer for our packed struct of attributes - const AttrInt = @Type(.{ .Int = .{ - .signedness = .unsigned, - .bits = @bitSizeOf(@TypeOf(self.attrs)), - } }); + const start_pin = pin_remap.get(ordered.tl) orelse start: { - // We're empty if we have no char AND we have no styling - return self.char == 0 and - self.fg == .none and - self.bg == .none and - @as(AttrInt, @bitCast(self.attrs)) == 0; - } + // No start means it is outside the cloned area. We change it + // to the top-left. If we have no end pin then our whole + // selection is outside the cloned area so we can just set it + // as null. + if (pin_remap.get(ordered.br) == null) break :sel null; + break :start try pages.trackPin(.{ .page = pages.pages.first.? }); + }; - /// The width of the cell. - /// - /// This uses the legacy calculation of a per-codepoint width calculation - /// to determine the width. This legacy calculation is incorrect because - /// it doesn't take into account multi-codepoint graphemes. - /// - /// The goal of this function is to match the expectation of shells - /// that aren't grapheme aware (at the time of writing this comment: none - /// are grapheme aware). This means it should match wcswidth. - pub fn widthLegacy(self: Cell) u8 { - // Wide is always 2 - if (self.attrs.wide) return 2; + const end_pin = pin_remap.get(ordered.br) orelse end: { + // No end means it is outside the cloned area. We change it + // to the bottom-right. + break :end try pages.trackPin(pages.pin(.{ .active = .{ + .x = pages.cols - 1, + .y = pages.rows - 1, + } }) orelse break :sel null); + }; - // Wide spacers are always 0 because their width is accounted for - // in the wide char. - if (self.attrs.wide_spacer_tail or self.attrs.wide_spacer_head) return 0; + break :sel .{ + .bounds = .{ .tracked = .{ + .start = start_pin, + .end = end_pin, + } }, + .rectangle = sel.rectangle, + }; + } else null; - return 1; - } + const result: Screen = .{ + .alloc = alloc, + .pages = pages, + .no_scrollback = self.no_scrollback, + .cursor = cursor, + .selection = sel, + }; + result.assertIntegrity(); + return result; +} - test "widthLegacy" { - const testing = std.testing; +pub fn cursorCellRight(self: *Screen, n: size.CellCountInt) *pagepkg.Cell { + assert(self.cursor.x + n < self.pages.cols); + const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell); + return @ptrCast(cell + n); +} - var c: Cell = .{}; - try testing.expectEqual(@as(u16, 1), c.widthLegacy()); +pub fn cursorCellLeft(self: *Screen, n: size.CellCountInt) *pagepkg.Cell { + assert(self.cursor.x >= n); + const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell); + return @ptrCast(cell - n); +} - c = .{ .attrs = .{ .wide = true } }; - try testing.expectEqual(@as(u16, 2), c.widthLegacy()); +pub fn cursorCellEndOfPrev(self: *Screen) *pagepkg.Cell { + assert(self.cursor.y > 0); - c = .{ .attrs = .{ .wide_spacer_tail = true } }; - try testing.expectEqual(@as(u16, 0), c.widthLegacy()); - } + var page_pin = self.cursor.page_pin.up(1).?; + page_pin.x = self.pages.cols - 1; + const page_rac = page_pin.rowAndCell(); + return page_rac.cell; +} - test { - // We use this test to ensure we always get the right size of the attrs - // const cell: Cell = .{ .char = 0 }; - // _ = @bitCast(u8, cell.attrs); - // try std.testing.expectEqual(1, @sizeOf(@TypeOf(cell.attrs))); - } +/// Move the cursor right. This is a specialized function that is very fast +/// if the caller can guarantee we have space to move right (no wrapping). +pub fn cursorRight(self: *Screen, n: size.CellCountInt) void { + assert(self.cursor.x + n < self.pages.cols); + defer self.assertIntegrity(); - test { - //log.warn("CELL={} bits={} {}", .{ @sizeOf(Cell), @bitSizeOf(Cell), @alignOf(Cell) }); - try std.testing.expectEqual(20, @sizeOf(Cell)); - } -}; + const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell); + self.cursor.page_cell = @ptrCast(cell + n); + self.cursor.page_pin.x += n; + self.cursor.x += n; +} -/// A row is a single row in the screen. -pub const Row = struct { - /// The screen this row is part of. - screen: *Screen, +/// Move the cursor left. +pub fn cursorLeft(self: *Screen, n: size.CellCountInt) void { + assert(self.cursor.x >= n); + defer self.assertIntegrity(); - /// Raw internal storage, do NOT write to this, use only the - /// helpers. Writing directly to this can easily mess up state - /// causing future crashes or misrendering. - storage: []StorageCell, + const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell); + self.cursor.page_cell = @ptrCast(cell - n); + self.cursor.page_pin.x -= n; + self.cursor.x -= n; +} - /// Returns the ID for this row. You can turn this into a cell ID - /// by adding the cell offset plus 1 (so it is 1-indexed). - pub inline fn getId(self: Row) RowHeader.Id { - return self.storage[0].header.id; - } +/// Move the cursor up. +/// +/// Precondition: The cursor is not at the top of the screen. +pub fn cursorUp(self: *Screen, n: size.CellCountInt) void { + assert(self.cursor.y >= n); + defer self.assertIntegrity(); - /// Set that this row is soft-wrapped. This doesn't change the contents - /// of this row so the row won't be marked dirty. - pub fn setWrapped(self: Row, v: bool) void { - self.storage[0].header.flags.wrap = v; - } + const page_pin = self.cursor.page_pin.up(n).?; + self.cursorChangePin(page_pin); + const page_rac = page_pin.rowAndCell(); + self.cursor.page_row = page_rac.row; + self.cursor.page_cell = page_rac.cell; + self.cursor.y -= n; +} - /// Set a row as dirty or not. Generally you only set a row as NOT dirty. - /// Various Row functions manage flagging dirty to true. - pub fn setDirty(self: Row, v: bool) void { - self.storage[0].header.flags.dirty = v; - } +pub fn cursorRowUp(self: *Screen, n: size.CellCountInt) *pagepkg.Row { + assert(self.cursor.y >= n); + defer self.assertIntegrity(); - pub inline fn isDirty(self: Row) bool { - return self.storage[0].header.flags.dirty; - } + const page_pin = self.cursor.page_pin.up(n).?; + const page_rac = page_pin.rowAndCell(); + return page_rac.row; +} - pub inline fn isWrapped(self: Row) bool { - return self.storage[0].header.flags.wrap; - } +/// Move the cursor down. +/// +/// Precondition: The cursor is not at the bottom of the screen. +pub fn cursorDown(self: *Screen, n: size.CellCountInt) void { + assert(self.cursor.y + n < self.pages.rows); + defer self.assertIntegrity(); + + // We move the offset into our page list to the next row and then + // get the pointers to the row/cell and set all the cursor state up. + const page_pin = self.cursor.page_pin.down(n).?; + self.cursorChangePin(page_pin); + const page_rac = page_pin.rowAndCell(); + self.cursor.page_row = page_rac.row; + self.cursor.page_cell = page_rac.cell; + + // Y of course increases + self.cursor.y += n; +} + +/// Move the cursor to some absolute horizontal position. +pub fn cursorHorizontalAbsolute(self: *Screen, x: size.CellCountInt) void { + assert(x < self.pages.cols); + defer self.assertIntegrity(); + + self.cursor.page_pin.x = x; + const page_rac = self.cursor.page_pin.rowAndCell(); + self.cursor.page_cell = page_rac.cell; + self.cursor.x = x; +} + +/// Move the cursor to some absolute position. +pub fn cursorAbsolute(self: *Screen, x: size.CellCountInt, y: size.CellCountInt) void { + assert(x < self.pages.cols); + assert(y < self.pages.rows); + defer self.assertIntegrity(); + + var page_pin = if (y < self.cursor.y) + self.cursor.page_pin.up(self.cursor.y - y).? + else if (y > self.cursor.y) + self.cursor.page_pin.down(y - self.cursor.y).? + else + self.cursor.page_pin.*; + page_pin.x = x; + self.cursorChangePin(page_pin); + const page_rac = page_pin.rowAndCell(); + self.cursor.page_row = page_rac.row; + self.cursor.page_cell = page_rac.cell; + self.cursor.x = x; + self.cursor.y = y; +} - /// Set the semantic prompt state for this row. - pub fn setSemanticPrompt(self: Row, p: RowHeader.SemanticPrompt) void { - self.storage[0].header.flags.semantic_prompt = p; - } +/// Reloads the cursor pointer information into the screen. This is expensive +/// so it should only be done in cases where the pointers are invalidated +/// in such a way that its difficult to recover otherwise. +pub fn cursorReload(self: *Screen) void { + defer self.assertIntegrity(); + + // Our tracked pin is ALWAYS accurate, so we derive the active + // point from the pin. If this returns null it means our pin + // points outside the active area. In that case, we update the + // pin to be the top-left. + const pt: point.Point = self.pages.pointFromPin( + .active, + self.cursor.page_pin.*, + ) orelse reset: { + const pin = self.pages.pin(.{ .active = .{} }).?; + self.cursor.page_pin.* = pin; + break :reset self.pages.pointFromPin(.active, pin).?; + }; - /// Retrieve the semantic prompt state for this row. - pub fn getSemanticPrompt(self: Row) RowHeader.SemanticPrompt { - return self.storage[0].header.flags.semantic_prompt; + self.cursor.x = @intCast(pt.active.x); + self.cursor.y = @intCast(pt.active.y); + const page_rac = self.cursor.page_pin.rowAndCell(); + self.cursor.page_row = page_rac.row; + self.cursor.page_cell = page_rac.cell; + + // If we have a style, we need to ensure it is in the page because this + // method may also be called after a page change. + if (self.cursor.style_id != style.default_id) { + // We set our ref to null because manualStyleUpdate will refresh it. + // If we had a valid ref and it was zero before, then manualStyleUpdate + // will reload the same ref. + // + // We want to avoid the scenario this was non-null but the pointer + // is now invalid because it pointed to a page that no longer exists. + self.cursor.style_ref = null; + + self.manualStyleUpdate() catch |err| { + // This failure should not happen because manualStyleUpdate + // handles page splitting, overflow, and more. This should only + // happen if we're out of RAM. In this case, we'll just degrade + // gracefully back to the default style. + log.err("failed to update style on cursor reload err={}", .{err}); + self.cursor.style = .{}; + self.cursor.style_id = 0; + self.cursor.style_ref = null; + }; } +} - /// Retrieve the header for this row. - pub fn header(self: Row) RowHeader { - return self.storage[0].header; - } +/// Scroll the active area and keep the cursor at the bottom of the screen. +/// This is a very specialized function but it keeps it fast. +pub fn cursorDownScroll(self: *Screen) !void { + assert(self.cursor.y == self.pages.rows - 1); + defer self.assertIntegrity(); - /// Returns the number of cells in this row. - pub fn lenCells(self: Row) usize { - return self.storage.len - 1; - } + // If we have no scrollback, then we shift all our rows instead. + if (self.no_scrollback) { + // If we have a single-row screen, we have no rows to shift + // so our cursor is in the correct place we just have to clear + // the cells. + if (self.pages.rows == 1) { + self.clearCells( + &self.cursor.page_pin.page.data, + self.cursor.page_row, + self.cursor.page_pin.page.data.getCells(self.cursor.page_row), + ); + } else { + // eraseRow will shift everything below it up. + try self.pages.eraseRow(.{ .active = .{} }); + + // The above may clear our cursor so we need to update that + // again. If this fails (highly unlikely) we just reset + // the cursor. + self.manualStyleUpdate() catch |err| { + // This failure should not happen because manualStyleUpdate + // handles page splitting, overflow, and more. This should only + // happen if we're out of RAM. In this case, we'll just degrade + // gracefully back to the default style. + log.err("failed to update style on cursor scroll err={}", .{err}); + self.cursor.style = .{}; + self.cursor.style_id = 0; + self.cursor.style_ref = null; + }; + + // We need to move our cursor down one because eraseRows will + // preserve our pin directly and we're erasing one row. + const page_pin = self.cursor.page_pin.down(1).?; + self.cursorChangePin(page_pin); + const page_rac = page_pin.rowAndCell(); + self.cursor.page_row = page_rac.row; + self.cursor.page_cell = page_rac.cell; + } + } else { + const old_pin = self.cursor.page_pin.*; + + // Grow our pages by one row. The PageList will handle if we need to + // allocate, prune scrollback, whatever. + _ = try self.pages.grow(); + + // If our pin page change it means that the page that the pin + // was on was pruned. In this case, grow() moves the pin to + // the top-left of the new page. This effectively moves it by + // one already, we just need to fix up the x value. + const page_pin = if (old_pin.page == self.cursor.page_pin.page) + self.cursor.page_pin.down(1).? + else reuse: { + var pin = self.cursor.page_pin.*; + pin.x = self.cursor.x; + break :reuse pin; + }; - /// Returns true if the row only has empty characters. This ignores - /// styling (i.e. styling does not count as non-empty). - pub fn isEmpty(self: Row) bool { - const len = self.storage.len; - for (self.storage[1..len]) |cell| { - if (cell.cell.char != 0) return false; + // These assertions help catch some pagelist math errors. Our + // x/y should be unchanged after the grow. + if (comptime std.debug.runtime_safety) { + const active = self.pages.pointFromPin( + .active, + page_pin, + ).?.active; + assert(active.x == self.cursor.x); + assert(active.y == self.cursor.y); } - return true; + self.cursorChangePin(page_pin); + const page_rac = page_pin.rowAndCell(); + self.cursor.page_row = page_rac.row; + self.cursor.page_cell = page_rac.cell; + + // Clear the new row so it gets our bg color. We only do this + // if we have a bg color at all. + if (self.cursor.style.bg_color != .none) { + self.clearCells( + &page_pin.page.data, + self.cursor.page_row, + page_pin.page.data.getCells(self.cursor.page_row), + ); + } } - /// Clear the row, making all cells empty. - pub fn clear(self: Row, pen: Cell) void { - var empty_pen = pen; - empty_pen.char = 0; - self.fill(empty_pen); + if (self.cursor.style_id != style.default_id) { + // The newly created line needs to be styled according to + // the bg color if it is set. + if (self.cursor.style.bgCell()) |blank_cell| { + const cell_current: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell); + const cells = cell_current - self.cursor.x; + @memset(cells[0..self.pages.cols], blank_cell); + } } +} - /// Fill the entire row with a copy of a single cell. - pub fn fill(self: Row, cell: Cell) void { - self.fillSlice(cell, 0, self.storage.len - 1); +/// Move the cursor down if we're not at the bottom of the screen. Otherwise +/// scroll. Currently only used for testing. +fn cursorDownOrScroll(self: *Screen) !void { + if (self.cursor.y + 1 < self.pages.rows) { + self.cursorDown(1); + } else { + try self.cursorDownScroll(); } +} - /// Fill a slice of a row. - pub fn fillSlice(self: Row, cell: Cell, start: usize, len: usize) void { - assert(len <= self.storage.len - 1); - assert(!cell.attrs.grapheme); // you can't fill with graphemes +/// Copy another cursor. The cursor can be on any screen but the x/y +/// must be within our screen bounds. +pub fn cursorCopy(self: *Screen, other: Cursor) !void { + assert(other.x < self.pages.cols); + assert(other.y < self.pages.rows); - // Always mark the row as dirty for this. - self.storage[0].header.flags.dirty = true; + const old = self.cursor; + self.cursor = other; + errdefer self.cursor = old; - // If our row has no graphemes, then this is a fast copy - if (!self.storage[0].header.flags.grapheme) { - @memset(self.storage[start + 1 .. len + 1], .{ .cell = cell }); - return; - } + // Clear our style information initially so runtime safety integrity + // checks pass since there is a period below where the cursor is + // invalid. + self.cursor.style = .{}; + self.cursor.style_id = 0; + self.cursor.style_ref = null; - // We have graphemes, so we have to clear those first. - for (self.storage[start + 1 .. len + 1], 0..) |*storage_cell, x| { - if (storage_cell.cell.attrs.grapheme) self.clearGraphemes(x); - storage_cell.* = .{ .cell = cell }; - } + // We need to keep our old x/y because that is our cursorAbsolute + // will fix up our pointers. + // + // We keep our old page pin because we expect to be in the active + // page relative to our own screen. + self.cursor.page_pin = old.page_pin; + self.cursor.x = old.x; + self.cursor.y = old.y; + self.cursorAbsolute(other.x, other.y); - // We only reset the grapheme flag if we fill the whole row, for now. - // We can improve performance by more correctly setting this but I'm - // going to defer that until we can measure. - if (start == 0 and len == self.storage.len - 1) { - self.storage[0].header.flags.grapheme = false; - } - } + // We keep the old style ref so manualStyleUpdate can clean our old style up. + self.cursor.style = other.style; + self.cursor.style_id = old.style_id; + self.cursor.style_ref = old.style_ref; + try self.manualStyleUpdate(); +} - /// Get a single immutable cell. - pub fn getCell(self: Row, x: usize) Cell { - assert(x < self.storage.len - 1); - return self.storage[x + 1].cell; +/// Always use this to write to cursor.page_pin.*. +/// +/// This specifically handles the case when the new pin is on a different +/// page than the old AND we have a style set. In that case, we must release +/// our old style and upsert our new style since styles are stored per-page. +fn cursorChangePin(self: *Screen, new: Pin) void { + // If we have a style set, then we need to migrate it over to the + // new page. This is expensive so we do everything we can with cheap + // ops to avoid it. + if (self.cursor.style_id == style.default_id or + self.cursor.page_pin.page == new.page) + { + self.cursor.page_pin.* = new; + return; } - /// Get a pointr to the cell at column x (0-indexed). This always - /// assumes that the cell was modified, notifying the renderer on the - /// next call to re-render this cell. Any change detection to avoid - /// this should be done prior. - pub fn getCellPtr(self: Row, x: usize) *Cell { - assert(x < self.storage.len - 1); + // Store our old full style so we can reapply it in the new page. + const old_style = self.cursor.style; + + // Clear our old style in the current pin + self.cursor.style = .{}; + self.manualStyleUpdate() catch unreachable; // Removing a style should never fail + + // Update our pin to the new page + self.cursor.page_pin.* = new; + self.cursor.style = old_style; + self.manualStyleUpdate() catch |err| { + // This failure should not happen because manualStyleUpdate + // handles page splitting, overflow, and more. This should only + // happen if we're out of RAM. In this case, we'll just degrade + // gracefully back to the default style. + log.err("failed to update style on cursor change err={}", .{err}); + self.cursor.style = .{}; + self.cursor.style_id = 0; + self.cursor.style_ref = null; + }; +} + +/// Options for scrolling the viewport of the terminal grid. The reason +/// we have this in addition to PageList.Scroll is because we have additional +/// scroll behaviors that are not part of the PageList.Scroll enum. +pub const Scroll = union(enum) { + /// For all of these, see PageList.Scroll. + active, + top, + pin: Pin, + delta_row: isize, + delta_prompt: isize, +}; + +/// Scroll the viewport of the terminal grid. +pub fn scroll(self: *Screen, behavior: Scroll) void { + defer self.assertIntegrity(); - // Always mark the row as dirty for this. - self.storage[0].header.flags.dirty = true; + // No matter what, scrolling marks our image state as dirty since + // it could move placements. If there are no placements or no images + // this is still a very cheap operation. + self.kitty_images.dirty = true; - return &self.storage[x + 1].cell; + switch (behavior) { + .active => self.pages.scroll(.{ .active = {} }), + .top => self.pages.scroll(.{ .top = {} }), + .pin => |p| self.pages.scroll(.{ .pin = p }), + .delta_row => |v| self.pages.scroll(.{ .delta_row = v }), + .delta_prompt => |v| self.pages.scroll(.{ .delta_prompt = v }), } +} - /// Attach a grapheme codepoint to the given cell. - pub fn attachGrapheme(self: Row, x: usize, cp: u21) !void { - assert(x < self.storage.len - 1); +/// See PageList.scrollClear. In addition to that, we reset the cursor +/// to be on top. +pub fn scrollClear(self: *Screen) !void { + defer self.assertIntegrity(); - const cell = &self.storage[x + 1].cell; - const key = self.getId() + x + 1; - const gop = try self.screen.graphemes.getOrPut(self.screen.alloc, key); - errdefer if (!gop.found_existing) { - _ = self.screen.graphemes.remove(key); - }; + try self.pages.scrollClear(); + self.cursorReload(); - // Our row now has a grapheme - self.storage[0].header.flags.grapheme = true; + // No matter what, scrolling marks our image state as dirty since + // it could move placements. If there are no placements or no images + // this is still a very cheap operation. + self.kitty_images.dirty = true; +} - // Our row is now dirty - self.storage[0].header.flags.dirty = true; +/// Returns true if the viewport is scrolled to the bottom of the screen. +pub fn viewportIsBottom(self: Screen) bool { + return self.pages.viewport == .active; +} - // If we weren't previously a grapheme and we found an existing value - // it means that it is old grapheme data. Just delete that. - if (!cell.attrs.grapheme and gop.found_existing) { - cell.attrs.grapheme = true; - gop.value_ptr.deinit(self.screen.alloc); - gop.value_ptr.* = .{ .one = cp }; - return; +/// Erase the region specified by tl and br, inclusive. This will physically +/// erase the rows meaning the memory will be reclaimed (if the underlying +/// page is empty) and other rows will be shifted up. +pub fn eraseRows( + self: *Screen, + tl: point.Point, + bl: ?point.Point, +) void { + defer self.assertIntegrity(); + + // Erase the rows + self.pages.eraseRows(tl, bl); + + // Just to be safe, reset our cursor since it is possible depending + // on the points that our active area shifted so our pointers are + // invalid. + self.cursorReload(); +} + +// Clear the region specified by tl and bl, inclusive. Cleared cells are +// colored with the current style background color. This will clear all +// cells in the rows. +// +// If protected is true, the protected flag will be respected and only +// unprotected cells will be cleared. Otherwise, all cells will be cleared. +pub fn clearRows( + self: *Screen, + tl: point.Point, + bl: ?point.Point, + protected: bool, +) void { + defer self.assertIntegrity(); + + var it = self.pages.pageIterator(.right_down, tl, bl); + while (it.next()) |chunk| { + for (chunk.rows()) |*row| { + const cells_offset = row.cells; + const cells_multi: [*]Cell = row.cells.ptr(chunk.page.data.memory); + const cells = cells_multi[0..self.pages.cols]; + + // Clear all cells + if (protected) { + self.clearUnprotectedCells(&chunk.page.data, row, cells); + // We need to preserve other row attributes since we only + // cleared unprotected cells. + row.cells = cells_offset; + } else { + self.clearCells(&chunk.page.data, row, cells); + row.* = .{ .cells = cells_offset }; + } } + } +} - // If we didn't have a previous value, attach the single codepoint. - if (!gop.found_existing) { - cell.attrs.grapheme = true; - gop.value_ptr.* = .{ .one = cp }; - return; +/// Clear the cells with the blank cell. This takes care to handle +/// cleaning up graphemes and styles. +pub fn clearCells( + self: *Screen, + page: *Page, + row: *Row, + cells: []Cell, +) void { + // This whole operation does unsafe things, so we just want to assert + // the end state. + page.pauseIntegrityChecks(true); + defer { + page.pauseIntegrityChecks(false); + page.assertIntegrity(); + self.assertIntegrity(); + } + + // If this row has graphemes, then we need go through a slow path + // and delete the cell graphemes. + if (row.grapheme) { + for (cells) |*cell| { + if (cell.hasGrapheme()) page.clearGrapheme(row, cell); } - - // We have an existing value, promote - assert(cell.attrs.grapheme); - try gop.value_ptr.append(self.screen.alloc, cp); } - /// Removes all graphemes associated with a cell. - pub fn clearGraphemes(self: Row, x: usize) void { - assert(x < self.storage.len - 1); + if (row.styled) { + for (cells) |*cell| { + if (cell.style_id == style.default_id) continue; - // Our row is now dirty - self.storage[0].header.flags.dirty = true; + // Fast-path, the style ID matches, in this case we just update + // our own ref and continue. We never delete because our style + // is still active. + if (page == &self.cursor.page_pin.page.data and + cell.style_id == self.cursor.style_id) + { + self.cursor.style_ref.?.* -= 1; + continue; + } - const cell = &self.storage[x + 1].cell; - const key = self.getId() + x + 1; - cell.attrs.grapheme = false; - if (self.screen.graphemes.fetchRemove(key)) |kv| { - kv.value.deinit(self.screen.alloc); + // Slow path: we need to lookup this style so we can decrement + // the ref count. Since we've already loaded everything, we also + // just go ahead and GC it if it reaches zero, too. + if (page.styles.lookupId( + page.memory, + cell.style_id, + )) |prev_style| { + // Below upsert can't fail because it should already be present + const md = page.styles.upsert( + page.memory, + prev_style.*, + ) catch unreachable; + assert(md.ref > 0); + md.ref -= 1; + if (md.ref == 0) page.styles.remove(page.memory, cell.style_id); + } } - } - - /// Copy a single cell from column x in src to column x in this row. - pub fn copyCell(self: Row, src: Row, x: usize) !void { - const dst_cell = self.getCellPtr(x); - const src_cell = src.getCellPtr(x); - // If our destination has graphemes, we have to clear them. - if (dst_cell.attrs.grapheme) self.clearGraphemes(x); - dst_cell.* = src_cell.*; + // If we have no left/right scroll region we can be sure that + // the row is no longer styled. + if (cells.len == self.pages.cols) row.styled = false; + } - // If the source doesn't have any graphemes, then we can just copy. - if (!src_cell.attrs.grapheme) return; + @memset(cells, self.blankCell()); +} - // Source cell has graphemes. Copy them. - const src_key = src.getId() + x + 1; - const src_data = src.screen.graphemes.get(src_key) orelse return; - const dst_key = self.getId() + x + 1; - const dst_gop = try self.screen.graphemes.getOrPut(self.screen.alloc, dst_key); - dst_gop.value_ptr.* = try src_data.copy(self.screen.alloc); - self.storage[0].header.flags.grapheme = true; +/// Clear cells but only if they are not protected. +pub fn clearUnprotectedCells( + self: *Screen, + page: *Page, + row: *Row, + cells: []Cell, +) void { + for (cells) |*cell| { + if (cell.protected) continue; + const cell_multi: [*]Cell = @ptrCast(cell); + self.clearCells(page, row, cell_multi[0..1]); } - /// Copy the row src into this row. The row can be from another screen. - pub fn copyRow(self: Row, src: Row) !void { - // If we have graphemes, clear first to unset them. - if (self.storage[0].header.flags.grapheme) self.clear(.{}); + page.assertIntegrity(); + self.assertIntegrity(); +} - // Copy the flags - self.storage[0].header.flags = src.storage[0].header.flags; +/// Clears the prompt lines if the cursor is currently at a prompt. This +/// clears the entire line. This is used for resizing when the shell +/// handles reflow. +/// +/// The cleared cells are not colored with the current style background +/// color like other clear functions, because this is a special case used +/// for a specific purpose that does not want that behavior. +pub fn clearPrompt(self: *Screen) void { + var found: ?Pin = null; + + // From our cursor, move up and find all prompt lines. + var it = self.cursor.page_pin.rowIterator( + .left_up, + self.pages.pin(.{ .active = .{} }), + ); + while (it.next()) |p| { + const row = p.rowAndCell().row; + switch (row.semantic_prompt) { + // We are at a prompt but we're not at the start of the prompt. + // We mark our found value and continue because the prompt + // may be multi-line. + .input => found = p, + + // If we find the prompt then we're done. We are also done + // if we find any prompt continuation, because the shells + // that send this currently (zsh) cannot redraw every line. + .prompt, .prompt_continuation => { + found = p; + break; + }, - // Always mark the row as dirty for this. - self.storage[0].header.flags.dirty = true; + // If we have command output, then we're most certainly not + // at a prompt. Break out of the loop. + .command => break, - // If the source has no graphemes (likely) then this is fast. - const end = @min(src.storage.len, self.storage.len); - if (!src.storage[0].header.flags.grapheme) { - fastmem.copy(StorageCell, self.storage[1..], src.storage[1..end]); - return; + // If we don't know, we keep searching. + .unknown => {}, } + } - // Source has graphemes, this is slow. - for (src.storage[1..end], 0..) |storage, x| { - self.storage[x + 1] = .{ .cell = storage.cell }; - - // Copy grapheme data if it exists - if (storage.cell.attrs.grapheme) { - const src_key = src.getId() + x + 1; - const src_data = src.screen.graphemes.get(src_key) orelse continue; - - const dst_key = self.getId() + x + 1; - const dst_gop = try self.screen.graphemes.getOrPut(self.screen.alloc, dst_key); - dst_gop.value_ptr.* = try src_data.copy(self.screen.alloc); - - self.storage[0].header.flags.grapheme = true; - } + // If we found a prompt, we clear it. + if (found) |top| { + var clear_it = top.rowIterator(.right_down, null); + while (clear_it.next()) |p| { + const row = p.rowAndCell().row; + p.page.data.clearCells(row, 0, p.page.data.size.cols); + p.page.data.assertIntegrity(); } } +} - /// Read-only iterator for the cells in the row. - pub fn cellIterator(self: Row) CellIterator { - return .{ .row = self }; - } +/// Returns the blank cell to use when doing terminal operations that +/// require preserving the bg color. +fn blankCell(self: *const Screen) Cell { + if (self.cursor.style_id == style.default_id) return .{}; + return self.cursor.style.bgCell() orelse .{}; +} - /// Returns the number of codepoints in the cell at column x, - /// including the primary codepoint. - pub fn codepointLen(self: Row, x: usize) usize { - var it = self.codepointIterator(x); - return it.len() + 1; - } +/// Resize the screen. The rows or cols can be bigger or smaller. +/// +/// This will reflow soft-wrapped text. If the screen size is getting +/// smaller and the maximum scrollback size is exceeded, data will be +/// lost from the top of the scrollback. +/// +/// If this returns an error, the screen is left in a likely garbage state. +/// It is very hard to undo this operation without blowing up our memory +/// usage. The only way to recover is to reset the screen. The only way +/// this really fails is if page allocation is required and fails, which +/// probably means the system is in trouble anyways. I'd like to improve this +/// in the future but it is not a priority particularly because this scenario +/// (resize) is difficult. +pub fn resize( + self: *Screen, + cols: size.CellCountInt, + rows: size.CellCountInt, +) !void { + try self.resizeInternal(cols, rows, true); +} - /// Read-only iterator for the grapheme codepoints in a cell. This only - /// iterates over the EXTRA GRAPHEME codepoints and not the primary - /// codepoint in cell.char. - pub fn codepointIterator(self: Row, x: usize) CodepointIterator { - const cell = &self.storage[x + 1].cell; - if (!cell.attrs.grapheme) return .{ .data = .{ .zero = {} } }; +/// Resize the screen without any reflow. In this mode, columns/rows will +/// be truncated as they are shrunk. If they are grown, the new space is filled +/// with zeros. +pub fn resizeWithoutReflow( + self: *Screen, + cols: size.CellCountInt, + rows: size.CellCountInt, +) !void { + try self.resizeInternal(cols, rows, false); +} - const key = self.getId() + x + 1; - const data: GraphemeData = self.screen.graphemes.get(key) orelse data: { - // This is probably a bug somewhere in our internal state, - // but we don't want to just hard crash so its easier to just - // have zero codepoints. - log.debug("cell with grapheme flag but no grapheme data", .{}); - break :data .{ .zero = {} }; - }; - return .{ .data = data }; - } +/// Resize the screen. +// TODO: replace resize and resizeWithoutReflow with this. +fn resizeInternal( + self: *Screen, + cols: size.CellCountInt, + rows: size.CellCountInt, + reflow: bool, +) !void { + // No matter what we mark our image state as dirty + self.kitty_images.dirty = true; - /// Returns true if this cell is the end of a grapheme cluster. - /// - /// NOTE: If/when "real" grapheme cluster support is in then - /// this will be removed because every cell will represent exactly - /// one grapheme cluster. - pub fn graphemeBreak(self: Row, x: usize) bool { - const cell = &self.storage[x + 1].cell; + // Perform the resize operation. This will update cursor by reference. + try self.pages.resize(.{ + .rows = rows, + .cols = cols, + .reflow = reflow, + .cursor = .{ .x = self.cursor.x, .y = self.cursor.y }, + }); - // Right now, if we are a grapheme, we only store ZWJs on - // the grapheme data so that means we can't be a break. - if (cell.attrs.grapheme) return false; + // If we have no scrollback and we shrunk our rows, we must explicitly + // erase our history. This is beacuse PageList always keeps at least + // a page size of history. + if (self.no_scrollback) { + self.pages.eraseRows(.{ .history = .{} }, null); + } - // If we are a tail then we check our prior cell. - if (cell.attrs.wide_spacer_tail and x > 0) { - return self.graphemeBreak(x - 1); - } + // If our cursor was updated, we do a full reload so all our cursor + // state is correct. + self.cursorReload(); + self.assertIntegrity(); +} - // If we are a wide char, then we have to check our prior cell. - if (cell.attrs.wide and x > 0) { - return self.graphemeBreak(x - 1); - } +/// Set a style attribute for the current cursor. +/// +/// This can cause a page split if the current page cannot fit this style. +/// This is the only scenario an error return is possible. +pub fn setAttribute(self: *Screen, attr: sgr.Attribute) !void { + switch (attr) { + .unset => { + self.cursor.style = .{}; + }, - return true; - } -}; + .bold => { + self.cursor.style.flags.bold = true; + }, -/// Used to iterate through the rows of a specific region. -pub const RowIterator = struct { - screen: *Screen, - tag: RowIndexTag, - max: usize, - value: usize = 0, + .reset_bold => { + // Bold and faint share the same SGR code for this + self.cursor.style.flags.bold = false; + self.cursor.style.flags.faint = false; + }, - pub fn next(self: *RowIterator) ?Row { - if (self.value >= self.max) return null; - const idx = self.tag.index(self.value); - const res = self.screen.getRow(idx); - self.value += 1; - return res; - } -}; + .italic => { + self.cursor.style.flags.italic = true; + }, -/// Used to iterate through the rows of a specific region. -pub const CellIterator = struct { - row: Row, - i: usize = 0, + .reset_italic => { + self.cursor.style.flags.italic = false; + }, - pub fn next(self: *CellIterator) ?Cell { - if (self.i >= self.row.storage.len - 1) return null; - const res = self.row.storage[self.i + 1].cell; - self.i += 1; - return res; - } -}; + .faint => { + self.cursor.style.flags.faint = true; + }, -/// Used to iterate through the codepoints of a cell. This only iterates -/// over the extra grapheme codepoints and not the primary codepoint. -pub const CodepointIterator = struct { - data: GraphemeData, - i: usize = 0, - - /// Returns the number of codepoints in the iterator. - pub fn len(self: CodepointIterator) usize { - switch (self.data) { - .zero => return 0, - .one => return 1, - .two => return 2, - .three => return 3, - .four => return 4, - .many => |v| return v.len, - } - } + .underline => |v| { + self.cursor.style.flags.underline = v; + }, - pub fn next(self: *CodepointIterator) ?u21 { - switch (self.data) { - .zero => return null, + .reset_underline => { + self.cursor.style.flags.underline = .none; + }, - .one => |v| { - if (self.i >= 1) return null; - self.i += 1; - return v; - }, + .underline_color => |rgb| { + self.cursor.style.underline_color = .{ .rgb = .{ + .r = rgb.r, + .g = rgb.g, + .b = rgb.b, + } }; + }, - .two => |v| { - if (self.i >= v.len) return null; - defer self.i += 1; - return v[self.i]; - }, + .@"256_underline_color" => |idx| { + self.cursor.style.underline_color = .{ .palette = idx }; + }, - .three => |v| { - if (self.i >= v.len) return null; - defer self.i += 1; - return v[self.i]; - }, + .reset_underline_color => { + self.cursor.style.underline_color = .none; + }, - .four => |v| { - if (self.i >= v.len) return null; - defer self.i += 1; - return v[self.i]; - }, + .blink => { + self.cursor.style.flags.blink = true; + }, - .many => |v| { - if (self.i >= v.len) return null; - defer self.i += 1; - return v[self.i]; - }, - } - } + .reset_blink => { + self.cursor.style.flags.blink = false; + }, - pub fn reset(self: *CodepointIterator) void { - self.i = 0; - } -}; + .inverse => { + self.cursor.style.flags.inverse = true; + }, -/// RowIndex represents a row within the screen. There are various meanings -/// of a row index and this union represents the available types. For example, -/// when talking about row "0" you may want the first row in the viewport, -/// the first row in the scrollback, or the first row in the active area. -/// -/// All row indexes are 0-indexed. -pub const RowIndex = union(RowIndexTag) { - /// The index is from the top of the screen. The screen includes all - /// the history. - screen: usize, - - /// The index is from the top of the viewport. Therefore, depending - /// on where the user has scrolled the viewport, "0" is different. - viewport: usize, - - /// The index is from the top of the active area. The active area is - /// always "rows" tall, and 0 is the top row. The active area is the - /// "edit-able" area where the terminal cursor is. - active: usize, - - /// The index is from the top of the history (scrollback) to just - /// prior to the active area. - history: usize, - - /// Convert this row index into a screen offset. This will validate - /// the value so even if it is already a screen value, this may error. - pub fn toScreen(self: RowIndex, screen: *const Screen) RowIndex { - const y = switch (self) { - .screen => |y| y: { - // NOTE for this and others below: Zig is supposed to optimize - // away assert in releasefast but for some reason these were - // not being optimized away. I don't know why. For these asserts - // only, I comptime gate them. - if (std.debug.runtime_safety) assert(y < RowIndexTag.screen.maxLen(screen)); - break :y y; - }, + .reset_inverse => { + self.cursor.style.flags.inverse = false; + }, - .viewport => |y| y: { - if (std.debug.runtime_safety) assert(y < RowIndexTag.viewport.maxLen(screen)); - break :y y + screen.viewport; - }, + .invisible => { + self.cursor.style.flags.invisible = true; + }, - .active => |y| y: { - if (std.debug.runtime_safety) assert(y < RowIndexTag.active.maxLen(screen)); - break :y screen.history + y; - }, + .reset_invisible => { + self.cursor.style.flags.invisible = false; + }, - .history => |y| y: { - if (std.debug.runtime_safety) assert(y < RowIndexTag.history.maxLen(screen)); - break :y y; - }, - }; + .strikethrough => { + self.cursor.style.flags.strikethrough = true; + }, - return .{ .screen = y }; - } -}; + .reset_strikethrough => { + self.cursor.style.flags.strikethrough = false; + }, -/// The tags of RowIndex -pub const RowIndexTag = enum { - screen, - viewport, - active, - history, - - /// The max length for a given tag. This is a length, not an index, - /// so it is 1-indexed. If the value is zero, it means that this - /// section of the screen is empty or disabled. - pub inline fn maxLen(self: RowIndexTag, screen: *const Screen) usize { - return switch (self) { - // Screen can be any of the written rows - .screen => screen.rowsWritten(), - - // Viewport can be any of the written rows or the max size - // of a viewport. - .viewport => @max(1, @min(screen.rows, screen.rowsWritten())), - - // History is all the way up to the top of our active area. If - // we haven't filled our active area, there is no history. - .history => screen.history, - - // Active area can be any number of rows. We ignore rows - // written here because this is the only row index that can - // actively grow our rows. - .active => screen.rows, - //TODO .active => @min(rows_written, screen.rows), - }; - } + .direct_color_fg => |rgb| { + self.cursor.style.fg_color = .{ + .rgb = .{ + .r = rgb.r, + .g = rgb.g, + .b = rgb.b, + }, + }; + }, - /// Construct a RowIndex from a tag. - pub fn index(self: RowIndexTag, value: usize) RowIndex { - return switch (self) { - .screen => .{ .screen = value }, - .viewport => .{ .viewport = value }, - .active => .{ .active = value }, - .history => .{ .history = value }, - }; - } -}; + .direct_color_bg => |rgb| { + self.cursor.style.bg_color = .{ + .rgb = .{ + .r = rgb.r, + .g = rgb.g, + .b = rgb.b, + }, + }; + }, -/// Stores the extra unicode codepoints that form a complete grapheme -/// cluster alongside a cell. We store this separately from a Cell because -/// grapheme clusters are relatively rare (depending on the language) and -/// we don't want to pay for the full cost all the time. -pub const GraphemeData = union(enum) { - // The named counts allow us to avoid allocators. We do this because - // []u21 is sizeof([4]u21) anyways so if we can store avoid small allocations - // we prefer it. Grapheme clusters are almost always <= 4 codepoints. - - zero: void, - one: u21, - two: [2]u21, - three: [3]u21, - four: [4]u21, - many: []u21, - - pub fn deinit(self: GraphemeData, alloc: Allocator) void { - switch (self) { - .many => |v| alloc.free(v), - else => {}, - } - } + .@"8_fg" => |n| { + self.cursor.style.fg_color = .{ .palette = @intFromEnum(n) }; + }, - /// Append the codepoint cp to the grapheme data. - pub fn append(self: *GraphemeData, alloc: Allocator, cp: u21) !void { - switch (self.*) { - .zero => self.* = .{ .one = cp }, - .one => |v| self.* = .{ .two = .{ v, cp } }, - .two => |v| self.* = .{ .three = .{ v[0], v[1], cp } }, - .three => |v| self.* = .{ .four = .{ v[0], v[1], v[2], cp } }, - .four => |v| { - const many = try alloc.alloc(u21, 5); - fastmem.copy(u21, many, &v); - many[4] = cp; - self.* = .{ .many = many }; - }, + .@"8_bg" => |n| { + self.cursor.style.bg_color = .{ .palette = @intFromEnum(n) }; + }, - .many => |v| { - // Note: this is super inefficient, we should use an arraylist - // or something so we have extra capacity. - const many = try alloc.realloc(v, v.len + 1); - many[v.len] = cp; - self.* = .{ .many = many }; - }, - } - } + .reset_fg => self.cursor.style.fg_color = .none, - pub fn copy(self: GraphemeData, alloc: Allocator) !GraphemeData { - // If we're not many we're not allocated so just copy on stack. - if (self != .many) return self; + .reset_bg => self.cursor.style.bg_color = .none, - // Heap allocated - return GraphemeData{ .many = try alloc.dupe(u21, self.many) }; - } + .@"8_bright_fg" => |n| { + self.cursor.style.fg_color = .{ .palette = @intFromEnum(n) }; + }, - test { - log.warn("Grapheme={}", .{@sizeOf(GraphemeData)}); - } + .@"8_bright_bg" => |n| { + self.cursor.style.bg_color = .{ .palette = @intFromEnum(n) }; + }, - test "append" { - const testing = std.testing; - const alloc = testing.allocator; + .@"256_fg" => |idx| { + self.cursor.style.fg_color = .{ .palette = idx }; + }, - var data: GraphemeData = .{ .one = 1 }; - defer data.deinit(alloc); + .@"256_bg" => |idx| { + self.cursor.style.bg_color = .{ .palette = idx }; + }, - try data.append(alloc, 2); - try testing.expectEqual(GraphemeData{ .two = .{ 1, 2 } }, data); - try data.append(alloc, 3); - try testing.expectEqual(GraphemeData{ .three = .{ 1, 2, 3 } }, data); - try data.append(alloc, 4); - try testing.expectEqual(GraphemeData{ .four = .{ 1, 2, 3, 4 } }, data); - try data.append(alloc, 5); - try testing.expect(data == .many); - try testing.expectEqualSlices(u21, &[_]u21{ 1, 2, 3, 4, 5 }, data.many); - try data.append(alloc, 6); - try testing.expect(data == .many); - try testing.expectEqualSlices(u21, &[_]u21{ 1, 2, 3, 4, 5, 6 }, data.many); + .unknown => return, } - comptime { - // We want to keep this at most the size of the tag + []u21 so that - // at most we're paying for the cost of a slice. - //assert(@sizeOf(GraphemeData) == 24); - } -}; + try self.manualStyleUpdate(); +} -/// A line represents a line of text, potentially across soft-wrapped -/// boundaries. This differs from row, which is a single physical row within -/// the terminal screen. -pub const Line = struct { - screen: *Screen, - tag: RowIndexTag, - start: usize, - len: usize, - - /// Return the string for this line. - pub fn string(self: *const Line, alloc: Allocator) ![:0]const u8 { - return try self.screen.selectionString(alloc, self.selection(), true); - } - - /// Receive the string for this line along with the byte-to-point mapping. - pub fn stringMap(self: *const Line, alloc: Allocator) !StringMap { - return try self.screen.selectionStringMap(alloc, self.selection()); - } - - /// Return a selection that covers the entire line. - pub fn selection(self: *const Line) Selection { - // Get the start and end screen point. - const start_idx = self.tag.index(self.start).toScreen(self.screen).screen; - const end_idx = self.tag.index(self.start + (self.len - 1)).toScreen(self.screen).screen; - - // Convert the start and end screen points into a selection across - // the entire rows. We then use selectionString because it handles - // unwrapping, graphemes, etc. - return .{ - .start = .{ .y = start_idx, .x = 0 }, - .end = .{ .y = end_idx, .x = self.screen.cols - 1 }, - }; - } -}; +/// Call this whenever you manually change the cursor style. +pub fn manualStyleUpdate(self: *Screen) !void { + var page = &self.cursor.page_pin.page.data; -/// Iterator over textual lines within the terminal. This will unwrap -/// wrapped lines and consider them a single line. -pub const LineIterator = struct { - row_it: RowIterator, - - pub fn next(self: *LineIterator) ?Line { - const start = self.row_it.value; - - // Get our current row - var row = self.row_it.next() orelse return null; - var len: usize = 1; - - // While the row is wrapped we keep iterating over the rows - // and incrementing the length. - while (row.isWrapped()) { - // Note: this orelse shouldn't happen. A wrapped row should - // always have a next row. However, this isn't the place where - // we want to assert that. - row = self.row_it.next() orelse break; - len += 1; + // std.log.warn("active styles={}", .{page.styles.count(page.memory)}); + + // Remove our previous style if is unused. + if (self.cursor.style_ref) |ref| { + if (ref.* == 0) { + page.styles.remove(page.memory, self.cursor.style_id); } - return .{ - .screen = self.row_it.screen, - .tag = self.row_it.tag, - .start = start, - .len = len, - }; + // Reset our ID and ref to null since the ref is now invalid. + self.cursor.style_id = 0; + self.cursor.style_ref = null; } -}; - -// Initialize to header and not a cell so that we can check header.init -// to know if the remainder of the row has been initialized or not. -const StorageBuf = CircBuf(StorageCell, .{ .header = .{} }); - -/// Stores a mapping of cell ID (row ID + cell offset + 1) to -/// graphemes associated with a cell. To know if a cell has graphemes, -/// check the "grapheme" flag of a cell. -const GraphemeMap = std.AutoHashMapUnmanaged(usize, GraphemeData); - -/// The allocator used for all the storage operations -alloc: Allocator, - -/// The full set of storage. -storage: StorageBuf, - -/// Graphemes associated with our current screen. -graphemes: GraphemeMap = .{}, -/// The next ID to assign to a row. The value of this is NOT assigned. -next_row_id: RowHeader.Id = 1, - -/// The number of rows and columns in the visible space. -rows: usize, -cols: usize, - -/// The maximum number of lines that are available in scrollback. This -/// is in addition to the number of visible rows. -max_scrollback: usize, - -/// The row (offset from the top) where the viewport currently is. -viewport: usize, - -/// The amount of history (scrollback) that has been written so far. This -/// can be calculated dynamically using the storage buffer but its an -/// extremely hot piece of data so we cache it. Empirically this eliminates -/// millions of function calls and saves seconds under high scroll scenarios -/// (i.e. reading a large file). -history: usize, - -/// Each screen maintains its own cursor state. -cursor: Cursor = .{}, - -/// Saved cursor saved with DECSC (ESC 7). -saved_cursor: ?Cursor.Saved = null, - -/// The selection for this screen (if any). -selection: ?Selection = null, - -/// The kitty keyboard settings. -kitty_keyboard: kitty.KeyFlagStack = .{}, + // If our new style is the default, just reset to that + if (self.cursor.style.default()) { + self.cursor.style_id = 0; + self.cursor.style_ref = null; + return; + } -/// Kitty graphics protocol state. -kitty_images: kitty.graphics.ImageStorage = .{}, + // From here on out, we may need to reload the cursor if we adjust + // the pages to account for space errors below... flag this to true + // in that case. + var cursor_reload = false; + + // After setting the style, we need to update our style map. + // Note that we COULD lazily do this in print. We should look into + // if that makes a meaningful difference. Our priority is to keep print + // fast because setting a ton of styles that do nothing is uncommon + // and weird. + const md = page.styles.upsert( + page.memory, + self.cursor.style, + ) catch |err| md: { + switch (err) { + // Our style map is full. Let's allocate a new page by doubling + // the size and then try again. + error.OutOfMemory => { + const node = try self.pages.adjustCapacity( + self.cursor.page_pin.page, + .{ .styles = page.capacity.styles * 2 }, + ); -/// The charset state -charset: CharsetState = .{}, + page = &node.data; + }, -/// The current or most recent protected mode. Once a protection mode is -/// set, this will never become "off" again until the screen is reset. -/// The current state of whether protection attributes should be set is -/// set on the Cell pen; this is only used to determine the most recent -/// protection mode since some sequences such as ECH depend on this. -protected_mode: ansi.ProtectedMode = .off, + // We've run out of style IDs. This is fixed by doing a page + // compaction. + error.Overflow => { + const node = try self.pages.compact( + self.cursor.page_pin.page, + ); + page = &node.data; + }, + } -/// Initialize a new screen. -pub fn init( - alloc: Allocator, - rows: usize, - cols: usize, - max_scrollback: usize, -) !Screen { - // * Our buffer size is preallocated to fit double our visible space - // or the maximum scrollback whichever is smaller. - // * We add +1 to cols to fit the row header - const buf_size = (rows + @min(max_scrollback, rows)) * (cols + 1); + // Since this modifies our cursor page, we need to reload + cursor_reload = true; - return Screen{ - .alloc = alloc, - .storage = try StorageBuf.init(alloc, buf_size), - .rows = rows, - .cols = cols, - .max_scrollback = max_scrollback, - .viewport = 0, - .history = 0, + break :md try page.styles.upsert( + page.memory, + self.cursor.style, + ); + }; + self.cursor.style_id = md.id; + self.cursor.style_ref = &md.ref; + if (cursor_reload) self.cursorReload(); + self.assertIntegrity(); +} + +/// Append a grapheme to the given cell within the current cursor row. +pub fn appendGrapheme(self: *Screen, cell: *Cell, cp: u21) !void { + defer self.cursor.page_pin.page.data.assertIntegrity(); + self.cursor.page_pin.page.data.appendGrapheme( + self.cursor.page_row, + cell, + cp, + ) catch |err| switch (err) { + error.OutOfMemory => { + // We need to determine the actual cell index of the cell so + // that after we adjust the capacity we can reload the cell. + const cell_idx: usize = cell_idx: { + const cells: [*]Cell = @ptrCast(self.cursor.page_cell); + const zero: [*]Cell = cells - self.cursor.x; + const target: [*]Cell = @ptrCast(cell); + const cell_idx = (@intFromPtr(target) - @intFromPtr(zero)) / @sizeOf(Cell); + break :cell_idx cell_idx; + }; + + // Adjust our capacity. This will update our cursor page pin and + // force us to reload. + const original_node = self.cursor.page_pin.page; + const new_bytes = original_node.data.capacity.grapheme_bytes * 2; + _ = try self.pages.adjustCapacity(original_node, .{ .grapheme_bytes = new_bytes }); + self.cursorReload(); + + // The cell pointer is now invalid, so we need to get it from + // the reloaded cursor pointers. + const reloaded_cell: *Cell = switch (std.math.order(cell_idx, self.cursor.x)) { + .eq => self.cursor.page_cell, + .lt => self.cursorCellLeft(@intCast(self.cursor.x - cell_idx)), + .gt => self.cursorCellRight(@intCast(cell_idx - self.cursor.x)), + }; + + try self.cursor.page_pin.page.data.appendGrapheme( + self.cursor.page_row, + reloaded_cell, + cp, + ); + }, }; } -pub fn deinit(self: *Screen) void { - self.kitty_images.deinit(self.alloc); - self.storage.deinit(self.alloc); - self.deinitGraphemes(); -} - -fn deinitGraphemes(self: *Screen) void { - var grapheme_it = self.graphemes.valueIterator(); - while (grapheme_it.next()) |data| data.deinit(self.alloc); - self.graphemes.deinit(self.alloc); -} - -/// Copy the screen portion given by top and bottom into a new screen instance. -/// This clone is meant for read-only access and hasn't been tested for -/// mutability. -pub fn clone(self: *Screen, alloc: Allocator, top: RowIndex, bottom: RowIndex) !Screen { - // Convert our top/bottom to screen coordinates - const top_y = top.toScreen(self).screen; - const bot_y = bottom.toScreen(self).screen; - assert(bot_y >= top_y); - const height = (bot_y - top_y) + 1; - - // We also figure out the "max y" we can have based on the number - // of rows written. This is used to prevent from reading out of the - // circular buffer where we might have no initialized data yet. - const max_y = max_y: { - const rows_written = self.rowsWritten(); - const index = RowIndex{ .active = @min(rows_written -| 1, self.rows - 1) }; - break :max_y index.toScreen(self).screen; +/// Set the selection to the given selection. If this is a tracked selection +/// then the screen will take overnship of the selection. If this is untracked +/// then the screen will convert it to tracked internally. This will automatically +/// untrack the prior selection (if any). +/// +/// Set the selection to null to clear any previous selection. +/// +/// This is always recommended over setting `selection` directly. Beyond +/// managing memory for you, it also performs safety checks that the selection +/// is always tracked. +pub fn select(self: *Screen, sel_: ?Selection) !void { + const sel = sel_ orelse { + self.clearSelection(); + return; }; - // The "real" Y value we use is whichever is smaller: the bottom - // requested or the max. This prevents from reading zero data. - // The "real" height is the amount of height of data we can actually - // copy. - const real_y = @min(bot_y, max_y); - const real_height = (real_y - top_y) + 1; - //log.warn("bot={} max={} top={} real={}", .{ bot_y, max_y, top_y, real_y }); - - // Init a new screen that exactly fits the height. The height is the - // non-real value because we still want the requested height by the - // caller. - var result = try init(alloc, height, self.cols, 0); - errdefer result.deinit(); - - // Copy some data - result.cursor = self.cursor; - - // Get the pointer to our source buffer - const len = real_height * (self.cols + 1); - const src = self.storage.getPtrSlice(top_y * (self.cols + 1), len); - - // Get a direct pointer into our storage buffer. This should always be - // one slice because we created a perfectly fitting buffer. - const dst = result.storage.getPtrSlice(0, len); - assert(dst[1].len == 0); - - // Perform the copy - fastmem.copy(StorageCell, dst[0], src[0]); - fastmem.copy(StorageCell, dst[0][src[0].len..], src[1]); - - // If there are graphemes, we just copy them all - if (self.graphemes.count() > 0) { - // Clone the map - const graphemes = try self.graphemes.clone(alloc); - - // Go through all the values and clone the data because it MAY - // (rarely) be allocated. - var it = graphemes.iterator(); - while (it.next()) |kv| { - kv.value_ptr.* = try kv.value_ptr.copy(alloc); - } - - result.graphemes = graphemes; - } - - return result; -} - -/// Returns true if the viewport is scrolled to the bottom of the screen. -pub fn viewportIsBottom(self: Screen) bool { - return self.viewport == self.history; -} + // If this selection is untracked then we track it. + const tracked_sel = if (sel.tracked()) sel else try sel.track(self); + errdefer if (!sel.tracked()) tracked_sel.deinit(self); -/// Shortcut for getRow followed by getCell as a quick way to read a cell. -/// This is particularly useful for quickly reading the cell under a cursor -/// with `getCell(.active, cursor.y, cursor.x)`. -pub fn getCell(self: *Screen, tag: RowIndexTag, y: usize, x: usize) Cell { - return self.getRow(tag.index(y)).getCell(x); + // Untrack prior selection + if (self.selection) |*old| old.deinit(self); + self.selection = tracked_sel; } -/// Shortcut for getRow followed by getCellPtr as a quick way to read a cell. -pub fn getCellPtr(self: *Screen, tag: RowIndexTag, y: usize, x: usize) *Cell { - return self.getRow(tag.index(y)).getCellPtr(x); +/// Same as select(null) but can't fail. +pub fn clearSelection(self: *Screen) void { + if (self.selection) |*sel| sel.deinit(self); + self.selection = null; } -/// Returns an iterator that can be used to iterate over all of the rows -/// from index zero of the given row index type. This can therefore iterate -/// from row 0 of the active area, history, viewport, etc. -pub fn rowIterator(self: *Screen, tag: RowIndexTag) RowIterator { - return .{ - .screen = self, - .tag = tag, - .max = tag.maxLen(self), - }; -} +pub const SelectionString = struct { + /// The selection to convert to a string. + sel: Selection, -/// Returns an iterator that iterates over the lines of the screen. A line -/// is a single line of text which may wrap across multiple rows. A row -/// is a single physical row of the terminal. -pub fn lineIterator(self: *Screen, tag: RowIndexTag) LineIterator { - return .{ .row_it = self.rowIterator(tag) }; -} + /// If true, trim whitespace around the selection. + trim: bool = true, -/// Returns the line that contains the given point. This may be null if the -/// point is outside the screen. -pub fn getLine(self: *Screen, pt: point.ScreenPoint) ?Line { - // If our y is outside of our written area, we have no line. - if (pt.y >= RowIndexTag.screen.maxLen(self)) return null; - if (pt.x >= self.cols) return null; + /// If non-null, a stringmap will be written here. This will use + /// the same allocator as the call to selectionString. The string will + /// be duplicated here and in the return value so both must be freed. + map: ?*StringMap = null, +}; - // Find the starting y. We go back and as soon as we find a row that - // isn't wrapped, we know the NEXT line is the one we want. - const start_y: usize = if (pt.y == 0) 0 else start_y: { - for (1..pt.y) |y| { - const bot_y = pt.y - y; - const row = self.getRow(.{ .screen = bot_y }); - if (!row.isWrapped()) break :start_y bot_y + 1; - } +/// Returns the raw text associated with a selection. This will unwrap +/// soft-wrapped edges. The returned slice is owned by the caller and allocated +/// using alloc, not the allocator associated with the screen (unless they match). +pub fn selectionString(self: *Screen, alloc: Allocator, opts: SelectionString) ![:0]const u8 { + // Use an ArrayList so that we can grow the array as we go. We + // build an initial capacity of just our rows in our selection times + // columns. It can be more or less based on graphemes, newlines, etc. + var strbuilder = std.ArrayList(u8).init(alloc); + defer strbuilder.deinit(); - break :start_y 0; + // If we're building a stringmap, create our builder for the pins. + const MapBuilder = std.ArrayList(Pin); + var mapbuilder: ?MapBuilder = if (opts.map != null) MapBuilder.init(alloc) else null; + defer if (mapbuilder) |*b| b.deinit(); + + const sel_ordered = opts.sel.ordered(self, .forward); + const sel_start = start: { + var start = sel_ordered.start(); + const cell = start.rowAndCell().cell; + if (cell.wide == .spacer_tail) start.x -= 1; + break :start start; }; - - // Find the end y, which is the first row that isn't wrapped. - const end_y = end_y: { - for (pt.y..self.rowsWritten()) |y| { - const row = self.getRow(.{ .screen = y }); - if (!row.isWrapped()) break :end_y y; + const sel_end = end: { + var end = sel_ordered.end(); + const cell = end.rowAndCell().cell; + switch (cell.wide) { + .narrow, .wide => {}, + + // We can omit the tail + .spacer_tail => end.x -= 1, + + // With the head we want to include the wrapped wide character. + .spacer_head => if (end.down(1)) |p| { + end = p; + end.x = 0; + }, } - - break :end_y self.rowsWritten() - 1; + break :end end; }; - return .{ - .screen = self, - .tag = .screen, - .start = start_y, - .len = (end_y - start_y) + 1, - }; -} - -/// Returns the row at the given index. This row is writable, although -/// only the active area should probably be written to. -pub fn getRow(self: *Screen, index: RowIndex) Row { - // Get our offset into storage - const offset = index.toScreen(self).screen * (self.cols + 1); - - // Get the slices into the storage. This should never wrap because - // we're perfectly aligned on row boundaries. - const slices = self.storage.getPtrSlice(offset, self.cols + 1); - assert(slices[0].len == self.cols + 1 and slices[1].len == 0); - - const row: Row = .{ .screen = self, .storage = slices[0] }; - if (row.storage[0].header.id == 0) { - const Id = @TypeOf(self.next_row_id); - const id = self.next_row_id; - self.next_row_id +%= @as(Id, @intCast(self.cols)); - - // Store the header - row.storage[0].header.id = id; - - // We only set dirty and fill if its not dirty. If its dirty - // we assume this row has been written but just hasn't had - // an ID assigned yet. - if (!row.storage[0].header.flags.dirty) { - // Mark that we're dirty since we're a new row - row.storage[0].header.flags.dirty = true; - - // We only need to fill with runtime safety because unions are - // tag-checked. Otherwise, the default value of zero will be valid. - if (std.debug.runtime_safety) row.fill(.{}); - } - } - return row; -} - -/// Copy the row at src to dst. -pub fn copyRow(self: *Screen, dst: RowIndex, src: RowIndex) !void { - // One day we can make this more efficient but for now - // we do the easy thing. - const dst_row = self.getRow(dst); - const src_row = self.getRow(src); - try dst_row.copyRow(src_row); -} - -/// Scroll rows in a region up. Rows that go beyond the region -/// top or bottom are deleted, and new rows inserted are blank according -/// to the current pen. -/// -/// This does NOT create any new scrollback. This modifies an existing -/// region within the screen (including possibly the scrollback if -/// the top/bottom are within it). -/// -/// This can be used to implement terminal scroll regions efficiently. -pub fn scrollRegionUp(self: *Screen, top: RowIndex, bottom: RowIndex, count_req: usize) void { - // Avoid a lot of work if we're doing nothing. - if (count_req == 0) return; - - // Convert our top/bottom to screen y values. This is the y offset - // in the entire screen buffer. - const top_y = top.toScreen(self).screen; - const bot_y = bottom.toScreen(self).screen; - - // If top is outside of the range of bot, we do nothing. - if (top_y >= bot_y) return; - - // We can only scroll up to the number of rows in the region. The "+ 1" - // is because our y values are 0-based and count is 1-based. - const count = @min(count_req, bot_y - top_y + 1); - - // Get the storage pointer for the full scroll region. We're going to - // be modifying the whole thing so we get it right away. - const height = (bot_y - top_y) + 1; - const len = height * (self.cols + 1); - const slices = self.storage.getPtrSlice(top_y * (self.cols + 1), len); - - // The total amount we're going to copy - const total_copy = (height - count) * (self.cols + 1); - - // The pen we'll use for new cells (only the BG attribute is applied to new - // cells) - const pen: Cell = switch (self.cursor.pen.bg) { - .none => .{}, - else => |bg| .{ .bg = bg }, - }; + var page_it = sel_start.pageIterator(.right_down, sel_end); + var row_count: usize = 0; + while (page_it.next()) |chunk| { + const rows = chunk.rows(); + for (rows, chunk.start..) |row, y| { + const cells_ptr = row.cells.ptr(chunk.page.data.memory); - // Fast-path is that we have a contiguous buffer in our circular buffer. - // In this case we can do some memmoves. - if (slices[1].len == 0) { - const buf = slices[0]; + const start_x = if (row_count == 0 or sel_ordered.rectangle) + sel_start.x + else + 0; + const end_x = if (row_count == rows.len - 1 or sel_ordered.rectangle) + sel_end.x + 1 + else + self.pages.cols; + + const cells = cells_ptr[start_x..end_x]; + for (cells, start_x..) |*cell, x| { + // Skip wide spacers + switch (cell.wide) { + .narrow, .wide => {}, + .spacer_head, .spacer_tail => continue, + } - { - // Our copy starts "count" rows below and is the length of - // the remainder of the data. Our destination is the top since - // we're scrolling up. - // - // Note we do NOT need to set any row headers to dirty because - // the row contents are not changing for the row ID. - const dst = buf; - const src_offset = count * (self.cols + 1); - const src = buf[src_offset..]; - assert(@intFromPtr(dst.ptr) < @intFromPtr(src.ptr)); - fastmem.move(StorageCell, dst, src); - } + var buf: [4]u8 = undefined; + { + const raw: u21 = if (cell.hasText()) cell.content.codepoint else 0; + const char = if (raw > 0) raw else ' '; + const encode_len = try std.unicode.utf8Encode(char, &buf); + try strbuilder.appendSlice(buf[0..encode_len]); + if (mapbuilder) |*b| { + for (0..encode_len) |_| try b.append(.{ + .page = chunk.page, + .y = y, + .x = x, + }); + } + } + if (cell.hasGrapheme()) { + const cps = chunk.page.data.lookupGrapheme(cell).?; + for (cps) |cp| { + const encode_len = try std.unicode.utf8Encode(cp, &buf); + try strbuilder.appendSlice(buf[0..encode_len]); + if (mapbuilder) |*b| { + for (0..encode_len) |_| try b.append(.{ + .page = chunk.page, + .y = y, + .x = x, + }); + } + } + } + } - { - // Copy in our empties. The destination is the bottom - // count rows. We first fill with the pen values since there - // is a lot more of that. - const dst_offset = total_copy; - const dst = buf[dst_offset..]; - @memset(dst, .{ .cell = pen }); - - // Then we make sure our row headers are zeroed out. We set - // the value to a dirty row header so that the renderer re-draws. - // - // NOTE: we do NOT set a valid row ID here. The next time getRow - // is called it will be initialized. This should work fine as - // far as I can tell. It is important to set dirty so that the - // renderer knows to redraw this. - var i: usize = dst_offset; - while (i < buf.len) : (i += self.cols + 1) { - buf[i] = .{ .header = .{ - .flags = .{ .dirty = true }, - } }; + if (row_count < rows.len - 1 and + (!row.wrap or sel_ordered.rectangle)) + { + try strbuilder.append('\n'); + if (mapbuilder) |*b| try b.append(.{ + .page = chunk.page, + .y = y, + .x = chunk.page.data.size.cols - 1, + }); } + + row_count += 1; } + } - return; + if (comptime std.debug.runtime_safety) { + if (mapbuilder) |b| assert(strbuilder.items.len == b.items.len); } - // If we're split across two buffers this is a "slow" path. This shouldn't - // happen with the "active" area but it appears it does... in the future - // I plan on changing scroll region stuff to make it much faster so for - // now we just deal with this slow path. - - // This is the offset where we have to start copying. - const src_offset = count * (self.cols + 1); - - // Perform the copy and calculate where we need to start zero-ing. - const zero_offset: [2]usize = if (src_offset < slices[0].len) zero_offset: { - var remaining: usize = len; - - // Source starts in the top... so we can copy some from there. - const dst = slices[0]; - const src = slices[0][src_offset..]; - assert(@intFromPtr(dst.ptr) < @intFromPtr(src.ptr)); - fastmem.move(StorageCell, dst, src); - remaining = total_copy - src.len; - if (remaining == 0) break :zero_offset .{ src.len, 0 }; - - // We have data remaining, which means that we have to grab some - // from the bottom slice. - const dst2 = slices[0][src.len..]; - const src2_len = @min(dst2.len, remaining); - const src2 = slices[1][0..src2_len]; - fastmem.copy(StorageCell, dst2, src2); - remaining -= src2_len; - if (remaining == 0) break :zero_offset .{ src.len + src2.len, 0 }; - - // We still have data remaining, which means we copy into the bot. - const dst3 = slices[1]; - const src3 = slices[1][src2_len .. src2_len + remaining]; - fastmem.move(StorageCell, dst3, src3); - - break :zero_offset .{ slices[0].len, src3.len }; - } else zero_offset: { - var remaining: usize = len; - - // Source is in the bottom, so we copy from there into top. - const bot_src_offset = src_offset - slices[0].len; - const dst = slices[0]; - const src = slices[1][bot_src_offset..]; - const src_len = @min(dst.len, src.len); - fastmem.copy(StorageCell, dst, src[0..src_len]); - remaining = total_copy - src_len; - if (remaining == 0) break :zero_offset .{ src_len, 0 }; - - // We have data remaining, this has to go into the bottom. - const dst2 = slices[1]; - const src2_offset = bot_src_offset + src_len; - const src2 = slices[1][src2_offset..]; - const src2_len = remaining; - fastmem.move(StorageCell, dst2, src2[0..src2_len]); - break :zero_offset .{ src_len, src2_len }; - }; + // If we have a mapbuilder, we need to setup our string map. + if (mapbuilder) |*b| { + var strclone = try strbuilder.clone(); + defer strclone.deinit(); + const str = try strclone.toOwnedSliceSentinel(0); + errdefer alloc.free(str); + const map = try b.toOwnedSlice(); + errdefer alloc.free(map); + opts.map.?.* = .{ .string = str, .map = map }; + } - // Zero - for (zero_offset, 0..) |offset, i| { - if (offset >= slices[i].len) continue; + // Remove any trailing spaces on lines. We could do optimize this by + // doing this in the loop above but this isn't very hot path code and + // this is simple. + if (opts.trim) { + var it = std.mem.tokenizeScalar(u8, strbuilder.items, '\n'); - const dst = slices[i][offset..]; - @memset(dst, .{ .cell = pen }); + // Reset our items. We retain our capacity. Because we're only + // removing bytes, we know that the trimmed string must be no longer + // than the original string so we copy directly back into our + // allocated memory. + strbuilder.clearRetainingCapacity(); + while (it.next()) |line| { + const trimmed = std.mem.trimRight(u8, line, " \t"); + const i = strbuilder.items.len; + strbuilder.items.len += trimmed.len; + std.mem.copyForwards(u8, strbuilder.items[i..], trimmed); + try strbuilder.append('\n'); + } - var j: usize = offset; - while (j < slices[i].len) : (j += self.cols + 1) { - slices[i][j] = .{ .header = .{ - .flags = .{ .dirty = true }, - } }; + // Remove all trailing newlines + for (0..strbuilder.items.len) |_| { + if (strbuilder.items[strbuilder.items.len - 1] != '\n') break; + strbuilder.items.len -= 1; } } -} - -/// Returns the offset into the storage buffer that the given row can -/// be found. This assumes valid input and will crash if the input is -/// invalid. -fn rowOffset(self: Screen, index: RowIndex) usize { - // +1 for row header - return index.toScreen(&self).screen * (self.cols + 1); -} -/// Returns the number of rows that have actually been written to the -/// screen. This assumes a row is "written" if getRow was ever called -/// on the row. -fn rowsWritten(self: Screen) usize { - // The number of rows we've actually written into our buffer - // This should always be cleanly divisible since we only request - // data in row chunks from the buffer. - assert(@mod(self.storage.len(), self.cols + 1) == 0); - return self.storage.len() / (self.cols + 1); -} + // Get our final string + const string = try strbuilder.toOwnedSliceSentinel(0); + errdefer alloc.free(string); -/// The number of rows our backing storage supports. This should -/// always be self.rows but we use the backing storage as a source of truth. -fn rowsCapacity(self: Screen) usize { - assert(@mod(self.storage.capacity(), self.cols + 1) == 0); - return self.storage.capacity() / (self.cols + 1); + return string; } -/// The maximum possible capacity of the underlying buffer if we reached -/// the max scrollback. -fn maxCapacity(self: Screen) usize { - return (self.rows + self.max_scrollback) * (self.cols + 1); -} +pub const SelectLine = struct { + /// The pin of some part of the line to select. + pin: Pin, -pub const ClearMode = enum { - /// Delete all history. This will also move the viewport area to the top - /// so that the viewport area never contains history. This does NOT - /// change the active area. - history, + /// These are the codepoints to consider whitespace to trim + /// from the ends of the selection. + whitespace: ?[]const u21 = &.{ 0, ' ', '\t' }, - /// Clear all the lines above the cursor in the active area. This does - /// not touch history. - above_cursor, + /// If true, line selection will consider semantic prompt + /// state changing a boundary. State changing is ANY state + /// change. + semantic_prompt_boundary: bool = true, }; -/// Clear the screen contents according to the given mode. -pub fn clear(self: *Screen, mode: ClearMode) !void { - switch (mode) { - .history => { - // If there is no history, do nothing. - if (self.history == 0) return; - - // Delete all our history - self.storage.deleteOldest(self.history * (self.cols + 1)); - self.history = 0; - - // Back to the top - self.viewport = 0; - }, - - .above_cursor => { - // First we copy all the rows from our cursor down to the top - // of the active area. - var y: usize = self.cursor.y; - const y_max = @min(self.rows, self.rowsWritten()) - 1; - const copy_n = (y_max - y) + 1; - while (y <= y_max) : (y += 1) { - const dst_y = y - self.cursor.y; - const dst = self.getRow(.{ .active = dst_y }); - const src = self.getRow(.{ .active = y }); - try dst.copyRow(src); - } - - // Next we want to clear all the rows below the copied amount. - y = copy_n; - while (y <= y_max) : (y += 1) { - const dst = self.getRow(.{ .active = y }); - dst.clear(.{}); - } - - // Move our cursor to the top - self.cursor.y = 0; - - // Scroll to the top of the viewport - self.viewport = self.history; - }, - } -} - -/// Return the selection for all contents on the screen. Surrounding -/// whitespace is omitted. If there is no selection, this returns null. -pub fn selectAll(self: *Screen) ?Selection { - const whitespace = &[_]u32{ 0, ' ', '\t' }; - const y_max = self.rowsWritten() - 1; - - const start: point.ScreenPoint = start: { - var y: usize = 0; - 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; - }; - - const end: point.ScreenPoint = end: { - var y: usize = y_max; - 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; - } - }; - - return Selection{ - .start = start, - .end = end, - }; -} - /// 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 { - // Whitespace characters for selection purposes - const whitespace = &[_]u32{ 0, ' ', '\t' }; - - // 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; +pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { + _ = self; // Get the current point semantic prompt state since that determines // boundary conditions too. This makes it so that line selection can // only happen within the same prompt state. For example, if you triple // click output, but the shell uses spaces to soft-wrap to the prompt // then the selection will stop prior to the prompt. See issue #1329. - const semantic_prompt_state = self.getRow(.{ .screen = pt.y }) - .getSemanticPrompt() - .promptOrInput(); + const semantic_prompt_state: ?bool = state: { + if (!opts.semantic_prompt_boundary) break :state null; + const rac = opts.pin.rowAndCell(); + break :state rac.row.semantic_prompt.promptOrInput(); + }; // 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; + const start_pin: Pin = start_pin: { + var it = opts.pin.rowIterator(.left_up, null); + var it_prev: Pin = it.next().?; // skip self + while (it.next()) |p| { + const row = p.rowAndCell().row; + + if (!row.wrap) { + var copy = it_prev; + copy.x = 0; + break :start_pin copy; + } - // See semantic_prompt_state comment for why - const current_prompt = current.getSemanticPrompt().promptOrInput(); - if (current_prompt != semantic_prompt_state) break :start_row y + 1; + if (semantic_prompt_state) |v| { + // See semantic_prompt_state comment for why + const current_prompt = row.semantic_prompt.promptOrInput(); + if (current_prompt != v) { + var copy = it_prev; + copy.x = 0; + break :start_pin copy; + } + } - if (y == 0) break :start_row y; - y -= 1; + it_prev = p; + } else { + var copy = it_prev; + copy.x = 0; + break :start_pin copy; } - 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 }); - - // See semantic_prompt_state comment for why - const current_prompt = current.getSemanticPrompt().promptOrInput(); - if (current_prompt != semantic_prompt_state) break :end_row y - 1; + const end_pin: Pin = end_pin: { + var it = opts.pin.rowIterator(.right_down, null); + while (it.next()) |p| { + const row = p.rowAndCell().row; + + if (semantic_prompt_state) |v| { + // See semantic_prompt_state comment for why + const current_prompt = row.semantic_prompt.promptOrInput(); + if (current_prompt != v) { + var prev = p.up(1).?; + prev.x = p.page.data.size.cols - 1; + break :end_pin prev; + } + } - // End of the screen or not wrapped, we're done. - if (y == y_max or !current.header().flags.wrap) break :end_row y; + if (!row.wrap) { + var copy = p; + copy.x = p.page.data.size.cols - 1; + break :end_pin copy; + } } - unreachable; + + return null; }; // 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 }; - } + const start: Pin = start: { + const whitespace = opts.whitespace orelse break :start start_pin; + var it = start_pin.cellIterator(.right_down, end_pin); + while (it.next()) |p| { + const cell = p.rowAndCell().cell; + if (!cell.hasText()) continue; + + // Non-empty means we found it. + const this_whitespace = std.mem.indexOfAny( + u21, + whitespace, + &[_]u21{cell.content.codepoint}, + ) != null; + if (this_whitespace) continue; + + break :start p; } - // 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 }; - } + const end: Pin = end: { + const whitespace = opts.whitespace orelse break :end end_pin; + var it = end_pin.cellIterator(.left_up, start_pin); + while (it.next()) |p| { + const cell = p.rowAndCell().cell; + if (!cell.hasText()) continue; + + // Non-empty means we found it. + const this_whitespace = std.mem.indexOfAny( + u21, + whitespace, + &[_]u21{cell.content.codepoint}, + ) != null; + if (this_whitespace) continue; + + break :end p; + } + + return null; + }; + + return Selection.init(start, end, false); +} + +/// Return the selection for all contents on the screen. Surrounding +/// whitespace is omitted. If there is no selection, this returns null. +pub fn selectAll(self: *Screen) ?Selection { + const whitespace = &[_]u32{ 0, ' ', '\t' }; - if (y == 0) break; - y -= 1; + const start: Pin = start: { + var it = self.pages.cellIterator( + .right_down, + .{ .screen = .{} }, + null, + ); + while (it.next()) |p| { + const cell = p.rowAndCell().cell; + if (!cell.hasText()) continue; + + // Non-empty means we found it. + const this_whitespace = std.mem.indexOfAny( + u32, + whitespace, + &[_]u32{cell.content.codepoint}, + ) != null; + if (this_whitespace) continue; + + break :start p; } - // There is no start point and therefore no line that can be selected. return null; }; - return Selection{ - .start = start, - .end = end, + const end: Pin = end: { + var it = self.pages.cellIterator( + .left_up, + .{ .screen = .{} }, + null, + ); + while (it.next()) |p| { + const cell = p.rowAndCell().cell; + if (!cell.hasText()) continue; + + // Non-empty means we found it. + const this_whitespace = std.mem.indexOfAny( + u32, + whitespace, + &[_]u32{cell.content.codepoint}, + ) != null; + if (this_whitespace) continue; + + break :end p; + } + + return null; }; + + return Selection.init(start, end, false); } /// Select the nearest word to start point that is between start_pt and /// end_pt (inclusive). Because it selects "nearest" to start point, start /// point can be before or after end point. +/// +/// TODO: test this pub fn selectWordBetween( self: *Screen, - start_pt: point.ScreenPoint, - end_pt: point.ScreenPoint, + start: Pin, + end: Pin, ) ?Selection { - const dir: point.Direction = if (start_pt.before(end_pt)) .right_down else .left_up; - var it = start_pt.iterator(self, dir); - while (it.next()) |pt| { + const dir: PageList.Direction = if (start.before(end)) .right_down else .left_up; + var it = start.cellIterator(dir, end); + while (it.next()) |pin| { // Boundary conditions switch (dir) { - .right_down => if (end_pt.before(pt)) return null, - .left_up => if (pt.before(end_pt)) return null, + .right_down => if (end.before(pin)) return null, + .left_up => if (pin.before(end)) return null, } // If we found a word, then return it - if (self.selectWord(pt)) |sel| return sel; + if (self.selectWord(pin)) |sel| return sel; } return null; @@ -1737,7 +1697,9 @@ pub fn selectWordBetween( /// /// 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 { +pub fn selectWord(self: *Screen, pin: Pin) ?Selection { + _ = self; + // Boundary characters for selection purposes const boundary = &[_]u32{ 0, @@ -1760,112 +1722,81 @@ 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; + const start_cell = pin.rowAndCell().cell; + if (!start_cell.hasText()) return null; // Determine if we are a boundary or not to determine what our boundary is. - const expect_boundary = std.mem.indexOfAny(u32, boundary, &[_]u32{start_cell.char}) != null; + const expect_boundary = std.mem.indexOfAny( + u32, + boundary, + &[_]u32{start_cell.content.codepoint}, + ) != null; // Go forwards to find our end boundary - const end: point.ScreenPoint = boundary: { - var prev: point.ScreenPoint = pt; - var y: usize = pt.y; - 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; - - // If we do not match our expected set, we hit a boundary - const this_boundary = std.mem.indexOfAny( - u32, - boundary, - &[_]u32{cell.char}, - ) != null; - if (this_boundary != expect_boundary) break :boundary prev; - - // Increase our prev - prev.x = x; - prev.y = y; + const end: Pin = end: { + var it = pin.cellIterator(.right_down, null); + var prev = it.next().?; // Consume one, our start + while (it.next()) |p| { + const rac = p.rowAndCell(); + const cell = rac.cell; + + // If we reached an empty cell its always a boundary + if (!cell.hasText()) break :end prev; + + // If we do not match our expected set, we hit a boundary + const this_boundary = std.mem.indexOfAny( + u32, + boundary, + &[_]u32{cell.content.codepoint}, + ) != null; + if (this_boundary != expect_boundary) break :end prev; + + // If we are going to the next row and it isn't wrapped, we + // return the previous. + if (p.x == p.page.data.size.cols - 1 and !rac.row.wrap) { + break :end p; } - // 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; + prev = p; } - break :boundary .{ .x = self.cols - 1, .y = y_max }; + break :end prev; }; // 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_boundary = std.mem.indexOfAny( - u32, - boundary, - &[_]u32{cell.char}, - ) != null; - if (this_boundary != expect_boundary) break :boundary prev; - - // Update our prev - prev.x = x - 1; - prev.y = y; + const start: Pin = start: { + var it = pin.cellIterator(.left_up, null); + var prev = it.next().?; // Consume one, our start + while (it.next()) |p| { + const rac = p.rowAndCell(); + const cell = rac.cell; + + // If we are going to the next row and it isn't wrapped, we + // return the previous. + if (p.x == p.page.data.size.cols - 1 and !rac.row.wrap) { + break :start prev; } - // 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 reached an empty cell its always a boundary + if (!cell.hasText()) break :start prev; - // If we're at the end, we're done! - if (y == 0) break; + // If we do not match our expected set, we hit a boundary + const this_boundary = std.mem.indexOfAny( + u32, + boundary, + &[_]u32{cell.content.codepoint}, + ) != null; + if (this_boundary != expect_boundary) break :start prev; - // 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; - } + prev = p; } - break :boundary .{ .x = 0, .y = 0 }; + break :start prev; }; - return Selection{ - .start = start, - .end = end, - }; + return Selection.init(start, end, false); } /// Select the command output under the given point. The limits of the output @@ -1876,54 +1807,73 @@ pub fn selectWord(self: *Screen, pt: point.ScreenPoint) ?Selection { /// this happens is if: /// - the point pt is outside of the written screen space. /// - the point pt is on a prompt / input line. -pub fn selectOutput(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; - const point_row = self.getRow(.{ .screen = pt.y }); - switch (point_row.getSemanticPrompt()) { +pub fn selectOutput(self: *Screen, pin: Pin) ?Selection { + _ = self; + + switch (pin.rowAndCell().row.semantic_prompt) { .input, .prompt_continuation, .prompt => { // Cursor on a prompt line, selection impossible return null; }, + else => {}, } // Go forwards to find our end boundary // We are looking for input start / prompt markers - const end: point.ScreenPoint = boundary: { - for (pt.y..y_max + 1) |y| { - const row = self.getRow(.{ .screen = y }); - switch (row.getSemanticPrompt()) { + const end: Pin = boundary: { + var it = pin.rowIterator(.right_down, null); + var it_prev = pin; + while (it.next()) |p| { + const row = p.rowAndCell().row; + switch (row.semantic_prompt) { .input, .prompt_continuation, .prompt => { - const prev_row = self.getRow(.{ .screen = y - 1 }); - break :boundary .{ .x = prev_row.lenCells(), .y = y - 1 }; + var copy = it_prev; + copy.x = it_prev.page.data.size.cols - 1; + break :boundary copy; }, else => {}, } + + it_prev = p; + } + + // Find the last non-blank row + it = it_prev.rowIterator(.left_up, null); + while (it.next()) |p| { + const row = p.rowAndCell().row; + const cells = p.page.data.getCells(row); + if (Cell.hasTextAny(cells)) { + var copy = p; + copy.x = p.page.data.size.cols - 1; + break :boundary copy; + } } - break :boundary .{ .x = self.cols - 1, .y = y_max }; + // In this case it means that all our rows are blank. Let's + // just return no selection, this is a weird case. + return null; }; // Go backwards to find our start boundary // We are looking for output start markers - const start: point.ScreenPoint = boundary: { - var y: usize = pt.y; - while (y > 0) : (y -= 1) { - const row = self.getRow(.{ .screen = y }); - switch (row.getSemanticPrompt()) { - .command => break :boundary .{ .x = 0, .y = y }, + const start: Pin = boundary: { + var it = pin.rowIterator(.left_up, null); + var it_prev = pin; + while (it.next()) |p| { + const row = p.rowAndCell().row; + switch (row.semantic_prompt) { + .command => break :boundary p, else => {}, } + + it_prev = p; } - break :boundary .{ .x = 0, .y = 0 }; - }; - return Selection{ - .start = start, - .end = end, + break :boundary it_prev; }; + + return Selection.init(start, end, false); } /// Returns the selection bounds for the prompt at the given point. If the @@ -1934,10 +1884,11 @@ pub fn selectOutput(self: *Screen, pt: point.ScreenPoint) ?Selection { /// /// Note that this feature requires shell integration. If shell integration /// is not enabled, this will always return null. -pub fn selectPrompt(self: *Screen, pt: point.ScreenPoint) ?Selection { +pub fn selectPrompt(self: *Screen, pin: Pin) ?Selection { + _ = self; + // Ensure that the line the point is on is a prompt. - const pt_row = self.getRow(.{ .screen = pt.y }); - const is_known = switch (pt_row.getSemanticPrompt()) { + const is_known = switch (pin.rowAndCell().row.semantic_prompt) { .prompt, .prompt_continuation, .input => true, .command => return null, @@ -1952,22 +1903,29 @@ pub fn selectPrompt(self: *Screen, pt: point.ScreenPoint) ?Selection { // Find the start of the prompt. var saw_semantic_prompt = is_known; - const start: usize = start: for (0..pt.y) |offset| { - const y = pt.y - offset; - const row = self.getRow(.{ .screen = y - 1 }); - switch (row.getSemanticPrompt()) { - // A prompt, we continue searching. - .prompt, .prompt_continuation, .input => saw_semantic_prompt = true, - - // See comment about "unknown" a few lines above. If we have - // previously seen a semantic prompt then if we see an unknown - // we treat it as a boundary. - .unknown => if (saw_semantic_prompt) break :start y, - - // Command output or unknown, definitely not a prompt. - .command => break :start y, + const start: Pin = start: { + var it = pin.rowIterator(.left_up, null); + var it_prev = it.next().?; + while (it.next()) |p| { + const row = p.rowAndCell().row; + switch (row.semantic_prompt) { + // A prompt, we continue searching. + .prompt, .prompt_continuation, .input => saw_semantic_prompt = true, + + // See comment about "unknown" a few lines above. If we have + // previously seen a semantic prompt then if we see an unknown + // we treat it as a boundary. + .unknown => if (saw_semantic_prompt) break :start it_prev, + + // Command output or unknown, definitely not a prompt. + .command => break :start it_prev, + } + + it_prev = p; } - } else 0; + + break :start it_prev; + }; // If we never saw a semantic prompt flag, then we can't trust our // start value and we return null. This scenario usually means that @@ -1975,20 +1933,56 @@ pub fn selectPrompt(self: *Screen, pt: point.ScreenPoint) ?Selection { if (!saw_semantic_prompt) return null; // Find the end of the prompt. - const end: usize = end: for (pt.y..self.rowsWritten()) |y| { - const row = self.getRow(.{ .screen = y }); - switch (row.getSemanticPrompt()) { - // A prompt, we continue searching. - .prompt, .prompt_continuation, .input => {}, - - // Command output or unknown, definitely not a prompt. - .command, .unknown => break :end y - 1, + const end: Pin = end: { + var it = pin.rowIterator(.right_down, null); + var it_prev = it.next().?; + it_prev.x = it_prev.page.data.size.cols - 1; + while (it.next()) |p| { + const row = p.rowAndCell().row; + switch (row.semantic_prompt) { + // A prompt, we continue searching. + .prompt, .prompt_continuation, .input => {}, + + // Command output or unknown, definitely not a prompt. + .command, .unknown => break :end it_prev, + } + + it_prev = p; + it_prev.x = it_prev.page.data.size.cols - 1; } - } else self.rowsWritten() - 1; - return .{ - .start = .{ .x = 0, .y = start }, - .end = .{ .x = self.cols - 1, .y = end }, + break :end it_prev; + }; + + return Selection.init(start, end, false); +} + +pub const LineIterator = struct { + screen: *const Screen, + current: ?Pin = null, + + pub fn next(self: *LineIterator) ?Selection { + const current = self.current orelse return null; + const result = self.screen.selectLine(.{ + .pin = current, + .whitespace = null, + .semantic_prompt_boundary = false, + }) orelse { + self.current = null; + return null; + }; + + self.current = result.end().down(1); + return result; + } +}; + +/// Returns an iterator to move through the soft-wrapped lines starting +/// from pin. +pub fn lineIterator(self: *const Screen, start: Pin) LineIterator { + return LineIterator{ + .screen = self, + .current = start, }; } @@ -2000,8 +1994,8 @@ pub fn selectPrompt(self: *Screen, pt: point.ScreenPoint) ?Selection { /// enabled, this will always return zero for both x and y (no path). pub fn promptPath( self: *Screen, - from: point.ScreenPoint, - to: point.ScreenPoint, + from: Pin, + to: Pin, ) struct { x: isize, y: isize, @@ -2010,5785 +2004,5060 @@ pub fn promptPath( const bounds = self.selectPrompt(from) orelse return .{ .x = 0, .y = 0 }; // Get our actual "to" point clamped to the bounds of the prompt. - const to_clamped = if (bounds.contains(to)) + const to_clamped = if (bounds.contains(self, to)) to - else if (to.before(bounds.start)) - bounds.start + else if (to.before(bounds.start())) + bounds.start() else - bounds.end; + bounds.end(); + + // Convert to points + const from_pt = self.pages.pointFromPin(.screen, from).?.screen; + const to_pt = self.pages.pointFromPin(.screen, to_clamped).?.screen; // Basic math to calculate our path. - const from_x: isize = @intCast(from.x); - const from_y: isize = @intCast(from.y); - const to_x: isize = @intCast(to_clamped.x); - const to_y: isize = @intCast(to_clamped.y); + const from_x: isize = @intCast(from_pt.x); + const from_y: isize = @intCast(from_pt.y); + const to_x: isize = @intCast(to_pt.x); + const to_y: isize = @intCast(to_pt.y); return .{ .x = to_x - from_x, .y = to_y - from_y }; } -/// Scroll behaviors for the scroll function. -pub const Scroll = union(enum) { - /// Scroll to the top of the scroll buffer. The first line of the - /// viewport will be the top line of the scroll buffer. - top: void, - - /// Scroll to the bottom, where the last line of the viewport - /// will be the last line of the buffer. TODO: are we sure? - bottom: void, - - /// Scroll up (negative) or down (positive) some fixed amount. - /// Scrolling direction (up/down) describes the direction the viewport - /// moves, not the direction text moves. This is the colloquial way that - /// scrolling is described: "scroll the page down". This scrolls the - /// screen (potentially in addition to the viewport) and may therefore - /// create more rows if necessary. - screen: isize, - - /// This is the same as "screen" but only scrolls the viewport. The - /// delta will be clamped at the current size of the screen and will - /// never create new scrollback. - viewport: isize, - - /// Scroll so the given row is in view. If the row is in the viewport, - /// this will change nothing. If the row is outside the viewport, the - /// viewport will change so that this row is at the top of the viewport. - row: RowIndex, - - /// Scroll down and move all viewport contents into the scrollback - /// so that the screen is clear. This isn't eqiuivalent to "screen" with - /// the value set to the viewport size because this will handle the case - /// that the viewport is not full. - /// - /// This will ignore empty trailing rows. An empty row is a row that - /// has never been written to at all. A row with spaces is not empty. - clear: void, +pub const DumpString = struct { + /// The start and end points of the dump, both inclusive. The x will + /// be ignored and the full row will always be dumped. + tl: Pin, + br: ?Pin = null, + + /// If true, this will unwrap soft-wrapped lines. If false, this will + /// dump the screen as it is visually seen in a rendered window. + unwrap: bool = true, }; -/// Scroll the screen by the given behavior. Note that this will always -/// "move" the screen. It is up to the caller to determine if they actually -/// want to do that yet (i.e. are they writing to the end of the screen -/// or not). -pub fn scroll(self: *Screen, behavior: Scroll) Allocator.Error!void { - // No matter what, scrolling marks our image state as dirty since - // it could move placements. If there are no placements or no images - // this is still a very cheap operation. - self.kitty_images.dirty = true; +/// Dump the screen to a string. The writer given should be buffered; +/// this function does not attempt to efficiently write and generally writes +/// one byte at a time. +pub fn dumpString( + self: *const Screen, + writer: anytype, + opts: DumpString, +) !void { + var blank_rows: usize = 0; - switch (behavior) { - // Setting viewport offset to zero makes row 0 be at self.top - // which is the top! - .top => self.viewport = 0, + var iter = opts.tl.rowIterator(.right_down, opts.br); + while (iter.next()) |row_offset| { + const rac = row_offset.rowAndCell(); + const row = rac.row; + const cells = cells: { + const cells: [*]pagepkg.Cell = @ptrCast(rac.cell); + break :cells cells[0..self.pages.cols]; + }; - // Bottom is the end of the history area (end of history is the - // top of the active area). - .bottom => self.viewport = self.history, + if (!pagepkg.Cell.hasTextAny(cells)) { + blank_rows += 1; + continue; + } + if (blank_rows > 0) { + for (0..blank_rows) |_| try writer.writeByte('\n'); + blank_rows = 0; + } - // TODO: deltas greater than the entire scrollback - .screen => |delta| try self.scrollDelta(delta, false), - .viewport => |delta| try self.scrollDelta(delta, true), + if (!row.wrap or !opts.unwrap) { + // If we're not wrapped, we always add a newline. + // If we are wrapped, we only add a new line if we're unwrapping + // soft-wrapped lines. + blank_rows += 1; + } - // Scroll to a specific row - .row => |idx| self.scrollRow(idx), + var blank_cells: usize = 0; + for (cells) |*cell| { + // Skip spacers + switch (cell.wide) { + .narrow, .wide => {}, + .spacer_head, .spacer_tail => continue, + } - // Scroll until the viewport is clear by moving the viewport contents - // into the scrollback. - .clear => try self.scrollClear(), - } -} + // If we have a zero value, then we accumulate a counter. We + // only want to turn zero values into spaces if we have a non-zero + // char sometime later. + if (!cell.hasText()) { + blank_cells += 1; + continue; + } + if (blank_cells > 0) { + for (0..blank_cells) |_| try writer.writeByte(' '); + blank_cells = 0; + } -fn scrollClear(self: *Screen) Allocator.Error!void { - // The full amount of rows in the viewport - const full_amount = self.rowsWritten() - self.viewport; + switch (cell.content_tag) { + .codepoint => { + try writer.print("{u}", .{cell.content.codepoint}); + }, - // Find the number of non-empty rows - const non_empty = for (0..full_amount) |i| { - const rev_i = full_amount - i - 1; - const row = self.getRow(.{ .viewport = rev_i }); - if (!row.isEmpty()) break rev_i + 1; - } else full_amount; + .codepoint_grapheme => { + try writer.print("{u}", .{cell.content.codepoint}); + const cps = row_offset.page.data.lookupGrapheme(cell).?; + for (cps) |cp| { + try writer.print("{u}", .{cp}); + } + }, - try self.scroll(.{ .screen = @intCast(non_empty) }); + else => unreachable, + } + } + } } -fn scrollRow(self: *Screen, idx: RowIndex) void { - // Convert the given row to a screen point. - const screen_idx = idx.toScreen(self); - const screen_pt: point.ScreenPoint = .{ .y = screen_idx.screen }; +/// You should use dumpString, this is a restricted version mostly for +/// legacy and convenience reasons for unit tests. +pub fn dumpStringAlloc( + self: *const Screen, + alloc: Allocator, + tl: point.Point, +) ![]const u8 { + var builder = std.ArrayList(u8).init(alloc); + defer builder.deinit(); + + try self.dumpString(builder.writer(), .{ + .tl = self.pages.getTopLeft(tl), + .br = self.pages.getBottomRight(tl) orelse return error.UnknownPoint, + .unwrap = false, + }); - // Move the viewport so that the screen point is in view. We do the - // @min here so that we don't scroll down below where our "bottom" - // viewport is. - self.viewport = @min(self.history, screen_pt.y); - assert(screen_pt.inViewport(self)); + return try builder.toOwnedSlice(); } -fn scrollDelta(self: *Screen, delta: isize, viewport_only: bool) Allocator.Error!void { - // Just in case, to avoid a bunch of stuff below. - if (delta == 0) return; +/// This is basically a really jank version of Terminal.printString. We +/// have to reimplement it here because we want a way to print to the screen +/// to test it but don't want all the features of Terminal. +pub fn testWriteString(self: *Screen, text: []const u8) !void { + const view = try std.unicode.Utf8View.init(text); + var iter = view.iterator(); + while (iter.nextCodepoint()) |c| { + // Explicit newline forces a new row + if (c == '\n') { + try self.cursorDownOrScroll(); + self.cursorHorizontalAbsolute(0); + self.cursor.pending_wrap = false; + continue; + } - // If we're scrolling up, then we just subtract and we're done. - // We just clamp at 0 which blocks us from scrolling off the top. - if (delta < 0) { - self.viewport -|= @as(usize, @intCast(-delta)); - return; - } + const width: usize = if (c <= 0xFF) 1 else @intCast(unicode.table.get(c).width); + if (width == 0) { + const cell = cell: { + var cell = self.cursorCellLeft(1); + switch (cell.wide) { + .narrow => {}, + .wide => {}, + .spacer_head => unreachable, + .spacer_tail => cell = self.cursorCellLeft(2), + } - // If we're scrolling only the viewport, then we just add to the viewport. - if (viewport_only) { - self.viewport = @min( - self.history, - self.viewport + @as(usize, @intCast(delta)), - ); - return; - } + break :cell cell; + }; - // Add our delta to our viewport. If we're less than the max currently - // allowed to scroll to the bottom (the end of the history), then we - // have space and we just return. - const start_viewport_bottom = self.viewportIsBottom(); - const viewport = self.history + @as(usize, @intCast(delta)); - if (viewport <= self.history) return; - - // If our viewport is past the top of our history then we potentially need - // to write more blank rows. If our viewport is more than our rows written - // then we expand out to there. - const rows_written = self.rowsWritten(); - const viewport_bottom = viewport + self.rows; - if (viewport_bottom <= rows_written) return; - - // 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 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), + try self.cursor.page_pin.page.data.appendGrapheme( + self.cursor.page_row, + cell, + c, ); + continue; + } - // Allocate what we can. - try self.storage.resize( - self.alloc, - @min(max_capacity, needed_capacity), - ); + if (self.cursor.pending_wrap) { + assert(self.cursor.x == self.pages.cols - 1); + self.cursor.pending_wrap = false; + self.cursor.page_row.wrap = true; + try self.cursorDownOrScroll(); + self.cursorHorizontalAbsolute(0); + self.cursor.page_row.wrap_continuation = true; } - } - // 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(); + assert(width == 1 or width == 2); + switch (width) { + 1 => { + self.cursor.page_cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = c }, + .style_id = self.cursor.style_id, + }; + + // If we have a ref-counted style, increase. + if (self.cursor.style_ref) |ref| { + ref.* += 1; + self.cursor.page_row.styled = true; + } + }, - // 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(.{ .screen = y }); - if (row.storage[0].header.flags.grapheme) row.clear(.{}); - } - } + 2 => { + // Need a wide spacer head + if (self.cursor.x == self.pages.cols - 1) { + self.cursor.page_cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0 }, + .wide = .spacer_head, + }; + + self.cursor.page_row.wrap = true; + try self.cursorDownOrScroll(); + self.cursorHorizontalAbsolute(0); + self.cursor.page_row.wrap_continuation = true; + } - self.storage.deleteOldest(rows_to_delete * (self.cols + 1)); - break :deleted rows_to_delete; - } else 0; + // Write our wide char + self.cursor.page_cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = c }, + .style_id = self.cursor.style_id, + .wide = .wide, + }; - // 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; + // Write our tail + self.cursorRight(1); + self.cursor.page_cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0 }, + .wide = .spacer_tail, + }; + }, - // 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; + else => unreachable, + } - // If the selection is now empty, just clear it. - if (sel.empty()) self.selection = null; + if (self.cursor.x + 1 < self.pages.cols) { + self.cursorRight(1); + } else { + self.cursor.pending_wrap = true; + } } +} - // 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; - } +test "Screen read and write" { + const testing = std.testing; + const alloc = testing.allocator; - // Ensure we have "written" our last row so that it shows up - const slices = self.storage.getPtrSlice( - (rows_written_final - 1) * (self.cols + 1), - self.cols + 1, - ); - // We should never be wrapped here - assert(slices[1].len == 0); - - // We only grabbed our new row(s), copy cells into the whole slice - const dst = slices[0]; - // The pen we'll use for new cells (only the BG attribute is applied to new - // cells) - const pen: Cell = switch (self.cursor.pen.bg) { - .none => .{}, - else => |bg| .{ .bg = bg }, - }; - @memset(dst, .{ .cell = pen }); - - // Then we make sure our row headers are zeroed out. We set - // the value to a dirty row header so that the renderer re-draws. - var i: usize = 0; - while (i < dst.len) : (i += self.cols + 1) { - dst[i] = .{ .header = .{ - .flags = .{ .dirty = true }, - } }; - } - - if (start_viewport_bottom) { - // If our viewport is on the bottom, we always update the viewport - // to the latest so that it remains in view. - self.viewport = self.history; - } else if (rows_deleted > 0) { - // If our viewport is NOT on the bottom, we want to keep our viewport - // where it was so that we don't jump around. However, we need to - // subtract the final rows written if we had to delete rows since - // that changes the viewport offset. - self.viewport -|= rows_deleted; - } -} - -/// The options for where you can jump to on the screen. -pub const JumpTarget = union(enum) { - /// Jump forwards (positive) or backwards (negative) a set number of - /// prompts. If the absolute value is greater than the number of prompts - /// in either direction, jump to the furthest prompt. - prompt_delta: isize, -}; + var s = try Screen.init(alloc, 80, 24, 1000); + defer s.deinit(); + try testing.expectEqual(@as(style.Id, 0), s.cursor.style_id); -/// Jump the viewport to specific location. -pub fn jump(self: *Screen, target: JumpTarget) bool { - return switch (target) { - .prompt_delta => |delta| self.jumpPrompt(delta), - }; + try s.testWriteString("hello, world"); + const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("hello, world", str); } -/// Jump the viewport forwards (positive) or backwards (negative) a set number of -/// prompts (delta). Returns true if the viewport changed and false if no jump -/// occurred. -fn jumpPrompt(self: *Screen, delta: isize) bool { - // If we aren't jumping any prompts then we don't need to do anything. - if (delta == 0) return false; +test "Screen read and write newline" { + const testing = std.testing; + const alloc = testing.allocator; - // The screen y value we start at - const start_y: isize = start_y: { - const idx: RowIndex = .{ .viewport = 0 }; - const screen = idx.toScreen(self); - break :start_y @intCast(screen.screen); - }; + var s = try Screen.init(alloc, 80, 24, 1000); + defer s.deinit(); + try testing.expectEqual(@as(style.Id, 0), s.cursor.style_id); - // The maximum y in the positive direction. Negative is always 0. - const max_y: isize = @intCast(self.rowsWritten() - 1); - - // Go line-by-line counting the number of prompts we see. - const step: isize = if (delta > 0) 1 else -1; - var y: isize = start_y + step; - const delta_start: usize = @intCast(if (delta > 0) delta else -delta); - var delta_rem: usize = delta_start; - while (y >= 0 and y <= max_y and delta_rem > 0) : (y += step) { - const row = self.getRow(.{ .screen = @intCast(y) }); - switch (row.getSemanticPrompt()) { - .prompt, .prompt_continuation, .input => delta_rem -= 1, - .command, .unknown => {}, - } - } + try s.testWriteString("hello\nworld"); + const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("hello\nworld", str); +} - //log.warn("delta={} delta_rem={} start_y={} y={}", .{ delta, delta_rem, start_y, y }); +test "Screen read and write scrollback" { + const testing = std.testing; + const alloc = testing.allocator; - // If we didn't find any, do nothing. - if (delta_rem == delta_start) return false; + var s = try Screen.init(alloc, 80, 2, 1000); + defer s.deinit(); - // Done! We count the number of lines we changed and scroll. - const y_delta = (y - step) - start_y; - const new_y: usize = @intCast(start_y + y_delta); - const old_viewport = self.viewport; - self.scroll(.{ .row = .{ .screen = new_y } }) catch unreachable; - //log.warn("delta={} y_delta={} start_y={} new_y={}", .{ delta, y_delta, start_y, new_y }); - return self.viewport != old_viewport; + try s.testWriteString("hello\nworld\ntest"); + { + const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("hello\nworld\ntest", str); + } + { + const str = try s.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("world\ntest", str); + } } -/// Returns the raw text associated with a selection. This will unwrap -/// soft-wrapped edges. The returned slice is owned by the caller and allocated -/// using alloc, not the allocator associated with the screen (unless they match). -pub fn selectionString( - self: *Screen, - alloc: Allocator, - sel: Selection, - trim: bool, -) ![:0]const u8 { - // Get the slices for the string - const slices = self.selectionSlices(sel); +test "Screen read and write no scrollback small" { + const testing = std.testing; + const alloc = testing.allocator; - // Use an ArrayList so that we can grow the array as we go. We - // build an initial capacity of just our rows in our selection times - // columns. It can be more or less based on graphemes, newlines, etc. - var strbuilder = try std.ArrayList(u8).initCapacity(alloc, slices.rows * self.cols); - defer strbuilder.deinit(); + var s = try Screen.init(alloc, 80, 2, 0); + defer s.deinit(); - // Get our string result. - try self.selectionSliceString(slices, &strbuilder, null); + try s.testWriteString("hello\nworld\ntest"); + { + const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("world\ntest", str); + } + { + const str = try s.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("world\ntest", str); + } +} - // Remove any trailing spaces on lines. We could do optimize this by - // doing this in the loop above but this isn't very hot path code and - // this is simple. - if (trim) { - var it = std.mem.tokenizeScalar(u8, strbuilder.items, '\n'); +test "Screen read and write no scrollback large" { + const testing = std.testing; + const alloc = testing.allocator; - // Reset our items. We retain our capacity. Because we're only - // removing bytes, we know that the trimmed string must be no longer - // than the original string so we copy directly back into our - // allocated memory. - strbuilder.clearRetainingCapacity(); - while (it.next()) |line| { - const trimmed = std.mem.trimRight(u8, line, " \t"); - const i = strbuilder.items.len; - strbuilder.items.len += trimmed.len; - std.mem.copyForwards(u8, strbuilder.items[i..], trimmed); - strbuilder.appendAssumeCapacity('\n'); - } + var s = try Screen.init(alloc, 80, 2, 0); + defer s.deinit(); - // Remove our trailing newline again - if (strbuilder.items.len > 0) strbuilder.items.len -= 1; + for (0..1_000) |i| { + var buf: [128]u8 = undefined; + const str = try std.fmt.bufPrint(&buf, "{}\n", .{i}); + try s.testWriteString(str); } + try s.testWriteString("1000"); - // Get our final string - const string = try strbuilder.toOwnedSliceSentinel(0); - errdefer alloc.free(string); - - return string; + { + const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("999\n1000", str); + } } -/// Returns the row text associated with a selection along with the -/// mapping of each individual byte in the string to the point in the screen. -fn selectionStringMap( - self: *Screen, - alloc: Allocator, - sel: Selection, -) !StringMap { - // Get the slices for the string - const slices = self.selectionSlices(sel); +test "Screen cursorCopy x/y" { + const testing = std.testing; + const alloc = testing.allocator; - // Use an ArrayList so that we can grow the array as we go. We - // build an initial capacity of just our rows in our selection times - // columns. It can be more or less based on graphemes, newlines, etc. - var strbuilder = try std.ArrayList(u8).initCapacity(alloc, slices.rows * self.cols); - defer strbuilder.deinit(); - var mapbuilder = try std.ArrayList(point.ScreenPoint).initCapacity(alloc, strbuilder.capacity); - defer mapbuilder.deinit(); + var s = try Screen.init(alloc, 10, 10, 0); + defer s.deinit(); + s.cursorAbsolute(2, 3); + try testing.expect(s.cursor.x == 2); + try testing.expect(s.cursor.y == 3); - // Get our results - try self.selectionSliceString(slices, &strbuilder, &mapbuilder); + var s2 = try Screen.init(alloc, 10, 10, 0); + defer s2.deinit(); + try s2.cursorCopy(s.cursor); + try testing.expect(s2.cursor.x == 2); + try testing.expect(s2.cursor.y == 3); + try s2.testWriteString("Hello"); - // Get our final string - const string = try strbuilder.toOwnedSliceSentinel(0); - errdefer alloc.free(string); - const map = try mapbuilder.toOwnedSlice(); - errdefer alloc.free(map); - return .{ .string = string, .map = map }; + { + const str = try s2.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("\n\n\n Hello", str); + } } -/// Takes a SelectionSlices value and builds the string and mapping for it. -fn selectionSliceString( - self: *Screen, - slices: SelectionSlices, - strbuilder: *std.ArrayList(u8), - mapbuilder: ?*std.ArrayList(point.ScreenPoint), -) !void { - // Connect the text from the two slices - const arr = [_][]StorageCell{ slices.top, slices.bot }; - var row_count: usize = 0; - for (arr) |slice| { - const row_start: usize = row_count; - while (row_count < slices.rows) : (row_count += 1) { - const row_i = row_count - row_start; - - // Calculate our start index. If we are beyond the length - // of this slice, then its time to move on (we exhausted top). - const start_idx = row_i * (self.cols + 1); - if (start_idx >= slice.len) break; - - 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, 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 - // don't need to do this because we zero the memory. - if (std.debug.runtime_safety) { - _ = self.getRow(.{ .screen = slices.sel.start.y + row_i }); - } - - const row: Row = .{ .screen = self, .storage = slice[start_idx..end_idx] }; - var it = row.cellIterator(); - var x: usize = 0; - while (it.next()) |cell| { - defer x += 1; +test "Screen cursorCopy style deref" { + const testing = std.testing; + const alloc = testing.allocator; - if (skip > 0) { - skip -= 1; - continue; - } + var s = try Screen.init(alloc, 10, 10, 0); + defer s.deinit(); - // Skip spacers - if (cell.attrs.wide_spacer_head or - cell.attrs.wide_spacer_tail) continue; + var s2 = try Screen.init(alloc, 10, 10, 0); + defer s2.deinit(); + const page = s2.cursor.page_pin.page.data; - var buf: [4]u8 = undefined; - const char = if (cell.char > 0) cell.char else ' '; - { - const encode_len = try std.unicode.utf8Encode(@intCast(char), &buf); - try strbuilder.appendSlice(buf[0..encode_len]); - if (mapbuilder) |b| { - for (0..encode_len) |_| try b.append(.{ - .x = x, - .y = slices.sel.start.y + row_i, - }); - } - } + // Bold should create our style + try s2.setAttribute(.{ .bold = {} }); + try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); + try testing.expect(s2.cursor.style.flags.bold); - var cp_it = row.codepointIterator(x); - while (cp_it.next()) |cp| { - const encode_len = try std.unicode.utf8Encode(cp, &buf); - try strbuilder.appendSlice(buf[0..encode_len]); - if (mapbuilder) |b| { - for (0..encode_len) |_| try b.append(.{ - .x = x, - .y = slices.sel.start.y + row_i, - }); - } - } - } + // Copy default style, should release our style + try s2.cursorCopy(s.cursor); + try testing.expect(!s2.cursor.style.flags.bold); + try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); +} - // 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(.{ - .x = self.cols - 1, - .y = slices.sel.start.y + row_i, - }); - } - } - } - } +test "Screen cursorCopy style copy" { + const testing = std.testing; + const alloc = testing.allocator; - // Remove our trailing newline, its never correct. - if (strbuilder.items.len > 0 and - strbuilder.items[strbuilder.items.len - 1] == '\n') - { - strbuilder.items.len -= 1; - if (mapbuilder) |b| b.items.len -= 1; - } + var s = try Screen.init(alloc, 10, 10, 0); + defer s.deinit(); + try s.setAttribute(.{ .bold = {} }); - if (std.debug.runtime_safety) { - if (mapbuilder) |b| { - assert(strbuilder.items.len == b.items.len); - } - } + var s2 = try Screen.init(alloc, 10, 10, 0); + defer s2.deinit(); + const page = s2.cursor.page_pin.page.data; + try s2.cursorCopy(s.cursor); + try testing.expect(s2.cursor.style.flags.bold); + try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); } -const SelectionSlices = struct { - rows: usize, +test "Screen style basics" { + const testing = std.testing; + const alloc = testing.allocator; - // The selection that the slices below represent. This may not - // be the same as the input selection since some normalization - // occurs. - sel: Selection, + var s = try Screen.init(alloc, 80, 24, 1000); + defer s.deinit(); + const page = s.cursor.page_pin.page.data; + try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); - // 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, + // Set a new style + try s.setAttribute(.{ .bold = {} }); + try testing.expect(s.cursor.style_id != 0); + try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); + try testing.expect(s.cursor.style.flags.bold); - // Our bottom offset is used in rectangle select to always determine the - // maximum cell in a given row. - bot_offset: usize, + // Set another style, we should still only have one since it was unused + try s.setAttribute(.{ .italic = {} }); + try testing.expect(s.cursor.style_id != 0); + try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); + try testing.expect(s.cursor.style.flags.italic); +} - // Our selection storage cell chunks. - top: []StorageCell, - bot: []StorageCell, -}; +test "Screen style reset to default" { + const testing = std.testing; + const alloc = testing.allocator; -/// Returns the slices that make up the selection, in order. There are at most -/// two parts to handle the ring buffer. If the selection fits in one contiguous -/// slice, then the second slice will have a length of zero. -fn selectionSlices(self: *Screen, sel_raw: Selection) SelectionSlices { - // Note: this function is tested via selectionString - - // If the selection starts beyond the end of the screen, then we return empty - if (sel_raw.start.y >= self.rowsWritten()) return .{ - .rows = 0, - .sel = sel_raw, - .top_offset = 0, - .bot_offset = 0, - .top = self.storage.storage[0..0], - .bot = self.storage.storage[0..0], - }; + var s = try Screen.init(alloc, 80, 24, 1000); + defer s.deinit(); + const page = s.cursor.page_pin.page.data; + try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); - const sel = sel: { - var sel = sel_raw; + // Set a new style + try s.setAttribute(.{ .bold = {} }); + try testing.expect(s.cursor.style_id != 0); + try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); - // Clamp the selection to the screen - if (sel.end.y >= self.rowsWritten()) { - sel.end.y = self.rowsWritten() - 1; - sel.end.x = self.cols - 1; - } + // Reset to default + try s.setAttribute(.{ .reset_bold = {} }); + try testing.expect(s.cursor.style_id == 0); + try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); +} - // If the end of our selection is a wide char leader, include the - // first part of the next line. - if (sel.end.x == self.cols - 1) { - const row = self.getRow(.{ .screen = sel.end.y }); - const cell = row.getCell(sel.end.x); - if (cell.attrs.wide_spacer_head) { - sel.end.y += 1; - sel.end.x = 0; - } - } +test "Screen style reset with unset" { + const testing = std.testing; + const alloc = testing.allocator; - // If the start of our selection is a wide char spacer, include the - // wide char. - if (sel.start.x > 0) { - const row = self.getRow(.{ .screen = sel.start.y }); - const cell = row.getCell(sel.start.x); - if (cell.attrs.wide_spacer_tail) { - sel.start.x -= 1; - } - } + var s = try Screen.init(alloc, 80, 24, 1000); + defer s.deinit(); + const page = s.cursor.page_pin.page.data; + try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); - break :sel sel; - }; - - // 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 }); - const sel_bot_offset = self.rowOffset(.{ .screen = sel_bot.y }); - const slices = self.storage.getPtrSlice( - sel_top_offset, - (sel_bot_offset - sel_top_offset) + (sel_bot.x + 2), - ); - - // The bottom and top are split into two slices, so we slice to the - // bottom of the storage, then from the top. - return .{ - .rows = sel_bot.y - sel_top.y + 1, - .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], - }; -} - -/// Resize the screen without any reflow. In this mode, columns/rows will -/// be truncated as they are shrunk. If they are grown, the new space is filled -/// with zeros. -pub fn resizeWithoutReflow(self: *Screen, rows: usize, cols: usize) !void { - // If we're resizing to the same size, do nothing. - if (self.cols == cols and self.rows == rows) return; - - // The number of no-character lines after our cursor. This is used - // to trim those lines on a resize first without generating history. - // This is only done if we don't have history yet. - // - // This matches macOS Terminal.app behavior. I chose to match that - // behavior because it seemed fine in an ocean of differing behavior - // between terminal apps. I'm completely open to changing it as long - // as resize behavior isn't regressed in a user-hostile way. - const trailing_blank_lines = blank: { - // If we aren't changing row length, then don't bother calculating - // because we aren't going to trim. - if (self.rows == rows) break :blank 0; - - const blank = self.trailingBlankLines(); - - // If we are shrinking the number of rows, we don't want to trim - // off more blank rows than the number we're shrinking because it - // creates a jarring screen move experience. - if (self.rows > rows) break :blank @min(blank, self.rows - rows); - - break :blank blank; - }; - - // Make a copy so we can access the old indexes. - var old = self.*; - errdefer self.* = old; - - // Change our rows and cols so calculations make sense - self.rows = rows; - self.cols = cols; - - // The end of the screen is the rows we wrote minus any blank lines - // we're trimming. - const end_of_screen_y = old.rowsWritten() - trailing_blank_lines; - - // Calculate our buffer size. This is going to be either the old data - // with scrollback or the max capacity of our new size. We prefer the old - // length so we can save all the data (ignoring col truncation). - const old_len = @max(end_of_screen_y, rows) * (cols + 1); - const new_max_capacity = self.maxCapacity(); - const buf_size = @min(old_len, new_max_capacity); - - // Reallocate the storage - self.storage = try StorageBuf.init(self.alloc, buf_size); - errdefer self.storage.deinit(self.alloc); - defer old.storage.deinit(self.alloc); - - // Our viewport and history resets to the top because we're going to - // rewrite the screen - self.viewport = 0; - self.history = 0; - - // Reset our grapheme map and ensure the old one is deallocated - // on success. - self.graphemes = .{}; - errdefer self.deinitGraphemes(); - defer old.deinitGraphemes(); - - // Rewrite all our rows - var y: usize = 0; - for (0..end_of_screen_y) |it_y| { - const old_row = old.getRow(.{ .screen = it_y }); - - // If we're past the end, scroll - if (y >= self.rows) { - // If we're shrinking rows then its possible we'll trim scrollback - // and we have to account for how much we actually trimmed and - // reflect that in the cursor. - if (self.storage.len() >= self.maxCapacity()) { - old.cursor.y -|= 1; - } - - y -= 1; - try self.scroll(.{ .screen = 1 }); - } - - // Get this row - const new_row = self.getRow(.{ .active = y }); - try new_row.copyRow(old_row); - - // Next row - y += 1; - } - - // Convert our cursor to screen coordinates so we can preserve it. - // The cursor is normally in active coordinates, but by converting to - // screen we can accommodate keeping it on the same place if we retain - // the same scrollback. - const old_cursor_y_screen = RowIndexTag.active.index(old.cursor.y).toScreen(&old).screen; - self.cursor.x = @min(old.cursor.x, self.cols - 1); - self.cursor.y = if (old_cursor_y_screen <= RowIndexTag.screen.maxLen(self)) - old_cursor_y_screen -| self.history - else - self.rows - 1; - - // If our rows increased and our cursor is NOT at the bottom, we want - // to try to preserve the y value of the old cursor. In other words, we - // don't want to "pull down" scrollback. This is purely a UX feature. - if (self.rows > old.rows and - old.cursor.y < old.rows - 1 and - self.cursor.y > old.cursor.y) - { - const delta = self.cursor.y - old.cursor.y; - if (self.scroll(.{ .screen = @intCast(delta) })) { - self.cursor.y -= delta; - } else |err| { - // If this scroll fails its not that big of a deal so we just - // log and ignore. - log.warn("failed to scroll for resize, cursor may be off err={}", .{err}); - } - } -} - -/// Resize the screen. The rows or cols can be bigger or smaller. This -/// function can only be used to resize the viewport. The scrollback size -/// (in lines) can't be changed. But due to the resize, more or less scrollback -/// "space" becomes available due to the width of lines. -/// -/// Due to the internal representation of a screen, this usually involves a -/// significant amount of copying compared to any other operations. -/// -/// This will trim data if the size is getting smaller. This will reflow the -/// soft wrapped text. -pub fn resize(self: *Screen, rows: usize, cols: usize) !void { - if (self.cols == cols) { - // No resize necessary - if (self.rows == rows) return; - - // No matter what we mark our image state as dirty - self.kitty_images.dirty = true; - - // If we have the same number of columns, text can't possibly - // reflow in any way, so we do the quicker thing and do a resize - // without reflow checks. - try self.resizeWithoutReflow(rows, cols); - return; - } - - // No matter what we mark our image state as dirty - self.kitty_images.dirty = true; - - // Keep track if our cursor is at the bottom - const cursor_bottom = self.cursor.y == self.rows - 1; - - // If our columns increased, we alloc space for the new column width - // and go through each row and reflow if necessary. - if (cols > self.cols) { - var old = self.*; - errdefer self.* = old; - - // Allocate enough to store our screen plus history. - const buf_size = (self.rows + @max(self.history, self.max_scrollback)) * (cols + 1); - self.storage = try StorageBuf.init(self.alloc, buf_size); - errdefer self.storage.deinit(self.alloc); - defer old.storage.deinit(self.alloc); - - // Copy grapheme map - self.graphemes = .{}; - errdefer self.deinitGraphemes(); - defer old.deinitGraphemes(); - - // Convert our cursor coordinates to screen coordinates because - // we may have to reflow the cursor if the line it is on is unwrapped. - const cursor_pos = (point.Active{ - .x = old.cursor.x, - .y = old.cursor.y, - }).toScreen(&old); - - // Whether we need to move the cursor or not - var new_cursor: ?point.ScreenPoint = null; - - // Reset our variables because we're going to reprint the screen. - self.cols = cols; - self.viewport = 0; - self.history = 0; - - // Iterate over the screen since we need to check for reflow. - var iter = old.rowIterator(.screen); - var y: usize = 0; - while (iter.next()) |old_row| { - // If we're past the end, scroll - if (y >= self.rows) { - try self.scroll(.{ .screen = 1 }); - y -= 1; - } - - // We need to check if our cursor was on this line. If so, - // we set the new cursor. - if (cursor_pos.y == iter.value - 1) { - assert(new_cursor == null); // should only happen once - new_cursor = .{ .y = self.history + y, .x = cursor_pos.x }; - } - - // At this point, we're always at x == 0 so we can just copy - // the row (we know old.cols < self.cols). - var new_row = self.getRow(.{ .active = y }); - try new_row.copyRow(old_row); - if (!old_row.header().flags.wrap) { - // We used to do have this behavior, but it broke some programs. - // I know I copied this behavior while observing some other - // terminal, but I can't remember which one. I'm leaving this - // here in case we want to bring this back (with probably - // slightly different behavior). - // - // If we have no reflow, we attempt to extend any stylized - // cells at the end of the line if there is one. - // const len = old_row.lenCells(); - // const end = new_row.getCell(len - 1); - // if ((end.char == 0 or end.char == ' ') and !end.empty()) { - // for (len..self.cols) |x| { - // const cell = new_row.getCellPtr(x); - // cell.* = end; - // } - // } - - y += 1; - continue; - } - - // We need to reflow. At this point things get a bit messy. - // The goal is to keep the messiness of reflow down here and - // only reloop when we're back to clean non-wrapped lines. - - // Mark the last element as not wrapped - new_row.setWrapped(false); - - // x is the offset where we start copying into new_row. Its also - // used for cursor tracking. - var x: usize = old.cols; - - // Edge case: if the end of our old row is a wide spacer head, - // we want to overwrite it. - if (old_row.getCellPtr(x - 1).attrs.wide_spacer_head) x -= 1; - - wrapping: while (iter.next()) |wrapped_row| { - const wrapped_cells = trim: { - var i: usize = old.cols; - - // Trim the row from the right so that we ignore all trailing - // empty chars and don't wrap them. We only do this if the - // row is NOT wrapped again because the whitespace would be - // meaningful. - if (!wrapped_row.header().flags.wrap) { - while (i > 0) : (i -= 1) { - if (!wrapped_row.getCell(i - 1).empty()) break; - } - } else { - // If we are wrapped, then similar to above "edge case" - // we want to overwrite the wide spacer head if we end - // in one. - if (wrapped_row.getCellPtr(i - 1).attrs.wide_spacer_head) { - i -= 1; - } - } - - break :trim wrapped_row.storage[1 .. i + 1]; - }; - - var wrapped_i: usize = 0; - while (wrapped_i < wrapped_cells.len) { - // Remaining space in our new row - const new_row_rem = self.cols - x; - - // Remaining cells in our wrapped row - const wrapped_cells_rem = wrapped_cells.len - wrapped_i; - - // We copy as much as we can into our new row - const copy_len = if (new_row_rem <= wrapped_cells_rem) copy_len: { - // We are going to end up filling our new row. We need - // to check if the end of the row is a wide char and - // if so, we need to insert a wide char header and wrap - // there. - var proposed: usize = new_row_rem; - - // If the end of our copy is wide, we copy one less and - // set the wide spacer header now since we're not going - // to write over it anyways. - if (proposed > 0 and wrapped_cells[wrapped_i + proposed - 1].cell.attrs.wide) { - proposed -= 1; - new_row.getCellPtr(x + proposed).* = .{ - .char = ' ', - .attrs = .{ .wide_spacer_head = true }, - }; - } - - break :copy_len proposed; - } else wrapped_cells_rem; - - // The row doesn't fit, meaning we have to soft-wrap the - // new row but probably at a diff boundary. - fastmem.copy( - StorageCell, - new_row.storage[x + 1 ..], - wrapped_cells[wrapped_i .. wrapped_i + copy_len], - ); - - // We need to check if our cursor was on this line - // and in the part that WAS copied. If so, we need to move it. - if (cursor_pos.y == iter.value - 1 and - cursor_pos.x < copy_len and - new_cursor == null) - { - new_cursor = .{ .y = self.history + y, .x = x + cursor_pos.x }; - } - - // We copied the full amount left in this wrapped row. - if (copy_len == wrapped_cells_rem) { - // If this row isn't also wrapped, we're done! - if (!wrapped_row.header().flags.wrap) { - y += 1; - break :wrapping; - } - - // Wrapped again! - x += wrapped_cells_rem; - break; - } - - // We still need to copy the remainder - wrapped_i += copy_len; - - // Move to a new line in our new screen - new_row.setWrapped(true); - y += 1; - x = 0; - - // If we're past the end, scroll - if (y >= self.rows) { - y -= 1; - try self.scroll(.{ .screen = 1 }); - } - new_row = self.getRow(.{ .active = y }); - new_row.setSemanticPrompt(old_row.getSemanticPrompt()); - } - } - } - - // If we have a new cursor, we need to convert that to a viewport - // point and set it up. - if (new_cursor) |pos| { - const viewport_pos = pos.toViewport(self); - self.cursor.x = viewport_pos.x; - self.cursor.y = viewport_pos.y; - } - } - - // We grow rows after cols so that we can do our unwrapping/reflow - // before we do a no-reflow grow. - if (rows > self.rows) try self.resizeWithoutReflow(rows, self.cols); - - // If our rows got smaller, we trim the scrollback. We do this after - // handling cols growing so that we can save as many lines as we can. - // We do it before cols shrinking so we can save compute on that operation. - if (rows < self.rows) try self.resizeWithoutReflow(rows, self.cols); - - // If our cols got smaller, we have to reflow text. This is the worst - // possible case because we can't do any easy tricks to get reflow, - // we just have to iterate over the screen and "print", wrapping as - // needed. - if (cols < self.cols) { - var old = self.*; - errdefer self.* = old; - - // Allocate enough to store our screen plus history. - const buf_size = (self.rows + @max(self.history, self.max_scrollback)) * (cols + 1); - self.storage = try StorageBuf.init(self.alloc, buf_size); - errdefer self.storage.deinit(self.alloc); - defer old.storage.deinit(self.alloc); - - // Create empty grapheme map. Cell IDs change so we can't just copy it, - // we'll rebuild it. - self.graphemes = .{}; - errdefer self.deinitGraphemes(); - defer old.deinitGraphemes(); - - // Convert our cursor coordinates to screen coordinates because - // we may have to reflow the cursor if the line it is on is moved. - const cursor_pos = (point.Active{ - .x = old.cursor.x, - .y = old.cursor.y, - }).toScreen(&old); - - // Whether we need to move the cursor or not - var new_cursor: ?point.ScreenPoint = null; - var new_cursor_wrap: usize = 0; - - // Reset our variables because we're going to reprint the screen. - self.cols = cols; - self.viewport = 0; - self.history = 0; - - // Iterate over the screen since we need to check for reflow. We - // clear all the trailing blank lines so that shells like zsh and - // fish that often clear the display below don't force us to have - // scrollback. - var old_y: usize = 0; - const end_y = RowIndexTag.screen.maxLen(&old) - old.trailingBlankLines(); - var y: usize = 0; - while (old_y < end_y) : (old_y += 1) { - const old_row = old.getRow(.{ .screen = old_y }); - const old_row_wrapped = old_row.header().flags.wrap; - const trimmed_row = self.trimRowForResizeLessCols(&old, old_row); - - // If our y is more than our rows, we need to scroll - if (y >= self.rows) { - try self.scroll(.{ .screen = 1 }); - y -= 1; - } - - // Fast path: our old row is not wrapped AND our old row fits - // into our new smaller size AND this row has no grapheme clusters. - // In this case, we just do a fast copy and move on. - if (!old_row_wrapped and - trimmed_row.len <= self.cols and - !old_row.header().flags.grapheme) - { - // If our cursor is on this line, then set the new cursor. - if (cursor_pos.y == old_y) { - assert(new_cursor == null); - new_cursor = .{ .x = cursor_pos.x, .y = self.history + y }; - } - - const row = self.getRow(.{ .active = y }); - row.setSemanticPrompt(old_row.getSemanticPrompt()); - - fastmem.copy( - StorageCell, - row.storage[1..], - trimmed_row, - ); - - y += 1; - continue; - } - - // Slow path: the row is wrapped or doesn't fit so we have to - // wrap ourselves. In this case, we basically just "print and wrap" - var row = self.getRow(.{ .active = y }); - row.setSemanticPrompt(old_row.getSemanticPrompt()); - var x: usize = 0; - var cur_old_row = old_row; - var cur_old_row_wrapped = old_row_wrapped; - var cur_trimmed_row = trimmed_row; - while (true) { - for (cur_trimmed_row, 0..) |old_cell, old_x| { - var cell: StorageCell = old_cell; - - // This is a really wild edge case if we're resizing down - // to 1 column. In reality this is pretty broken for end - // users so downstream should prevent this. - if (self.cols == 1 and - (cell.cell.attrs.wide or - cell.cell.attrs.wide_spacer_head or - cell.cell.attrs.wide_spacer_tail)) - { - cell = .{ .cell = .{ .char = ' ' } }; - } - - // We need to wrap wide chars with a spacer head. - if (cell.cell.attrs.wide and x == self.cols - 1) { - row.getCellPtr(x).* = .{ - .char = ' ', - .attrs = .{ .wide_spacer_head = true }, - }; - x += 1; - } - - // Soft wrap if we have to. - if (x == self.cols) { - row.setWrapped(true); - x = 0; - y += 1; - - // Wrapping can cause us to overflow our visible area. - // If so, scroll. - if (y >= self.rows) { - try self.scroll(.{ .screen = 1 }); - y -= 1; - - // Clear if our current cell is a wide spacer tail - if (cell.cell.attrs.wide_spacer_tail) { - cell = .{ .cell = .{} }; - } - } - - if (cursor_pos.y == old_y) { - // If this original y is where our cursor is, we - // track the number of wraps we do so we can try to - // keep this whole line on the screen. - new_cursor_wrap += 1; - } - - row = self.getRow(.{ .active = y }); - row.setSemanticPrompt(cur_old_row.getSemanticPrompt()); - } - - // If our cursor is on this char, then set the new cursor. - if (cursor_pos.y == old_y and cursor_pos.x == old_x) { - assert(new_cursor == null); - new_cursor = .{ .x = x, .y = self.history + y }; - } - - // Write the cell - const new_cell = row.getCellPtr(x); - new_cell.* = cell.cell; - - // If the old cell is a multi-codepoint grapheme then we - // need to also attach the graphemes. - if (cell.cell.attrs.grapheme) { - var it = cur_old_row.codepointIterator(old_x); - while (it.next()) |cp| try row.attachGrapheme(x, cp); - } - - x += 1; - } - - // If we're done wrapping, we move on. - if (!cur_old_row_wrapped) { - y += 1; - break; - } - - // If the old row is wrapped we continue with the loop with - // the next row. - old_y += 1; - cur_old_row = old.getRow(.{ .screen = old_y }); - cur_old_row_wrapped = cur_old_row.header().flags.wrap; - cur_trimmed_row = self.trimRowForResizeLessCols(&old, cur_old_row); - } - } - - // If we have a new cursor, we need to convert that to a viewport - // point and set it up. - if (new_cursor) |pos| { - const viewport_pos = pos.toViewport(self); - self.cursor.x = @min(viewport_pos.x, self.cols - 1); - self.cursor.y = @min(viewport_pos.y, self.rows - 1); - - // We want to keep our cursor y at the same place. To do so, we - // scroll the screen. This scrolls all of the content so the cell - // the cursor is over doesn't change. - if (!cursor_bottom and old.cursor.y < self.cursor.y) scroll: { - const delta: isize = delta: { - var delta: isize = @intCast(self.cursor.y - old.cursor.y); - - // new_cursor_wrap is the number of times the line that the - // cursor was on previously was wrapped to fit this new col - // width. We want to scroll that many times less so that - // the whole line the cursor was on attempts to remain - // in view. - delta -= @intCast(new_cursor_wrap); - - if (delta <= 0) break :scroll; - break :delta delta; - }; - - self.scroll(.{ .screen = delta }) catch |err| { - log.warn("failed to scroll for resize, cursor may be off err={}", .{err}); - break :scroll; - }; - - self.cursor.y -= @intCast(delta); - } - } else { - // TODO: why is this necessary? Without this, neovim will - // crash when we shrink the window to the smallest size. We - // never got a test case to cover this. - self.cursor.x = @min(self.cursor.x, self.cols - 1); - self.cursor.y = @min(self.cursor.y, self.rows - 1); - } - } -} - -/// Counts the number of trailing lines from the cursor that are blank. -/// This is specifically used for resizing and isn't meant to be a general -/// purpose tool. -fn trailingBlankLines(self: *Screen) usize { - // Start one line below our cursor and continue to the last line - // of the screen or however many rows we have written. - const start = self.cursor.y + 1; - const end = @min(self.rowsWritten(), self.rows); - if (start >= end) return 0; - - var blank: usize = 0; - for (0..(end - start)) |i| { - const y = end - i - 1; - const row = self.getRow(.{ .active = y }); - if (!row.isEmpty()) break; - blank += 1; - } - - return blank; -} - -/// When resizing to less columns, this trims the row from the right -/// so we don't unnecessarily wrap. This will freely throw away trailing -/// colored but empty (character) cells. This matches Terminal.app behavior, -/// which isn't strictly correct but seems nice. -fn trimRowForResizeLessCols(self: *Screen, old: *Screen, row: Row) []StorageCell { - assert(old.cols > self.cols); - - // We only trim if this isn't a wrapped line. If its a wrapped - // line we need to keep all the empty cells because they are - // meaningful whitespace before our wrap. - if (row.header().flags.wrap) return row.storage[1 .. old.cols + 1]; - - var i: usize = old.cols; - while (i > 0) : (i -= 1) { - const cell = row.getCell(i - 1); - if (!cell.empty()) { - // If we are beyond our new width and this is just - // an empty-character stylized cell, then we trim it. - // We also have to ignore wide spacers because they form - // a critical part of a wide character. - if (i > self.cols) { - if ((cell.char == 0 or cell.char == ' ') and - !cell.attrs.wide_spacer_tail and - !cell.attrs.wide_spacer_head) continue; - } - - break; - } - } - - return row.storage[1 .. i + 1]; -} - -/// Writes a basic string into the screen for testing. Newlines (\n) separate -/// each row. If a line is longer than the available columns, soft-wrapping -/// will occur. This will automatically handle basic wide chars. -pub fn testWriteString(self: *Screen, text: []const u8) !void { - var y: usize = self.cursor.y; - var x: usize = self.cursor.x; - - var grapheme: struct { - x: usize = 0, - cell: ?*Cell = null, - } = .{}; - - const view = std.unicode.Utf8View.init(text) catch unreachable; - var iter = view.iterator(); - while (iter.nextCodepoint()) |c| { - // Explicit newline forces a new row - if (c == '\n') { - y += 1; - x = 0; - grapheme = .{}; - continue; - } - - // If we're writing past the end of the active area, scroll. - if (y >= self.rows) { - y -= 1; - try self.scroll(.{ .screen = 1 }); - } - - // Get our row - var row = self.getRow(.{ .active = y }); - - // NOTE: graphemes are currently disabled - if (false) { - // If we have a previous cell, we check if we're part of a grapheme. - if (grapheme.cell) |prev_cell| { - const grapheme_break = brk: { - var state: u3 = 0; - var cp1 = @as(u21, @intCast(prev_cell.char)); - if (prev_cell.attrs.grapheme) { - var it = row.codepointIterator(grapheme.x); - while (it.next()) |cp2| { - assert(!ziglyph.graphemeBreak( - cp1, - cp2, - &state, - )); - - cp1 = cp2; - } - } - - break :brk ziglyph.graphemeBreak(cp1, c, &state); - }; - - if (!grapheme_break) { - try row.attachGrapheme(grapheme.x, c); - continue; - } - } - } - - const width: usize = @intCast(@max(0, ziglyph.display_width.codePointWidth(c, .half))); - //log.warn("c={x} width={}", .{ c, width }); - - // Zero-width are attached as grapheme data. - // NOTE: if/when grapheme clustering is ever enabled (above) this - // is not necessary - if (width == 0) { - if (grapheme.cell != null) { - try row.attachGrapheme(grapheme.x, c); - } - - continue; - } - - // If we're writing past the end, we need to soft wrap. - if (x == self.cols) { - row.setWrapped(true); - y += 1; - x = 0; - if (y >= self.rows) { - y -= 1; - try self.scroll(.{ .screen = 1 }); - } - row = self.getRow(.{ .active = y }); - } - - // If our character is double-width, handle it. - assert(width == 1 or width == 2); - switch (width) { - 1 => { - const cell = row.getCellPtr(x); - cell.* = self.cursor.pen; - cell.char = @intCast(c); - - grapheme.x = x; - grapheme.cell = cell; - }, - - 2 => { - if (x == self.cols - 1) { - const cell = row.getCellPtr(x); - cell.char = ' '; - cell.attrs.wide_spacer_head = true; - - // wrap - row.setWrapped(true); - y += 1; - x = 0; - if (y >= self.rows) { - y -= 1; - try self.scroll(.{ .screen = 1 }); - } - row = self.getRow(.{ .active = y }); - } - - { - const cell = row.getCellPtr(x); - cell.* = self.cursor.pen; - cell.char = @intCast(c); - cell.attrs.wide = true; - - grapheme.x = x; - grapheme.cell = cell; - } - - { - x += 1; - const cell = row.getCellPtr(x); - cell.char = ' '; - cell.attrs.wide_spacer_tail = true; - } - }, - - else => unreachable, - } - - x += 1; - } - - // So the cursor doesn't go off screen - self.cursor.x = @min(x, self.cols - 1); - self.cursor.y = y; -} - -/// Options for dumping the screen to a string. -pub const Dump = struct { - /// The start and end rows. These don't have to be in order, the dump - /// function will automatically sort them. - start: RowIndex, - end: RowIndex, - - /// If true, this will unwrap soft-wrapped lines into a single line. - unwrap: bool = true, -}; - -/// Dump the screen to a string. The writer given should be buffered; -/// this function does not attempt to efficiently write and generally writes -/// one byte at a time. -/// -/// TODO: look at selectionString implementation for more efficiency -/// TODO: change selectionString to use this too after above todo -pub fn dumpString(self: *Screen, writer: anytype, opts: Dump) !void { - const start_screen = opts.start.toScreen(self); - const end_screen = opts.end.toScreen(self); - - // If we have no rows in our screen, do nothing. - const rows_written = self.rowsWritten(); - if (rows_written == 0) return; - - // Get the actual top and bottom y values. This handles situations - // where start/end are backwards. - const y_top = @min(start_screen.screen, end_screen.screen); - const y_bottom = @min( - @max(start_screen.screen, end_screen.screen), - rows_written - 1, - ); - - // This keeps track of the number of blank rows we see. We don't want - // to output blank rows unless they're followed by a non-blank row. - var blank_rows: usize = 0; - - // Iterate through the rows - var y: usize = y_top; - while (y <= y_bottom) : (y += 1) { - const row = self.getRow(.{ .screen = y }); - - // Handle blank rows - if (row.isEmpty()) { - blank_rows += 1; - continue; - } - if (blank_rows > 0) { - for (0..blank_rows) |_| try writer.writeByte('\n'); - blank_rows = 0; - } - - if (!row.header().flags.wrap) { - // If we're not wrapped, we always add a newline. - blank_rows += 1; - } else if (!opts.unwrap) { - // If we are wrapped, we only add a new line if we're unwrapping - // soft-wrapped lines. - blank_rows += 1; - } - - // Output each of the cells - var cells = row.cellIterator(); - var spacers: usize = 0; - while (cells.next()) |cell| { - // Skip spacers - if (cell.attrs.wide_spacer_head or cell.attrs.wide_spacer_tail) continue; - - // If we have a zero value, then we accumulate a counter. We - // only want to turn zero values into spaces if we have a non-zero - // char sometime later. - if (cell.char == 0) { - spacers += 1; - continue; - } - if (spacers > 0) { - for (0..spacers) |_| try writer.writeByte(' '); - spacers = 0; - } - - const codepoint: u21 = @intCast(cell.char); - try writer.print("{u}", .{codepoint}); - - var it = row.codepointIterator(cells.i - 1); - while (it.next()) |cp| { - try writer.print("{u}", .{cp}); - } - } - } -} + // Set a new style + try s.setAttribute(.{ .bold = {} }); + try testing.expect(s.cursor.style_id != 0); + try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); -/// Turns the screen into a string. Different regions of the screen can -/// be selected using the "tag", i.e. if you want to output the viewport, -/// the scrollback, the full screen, etc. -/// -/// This is only useful for testing. -pub fn testString(self: *Screen, alloc: Allocator, tag: RowIndexTag) ![]const u8 { - var builder = std.ArrayList(u8).init(alloc); - defer builder.deinit(); - try self.dumpString(builder.writer(), .{ - .start = tag.index(0), - .end = tag.index(tag.maxLen(self) - 1), - - // historically our testString wants to view the screen as-is without - // unwrapping soft-wrapped lines so turn this off. - .unwrap = false, - }); - return try builder.toOwnedSlice(); -} - -test "Row: isEmpty with no data" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 5, 0); - defer s.deinit(); - - const row = s.getRow(.{ .active = 0 }); - try testing.expect(row.isEmpty()); -} - -test "Row: isEmpty with a character at the end" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 5, 0); - defer s.deinit(); - - const row = s.getRow(.{ .active = 0 }); - const cell = row.getCellPtr(4); - cell.*.char = 'A'; - try testing.expect(!row.isEmpty()); -} - -test "Row: isEmpty with only styled cells" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 5, 0); - defer s.deinit(); - - const row = s.getRow(.{ .active = 0 }); - for (0..s.cols) |x| { - const cell = row.getCellPtr(x); - cell.*.bg = .{ .rgb = .{ .r = 0xAA, .g = 0xBB, .b = 0xCC } }; - } - try testing.expect(row.isEmpty()); -} - -test "Row: clear with graphemes" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 5, 0); - defer s.deinit(); - - const row = s.getRow(.{ .active = 0 }); - try testing.expect(row.getId() > 0); - try testing.expectEqual(@as(usize, 5), row.lenCells()); - try testing.expect(!row.header().flags.grapheme); - - // Lets add a cell with a grapheme - { - const cell = row.getCellPtr(2); - cell.*.char = 'A'; - try row.attachGrapheme(2, 'B'); - try testing.expect(cell.attrs.grapheme); - try testing.expect(row.header().flags.grapheme); - try testing.expect(s.graphemes.count() == 1); - } - - // Clear the row - row.clear(.{}); - try testing.expect(!row.header().flags.grapheme); - try testing.expect(s.graphemes.count() == 0); -} - -test "Row: copy row with graphemes in destination" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 5, 0); - defer s.deinit(); - - // Source row does NOT have graphemes - const row_src = s.getRow(.{ .active = 0 }); - { - const cell = row_src.getCellPtr(2); - cell.*.char = 'A'; - } - - // Destination has graphemes - const row = s.getRow(.{ .active = 1 }); - { - const cell = row.getCellPtr(1); - cell.*.char = 'B'; - try row.attachGrapheme(1, 'C'); - try testing.expect(cell.attrs.grapheme); - try testing.expect(row.header().flags.grapheme); - try testing.expect(s.graphemes.count() == 1); - } - - // Copy - try row.copyRow(row_src); - try testing.expect(!row.header().flags.grapheme); - try testing.expect(s.graphemes.count() == 0); + // Reset to default + try s.setAttribute(.{ .unset = {} }); + try testing.expect(s.cursor.style_id == 0); + try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); } -test "Row: copy row with graphemes in source" { +test "Screen clearRows active one line" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 5, 0); + var s = try Screen.init(alloc, 80, 24, 1000); defer s.deinit(); - // Source row does NOT have graphemes - const row_src = s.getRow(.{ .active = 0 }); - { - const cell = row_src.getCellPtr(2); - cell.*.char = 'A'; - try row_src.attachGrapheme(2, 'B'); - try testing.expect(cell.attrs.grapheme); - try testing.expect(row_src.header().flags.grapheme); - try testing.expect(s.graphemes.count() == 1); - } - - // Destination has no graphemes - const row = s.getRow(.{ .active = 1 }); - try row.copyRow(row_src); - try testing.expect(row.header().flags.grapheme); - try testing.expect(s.graphemes.count() == 2); - - row_src.clear(.{}); - try testing.expect(s.graphemes.count() == 1); + try s.testWriteString("hello, world"); + s.clearRows(.{ .active = .{} }, null, false); + const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("", str); } -test "Screen" { +test "Screen clearRows active multi line" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 5, 0); + var s = try Screen.init(alloc, 80, 24, 1000); defer s.deinit(); - try testing.expect(s.rowsWritten() == 0); - - // Sanity check that our test helpers work - const str = "1ABCD\n2EFGH\n3IJKL"; - try s.testWriteString(str); - try testing.expect(s.rowsWritten() == 3); - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - // Test the row iterator - var count: usize = 0; - var iter = s.rowIterator(.viewport); - while (iter.next()) |row| { - // Rows should be pointer equivalent to getRow - const row_other = s.getRow(.{ .viewport = count }); - try testing.expectEqual(row.storage.ptr, row_other.storage.ptr); - count += 1; - } - - // Should go through all rows - try testing.expectEqual(@as(usize, 3), count); - - // Should be able to easily clear screen - { - var it = s.rowIterator(.viewport); - while (it.next()) |row| row.fill(.{ .char = 'A' }); - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings("AAAAA\nAAAAA\nAAAAA", contents); - } + try s.testWriteString("hello\nworld"); + s.clearRows(.{ .active = .{} }, null, false); + const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("", str); } -test "Screen: write graphemes" { +test "Screen clearRows active styled line" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 5, 0); + var s = try Screen.init(alloc, 80, 24, 1000); defer s.deinit(); - // Sanity check that our test helpers work - var buf: [32]u8 = undefined; - var buf_idx: usize = 0; - buf_idx += try std.unicode.utf8Encode(0x1F44D, buf[buf_idx..]); // Thumbs up plain - buf_idx += try std.unicode.utf8Encode(0x1F44D, buf[buf_idx..]); // Thumbs up plain - buf_idx += try std.unicode.utf8Encode(0x1F3FD, buf[buf_idx..]); // Medium skin tone + try s.setAttribute(.{ .bold = {} }); + try s.testWriteString("hello world"); + try s.setAttribute(.{ .unset = {} }); - // Note the assertions below are NOT the correct way to handle graphemes - // in general, but they're "correct" for historical purposes for terminals. - // For terminals, all double-wide codepoints are counted as part of the - // width. + // We should have one style + const page = s.cursor.page_pin.page.data; + try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); - try s.testWriteString(buf[0..buf_idx]); - try testing.expect(s.rowsWritten() == 2); - try testing.expectEqual(@as(usize, 2), s.cursor.x); -} + s.clearRows(.{ .active = .{} }, null, false); -test "Screen: write long emoji" { - const testing = std.testing; - const alloc = testing.allocator; + // We should have none because active cleared it + try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); - var s = try init(alloc, 5, 30, 0); - defer s.deinit(); - - // Sanity check that our test helpers work - var buf: [32]u8 = undefined; - var buf_idx: usize = 0; - buf_idx += try std.unicode.utf8Encode(0x1F9D4, buf[buf_idx..]); // man: beard - buf_idx += try std.unicode.utf8Encode(0x1F3FB, buf[buf_idx..]); // light skin tone (Fitz 1-2) - buf_idx += try std.unicode.utf8Encode(0x200D, buf[buf_idx..]); // ZWJ - buf_idx += try std.unicode.utf8Encode(0x2642, buf[buf_idx..]); // male sign - buf_idx += try std.unicode.utf8Encode(0xFE0F, buf[buf_idx..]); // emoji representation - - // Note the assertions below are NOT the correct way to handle graphemes - // in general, but they're "correct" for historical purposes for terminals. - // For terminals, all double-wide codepoints are counted as part of the - // width. - - try s.testWriteString(buf[0..buf_idx]); - try testing.expect(s.rowsWritten() == 1); - try testing.expectEqual(@as(usize, 5), s.cursor.x); + const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("", str); } -test "Screen: lineIterator" { +test "Screen eraseRows history" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 5, 0); - defer s.deinit(); - - // Sanity check that our test helpers work - const str = "1ABCD\n2EFGH"; - try s.testWriteString(str); - - // Test the line iterator - var iter = s.lineIterator(.viewport); - { - const line = iter.next().?; - const actual = try line.string(alloc); - defer alloc.free(actual); - try testing.expectEqualStrings("1ABCD", actual); - } - { - const line = iter.next().?; - const actual = try line.string(alloc); - defer alloc.free(actual); - try testing.expectEqualStrings("2EFGH", actual); - } - try testing.expect(iter.next() == null); -} - -test "Screen: lineIterator soft wrap" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 5, 0); + var s = try Screen.init(alloc, 5, 5, 1000); defer s.deinit(); - // Sanity check that our test helpers work - const str = "1ABCD2EFGH\n3ABCD"; - try s.testWriteString(str); + try s.testWriteString("1\n2\n3\n4\n5\n6"); - // Test the line iterator - var iter = s.lineIterator(.viewport); { - const line = iter.next().?; - const actual = try line.string(alloc); - defer alloc.free(actual); - try testing.expectEqualStrings("1ABCD2EFGH", actual); + const str = try s.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("2\n3\n4\n5\n6", str); } { - const line = iter.next().?; - const actual = try line.string(alloc); - defer alloc.free(actual); - try testing.expectEqualStrings("3ABCD", actual); + const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("1\n2\n3\n4\n5\n6", str); } - try testing.expect(iter.next() == null); -} - -test "Screen: getLine soft wrap" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 5, 0); - defer s.deinit(); - // Sanity check that our test helpers work - const str = "1ABCD2EFGH\n3ABCD"; - try s.testWriteString(str); + s.eraseRows(.{ .history = .{} }, null); - // Test the line iterator { - const line = s.getLine(.{ .x = 2, .y = 1 }).?; - const actual = try line.string(alloc); - defer alloc.free(actual); - try testing.expectEqualStrings("1ABCD2EFGH", actual); + const str = try s.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("2\n3\n4\n5\n6", str); } { - const line = s.getLine(.{ .x = 2, .y = 2 }).?; - const actual = try line.string(alloc); - defer alloc.free(actual); - try testing.expectEqualStrings("3ABCD", actual); + const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("2\n3\n4\n5\n6", str); } - - try testing.expect(s.getLine(.{ .x = 2, .y = 3 }) == null); - try testing.expect(s.getLine(.{ .x = 7, .y = 1 }) == null); } -test "Screen: scrolling" { +test "Screen eraseRows history with more lines" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try Screen.init(alloc, 5, 5, 1000); defer s.deinit(); - s.cursor.pen.bg = .{ .rgb = .{ .r = 155 } }; - try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - try testing.expect(s.viewportIsBottom()); - // Scroll down, should still be bottom - try s.scroll(.{ .screen = 1 }); - try testing.expect(s.viewportIsBottom()); + try s.testWriteString("A\nB\nC\n1\n2\n3\n4\n5\n6"); { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + const str = try s.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("2\n3\n4\n5\n6", str); } { - // Test that our new row has the correct background - const cell = s.getCell(.active, 2, 0); - try testing.expectEqual(@as(u8, 155), cell.bg.rgb.r); + const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("A\nB\nC\n1\n2\n3\n4\n5\n6", str); } - // Scrolling to the bottom does nothing - try s.scroll(.{ .bottom = {} }); + s.eraseRows(.{ .history = .{} }, null); { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + const str = try s.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("2\n3\n4\n5\n6", str); } -} - -test "Screen: scroll down from 0" { - 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"); - - // Scrolling up does nothing, but allows it - try s.scroll(.{ .screen = -1 }); - try testing.expect(s.viewportIsBottom()); - { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); + const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("2\n3\n4\n5\n6", str); } } -test "Screen: scrollback" { +test "Screen eraseRows active partial" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 1); + var s = try Screen.init(alloc, 5, 5, 0); defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - try s.scroll(.{ .screen = 1 }); - - { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL", contents); - } - - // Scrolling to the bottom - try s.scroll(.{ .bottom = {} }); - try testing.expect(s.viewportIsBottom()); - { - // Test our contents rotated - const 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(.{ .screen = -1 }); - try testing.expect(!s.viewportIsBottom()); - - { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); - } - - // Scrolling back again should do nothing - try s.scroll(.{ .screen = -1 }); + try s.testWriteString("1\n2\n3"); { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); + const str = try s.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("1\n2\n3", str); } - // Scrolling to the bottom - try s.scroll(.{ .bottom = {} }); + s.eraseRows(.{ .active = .{} }, .{ .active = .{ .y = 1 } }); { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + const str = try s.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("3", str); } - - // Scrolling forward with no grow should do nothing - try s.scroll(.{ .viewport = 1 }); - { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL", contents); - } - - // Scrolling to the top should work - try s.scroll(.{ .top = {} }); - - { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); - } - - // Should be able to easily clear active area only - var it = s.rowIterator(.active); - while (it.next()) |row| row.clear(.{}); - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD", contents); - } - - // Scrolling to the bottom - try s.scroll(.{ .bottom = {} }); - - { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("", contents); + const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("3", str); } } -test "Screen: scrollback with large delta" { +test "Screen: clearPrompt" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 3); + var s = try init(alloc, 5, 3, 0); defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH\n6IJKL"); - try testing.expect(s.viewportIsBottom()); + const str = "1ABCD\n2EFGH\n3IJKL"; + try s.testWriteString(str); - // Scroll to top - try s.scroll(.{ .top = {} }); + // Set one of the rows to be a prompt { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); + s.cursorAbsolute(0, 1); + s.cursor.page_row.semantic_prompt = .prompt; + s.cursorAbsolute(0, 2); + s.cursor.page_row.semantic_prompt = .input; } - // Scroll down a ton - try s.scroll(.{ .viewport = 5 }); - try testing.expect(s.viewportIsBottom()); + s.clearPrompt(); + { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); + try testing.expectEqualStrings("1ABCD", contents); } } -test "Screen: scrollback empty" { +test "Screen: clearPrompt no prompt" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 50); + var s = try init(alloc, 5, 3, 0); defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - try s.scroll(.{ .viewport = 1 }); + const str = "1ABCD\n2EFGH\n3IJKL"; + try s.testWriteString(str); + + s.clearPrompt(); { - // Test our contents - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); + try testing.expectEqualStrings(str, contents); } } -test "Screen: scrollback doesn't move viewport if not at bottom" { +test "Screen: cursorDown across pages preserves style" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 3); + var s = try init(alloc, 10, 3, 1); defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"); - // First test: we scroll up by 1, so we're not at the bottom anymore. - try s.scroll(.{ .screen = -1 }); - try testing.expect(!s.viewportIsBottom()); + // Scroll down enough to go to another page + const start_page = &s.pages.pages.last.?.data; + const rem = start_page.capacity.rows; + for (0..rem) |_| try s.cursorDownOrScroll(); + + // We need our page to change for this test o make sense. If this + // assertion fails then the bug is in the test: we should be scrolling + // above enough for a new page to show up. { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL\n4ABCD", contents); + const page = &s.cursor.page_pin.page.data; + try testing.expect(start_page != page); } - // Next, we scroll back down by 1, this grows the scrollback but we - // shouldn't move. - try s.scroll(.{ .screen = 1 }); - try testing.expect(!s.viewportIsBottom()); + // Scroll back to the previous page + s.cursorUp(1); { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL\n4ABCD", contents); + const page = &s.cursor.page_pin.page.data; + try testing.expect(start_page == page); } - // Scroll again, this clears scrollback so we should move viewports - // but still see the same thing since our original view fits. - try s.scroll(.{ .screen = 1 }); - try testing.expect(!s.viewportIsBottom()); + // Go back up, set a style + try s.setAttribute(.{ .bold = {} }); { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL\n4ABCD", contents); + const page = &s.cursor.page_pin.page.data; + const styleval = page.styles.lookupId( + page.memory, + s.cursor.style_id, + ).?; + try testing.expect(styleval.flags.bold); } - // Scroll again, this again goes into scrollback but is now deleting - // what we were looking at. We should see changes. - try s.scroll(.{ .screen = 1 }); - try testing.expect(!s.viewportIsBottom()); + // Go back down into the next page and we should have that style + s.cursorDown(1); { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("3IJKL\n4ABCD\n5EFGH", contents); + const page = &s.cursor.page_pin.page.data; + const styleval = page.styles.lookupId( + page.memory, + s.cursor.style_id, + ).?; + try testing.expect(styleval.flags.bold); } } -test "Screen: scrolling moves selection" { +test "Screen: cursorUp across pages preserves style" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 10, 3, 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(.{ .screen = 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.?); + // Scroll down enough to go to another page + const start_page = &s.pages.pages.last.?.data; + const rem = start_page.capacity.rows; + for (0..rem) |_| try s.cursorDownOrScroll(); + // We need our page to change for this test o make sense. If this + // assertion fails then the bug is in the test: we should be scrolling + // above enough for a new page to show up. { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + const page = &s.cursor.page_pin.page.data; + try testing.expect(start_page != page); } - // 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.?); - + // Go back up, set a style + try s.setAttribute(.{ .bold = {} }); { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + const page = &s.cursor.page_pin.page.data; + const styleval = page.styles.lookupId( + page.memory, + s.cursor.style_id, + ).?; + try testing.expect(styleval.flags.bold); } - // Scroll up again - try s.scroll(.{ .screen = 1 }); + // Go back down into the prev page and we should have that style + s.cursorUp(1); + { + const page = &s.cursor.page_pin.page.data; + try testing.expect(start_page == page); - // Our selection should be null because it left the screen. - try testing.expect(s.selection == null); + const styleval = page.styles.lookupId( + page.memory, + s.cursor.style_id, + ).?; + try testing.expect(styleval.flags.bold); + } } -test "Screen: scrolling with scrollback available doesn't move selection" { +test "Screen: cursorAbsolute across pages preserves style" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 1); + var s = try init(alloc, 10, 3, 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(.{ .screen = 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.?); + // Scroll down enough to go to another page + const start_page = &s.pages.pages.last.?.data; + const rem = start_page.capacity.rows; + for (0..rem) |_| try s.cursorDownOrScroll(); + // We need our page to change for this test o make sense. If this + // assertion fails then the bug is in the test: we should be scrolling + // above enough for a new page to show up. { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + const page = &s.cursor.page_pin.page.data; + try testing.expect(start_page != page); } - // Scrolling back should make it visible again - try s.scroll(.{ .screen = -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.?); - + // Go back up, set a style + try s.setAttribute(.{ .bold = {} }); { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); + const page = &s.cursor.page_pin.page.data; + const styleval = page.styles.lookupId( + page.memory, + s.cursor.style_id, + ).?; + try testing.expect(styleval.flags.bold); } - // Scroll down, this sends us off the scrollback - try s.scroll(.{ .screen = 2 }); - - // Selection should be gone since we selected a line that went off. - try testing.expect(s.selection == null); - + // Go back down into the prev page and we should have that style + s.cursorAbsolute(1, 1); { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("3IJKL", contents); + const page = &s.cursor.page_pin.page.data; + try testing.expect(start_page == page); + + const styleval = page.styles.lookupId( + page.memory, + s.cursor.style_id, + ).?; + try testing.expect(styleval.flags.bold); } } - -test "Screen: scroll and clear full screen" { +test "Screen: scrolling" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 5); + var s = try init(alloc, 10, 3, 0); defer s.deinit(); + try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } }); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + // Scroll down, should still be bottom + try s.cursorDownScroll(); { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); } - - try s.scroll(.{ .clear = {} }); { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("", contents); + const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 2 } }).?; + const cell = list_cell.cell; + try testing.expect(cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 155, + .g = 0, + .b = 0, + }, cell.content.color_rgb); } + + // Scrolling to the bottom does nothing + s.scroll(.{ .active = {} }); + { - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); } } -test "Screen: scroll and clear partial screen" { +test "Screen: scrolling with a single-row screen no scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 5); + var s = try init(alloc, 10, 1, 0); defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH"); + try s.testWriteString("1ABCD"); + // Scroll down, should still be bottom + try s.cursorDownScroll(); { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH", contents); + try testing.expectEqualStrings("", contents); } +} + +test "Screen: scrolling with a single-row screen with scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 1, 1); + defer s.deinit(); + try s.testWriteString("1ABCD"); - try s.scroll(.{ .clear = {} }); + // Scroll down, should still be bottom + try s.cursorDownScroll(); { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("", contents); } + + s.scroll(.{ .delta_row = -1 }); { - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH", contents); + try testing.expectEqualStrings("1ABCD", contents); } } -test "Screen: scroll and clear empty screen" { +test "Screen: scrolling across pages preserves style" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 5); + var s = try init(alloc, 10, 3, 1); defer s.deinit(); - try s.scroll(.{ .clear = {} }); - try testing.expectEqual(@as(usize, 0), s.viewport); + try s.setAttribute(.{ .bold = {} }); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + const start_page = &s.pages.pages.last.?.data; + + // Scroll down enough to go to another page + const rem = start_page.capacity.rows - start_page.size.rows + 1; + for (0..rem) |_| try s.cursorDownScroll(); + + // We need our page to change for this test o make sense. If this + // assertion fails then the bug is in the test: we should be scrolling + // above enough for a new page to show up. + const page = &s.pages.pages.last.?.data; + try testing.expect(start_page != page); + + const styleval = page.styles.lookupId( + page.memory, + s.cursor.style_id, + ).?; + try testing.expect(styleval.flags.bold); } -test "Screen: scroll and clear ignore blank lines" { +test "Screen: scroll down from 0" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 10); + var s = try init(alloc, 10, 3, 0); defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH"); - try s.scroll(.{ .clear = {} }); - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("", contents); - } - - // Move back to top-left - s.cursor.x = 0; - s.cursor.y = 0; - - // Write and clear - try s.testWriteString("3ABCD\n"); - try s.scroll(.{ .clear = {} }); - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("", contents); - } + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - // Move back to top-left - s.cursor.x = 0; - s.cursor.y = 0; - try s.testWriteString("X"); + // Scrolling up does nothing, but allows it + s.scroll(.{ .delta_row = -1 }); + try testing.expect(s.pages.viewport == .active); { - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3ABCD\nX", contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); } } -test "Screen: history region with no scrollback" { +test "Screen: scrollback various cases" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 1, 5, 0); + var s = try init(alloc, 10, 3, 1); defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + try s.cursorDownScroll(); - // Write a bunch that WOULD invoke scrollback if exists - const str = "1ABCD\n2EFGH\n3IJKL"; - try s.testWriteString(str); { - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - const expected = "3IJKL"; - try testing.expectEqualStrings(expected, contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); } - // Verify no scrollback - var it = s.rowIterator(.history); - var count: usize = 0; - while (it.next()) |_| count += 1; - try testing.expect(count == 0); -} - -test "Screen: history region with scrollback" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 1, 5, 2); - defer s.deinit(); - - // Write a bunch that WOULD invoke scrollback if exists - const str = "1ABCD\n2EFGH\n3IJKL"; - try s.testWriteString(str); + // Scrolling to the bottom + s.scroll(.{ .active = {} }); { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - const expected = "3IJKL"; - try testing.expectEqualStrings(expected, contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); } + + // Scrolling back should make it visible again + s.scroll(.{ .delta_row = -1 }); + try testing.expect(s.pages.viewport != .active); { - // Test our contents - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); } + // Scrolling back again should do nothing + s.scroll(.{ .delta_row = -1 }); { - const contents = try s.testString(alloc, .history); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - const expected = "1ABCD\n2EFGH"; - try testing.expectEqualStrings(expected, contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); } -} - -test "Screen: row copy" { - 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"); - - // Copy - try s.scroll(.{ .screen = 1 }); - try s.copyRow(.{ .active = 2 }, .{ .active = 0 }); - - // Test our contents - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL\n2EFGH", contents); -} - -test "Screen: clone" { - 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()); + // Scrolling to the bottom + s.scroll(.{ .active = {} }); { - var s2 = try s.clone(alloc, .{ .active = 1 }, .{ .active = 1 }); - defer s2.deinit(); - - // Test our contents rotated - const contents = try s2.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH", contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); } + // Scrolling forward with no grow should do nothing + s.scroll(.{ .delta_row = 1 }); { - var s2 = try s.clone(alloc, .{ .active = 1 }, .{ .active = 2 }); - defer s2.deinit(); - - // Test our contents rotated - const contents = try s2.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2EFGH\n3IJKL", contents); } -} - -test "Screen: clone empty viewport" { - const testing = std.testing; - const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); - defer s.deinit(); + // Scrolling to the top should work + s.scroll(.{ .top = {} }); + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); + } + // Should be able to easily clear active area only + s.clearRows(.{ .active = .{} }, null, false); { - var s2 = try s.clone(alloc, .{ .viewport = 0 }, .{ .viewport = 0 }); - defer s2.deinit(); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD", contents); + } - // Test our contents rotated - const contents = try s2.testString(alloc, .viewport); + // Scrolling to the bottom + s.scroll(.{ .active = {} }); + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("", contents); } } -test "Screen: clone one line viewport" { +test "Screen: scrollback with multi-row delta" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 10, 3, 3); defer s.deinit(); - try s.testWriteString("1ABC"); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH\n6IJKL"); + // Scroll to top + s.scroll(.{ .top = {} }); { - var s2 = try s.clone(alloc, .{ .viewport = 0 }, .{ .viewport = 0 }); - defer s2.deinit(); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); + } - // Test our contents - const contents = try s2.testString(alloc, .viewport); + // Scroll down multiple + s.scroll(.{ .delta_row = 5 }); + try testing.expect(s.pages.viewport == .active); + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("1ABC", contents); + try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); } } -test "Screen: clone empty active" { +test "Screen: scrollback empty" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 10, 3, 50); defer s.deinit(); - + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + s.scroll(.{ .delta_row = 1 }); { - var s2 = try s.clone(alloc, .{ .active = 0 }, .{ .active = 0 }); - defer s2.deinit(); - - // Test our contents rotated - const contents = try s2.testString(alloc, .active); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("", contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); } } -test "Screen: clone one line active with extra space" { +test "Screen: scrollback doesn't move viewport if not at bottom" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 10, 3, 3); defer s.deinit(); - try s.testWriteString("1ABC"); - - // Should have 1 line written - try testing.expectEqual(@as(usize, 1), s.rowsWritten()); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"); + // First test: we scroll up by 1, so we're not at the bottom anymore. + s.scroll(.{ .delta_row = -1 }); { - var s2 = try s.clone(alloc, .{ .active = 0 }, .{ .active = s.rows - 1 }); - defer s2.deinit(); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL\n4ABCD", contents); + } - // Test our contents rotated - const contents = try s2.testString(alloc, .active); + // Next, we scroll back down by 1, this grows the scrollback but we + // shouldn't move. + try s.cursorDownScroll(); + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("1ABC", contents); + try testing.expectEqualStrings("2EFGH\n3IJKL\n4ABCD", contents); } - // Should still have no history. A bug was that we were generating history - // in this case which is not good! This was causing resizes to have all - // sorts of problems. - try testing.expectEqual(@as(usize, 1), s.rowsWritten()); + // Scroll again, this clears scrollback so we should move viewports + // but still see the same thing since our original view fits. + try s.cursorDownScroll(); + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL\n4ABCD", contents); + } } -test "Screen: selectLine" { +test "Screen: scrolling moves selection" { 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"); + var s = try init(alloc, 5, 3, 1); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + + // Select a single line + try s.select(Selection.init( + s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, + s.pages.pin(.{ .active = .{ .x = s.pages.cols - 1, .y = 1 } }).?, + false, + )); + + // Scroll down, should still be bottom + try s.cursorDownScroll(); + + // Our selection should've moved up + { + const sel = s.selection.?; + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = s.pages.cols - 1, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } + + { + // Test our contents rotated + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + } - // Outside of active area - try testing.expect(s.selectLine(.{ .x = 13, .y = 0 }) == null); - try testing.expect(s.selectLine(.{ .x = 0, .y = 5 }) == null); + // Scrolling to the bottom does nothing + s.scroll(.{ .active = {} }); - // Going forward + // Our selection should've stayed the same { - 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); + const sel = s.selection.?; + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = s.pages.cols - 1, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end()).?); } - // 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); + // Test our contents rotated + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); } - // Going forward and backward + // Scroll up again + try s.cursorDownScroll(); + { - 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); + // Test our contents rotated + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("3IJKL", contents); } - // Outside active area + // Our selection should be null because it left the screen. { - 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); + const sel = s.selection.?; + try testing.expect(s.pages.pointFromPin(.active, sel.start()) == null); + try testing.expect(s.pages.pointFromPin(.active, sel.end()) == null); } } -test "Screen: selectAll" { + +test "Screen: scrolling moves viewport" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 10, 0); + var s = try init(alloc, 10, 3, 1); defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n"); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + s.scroll(.{ .delta_row = -2 }); { - try s.testWriteString("ABC DEF\n 123\n456"); - const sel = s.selectAll().?; - 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, 2), sel.end.y); + // Test our contents rotated + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL\n1ABCD", contents); } { - try s.testWriteString("\nFOO\n BAR\n BAZ\n QWERTY\n 12345678"); - const sel = s.selectAll().?; - try testing.expectEqual(@as(usize, 0), sel.start.x); - try testing.expectEqual(@as(usize, 0), sel.start.y); - try testing.expectEqual(@as(usize, 8), sel.end.x); - try testing.expectEqual(@as(usize, 7), sel.end.y); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 1, + } }, s.pages.pointFromPin(.screen, s.pages.getTopLeft(.viewport))); } } -test "Screen: selectLine across soft-wrap" { +test "Screen: scrolling when viewport is pruned" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 5, 0); + var s = try init(alloc, 215, 3, 1); defer s.deinit(); - try s.testWriteString(" 12 34012 \n 123"); - // Going forward + // Write some to create scrollback and move back into our scrollback. + try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n"); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + s.scroll(.{ .delta_row = -2 }); + + // Our viewport is now somewhere pinned. Create so much scrollback + // that we prune it. + try s.testWriteString("\n"); + for (0..1000) |_| try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n"); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + + { + // Test our contents rotated + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); + } + { - 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); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.screen, s.pages.getTopLeft(.viewport))); } } -// https://github.com/mitchellh/ghostty/issues/1329 -test "Screen: selectLine semantic prompt boundary" { +test "Screen: scroll and clear full screen" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 5, 0); + var s = try init(alloc, 10, 3, 5); defer s.deinit(); - try s.testWriteString("ABCDE\nA > "); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); { - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("ABCDE\nA \n> ", contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); } - var row = s.getRow(.{ .screen = 2 }); - row.setSemanticPrompt(.prompt); - - // Selecting output stops at the prompt even if soft-wrapped + try s.scrollClear(); { - const sel = s.selectLine(.{ .x = 1, .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); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("", contents); } { - const sel = s.selectLine(.{ .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, 0), sel.end.x); - try testing.expectEqual(@as(usize, 2), sel.end.y); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); } } -test "Screen: selectLine across soft-wrap ignores blank lines" { +test "Screen: scroll and clear partial screen" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 5, 0); + var s = try init(alloc, 10, 3, 5); defer s.deinit(); - try s.testWriteString(" 12 34012 \n 123"); + try s.testWriteString("1ABCD\n2EFGH"); - // 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); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH", contents); } - // Going backward + try s.scrollClear(); { - 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); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("", contents); } - - // 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); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH", contents); } } -test "Screen: selectLine with scrollback" { +test "Screen: scroll and clear empty screen" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 2, 5); + var s = try init(alloc, 10, 3, 5); defer s.deinit(); - try s.testWriteString("1A\n2B\n3C\n4D\n5E"); - - // Selecting first line + try s.scrollClear(); { - 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, 1), sel.end.x); - try testing.expectEqual(@as(usize, 0), sel.end.y); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("", contents); } - - // Selecting last line { - const sel = s.selectLine(.{ .x = 0, .y = 4 }).?; - try testing.expectEqual(@as(usize, 0), sel.start.x); - try testing.expectEqual(@as(usize, 4), sel.start.y); - try testing.expectEqual(@as(usize, 1), sel.end.x); - try testing.expectEqual(@as(usize, 4), sel.end.y); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("", contents); } } -test "Screen: selectWord" { +test "Screen: scroll and clear ignore blank lines" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 10, 0); + var s = try init(alloc, 10, 3, 10); defer s.deinit(); - try s.testWriteString("ABC DEF\n 123\n456"); + try s.testWriteString("1ABCD\n2EFGH"); + try s.scrollClear(); + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("", contents); + } - // Outside of active area - try testing.expect(s.selectWord(.{ .x = 9, .y = 0 }) == null); - try testing.expect(s.selectWord(.{ .x = 0, .y = 5 }) == null); + // Move back to top-left + s.cursorAbsolute(0, 0); - // Going forward + // Write and clear + try s.testWriteString("3ABCD\n"); { - 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); + const contents = try s.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("3ABCD", contents); } - // Going backward + try s.scrollClear(); { - 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); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("", contents); } - // Going forward and backward + // Move back to top-left + s.cursorAbsolute(0, 0); + try s.testWriteString("X"); + { - 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); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3ABCD\nX", contents); } +} - // Whitespace +test "Screen: clone" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 3, 10); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH"); { - 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); + const contents = try s.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH", contents); } + try testing.expectEqual(@as(usize, 5), s.cursor.x); + try testing.expectEqual(@as(usize, 1), s.cursor.y); - // Whitespace single char + // Clone + var s2 = try s.clone(alloc, .{ .active = .{} }, null); + defer s2.deinit(); { - 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); + const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH", contents); } + try testing.expectEqual(@as(usize, 5), s2.cursor.x); + try testing.expectEqual(@as(usize, 1), s2.cursor.y); - // End of screen + // Write to s1, should not be in s2 + try s.testWriteString("\n34567"); { - 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); + const contents = try s.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n34567", contents); + } + { + const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH", contents); } + try testing.expectEqual(@as(usize, 5), s2.cursor.x); + try testing.expectEqual(@as(usize, 1), s2.cursor.y); } -test "Screen: selectWord across soft-wrap" { +test "Screen: clone partial" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 5, 0); + var s = try init(alloc, 10, 3, 10); defer s.deinit(); - try s.testWriteString(" 1234012\n 123"); - - // Going forward + try s.testWriteString("1ABCD\n2EFGH"); { - 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); + const contents = try s.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH", contents); } + try testing.expectEqual(@as(usize, 5), s.cursor.x); + try testing.expectEqual(@as(usize, 1), s.cursor.y); - // Going backward + // Clone + var s2 = try s.clone(alloc, .{ .active = .{ .y = 1 } }, null); + defer s2.deinit(); { - 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); + const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH", contents); } - // 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); - } + // Cursor is shifted since we cloned partial + try testing.expectEqual(@as(usize, 5), s2.cursor.x); + try testing.expectEqual(@as(usize, 0), s2.cursor.y); } -test "Screen: selectWord whitespace across soft-wrap" { +test "Screen: clone partial cursor out of bounds" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 5, 0); + var s = try init(alloc, 10, 3, 10); defer s.deinit(); - try s.testWriteString("1 1\n 123"); - - // Going forward + try s.testWriteString("1ABCD\n2EFGH"); { - 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); + const contents = try s.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH", contents); } + try testing.expectEqual(@as(usize, 5), s.cursor.x); + try testing.expectEqual(@as(usize, 1), s.cursor.y); - // Going backward + // Clone + var s2 = try s.clone( + alloc, + .{ .active = .{ .y = 0 } }, + .{ .active = .{ .y = 0 } }, + ); + defer s2.deinit(); { - 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); + const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD", contents); } - // Going forward and backward + // Cursor is shifted since we cloned partial + try testing.expectEqual(@as(usize, 0), s2.cursor.x); + try testing.expectEqual(@as(usize, 0), s2.cursor.y); +} + +test "Screen: clone contains full selection" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, 1); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + + // Select a single line + try s.select(Selection.init( + s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, + s.pages.pin(.{ .active = .{ .x = s.pages.cols - 1, .y = 1 } }).?, + false, + )); + + // Clone + var s2 = try s.clone( + alloc, + .{ .active = .{} }, + null, + ); + defer s2.deinit(); + + // Our selection should remain valid { - 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); + const sel = s2.selection.?; + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 1, + } }, s2.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = s2.pages.cols - 1, + .y = 1, + } }, s2.pages.pointFromPin(.active, sel.end()).?); } } -test "Screen: selectWord with character boundary" { +test "Screen: clone contains none of selection" { const testing = std.testing; const alloc = testing.allocator; - const cases = [_][]const u8{ - " 'abc' \n123", - " \"abc\" \n123", - " │abc│ \n123", - " `abc` \n123", - " |abc| \n123", - " :abc: \n123", - " ,abc, \n123", - " (abc( \n123", - " )abc) \n123", - " [abc[ \n123", - " ]abc] \n123", - " {abc{ \n123", - " }abc} \n123", - " abc> \n123", - }; + var s = try init(alloc, 5, 3, 1); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - for (cases) |case| { - var s = try init(alloc, 10, 20, 0); - defer s.deinit(); - try s.testWriteString(case); + // Select a single line + try s.select(Selection.init( + s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, + s.pages.pin(.{ .active = .{ .x = s.pages.cols - 1, .y = 0 } }).?, + false, + )); + + // Clone + var s2 = try s.clone( + alloc, + .{ .active = .{ .y = 1 } }, + null, + ); + defer s2.deinit(); - // Inside character forward - { - const sel = s.selectWord(.{ .x = 2, .y = 0 }).?; - try testing.expectEqual(@as(usize, 2), 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); - } + // Our selection should be null + try testing.expect(s2.selection == null); +} - // Inside character backward - { - const sel = s.selectWord(.{ .x = 4, .y = 0 }).?; - try testing.expectEqual(@as(usize, 2), 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); - } +test "Screen: clone contains selection start cutoff" { + const testing = std.testing; + const alloc = testing.allocator; - // Inside character bidirectional - { - const sel = s.selectWord(.{ .x = 3, .y = 0 }).?; - try testing.expectEqual(@as(usize, 2), 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); - } + var s = try init(alloc, 5, 3, 1); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - // On quote - // NOTE: this behavior is not ideal, so we can change this one day, - // but I think its also not that important compared to the above. - { - 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, 1), sel.end.x); - try testing.expectEqual(@as(usize, 0), sel.end.y); - } + // Select a single line + try s.select(Selection.init( + s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, + s.pages.pin(.{ .active = .{ .x = s.pages.cols - 1, .y = 1 } }).?, + false, + )); + + // Clone + var s2 = try s.clone( + alloc, + .{ .active = .{ .y = 1 } }, + null, + ); + defer s2.deinit(); + + // Our selection should remain valid + { + const sel = s2.selection.?; + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 0, + } }, s2.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = s2.pages.cols - 1, + .y = 0, + } }, s2.pages.pointFromPin(.active, sel.end()).?); } } -test "Screen: selectOutput" { +test "Screen: clone contains selection end cutoff" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 15, 10, 0); + var s = try init(alloc, 5, 3, 1); defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - // zig fmt: off + // Select a single line + try s.select(Selection.init( + s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, + s.pages.pin(.{ .active = .{ .x = 2, .y = 2 } }).?, + false, + )); + + // Clone + var s2 = try s.clone( + alloc, + .{ .active = .{ .y = 0 } }, + .{ .active = .{ .y = 1 } }, + ); + defer s2.deinit(); + + // Our selection should remain valid { - // line number: - try s.testWriteString("output1\n"); // 0 - try s.testWriteString("output1\n"); // 1 - try s.testWriteString("prompt2\n"); // 2 - try s.testWriteString("input2\n"); // 3 - try s.testWriteString("output2\n"); // 4 - try s.testWriteString("output2\n"); // 5 - try s.testWriteString("prompt3$ input3\n"); // 6 - try s.testWriteString("output3\n"); // 7 - try s.testWriteString("output3\n"); // 8 - try s.testWriteString("output3"); // 9 + const sel = s2.selection.?; + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 1, + } }, s2.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = s2.pages.cols - 1, + .y = 2, + } }, s2.pages.pointFromPin(.active, sel.end()).?); } - // zig fmt: on +} - var row = s.getRow(.{ .screen = 2 }); - row.setSemanticPrompt(.prompt); - row = s.getRow(.{ .screen = 3 }); - row.setSemanticPrompt(.input); - row = s.getRow(.{ .screen = 4 }); - row.setSemanticPrompt(.command); - row = s.getRow(.{ .screen = 6 }); - row.setSemanticPrompt(.input); - row = s.getRow(.{ .screen = 7 }); - row.setSemanticPrompt(.command); +test "Screen: clone contains selection end cutoff reversed" { + const testing = std.testing; + const alloc = testing.allocator; - // No start marker, should select from the beginning - { - const sel = s.selectOutput(.{ .x = 1, .y = 1 }).?; - try testing.expectEqual(@as(usize, 0), sel.start.x); - try testing.expectEqual(@as(usize, 0), sel.start.y); - try testing.expectEqual(@as(usize, 10), sel.end.x); - try testing.expectEqual(@as(usize, 1), sel.end.y); - } - // Both start and end markers, should select between them - { - const sel = s.selectOutput(.{ .x = 3, .y = 5 }).?; - try testing.expectEqual(@as(usize, 0), sel.start.x); - try testing.expectEqual(@as(usize, 4), sel.start.y); - try testing.expectEqual(@as(usize, 10), sel.end.x); - try testing.expectEqual(@as(usize, 5), sel.end.y); - } - // No end marker, should select till the end - { - const sel = s.selectOutput(.{ .x = 2, .y = 7 }).?; - try testing.expectEqual(@as(usize, 0), sel.start.x); - try testing.expectEqual(@as(usize, 7), sel.start.y); - try testing.expectEqual(@as(usize, 9), sel.end.x); - try testing.expectEqual(@as(usize, 10), sel.end.y); - } - // input / prompt at y = 0, pt.y = 0 + var s = try init(alloc, 5, 3, 1); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + + // Select a single line + try s.select(Selection.init( + s.pages.pin(.{ .active = .{ .x = 2, .y = 2 } }).?, + s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, + false, + )); + + // Clone + var s2 = try s.clone( + alloc, + .{ .active = .{ .y = 0 } }, + .{ .active = .{ .y = 1 } }, + ); + defer s2.deinit(); + + // Our selection should remain valid { - s.deinit(); - s = try init(alloc, 5, 10, 0); - try s.testWriteString("prompt1$ input1\n"); - try s.testWriteString("output1\n"); - try s.testWriteString("prompt2\n"); - row = s.getRow(.{ .screen = 0 }); - row.setSemanticPrompt(.input); - row = s.getRow(.{ .screen = 1 }); - row.setSemanticPrompt(.command); - try testing.expect(s.selectOutput(.{ .x = 2, .y = 0 }) == null); + const sel = s2.selection.?; + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 1, + } }, s2.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = s2.pages.cols - 1, + .y = 2, + } }, s2.pages.pointFromPin(.active, sel.end()).?); } } -test "Screen: selectPrompt basics" { +test "Screen: clone basic" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 15, 10, 0); + var s = try init(alloc, 10, 3, 0); defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - // zig fmt: off { - // line number: - try s.testWriteString("output1\n"); // 0 - try s.testWriteString("output1\n"); // 1 - try s.testWriteString("prompt2\n"); // 2 - try s.testWriteString("input2\n"); // 3 - try s.testWriteString("output2\n"); // 4 - try s.testWriteString("output2\n"); // 5 - try s.testWriteString("prompt3$ input3\n"); // 6 - try s.testWriteString("output3\n"); // 7 - try s.testWriteString("output3\n"); // 8 - try s.testWriteString("output3"); // 9 - } - // zig fmt: on - - var row = s.getRow(.{ .screen = 2 }); - row.setSemanticPrompt(.prompt); - row = s.getRow(.{ .screen = 3 }); - row.setSemanticPrompt(.input); - row = s.getRow(.{ .screen = 4 }); - row.setSemanticPrompt(.command); - row = s.getRow(.{ .screen = 6 }); - row.setSemanticPrompt(.input); - row = s.getRow(.{ .screen = 7 }); - row.setSemanticPrompt(.command); + var s2 = try s.clone( + alloc, + .{ .active = .{ .y = 1 } }, + .{ .active = .{ .y = 1 } }, + ); + defer s2.deinit(); - // Not at a prompt - { - const sel = s.selectPrompt(.{ .x = 0, .y = 1 }); - try testing.expect(sel == null); - } - { - const sel = s.selectPrompt(.{ .x = 0, .y = 8 }); - try testing.expect(sel == null); + // Test our contents rotated + const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH", contents); } - // Single line prompt { - const sel = s.selectPrompt(.{ .x = 1, .y = 6 }).?; - try testing.expectEqual(Selection{ - .start = .{ .x = 0, .y = 6 }, - .end = .{ .x = 9, .y = 6 }, - }, sel); - } + var s2 = try s.clone( + alloc, + .{ .active = .{ .y = 1 } }, + .{ .active = .{ .y = 2 } }, + ); + defer s2.deinit(); - // Multi line prompt - { - const sel = s.selectPrompt(.{ .x = 1, .y = 3 }).?; - try testing.expectEqual(Selection{ - .start = .{ .x = 0, .y = 2 }, - .end = .{ .x = 9, .y = 3 }, - }, sel); + // Test our contents rotated + const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); } } -test "Screen: selectPrompt prompt at start" { +test "Screen: clone empty viewport" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 15, 10, 0); + var s = try init(alloc, 10, 3, 0); defer s.deinit(); - // zig fmt: off { - // line number: - try s.testWriteString("prompt1\n"); // 0 - try s.testWriteString("input1\n"); // 1 - try s.testWriteString("output2\n"); // 2 - try s.testWriteString("output2\n"); // 3 + var s2 = try s.clone( + alloc, + .{ .viewport = .{ .y = 0 } }, + .{ .viewport = .{ .y = 0 } }, + ); + defer s2.deinit(); + + // Test our contents rotated + const contents = try s2.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("", contents); } - // zig fmt: on +} - var row = s.getRow(.{ .screen = 0 }); - row.setSemanticPrompt(.prompt); - row = s.getRow(.{ .screen = 1 }); - row.setSemanticPrompt(.input); - row = s.getRow(.{ .screen = 2 }); - row.setSemanticPrompt(.command); +test "Screen: clone one line viewport" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 3, 0); + defer s.deinit(); + try s.testWriteString("1ABC"); - // Not at a prompt { - const sel = s.selectPrompt(.{ .x = 0, .y = 3 }); - try testing.expect(sel == null); + var s2 = try s.clone( + alloc, + .{ .viewport = .{ .y = 0 } }, + .{ .viewport = .{ .y = 0 } }, + ); + defer s2.deinit(); + + // Test our contents + const contents = try s2.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABC", contents); } +} + +test "Screen: clone empty active" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 3, 0); + defer s.deinit(); - // Multi line prompt { - const sel = s.selectPrompt(.{ .x = 1, .y = 1 }).?; - try testing.expectEqual(Selection{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = 9, .y = 1 }, - }, sel); + var s2 = try s.clone( + alloc, + .{ .active = .{ .y = 0 } }, + .{ .active = .{ .y = 0 } }, + ); + defer s2.deinit(); + + // Test our contents rotated + const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("", contents); } } -test "Screen: selectPrompt prompt at end" { +test "Screen: clone one line active with extra space" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 15, 10, 0); + var s = try init(alloc, 10, 3, 0); defer s.deinit(); + try s.testWriteString("1ABC"); - // zig fmt: off { - // line number: - try s.testWriteString("output2\n"); // 0 - try s.testWriteString("output2\n"); // 1 - try s.testWriteString("prompt1\n"); // 2 - try s.testWriteString("input1\n"); // 3 + var s2 = try s.clone( + alloc, + .{ .active = .{ .y = 0 } }, + null, + ); + defer s2.deinit(); + + // Test our contents rotated + const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABC", contents); } - // zig fmt: on +} - var row = s.getRow(.{ .screen = 2 }); - row.setSemanticPrompt(.prompt); - row = s.getRow(.{ .screen = 3 }); - row.setSemanticPrompt(.input); +test "Screen: clear history with no history" { + const testing = std.testing; + const alloc = testing.allocator; - // Not at a prompt + var s = try init(alloc, 10, 3, 3); + defer s.deinit(); + try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); + try testing.expect(s.pages.viewport == .active); + s.eraseRows(.{ .history = .{} }, null); + try testing.expect(s.pages.viewport == .active); { - const sel = s.selectPrompt(.{ .x = 0, .y = 1 }); - try testing.expect(sel == null); + // Test our contents rotated + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); } - - // Multi line prompt { - const sel = s.selectPrompt(.{ .x = 1, .y = 2 }).?; - try testing.expectEqual(Selection{ - .start = .{ .x = 0, .y = 2 }, - .end = .{ .x = 9, .y = 3 }, - }, sel); + // Test our contents rotated + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); } } -test "Screen: promtpPath" { +test "Screen: clear history" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 15, 10, 0); + var s = try init(alloc, 10, 3, 3); defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH\n6IJKL"); + try testing.expect(s.pages.viewport == .active); - // zig fmt: off + // Scroll to top + s.scroll(.{ .top = {} }); { - // line number: - try s.testWriteString("output1\n"); // 0 - try s.testWriteString("output1\n"); // 1 - try s.testWriteString("prompt2\n"); // 2 - try s.testWriteString("input2\n"); // 3 - try s.testWriteString("output2\n"); // 4 - try s.testWriteString("output2\n"); // 5 - try s.testWriteString("prompt3$ input3\n"); // 6 - try s.testWriteString("output3\n"); // 7 - try s.testWriteString("output3\n"); // 8 - try s.testWriteString("output3"); // 9 + // Test our contents rotated + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); } - // zig fmt: on - var row = s.getRow(.{ .screen = 2 }); - row.setSemanticPrompt(.prompt); - row = s.getRow(.{ .screen = 3 }); - row.setSemanticPrompt(.input); - row = s.getRow(.{ .screen = 4 }); - row.setSemanticPrompt(.command); - row = s.getRow(.{ .screen = 6 }); - row.setSemanticPrompt(.input); - row = s.getRow(.{ .screen = 7 }); - row.setSemanticPrompt(.command); - - // From is not in the prompt + s.eraseRows(.{ .history = .{} }, null); + try testing.expect(s.pages.viewport == .active); { - const path = s.promptPath( - .{ .x = 0, .y = 1 }, - .{ .x = 0, .y = 2 }, - ); - try testing.expectEqual(@as(isize, 0), path.x); - try testing.expectEqual(@as(isize, 0), path.y); + // Test our contents rotated + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); } - - // Same line { - const path = s.promptPath( - .{ .x = 6, .y = 2 }, - .{ .x = 3, .y = 2 }, - ); - try testing.expectEqual(@as(isize, -3), path.x); - try testing.expectEqual(@as(isize, 0), path.y); + // Test our contents rotated + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); } +} - // Different lines - { - const path = s.promptPath( - .{ .x = 6, .y = 2 }, - .{ .x = 3, .y = 3 }, - ); - try testing.expectEqual(@as(isize, -3), path.x); - try testing.expectEqual(@as(isize, 1), path.y); - } +test "Screen: clear above cursor" { + const testing = std.testing; + const alloc = testing.allocator; - // To is out of bounds before + var s = try init(alloc, 10, 10, 3); + defer s.deinit(); + try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); + s.clearRows( + .{ .active = .{ .y = 0 } }, + .{ .active = .{ .y = s.cursor.y - 1 } }, + false, + ); { - const path = s.promptPath( - .{ .x = 6, .y = 2 }, - .{ .x = 3, .y = 1 }, - ); - try testing.expectEqual(@as(isize, -6), path.x); - try testing.expectEqual(@as(isize, 0), path.y); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("\n\n6IJKL", contents); } - - // To is out of bounds after { - const path = s.promptPath( - .{ .x = 6, .y = 2 }, - .{ .x = 3, .y = 9 }, - ); - try testing.expectEqual(@as(isize, 3), path.x); - try testing.expectEqual(@as(isize, 1), path.y); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("\n\n6IJKL", contents); } + + try testing.expectEqual(@as(usize, 5), s.cursor.x); + try testing.expectEqual(@as(usize, 2), s.cursor.y); } -test "Screen: scrollRegionUp single" { +test "Screen: clear above cursor with history" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 4, 5, 0); + var s = try init(alloc, 10, 3, 3); defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); - - s.scrollRegionUp(.{ .active = 1 }, .{ .active = 2 }, 1); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n"); + try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); + s.clearRows( + .{ .active = .{ .y = 0 } }, + .{ .active = .{ .y = s.cursor.y - 1 } }, + false, + ); { - // Test our contents rotated - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("\n\n6IJKL", contents); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n3IJKL\n\n4ABCD", contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL\n\n\n6IJKL", contents); } + + try testing.expectEqual(@as(usize, 5), s.cursor.x); + try testing.expectEqual(@as(usize, 2), s.cursor.y); } -test "Screen: scrollRegionUp same line" { +test "Screen: resize (no reflow) more rows" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 4, 5, 0); + var s = try init(alloc, 10, 3, 0); defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); + const str = "1ABCD\n2EFGH\n3IJKL"; + try s.testWriteString(str); - s.scrollRegionUp(.{ .active = 1 }, .{ .active = 1 }, 1); + // Resize + try s.resizeWithoutReflow(10, 10); { - // Test our contents rotated - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL\n4ABCD", contents); + try testing.expectEqualStrings(str, contents); } } -test "Screen: scrollRegionUp single with pen" { +test "Screen: resize (no reflow) less rows" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 4, 5, 0); + var s = try init(alloc, 10, 3, 0); defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); + const str = "1ABCD\n2EFGH\n3IJKL"; + try s.testWriteString(str); + try testing.expectEqual(5, s.cursor.x); + try testing.expectEqual(2, s.cursor.y); + try s.resizeWithoutReflow(10, 2); + + // Since we shrunk, we should adjust our cursor + try testing.expectEqual(5, s.cursor.x); + try testing.expectEqual(1, s.cursor.y); - s.cursor.pen = .{ .char = 'X' }; - s.cursor.pen.bg = .{ .rgb = .{ .r = 155 } }; - s.cursor.pen.attrs.bold = true; - s.scrollRegionUp(.{ .active = 1 }, .{ .active = 2 }, 1); { - // Test our contents rotated - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n3IJKL\n\n4ABCD", contents); - const cell = s.getCell(.active, 2, 0); - try testing.expectEqual(@as(u8, 155), cell.bg.rgb.r); - try testing.expect(!cell.attrs.bold); - try testing.expect(s.cursor.pen.attrs.bold); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); } } -test "Screen: scrollRegionUp multiple" { +test "Screen: resize (no reflow) less rows trims blank lines" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 4, 5, 0); + var s = try init(alloc, 10, 3, 0); defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); + const str = "1ABCD"; + try s.testWriteString(str); + + // Write only a background color into the remaining rows + for (1..s.pages.rows) |y| { + const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = y } }).?; + list_cell.cell.* = .{ + .content_tag = .bg_color_rgb, + .content = .{ .color_rgb = .{ .r = 0xFF, .g = 0, .b = 0 } }, + }; + } + + const cursor = s.cursor; + try s.resizeWithoutReflow(6, 2); + + // Cursor should not move + try testing.expectEqual(cursor.x, s.cursor.x); + try testing.expectEqual(cursor.y, s.cursor.y); - s.scrollRegionUp(.{ .active = 1 }, .{ .active = 3 }, 1); { - // Test our contents rotated - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n3IJKL\n4ABCD", contents); + try testing.expectEqualStrings("1ABCD", contents); } } -test "Screen: scrollRegionUp multiple count" { +test "Screen: resize (no reflow) more rows trims blank lines" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 4, 5, 0); + var s = try init(alloc, 10, 3, 0); defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); + const str = "1ABCD"; + try s.testWriteString(str); + + // Write only a background color into the remaining rows + for (1..s.pages.rows) |y| { + const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = y } }).?; + list_cell.cell.* = .{ + .content_tag = .bg_color_rgb, + .content = .{ .color_rgb = .{ .r = 0xFF, .g = 0, .b = 0 } }, + }; + } + + const cursor = s.cursor; + try s.resizeWithoutReflow(10, 7); + + // Cursor should not move + try testing.expectEqual(cursor.x, s.cursor.x); + try testing.expectEqual(cursor.y, s.cursor.y); - s.scrollRegionUp(.{ .active = 1 }, .{ .active = 3 }, 2); { - // Test our contents rotated - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n4ABCD", contents); + try testing.expectEqualStrings("1ABCD", contents); } } -test "Screen: scrollRegionUp count greater than available lines" { +test "Screen: resize (no reflow) more cols" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 4, 5, 0); + var s = try init(alloc, 10, 3, 0); defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); + const str = "1ABCD\n2EFGH\n3IJKL"; + try s.testWriteString(str); + try s.resizeWithoutReflow(20, 3); - s.scrollRegionUp(.{ .active = 1 }, .{ .active = 2 }, 10); { - // Test our contents rotated - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n\n\n4ABCD", contents); + try testing.expectEqualStrings(str, contents); } } -test "Screen: scrollRegionUp fills with pen" { + +test "Screen: resize (no reflow) less cols" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 4, 5, 0); + var s = try init(alloc, 10, 3, 0); defer s.deinit(); - try s.testWriteString("A\nB\nC\nD"); + const str = "1ABCD\n2EFGH\n3IJKL"; + try s.testWriteString(str); + try s.resizeWithoutReflow(4, 3); - s.cursor.pen = .{ .char = 'X' }; - s.cursor.pen.bg = .{ .rgb = .{ .r = 155 } }; - s.cursor.pen.attrs.bold = true; - s.scrollRegionUp(.{ .active = 0 }, .{ .active = 2 }, 1); { - // Test our contents rotated - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("B\nC\n\nD", contents); - const cell = s.getCell(.active, 2, 0); - try testing.expectEqual(@as(u8, 155), cell.bg.rgb.r); - try testing.expect(!cell.attrs.bold); - try testing.expect(s.cursor.pen.attrs.bold); + const expected = "1ABC\n2EFG\n3IJK"; + try testing.expectEqualStrings(expected, contents); } } -test "Screen: scrollRegionUp buffer wrap" { +test "Screen: resize (no reflow) more rows with scrollback cursor end" { 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"); - - // Scroll down, should still be bottom, but should wrap because - // we're out of space. - try s.scroll(.{ .screen = 1 }); - s.cursor.x = 0; - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); - - // Scroll - s.cursor.pen = .{ .char = 'X' }; - s.cursor.pen.bg = .{ .rgb = .{ .r = 155 } }; - s.cursor.pen.attrs.bold = true; - s.scrollRegionUp(.{ .screen = 0 }, .{ .screen = 2 }, 1); + var s = try init(alloc, 7, 3, 2); + defer s.deinit(); + const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; + try s.testWriteString(str); + try s.resizeWithoutReflow(7, 10); { - // Test our contents rotated - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("3IJKL\n4ABCD", contents); - const cell = s.getCell(.active, 2, 0); - try testing.expectEqual(@as(u8, 155), cell.bg.rgb.r); - try testing.expect(!cell.attrs.bold); - try testing.expect(s.cursor.pen.attrs.bold); + try testing.expectEqualStrings(str, contents); } } -test "Screen: scrollRegionUp buffer wrap alternate" { +test "Screen: resize (no reflow) less rows with scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 7, 3, 2); defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - - // Scroll down, should still be bottom, but should wrap because - // we're out of space. - try s.scroll(.{ .screen = 1 }); - s.cursor.x = 0; - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); - - // Scroll - s.cursor.pen = .{ .char = 'X' }; - s.cursor.pen.bg = .{ .rgb = .{ .r = 155 } }; - s.cursor.pen.attrs.bold = true; - s.scrollRegionUp(.{ .screen = 0 }, .{ .screen = 2 }, 2); + const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; + try s.testWriteString(str); + try s.resizeWithoutReflow(7, 2); { - // Test our contents rotated - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("4ABCD", contents); - const cell = s.getCell(.active, 2, 0); - try testing.expectEqual(@as(u8, 155), cell.bg.rgb.r); - try testing.expect(!cell.attrs.bold); - try testing.expect(s.cursor.pen.attrs.bold); + const expected = "4ABCD\n5EFGH"; + try testing.expectEqualStrings(expected, contents); } } -test "Screen: scrollRegionUp buffer wrap alternative with extra lines" { +// https://github.com/mitchellh/ghostty/issues/1030 +test "Screen: resize (no reflow) less rows with empty trailing" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 5, 0); + var s = try init(alloc, 5, 3, 5); defer s.deinit(); + const str = "1\n2\n3\n4\n5\n6\n7\n8"; + try s.testWriteString(str); + try s.scrollClear(); + s.cursorAbsolute(0, 0); + try s.testWriteString("A\nB"); - // We artificially mess with the circular buffer here. This was discovered - // when debugging https://github.com/mitchellh/ghostty/issues/315. I - // don't know how to "naturally" get the circular buffer into this state - // although it is obviously possible, verified through various - // asciinema casts. - // - // I think the proper way to recreate this state would be to fill - // the screen, scroll the correct number of times, clear the screen - // with a fill. I can try that later to ensure we're hitting the same - // code path. - s.storage.head = 24; - s.storage.tail = 24; - s.storage.full = true; - - // Scroll down, should still be bottom, but should wrap because - // we're out of space. - // try s.scroll(.{ .screen = 2 }); - // s.cursor.x = 0; - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"); - - // Scroll - s.scrollRegionUp(.{ .screen = 0 }, .{ .screen = 3 }, 2); + const cursor = s.cursor; + try s.resizeWithoutReflow(5, 2); + try testing.expectEqual(cursor.x, s.cursor.x); + try testing.expectEqual(cursor.y, s.cursor.y); { - // Test our contents rotated - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("3IJKL\n4ABCD\n\n\n5EFGH", contents); + try testing.expectEqualStrings("A\nB", contents); } } -test "Screen: clear history with no history" { +test "Screen: resize (no reflow) more rows with soft wrapping" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 3); + var s = try init(alloc, 2, 3, 3); defer s.deinit(); - try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); - try testing.expect(s.viewportIsBottom()); - try s.clear(.history); - try testing.expect(s.viewportIsBottom()); - { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); + const str = "1A2B\n3C4E\n5F6G"; + try s.testWriteString(str); + + // Every second row should be wrapped + for (0..6) |y| { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = y } }).?; + const row = list_cell.row; + const wrapped = (y % 2 == 0); + try testing.expectEqual(wrapped, row.wrap); } + + // Resize + try s.resizeWithoutReflow(2, 10); { - // Test our contents rotated - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); + const expected = "1A\n2B\n3C\n4E\n5F\n6G"; + try testing.expectEqualStrings(expected, contents); + } + + // Every second row should be wrapped + for (0..6) |y| { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = y } }).?; + const row = list_cell.row; + const wrapped = (y % 2 == 0); + try testing.expectEqual(wrapped, row.wrap); } } -test "Screen: clear history" { +test "Screen: resize more rows no scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 3); + var s = try init(alloc, 5, 3, 0); defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH\n6IJKL"); - try testing.expect(s.viewportIsBottom()); + const str = "1ABCD\n2EFGH\n3IJKL"; + try s.testWriteString(str); + const cursor = s.cursor; + try s.resize(5, 10); - // Scroll to top - try s.scroll(.{ .top = {} }); - { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); - } + // Cursor should not move + try testing.expectEqual(cursor.x, s.cursor.x); + try testing.expectEqual(cursor.y, s.cursor.y); - try s.clear(.history); - try testing.expect(s.viewportIsBottom()); { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); + try testing.expectEqualStrings(str, contents); } { - // Test our contents rotated - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); + try testing.expectEqualStrings(str, contents); } } -test "Screen: clear above cursor" { +test "Screen: resize more rows with empty scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 10, 3); + var s = try init(alloc, 5, 3, 10); defer s.deinit(); - try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); - try testing.expect(s.viewportIsBottom()); - try s.clear(.above_cursor); - try testing.expect(s.viewportIsBottom()); + const str = "1ABCD\n2EFGH\n3IJKL"; + try s.testWriteString(str); + const cursor = s.cursor; + try s.resize(5, 10); + + // Cursor should not move + try testing.expectEqual(cursor.x, s.cursor.x); + try testing.expectEqual(cursor.y, s.cursor.y); + { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("6IJKL", contents); + try testing.expectEqualStrings(str, contents); } { - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("6IJKL", contents); + try testing.expectEqualStrings(str, contents); } - - try testing.expectEqual(@as(usize, 5), s.cursor.x); - try testing.expectEqual(@as(usize, 0), s.cursor.y); } -test "Screen: clear above cursor with history" { +test "Screen: resize more rows with populated scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 10, 3); + var s = try init(alloc, 5, 3, 5); defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n"); - try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); - try testing.expect(s.viewportIsBottom()); - try s.clear(.above_cursor); - try testing.expect(s.viewportIsBottom()); + const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; + try s.testWriteString(str); { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("6IJKL", contents); + const expected = "3IJKL\n4ABCD\n5EFGH"; + try testing.expectEqualStrings(expected, contents); } + + // Set our cursor to be on the "4" + s.cursorAbsolute(0, 1); { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL\n6IJKL", contents); + const list_cell = s.pages.getCell(.{ .active = .{ + .x = s.cursor.x, + .y = s.cursor.y, + } }).?; + try testing.expectEqual(@as(u21, '4'), list_cell.cell.content.codepoint); } - try testing.expectEqual(@as(usize, 5), s.cursor.x); - try testing.expectEqual(@as(usize, 0), s.cursor.y); -} - -test "Screen: selectionString basic" { - const testing = std.testing; - const alloc = testing.allocator; + // Resize + try s.resize(5, 10); - var s = try init(alloc, 3, 5, 0); - defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; - try s.testWriteString(str); + // Cursor should still be on the "4" + { + const list_cell = s.pages.getCell(.{ .active = .{ + .x = s.cursor.x, + .y = s.cursor.y, + } }).?; + try testing.expectEqual(@as(u21, '4'), list_cell.cell.content.codepoint); + } { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 1 }, - .end = .{ .x = 2, .y = 2 }, - }, true); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - const expected = "2EFGH\n3IJ"; + const expected = "3IJKL\n4ABCD\n5EFGH"; try testing.expectEqualStrings(expected, contents); } } -test "Screen: selectionString start outside of written area" { +test "Screen: resize more cols no reflow" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 5, 0); + var s = try init(alloc, 5, 3, 0); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); + const cursor = s.cursor; + try s.resize(10, 3); + + // Cursor should not move + try testing.expectEqual(cursor.x, s.cursor.x); + try testing.expectEqual(cursor.y, s.cursor.y); + { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 5 }, - .end = .{ .x = 2, .y = 6 }, - }, true); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - const expected = ""; - try testing.expectEqualStrings(expected, contents); + try testing.expectEqualStrings(str, contents); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); } } -test "Screen: selectionString end outside of written area" { +// https://github.com/mitchellh/ghostty/issues/272#issuecomment-1676038963 +test "Screen: resize more cols perfect split" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 5, 0); + var s = try init(alloc, 5, 3, 0); defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; + const str = "1ABCD2EFGH3IJKL"; try s.testWriteString(str); + try s.resize(10, 3); { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 2 }, - .end = .{ .x = 2, .y = 6 }, - }, true); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - const expected = "3IJKL"; - try testing.expectEqualStrings(expected, contents); + try testing.expectEqualStrings("1ABCD2EFGH\n3IJKL", contents); } } -test "Screen: selectionString trim space" { +// https://github.com/mitchellh/ghostty/issues/1159 +test "Screen: resize (no reflow) more cols with scrollback scrolled up" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 5, 3, 5); defer s.deinit(); - const str = "1AB \n2EFGH\n3IJKL"; + const str = "1\n2\n3\n4\n5\n6\n7\n8"; try s.testWriteString(str); + // Cursor at bottom + try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y); + + s.scroll(.{ .delta_row = -4 }); { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = 2, .y = 1 }, - }, true); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - const expected = "1AB\n2EF"; - try testing.expectEqualStrings(expected, contents); + try testing.expectEqualStrings("2\n3\n4", contents); } - // No trim + try s.resize(8, 3); { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = 2, .y = 1 }, - }, false); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - const expected = "1AB \n2EF"; - try testing.expectEqualStrings(expected, contents); + try testing.expectEqualStrings(str, contents); } + + // Cursor remains at bottom + try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y); } -test "Screen: selectionString trim empty line" { +// https://github.com/mitchellh/ghostty/issues/1159 +test "Screen: resize (no reflow) less cols with scrollback scrolled up" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 5, 0); + var s = try init(alloc, 5, 3, 5); defer s.deinit(); - const str = "1AB \n\n2EFGH\n3IJKL"; + const str = "1\n2\n3\n4\n5\n6\n7\n8"; try s.testWriteString(str); + // Cursor at bottom + try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y); + + s.scroll(.{ .delta_row = -4 }); { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = 2, .y = 2 }, - }, true); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - const expected = "1AB\n\n2EF"; - try testing.expectEqualStrings(expected, contents); + try testing.expectEqualStrings("2\n3\n4", contents); } - // No trim + try s.resize(4, 3); { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = 2, .y = 2 }, - }, false); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - const expected = "1AB \n \n2EF"; - try testing.expectEqualStrings(expected, contents); + try testing.expectEqualStrings(str, contents); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("6\n7\n8", contents); } + + // Cursor remains at bottom + try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y); + + // Old implementation doesn't do this but it makes sense to me: + // { + // const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + // defer alloc.free(contents); + // try testing.expectEqualStrings("2\n3\n4", contents); + // } } -test "Screen: selectionString soft wrap" { +test "Screen: resize more cols no reflow preserves semantic prompt" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 5, 3, 0); defer s.deinit(); - const str = "1ABCD2EFGH3IJKL"; + const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); + // Set one of the rows to be a prompt { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 1 }, - .end = .{ .x = 2, .y = 2 }, - }, true); - defer alloc.free(contents); - const expected = "2EFGH3IJ"; - try testing.expectEqualStrings(expected, contents); + s.cursorAbsolute(0, 1); + s.cursor.page_row.semantic_prompt = .prompt; } -} -test "Screen: selectionString wrap around" { - 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()); - - // Scroll down, should still be bottom, but should wrap because - // we're out of space. - try s.scroll(.{ .screen = 1 }); - try testing.expect(s.viewportIsBottom()); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + try s.resize(10, 3); { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 1 }, - .end = .{ .x = 2, .y = 2 }, - }, true); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - const expected = "2EFGH\n3IJ"; - try testing.expectEqualStrings(expected, contents); + try testing.expectEqualStrings(str, contents); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + + // Our one row should still be a semantic prompt, the others should not. + { + const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + try testing.expect(list_cell.row.semantic_prompt == .unknown); + } + { + const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 1 } }).?; + try testing.expect(list_cell.row.semantic_prompt == .prompt); + } + { + const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 2 } }).?; + try testing.expect(list_cell.row.semantic_prompt == .unknown); } } -test "Screen: selectionString wide char" { +test "Screen: resize more cols with reflow that fits full width" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 5, 3, 0); defer s.deinit(); - const str = "1A⚡"; + const str = "1ABCD2EFGH\n3IJKL"; try s.testWriteString(str); + // Verify we soft wrapped { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = 3, .y = 0 }, - }, true); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - const expected = str; + const expected = "1ABCD\n2EFGH\n3IJKL"; try testing.expectEqualStrings(expected, contents); } + // Let's put our cursor on row 2, where the soft wrap is + s.cursorAbsolute(0, 1); { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = 2, .y = 0 }, - }, true); - defer alloc.free(contents); - const expected = str; - try testing.expectEqualStrings(expected, contents); + const list_cell = s.pages.getCell(.{ .active = .{ + .x = s.cursor.x, + .y = s.cursor.y, + } }).?; + try testing.expectEqual(@as(u21, '2'), list_cell.cell.content.codepoint); } + // Resize and verify we undid the soft wrap because we have space now + try s.resize(10, 3); { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 3, .y = 0 }, - .end = .{ .x = 3, .y = 0 }, - }, true); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - const expected = "⚡"; - try testing.expectEqualStrings(expected, contents); + try testing.expectEqualStrings(str, contents); } + + // Our cursor should've moved + try testing.expectEqual(@as(usize, 5), s.cursor.x); + try testing.expectEqual(@as(usize, 0), s.cursor.y); } -test "Screen: selectionString wide char with header" { +test "Screen: resize more cols with reflow that ends in newline" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 6, 3, 0); defer s.deinit(); - const str = "1ABC⚡"; + const str = "1ABCD2EFGH\n3IJKL"; try s.testWriteString(str); + // Verify we soft wrapped { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = 4, .y = 0 }, - }, true); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - const expected = str; - try testing.expectEqualStrings(expected, contents); - } -} - -// https://github.com/mitchellh/ghostty/issues/289 -test "Screen: selectionString empty with soft wrap" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 2, 5, 0); - defer s.deinit(); - - // Let me describe the situation that caused this because this - // test is not obvious. By writing an emoji below, we introduce - // one cell with the emoji and one cell as a "wide char spacer". - // We then soft wrap the line by writing spaces. - // - // By selecting only the tail, we'd select nothing and we had - // a logic error that would cause a crash. - try s.testWriteString("👨"); - try s.testWriteString(" "); + const expected = "1ABCD2\nEFGH\n3IJKL"; + try testing.expectEqualStrings(expected, contents); + } + // Let's put our cursor on the last row + s.cursorAbsolute(0, 2); { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 1, .y = 0 }, - .end = .{ .x = 2, .y = 0 }, - }, true); + const list_cell = s.pages.getCell(.{ .active = .{ + .x = s.cursor.x, + .y = s.cursor.y, + } }).?; + try testing.expectEqual(@as(u21, '3'), list_cell.cell.content.codepoint); + } + + // Resize and verify we undid the soft wrap because we have space now + try s.resize(10, 3); + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - const expected = "👨"; - try testing.expectEqualStrings(expected, contents); + try testing.expectEqualStrings(str, contents); + } + + // Our cursor should still be on the 3 + { + const list_cell = s.pages.getCell(.{ .active = .{ + .x = s.cursor.x, + .y = s.cursor.y, + } }).?; + try testing.expectEqual(@as(u21, '3'), list_cell.cell.content.codepoint); } } -test "Screen: selectionString with zero width joiner" { +test "Screen: resize more cols with reflow that forces more wrapping" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 1, 10, 0); + var s = try init(alloc, 5, 3, 0); defer s.deinit(); - const str = "👨‍"; // this has a ZWJ + const str = "1ABCD2EFGH\n3IJKL"; try s.testWriteString(str); - // Integrity check - const row = s.getRow(.{ .screen = 0 }); + // Let's put our cursor on row 2, where the soft wrap is + s.cursorAbsolute(0, 1); { - const cell = row.getCell(0); - try testing.expectEqual(@as(u32, 0x1F468), cell.char); - try testing.expect(cell.attrs.wide); - try testing.expectEqual(@as(usize, 2), row.codepointLen(0)); + const list_cell = s.pages.getCell(.{ .active = .{ + .x = s.cursor.x, + .y = s.cursor.y, + } }).?; + try testing.expectEqual(@as(u21, '2'), list_cell.cell.content.codepoint); } + + // Verify we soft wrapped { - const cell = row.getCell(1); - try testing.expectEqual(@as(u32, ' '), cell.char); - try testing.expect(cell.attrs.wide_spacer_tail); - try testing.expectEqual(@as(usize, 1), row.codepointLen(1)); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "1ABCD\n2EFGH\n3IJKL"; + try testing.expectEqualStrings(expected, contents); } - // The real test + // Resize and verify we undid the soft wrap because we have space now + try s.resize(7, 3); { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = 1, .y = 0 }, - }, true); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - const expected = "👨‍"; + const expected = "1ABCD2E\nFGH\n3IJKL"; try testing.expectEqualStrings(expected, contents); } + + // Our cursor should've moved + try testing.expectEqual(@as(size.CellCountInt, 5), s.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 0), s.cursor.y); } -test "Screen: selectionString, rectangle, basic" { +test "Screen: resize more cols with reflow that unwraps multiple times" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 30, 0); + var s = try init(alloc, 5, 3, 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 - ; + const str = "1ABCD2EFGH3IJKL"; try s.testWriteString(str); - const contents = try s.selectionString(alloc, sel, true); - defer alloc.free(contents); - try testing.expectEqualStrings(expected, contents); -} + // Let's put our cursor on row 2, where the soft wrap is + s.cursorAbsolute(0, 2); + { + const list_cell = s.pages.getCell(.{ .active = .{ + .x = s.cursor.x, + .y = s.cursor.y, + } }).?; + try testing.expectEqual(@as(u21, '3'), list_cell.cell.content.codepoint); + } -test "Screen: selectionString, rectangle, w/EOL" { - const testing = std.testing; - const alloc = testing.allocator; + // Verify we soft wrapped + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "1ABCD\n2EFGH\n3IJKL"; + try testing.expectEqualStrings(expected, contents); + } - 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); + // Resize and verify we undid the soft wrap because we have space now + try s.resize(15, 3); + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "1ABCD2EFGH3IJKL"; + try testing.expectEqualStrings(expected, contents); + } - const contents = try s.selectionString(alloc, sel, true); - defer alloc.free(contents); - try testing.expectEqualStrings(expected, contents); + // Our cursor should've moved + try testing.expectEqual(@as(size.CellCountInt, 10), s.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 0), s.cursor.y); } -test "Screen: selectionString, rectangle, more complex w/breaks" { +test "Screen: resize more cols with populated scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 8, 30, 0); + var s = try init(alloc, 5, 3, 5); 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 - ; + const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD5EFGH"; try s.testWriteString(str); + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "3IJKL\n4ABCD\n5EFGH"; + try testing.expectEqualStrings(expected, contents); + } - const contents = try s.selectionString(alloc, sel, true); - defer alloc.free(contents); - try testing.expectEqualStrings(expected, contents); + // // Set our cursor to be on the "5" + s.cursorAbsolute(0, 2); + { + const list_cell = s.pages.getCell(.{ .active = .{ + .x = s.cursor.x, + .y = s.cursor.y, + } }).?; + try testing.expectEqual(@as(u21, '5'), list_cell.cell.content.codepoint); + } + + // Resize + try s.resize(10, 3); + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "2EFGH\n3IJKL\n4ABCD5EFGH"; + try testing.expectEqualStrings(expected, contents); + } + + // Cursor should still be on the "5" + { + const list_cell = s.pages.getCell(.{ .active = .{ + .x = s.cursor.x, + .y = s.cursor.y, + } }).?; + try testing.expectEqual(@as(u21, '5'), list_cell.cell.content.codepoint); + } } -test "Screen: dirty with getCellPtr" { +test "Screen: resize more cols with reflow" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 2, 3, 5); defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - try testing.expect(s.viewportIsBottom()); + const str = "1ABC\n2DEF\n3ABC\n4DEF"; + try s.testWriteString(str); - // Ensure all are dirty. Clear em. - var iter = s.rowIterator(.viewport); - while (iter.next()) |row| { - try testing.expect(row.isDirty()); - row.setDirty(false); + // Let's put our cursor on row 2, where the soft wrap is + s.cursorAbsolute(0, 2); + { + const list_cell = s.pages.getCell(.{ .active = .{ + .x = s.cursor.x, + .y = s.cursor.y, + } }).?; + try testing.expectEqual(@as(u32, 'E'), list_cell.cell.content.codepoint); } - // Reset our cursor onto the second row. - s.cursor.x = 0; - s.cursor.y = 1; - - try s.testWriteString("foo"); + // Verify we soft wrapped { - const row = s.getRow(.{ .active = 0 }); - try testing.expect(!row.isDirty()); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "BC\n4D\nEF"; + try testing.expectEqualStrings(expected, contents); } + + // Resize and verify we undid the soft wrap because we have space now + try s.resize(7, 3); { - const row = s.getRow(.{ .active = 1 }); - try testing.expect(row.isDirty()); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + const expected = "1ABC\n2DEF\n3ABC\n4DEF"; + try testing.expectEqualStrings(expected, contents); } - { - const row = s.getRow(.{ .active = 2 }); - try testing.expect(!row.isDirty()); - _ = row.getCell(0); - try testing.expect(!row.isDirty()); - } + // Our cursor should've moved + try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y); } -test "Screen: dirty with clear, fill, fillSlice, copyRow" { +test "Screen: resize more rows and cols with wrapping" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 2, 4, 0); defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - try testing.expect(s.viewportIsBottom()); - - // Ensure all are dirty. Clear em. - var iter = s.rowIterator(.viewport); - while (iter.next()) |row| { - try testing.expect(row.isDirty()); - row.setDirty(false); - } - + const str = "1A2B\n3C4D"; + try s.testWriteString(str); { - const row = s.getRow(.{ .active = 0 }); - try testing.expect(!row.isDirty()); - row.clear(.{}); - try testing.expect(row.isDirty()); - row.setDirty(false); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "1A\n2B\n3C\n4D"; + try testing.expectEqualStrings(expected, contents); } - { - const row = s.getRow(.{ .active = 0 }); - try testing.expect(!row.isDirty()); - row.fill(.{ .char = 'A' }); - try testing.expect(row.isDirty()); - row.setDirty(false); - } + try s.resize(5, 10); + + // Cursor should move due to wrapping + try testing.expectEqual(@as(size.CellCountInt, 3), s.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.y); { - const row = s.getRow(.{ .active = 0 }); - try testing.expect(!row.isDirty()); - row.fillSlice(.{ .char = 'A' }, 0, 2); - try testing.expect(row.isDirty()); - row.setDirty(false); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); } - { - const src = s.getRow(.{ .active = 0 }); - const row = s.getRow(.{ .active = 1 }); - try testing.expect(!row.isDirty()); - try row.copyRow(src); - try testing.expect(!src.isDirty()); - try testing.expect(row.isDirty()); - row.setDirty(false); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); } } -test "Screen: dirty with graphemes" { +test "Screen: resize less rows no scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 5, 3, 0); defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - try testing.expect(s.viewportIsBottom()); + const str = "1ABCD\n2EFGH\n3IJKL"; + try s.testWriteString(str); - // Ensure all are dirty. Clear em. - var iter = s.rowIterator(.viewport); - while (iter.next()) |row| { - try testing.expect(row.isDirty()); - row.setDirty(false); - } + s.cursorAbsolute(0, 0); + const cursor = s.cursor; + try s.resize(5, 1); + + // Cursor should not move + try testing.expectEqual(cursor.x, s.cursor.x); + try testing.expectEqual(cursor.y, s.cursor.y); { - const row = s.getRow(.{ .active = 0 }); - try testing.expect(!row.isDirty()); - try row.attachGrapheme(0, 0xFE0F); - try testing.expect(row.isDirty()); - row.setDirty(false); - row.clearGraphemes(0); - try testing.expect(row.isDirty()); - row.setDirty(false); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "3IJKL"; + try testing.expectEqualStrings(expected, contents); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + const expected = "3IJKL"; + try testing.expectEqualStrings(expected, contents); } } -test "Screen: resize (no reflow) more rows" { +test "Screen: resize less rows moving cursor" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 5, 3, 0); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); - // Clear dirty rows - var iter = s.rowIterator(.viewport); - while (iter.next()) |row| row.setDirty(false); + // Put our cursor on the last line + s.cursorAbsolute(1, 2); + { + const list_cell = s.pages.getCell(.{ .active = .{ + .x = s.cursor.x, + .y = s.cursor.y, + } }).?; + try testing.expectEqual(@as(u32, 'I'), list_cell.cell.content.codepoint); + } // Resize - try s.resizeWithoutReflow(10, 5); + try s.resize(5, 1); + { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + const expected = "3IJKL"; + try testing.expectEqualStrings(expected, contents); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + const expected = "3IJKL"; + try testing.expectEqualStrings(expected, contents); } - // Everything should be dirty - iter = s.rowIterator(.viewport); - while (iter.next()) |row| try testing.expect(row.isDirty()); + // Cursor should be on the last line + try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 0), s.cursor.y); } -test "Screen: resize (no reflow) less rows" { +test "Screen: resize less rows with empty scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 5, 3, 10); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); - try s.resizeWithoutReflow(2, 5); + try s.resize(5, 1); { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + try testing.expectEqualStrings(str, contents); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "3IJKL"; + try testing.expectEqualStrings(expected, contents); } } -test "Screen: resize (no reflow) less rows trims blank lines" { +test "Screen: resize less rows with populated scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 5, 3, 5); defer s.deinit(); - const str = "1ABCD"; + const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); - - // Write only a background color into the remaining rows - for (1..s.rows) |y| { - const row = s.getRow(.{ .active = y }); - for (0..s.cols) |x| { - const cell = row.getCellPtr(x); - cell.*.bg = .{ .rgb = .{ .r = 0xFF, .g = 0, .b = 0 } }; - } + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "3IJKL\n4ABCD\n5EFGH"; + try testing.expectEqualStrings(expected, contents); } - // Make sure our cursor is at the end of the first line - s.cursor.x = 4; - s.cursor.y = 0; - const cursor = s.cursor; - - try s.resizeWithoutReflow(2, 5); - - // Cursor should not move - try testing.expectEqual(cursor, s.cursor); + // Resize + try s.resize(5, 1); { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD", contents); + try testing.expectEqualStrings(str, contents); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "5EFGH"; + try testing.expectEqualStrings(expected, contents); } } -test "Screen: resize (no reflow) more rows trims blank lines" { +test "Screen: resize less rows with full scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 5, 3, 3); defer s.deinit(); - const str = "1ABCD"; + const str = "00000\n1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); - - // Write only a background color into the remaining rows - for (1..s.rows) |y| { - const row = s.getRow(.{ .active = y }); - for (0..s.cols) |x| { - const cell = row.getCellPtr(x); - cell.*.bg = .{ .rgb = .{ .r = 0xFF, .g = 0, .b = 0 } }; - } + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "3IJKL\n4ABCD\n5EFGH"; + try testing.expectEqualStrings(expected, contents); } - // Make sure our cursor is at the end of the first line - s.cursor.x = 4; - s.cursor.y = 0; - const cursor = s.cursor; + try testing.expectEqual(@as(size.CellCountInt, 4), s.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y); - try s.resizeWithoutReflow(7, 5); + // Resize + try s.resize(5, 2); - // Cursor should not move - try testing.expectEqual(cursor, s.cursor); + // Cursor should stay in the same relative place (bottom of the + // screen, same character). + try testing.expectEqual(@as(size.CellCountInt, 4), s.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.y); { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD", contents); + const expected = "00000\n1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; + try testing.expectEqualStrings(expected, contents); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "4ABCD\n5EFGH"; + try testing.expectEqualStrings(expected, contents); } } -test "Screen: resize (no reflow) more cols" { +test "Screen: resize less cols no reflow" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 5, 3, 0); defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; + const str = "1AB\n2EF\n3IJ"; try s.testWriteString(str); - try s.resizeWithoutReflow(3, 10); + s.cursorAbsolute(0, 0); + const cursor = s.cursor; + try s.resize(3, 3); + + // Cursor should not move + try testing.expectEqual(cursor.x, s.cursor.x); + try testing.expectEqual(cursor.y, s.cursor.y); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); } } -test "Screen: resize (no reflow) less cols" { +test "Screen: resize less cols with reflow but row space" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 5, 3, 1); defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; + const str = "1ABCD"; try s.testWriteString(str); - try s.resizeWithoutReflow(3, 4); + // Put our cursor on the end + s.cursorAbsolute(4, 0); + { + const list_cell = s.pages.getCell(.{ .active = .{ + .x = s.cursor.x, + .y = s.cursor.y, + } }).?; + try testing.expectEqual(@as(u32, 'D'), list_cell.cell.content.codepoint); + } + + try s.resize(3, 3); { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - const expected = "1ABC\n2EFG\n3IJK"; + const expected = "1AB\nCD"; + try testing.expectEqualStrings(expected, contents); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + const expected = "1AB\nCD"; try testing.expectEqualStrings(expected, contents); } + + // Cursor should be on the last line + try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.y); } -test "Screen: resize (no reflow) more rows with scrollback cursor end" { +test "Screen: resize less cols with reflow with trimmed rows" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 2); + var s = try init(alloc, 5, 3, 0); defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; + const str = "3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); - try s.resizeWithoutReflow(10, 5); + try s.resize(3, 3); { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + const expected = "CD\n5EF\nGH"; + try testing.expectEqualStrings(expected, contents); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + const expected = "CD\n5EF\nGH"; + try testing.expectEqualStrings(expected, contents); } } -test "Screen: resize (no reflow) less rows with scrollback" { +test "Screen: resize less cols with reflow with trimmed rows and scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 2); + var s = try init(alloc, 5, 3, 1); defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; + const str = "3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); - try s.resizeWithoutReflow(2, 5); + try s.resize(3, 3); { - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "CD\n5EF\nGH"; + try testing.expectEqualStrings(expected, contents); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - const expected = "2EFGH\n3IJKL\n4ABCD\n5EFGH"; + const expected = "3IJ\nKL\n4AB\nCD\n5EF\nGH"; try testing.expectEqualStrings(expected, contents); } } -// https://github.com/mitchellh/ghostty/issues/1030 -test "Screen: resize (no reflow) less rows with empty trailing" { +test "Screen: resize less cols with reflow previously wrapped" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 5); + var s = try init(alloc, 5, 3, 0); defer s.deinit(); - const str = "1\n2\n3\n4\n5\n6\n7\n8"; + const str = "3IJKL4ABCD5EFGH"; try s.testWriteString(str); - try s.scroll(.{ .clear = {} }); - s.cursor.x = 0; - s.cursor.y = 0; - try s.testWriteString("A\nB"); - - const cursor = s.cursor; - try s.resizeWithoutReflow(2, 5); - try testing.expectEqual(cursor, s.cursor); + // Check { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("A\nB", contents); + const expected = "3IJKL\n4ABCD\n5EFGH"; + try testing.expectEqualStrings(expected, contents); } -} - -test "Screen: resize (no reflow) empty screen" { - const testing = std.testing; - const alloc = testing.allocator; - var s = try init(alloc, 5, 5, 0); - defer s.deinit(); - try testing.expect(s.rowsWritten() == 0); - try testing.expectEqual(@as(usize, 5), s.rowsCapacity()); - - try s.resizeWithoutReflow(10, 10); - try testing.expect(s.rowsWritten() == 0); + try s.resize(3, 3); - // This is the primary test for this test, we want to ensure we - // always have at least enough capacity for our rows. - try testing.expectEqual(@as(usize, 10), s.rowsCapacity()); + // { + // const contents = try s.testString(alloc, .viewport); + // defer alloc.free(contents); + // const expected = "CD\n5EF\nGH"; + // try testing.expectEqualStrings(expected, contents); + // } + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + const expected = "ABC\nD5E\nFGH"; + try testing.expectEqualStrings(expected, contents); + } } -test "Screen: resize (no reflow) grapheme copy" { +test "Screen: resize less cols with reflow and scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 5, 3, 5); defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; + const str = "1A\n2B\n3C\n4D\n5E"; try s.testWriteString(str); - // Attach graphemes to all the columns + // Put our cursor on the end + s.cursorAbsolute(1, s.pages.rows - 1); { - var iter = s.rowIterator(.viewport); - while (iter.next()) |row| { - var col: usize = 0; - while (col < s.cols) : (col += 1) { - try row.attachGrapheme(col, 0xFE0F); - } - } + const list_cell = s.pages.getCell(.{ .active = .{ + .x = s.cursor.x, + .y = s.cursor.y, + } }).?; + try testing.expectEqual(@as(u32, 'E'), list_cell.cell.content.codepoint); } - // Clear dirty rows - { - var iter = s.rowIterator(.viewport); - while (iter.next()) |row| row.setDirty(false); - } + try s.resize(3, 3); - // Resize - try s.resizeWithoutReflow(10, 5); { - const expected = "1️A️B️C️D️\n2️E️F️G️H️\n3️I️J️K️L️"; - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); + const expected = "3C\n4D\n5E"; try testing.expectEqualStrings(expected, contents); } - // Everything should be dirty - { - var iter = s.rowIterator(.viewport); - while (iter.next()) |row| try testing.expect(row.isDirty()); - } + // Cursor should be on the last line + try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y); } -test "Screen: resize (no reflow) more rows with soft wrapping" { +test "Screen: resize less cols with reflow previously wrapped and scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 2, 3); + var s = try init(alloc, 5, 3, 2); defer s.deinit(); - const str = "1A2B\n3C4E\n5F6G"; + const str = "1ABCD2EFGH3IJKL4ABCD5EFGH"; try s.testWriteString(str); - // Every second row should be wrapped + // Check { - var y: usize = 0; - while (y < 6) : (y += 1) { - const row = s.getRow(.{ .screen = y }); - const wrapped = (y % 2 == 0); - try testing.expectEqual(wrapped, row.header().flags.wrap); - } + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "3IJKL\n4ABCD\n5EFGH"; + try testing.expectEqualStrings(expected, contents); } - // Resize - try s.resizeWithoutReflow(10, 2); + // Put our cursor on the end + s.cursorAbsolute(s.pages.cols - 1, s.pages.rows - 1); + { + const list_cell = s.pages.getCell(.{ .active = .{ + .x = s.cursor.x, + .y = s.cursor.y, + } }).?; + try testing.expectEqual(@as(u32, 'H'), list_cell.cell.content.codepoint); + } + + try s.resize(3, 3); + { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - const expected = "1A\n2B\n3C\n4E\n5F\n6G"; + const expected = "CD5\nEFG\nH"; + try testing.expectEqualStrings(expected, contents); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + const expected = "1AB\nCD2\nEFG\nH3I\nJKL\n4AB\nCD5\nEFG\nH"; try testing.expectEqualStrings(expected, contents); } - // Every second row should be wrapped + // Cursor should be on the last line + try testing.expectEqual(@as(size.CellCountInt, 0), s.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y); { - var y: usize = 0; - while (y < 6) : (y += 1) { - const row = s.getRow(.{ .screen = y }); - const wrapped = (y % 2 == 0); - try testing.expectEqual(wrapped, row.header().flags.wrap); - } + const list_cell = s.pages.getCell(.{ .active = .{ + .x = s.cursor.x, + .y = s.cursor.y, + } }).?; + try testing.expectEqual(@as(u32, 'H'), list_cell.cell.content.codepoint); } } -test "Screen: resize more rows no scrollback" { +test "Screen: resize less cols with scrollback keeps cursor row" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 5, 3, 5); defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; + const str = "1A\n2B\n3C\n4D\n5E"; try s.testWriteString(str); - const cursor = s.cursor; - try s.resize(10, 5); - // Cursor should not move - try testing.expectEqual(cursor, s.cursor); + // Lets do a scroll and clear operation + try s.scrollClear(); + + // Move our cursor to the beginning + s.cursorAbsolute(0, 0); + + try s.resize(3, 3); { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + const expected = ""; + try testing.expectEqualStrings(expected, contents); } + + // Cursor should be on the last line + try testing.expectEqual(@as(size.CellCountInt, 0), s.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 0), s.cursor.y); } -test "Screen: resize more rows with empty scrollback" { +test "Screen: resize more rows, less cols with reflow with scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 10); + var s = try init(alloc, 5, 3, 3); defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; + const str = "1ABCD\n2EFGH3IJKL\n4MNOP"; try s.testWriteString(str); - const cursor = s.cursor; - try s.resize(10, 5); - - // Cursor should not move - try testing.expectEqual(cursor, s.cursor); { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + const expected = "1ABCD\n2EFGH\n3IJKL\n4MNOP"; + try testing.expectEqualStrings(expected, contents); } { - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + const expected = "2EFGH\n3IJKL\n4MNOP"; + try testing.expectEqualStrings(expected, contents); } -} -test "Screen: resize more rows with populated scrollback" { - const testing = std.testing; - const alloc = testing.allocator; + try s.resize(2, 10); - var s = try init(alloc, 3, 5, 5); - defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; - try s.testWriteString(str); { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - const expected = "3IJKL\n4ABCD\n5EFGH"; + const expected = "BC\nD\n2E\nFG\nH3\nIJ\nKL\n4M\nNO\nP"; try testing.expectEqualStrings(expected, contents); } - - // Set our cursor to be on the "4" - s.cursor.x = 0; - s.cursor.y = 1; - try testing.expectEqual(@as(u32, '4'), s.getCell(.active, s.cursor.y, s.cursor.x).char); - - // Resize - try s.resize(10, 5); - - // Cursor should still be on the "4" - try testing.expectEqual(@as(u32, '4'), s.getCell(.active, s.cursor.y, s.cursor.x).char); - { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - const expected = "3IJKL\n4ABCD\n5EFGH"; + const expected = "1A\nBC\nD\n2E\nFG\nH3\nIJ\nKL\n4M\nNO\nP"; try testing.expectEqualStrings(expected, contents); } } -test "Screen: resize more rows and cols with wrapping" { +// This seems like it should work fine but for some reason in practice +// in the initial implementation I found this bug! This is a regression +// test for that. +test "Screen: resize more rows then shrink again" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 4, 2, 0); + var s = try init(alloc, 5, 3, 10); defer s.deinit(); - const str = "1A2B\n3C4D"; + const str = "1ABC"; try s.testWriteString(str); - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "1A\n2B\n3C\n4D"; - try testing.expectEqualStrings(expected, contents); - } - - try s.resize(10, 5); - - // Cursor should move due to wrapping - try testing.expectEqual(@as(usize, 3), s.cursor.x); - try testing.expectEqual(@as(usize, 1), s.cursor.y); + // Grow + try s.resize(5, 10); { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); } { - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); } -} - -test "Screen: resize more cols no reflow" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 3, 5, 0); - defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; - try s.testWriteString(str); - const cursor = s.cursor; - try s.resize(3, 10); - - // Cursor should not move - try testing.expectEqual(cursor, s.cursor); + // Shrink + try s.resize(5, 3); { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); } { - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); } -} - -// https://github.com/mitchellh/ghostty/issues/272#issuecomment-1676038963 -test "Screen: resize more cols perfect split" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 3, 5, 0); - defer s.deinit(); - const str = "1ABCD2EFGH3IJKL"; - try s.testWriteString(str); - try s.resize(3, 10); -} - -// https://github.com/mitchellh/ghostty/issues/1159 -test "Screen: resize (no reflow) more cols with scrollback scrolled up" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 3, 5, 5); - defer s.deinit(); - const str = "1\n2\n3\n4\n5\n6\n7\n8"; - try s.testWriteString(str); - - // Cursor at bottom - try testing.expectEqual(@as(usize, 1), s.cursor.x); - try testing.expectEqual(@as(usize, 2), s.cursor.y); - try s.scroll(.{ .viewport = -4 }); + // Grow again + try s.resize(5, 10); { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("2\n3\n4", contents); + try testing.expectEqualStrings(str, contents); } - - try s.resize(3, 8); { - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); } - - // Cursor remains at bottom - try testing.expectEqual(@as(usize, 1), s.cursor.x); - try testing.expectEqual(@as(usize, 2), s.cursor.y); } -// https://github.com/mitchellh/ghostty/issues/1159 -test "Screen: resize (no reflow) less cols with scrollback scrolled up" { +test "Screen: resize less cols to eliminate wide char" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 5); + var s = try init(alloc, 2, 1, 0); defer s.deinit(); - const str = "1\n2\n3\n4\n5\n6\n7\n8"; + const str = "😀"; try s.testWriteString(str); + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.wide, cell.wide); + try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint); + } - // Cursor at bottom - try testing.expectEqual(@as(usize, 1), s.cursor.x); - try testing.expectEqual(@as(usize, 2), s.cursor.y); - - try s.scroll(.{ .viewport = -4 }); + // Resize to 1 column can't fit a wide char. So it should be deleted. + try s.resize(1, 1); { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("2\n3\n4", contents); + try testing.expectEqualStrings("", contents); } - - try s.resize(3, 4); { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); } - - // Cursor remains at bottom - try testing.expectEqual(@as(usize, 1), s.cursor.x); - try testing.expectEqual(@as(usize, 2), s.cursor.y); } -test "Screen: resize more cols no reflow preserves semantic prompt" { +test "Screen: resize less cols to wrap wide char" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 3, 3, 0); defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; + const str = "x😀"; try s.testWriteString(str); - - // Set one of the rows to be a prompt - { - const row = s.getRow(.{ .active = 1 }); - row.setSemanticPrompt(.prompt); - } - - const cursor = s.cursor; - try s.resize(3, 10); - - // Cursor should not move - try testing.expectEqual(cursor, s.cursor); - { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); } { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.wide, cell.wide); + try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint); } - - // Our one row should still be a semantic prompt, the others should not. { - const row = s.getRow(.{ .active = 0 }); - try testing.expect(row.getSemanticPrompt() == .unknown); + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); } + + try s.resize(2, 3); { - const row = s.getRow(.{ .active = 1 }); - try testing.expect(row.getSemanticPrompt() == .prompt); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("x\n😀", contents); } { - const row = s.getRow(.{ .active = 2 }); - try testing.expect(row.getSemanticPrompt() == .unknown); + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); + try testing.expect(list_cell.row.wrap); } } -test "Screen: resize more cols grapheme map" { +test "Screen: resize less cols to eliminate wide char with row space" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 2, 2, 0); defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; + const str = "😀"; try s.testWriteString(str); - - // Attach graphemes to all the columns { - var iter = s.rowIterator(.viewport); - while (iter.next()) |row| { - var col: usize = 0; - while (col < s.cols) : (col += 1) { - try row.attachGrapheme(col, 0xFE0F); - } - } + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); } - - const cursor = s.cursor; - try s.resize(3, 10); - - // Cursor should not move - try testing.expectEqual(cursor, s.cursor); - { - const expected = "1️A️B️C️D️\n2️E️F️G️H️\n3️I️J️K️L️"; - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(expected, contents); + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.wide, cell.wide); + try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint); + } + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); } + + try s.resize(1, 2); { - const expected = "1️A️B️C️D️\n2️E️F️G️H️\n3️I️J️K️L️"; - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings(expected, contents); + try testing.expectEqualStrings("", contents); } } -test "Screen: resize more cols with reflow that fits full width" { +test "Screen: resize more cols with wide spacer head" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 3, 2, 0); defer s.deinit(); - const str = "1ABCD2EFGH\n3IJKL"; + const str = " 😀"; try s.testWriteString(str); - - // Verify we soft wrapped { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - const expected = "1ABCD\n2EFGH\n3IJKL"; - try testing.expectEqualStrings(expected, contents); + try testing.expectEqualStrings(" \n😀", contents); } - // Let's put our cursor on row 2, where the soft wrap is - s.cursor.x = 0; - s.cursor.y = 1; - try testing.expectEqual(@as(u32, '2'), s.getCell(.active, s.cursor.y, s.cursor.x).char); + // So this is the key point: we end up with a wide spacer head at + // the end of row 1, then the emoji, then a wide spacer tail on row 2. + // We should expect that if we resize to more cols, the wide spacer + // head is replaced with the emoji. + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); + } + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } - // Resize and verify we undid the soft wrap because we have space now - try s.resize(3, 10); + try s.resize(4, 2); { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); } - - // Our cursor should've moved - try testing.expectEqual(@as(usize, 5), s.cursor.x); - try testing.expectEqual(@as(usize, 0), s.cursor.y); + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.wide, cell.wide); + try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint); + } + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 3, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } } -test "Screen: resize more cols with reflow that ends in newline" { +test "Screen: resize more cols with wide spacer head multiple lines" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 6, 0); + var s = try init(alloc, 3, 3, 0); defer s.deinit(); - const str = "1ABCD2EFGH\n3IJKL"; + const str = "xxxyy😀"; try s.testWriteString(str); - - // Verify we soft wrapped { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - const expected = "1ABCD2\nEFGH\n3IJKL"; - try testing.expectEqualStrings(expected, contents); + try testing.expectEqualStrings("xxx\nyy\n😀", contents); } - // Let's put our cursor on the last row - s.cursor.x = 0; - s.cursor.y = 2; - try testing.expectEqual(@as(u32, '3'), s.getCell(.active, s.cursor.y, s.cursor.x).char); + // Similar to the "wide spacer head" test, but this time we'er going + // to increase our columns such that multiple rows are unwrapped. + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 2, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); + } + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 2 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 2 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } - // Resize and verify we undid the soft wrap because we have space now - try s.resize(3, 10); + try s.resize(8, 2); { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); } - - // Our cursor should still be on the 3 - try testing.expectEqual(@as(u32, '3'), s.getCell(.active, s.cursor.y, s.cursor.x).char); + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 5, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.wide, cell.wide); + try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint); + } + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 6, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } } -test "Screen: resize more cols with reflow that forces more wrapping" { +test "Screen: resize more cols requiring a wide spacer head" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 2, 2, 0); defer s.deinit(); - const str = "1ABCD2EFGH\n3IJKL"; + const str = "xx😀"; try s.testWriteString(str); - - // Let's put our cursor on row 2, where the soft wrap is - s.cursor.x = 0; - s.cursor.y = 1; - try testing.expectEqual(@as(u32, '2'), s.getCell(.active, s.cursor.y, s.cursor.x).char); - - // Verify we soft wrapped { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - const expected = "1ABCD\n2EFGH\n3IJKL"; - try testing.expectEqualStrings(expected, contents); + try testing.expectEqualStrings("xx\n😀", contents); + } + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); } - // Resize and verify we undid the soft wrap because we have space now - try s.resize(3, 7); + // This resizes to 3 columns, which isn't enough space for our wide + // char to enter row 1. But we need to mark the wide spacer head on the + // end of the first row since we're wrapping to the next row. + try s.resize(3, 2); { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - const expected = "1ABCD2E\nFGH\n3IJKL"; - try testing.expectEqualStrings(expected, contents); + try testing.expectEqualStrings("xx\n😀", contents); + } + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); + } + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.wide, cell.wide); + try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint); + } + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); } - - // Our cursor should've moved - try testing.expectEqual(@as(usize, 5), s.cursor.x); - try testing.expectEqual(@as(usize, 0), s.cursor.y); } -test "Screen: resize more cols with reflow that unwraps multiple times" { +test "Screen: select untracked" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 10, 10, 0); defer s.deinit(); - const str = "1ABCD2EFGH3IJKL"; - try s.testWriteString(str); + try s.testWriteString("ABC DEF\n 123\n456"); - // Let's put our cursor on row 2, where the soft wrap is - s.cursor.x = 0; - s.cursor.y = 2; - try testing.expectEqual(@as(u32, '3'), s.getCell(.active, s.cursor.y, s.cursor.x).char); + try testing.expect(s.selection == null); + const tracked = s.pages.countTrackedPins(); + try s.select(Selection.init( + s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, + s.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?, + false, + )); + try testing.expectEqual(tracked + 2, s.pages.countTrackedPins()); + try s.select(null); + try testing.expectEqual(tracked, s.pages.countTrackedPins()); +} + +test "Screen: selectAll" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); - // Verify we soft wrapped { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "1ABCD\n2EFGH\n3IJKL"; - try testing.expectEqualStrings(expected, contents); + try s.testWriteString("ABC DEF\n 123\n456"); + var sel = s.selectAll().?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 2, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } - // Resize and verify we undid the soft wrap because we have space now - try s.resize(3, 15); { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "1ABCD2EFGH3IJKL"; - try testing.expectEqualStrings(expected, contents); + try s.testWriteString("\nFOO\n BAR\n BAZ\n QWERTY\n 12345678"); + var sel = s.selectAll().?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 8, + .y = 7, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } - - // Our cursor should've moved - try testing.expectEqual(@as(usize, 10), s.cursor.x); - try testing.expectEqual(@as(usize, 0), s.cursor.y); } -test "Screen: resize more cols with populated scrollback" { +test "Screen: selectLine" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 5); + var s = try init(alloc, 10, 10, 0); defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD5EFGH"; - try s.testWriteString(str); + 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 contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "3IJKL\n4ABCD\n5EFGH"; - try testing.expectEqualStrings(expected, contents); + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 0, + .y = 0, + } }).? }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 7, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } - // // Set our cursor to be on the "5" - s.cursor.x = 0; - s.cursor.y = 2; - try testing.expectEqual(@as(u32, '5'), s.getCell(.active, s.cursor.y, s.cursor.x).char); - - // Resize - try s.resize(3, 10); + // Going backward + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 7, + .y = 0, + } }).? }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 7, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); + } - // Cursor should still be on the "5" - try testing.expectEqual(@as(u32, '5'), s.getCell(.active, s.cursor.y, s.cursor.x).char); + // Going forward and backward + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 3, + .y = 0, + } }).? }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 7, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); + } + // Outside active area { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "2EFGH\n3IJKL\n4ABCD5EFGH"; - try testing.expectEqualStrings(expected, contents); + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 9, + .y = 0, + } }).? }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 7, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } -test "Screen: resize more cols with reflow" { +test "Screen: selectLine across soft-wrap" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 2, 5); + var s = try init(alloc, 5, 10, 0); defer s.deinit(); - const str = "1ABC\n2DEF\n3ABC\n4DEF"; - try s.testWriteString(str); - - // Let's put our cursor on row 2, where the soft wrap is - s.cursor.x = 0; - s.cursor.y = 2; - try testing.expectEqual(@as(u32, 'E'), s.getCell(.active, s.cursor.y, s.cursor.x).char); + try s.testWriteString(" 12 34012 \n 123"); - // Verify we soft wrapped + // Going forward { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "BC\n4D\nEF"; - try testing.expectEqualStrings(expected, contents); + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 1, + .y = 0, + } }).? }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } +} - // Resize and verify we undid the soft wrap because we have space now - try s.resize(3, 7); +test "Screen: selectLine across full soft-wrap" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 5, 0); + defer s.deinit(); + try s.testWriteString("1ABCD2EFGH\n3IJKL"); { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - const expected = "1ABC\n2DEF\n3ABC\n4DEF"; - try testing.expectEqualStrings(expected, contents); + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 2, + .y = 1, + } }).? }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } - - // Our cursor should've moved - try testing.expectEqual(@as(usize, 2), s.cursor.x); - try testing.expectEqual(@as(usize, 2), s.cursor.y); } -test "Screen: resize less rows no scrollback" { +test "Screen: selectLine across soft-wrap ignores blank lines" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 5, 10, 0); defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; - try s.testWriteString(str); - s.cursor.x = 0; - s.cursor.y = 0; - const cursor = s.cursor; - try s.resize(1, 5); + try s.testWriteString(" 12 34012 \n 123"); - // Cursor should not move - try testing.expectEqual(cursor, s.cursor); + // Going forward + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 1, + .y = 0, + } }).? }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.end()).?); + } + // Going backward { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "3IJKL"; - try testing.expectEqualStrings(expected, contents); + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 1, + .y = 1, + } }).? }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } + + // Going forward and backward { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - const expected = "3IJKL"; - try testing.expectEqualStrings(expected, contents); + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 3, + .y = 0, + } }).? }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } -test "Screen: resize less rows moving cursor" { +test "Screen: selectLine disabled whitespace trimming" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 5, 10, 0); defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; - try s.testWriteString(str); + try s.testWriteString(" 12 34012 \n 123"); - // Put our cursor on the last line - s.cursor.x = 1; - s.cursor.y = 2; - try testing.expectEqual(@as(u32, 'I'), s.getCell(.active, s.cursor.y, s.cursor.x).char); + // Going forward + { + var sel = s.selectLine(.{ + .pin = s.pages.pin(.{ .active = .{ + .x = 1, + .y = 0, + } }).?, + .whitespace = null, + }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 2, + } }, s.pages.pointFromPin(.screen, sel.end()).?); + } + + // Non-wrapped + { + var sel = s.selectLine(.{ + .pin = s.pages.pin(.{ .active = .{ + .x = 1, + .y = 3, + } }).?, + .whitespace = null, + }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 3, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 3, + } }, s.pages.pointFromPin(.screen, sel.end()).?); + } +} - // Resize - try s.resize(1, 5); +test "Screen: selectLine with scrollback" { + const testing = std.testing; + const alloc = testing.allocator; - // Cursor should be on the last line - try testing.expectEqual(@as(usize, 1), s.cursor.x); - try testing.expectEqual(@as(usize, 0), s.cursor.y); + var s = try init(alloc, 2, 3, 5); + defer s.deinit(); + try s.testWriteString("1A\n2B\n3C\n4D\n5E"); + // Selecting first line { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "3IJKL"; - try testing.expectEqualStrings(expected, contents); + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 0, + .y = 0, + } }).? }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 1, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end()).?); } + + // Selecting last line { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - const expected = "3IJKL"; - try testing.expectEqualStrings(expected, contents); + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 0, + .y = 2, + } }).? }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 2, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 1, + .y = 2, + } }, s.pages.pointFromPin(.active, sel.end()).?); } } -test "Screen: resize less rows with empty scrollback" { +// https://github.com/mitchellh/ghostty/issues/1329 +test "Screen: selectLine semantic prompt boundary" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 10); + var s = try init(alloc, 5, 10, 0); defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; - try s.testWriteString(str); - try s.resize(1, 5); + try s.testWriteString("ABCDE\nA > "); { - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + try testing.expectEqualStrings("ABCDE\nA \n> ", contents); } + { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "3IJKL"; - try testing.expectEqualStrings(expected, contents); + const pin = s.pages.pin(.{ .screen = .{ .y = 1 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .prompt; + } + + // Selecting output stops at the prompt even if soft-wrapped + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 1, + .y = 1, + } }).? }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 1, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 1, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 1, + .y = 2, + } }).? }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 2, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 2, + } }, s.pages.pointFromPin(.active, sel.end()).?); } } -test "Screen: resize less rows with populated scrollback" { +test "Screen: selectWord" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 5); + var s = try init(alloc, 10, 10, 0); defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; - try s.testWriteString(str); + 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 { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "3IJKL\n4ABCD\n5EFGH"; - try testing.expectEqualStrings(expected, contents); + var sel = s.selectWord(s.pages.pin(.{ .active = .{ + .x = 0, + .y = 0, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } - // Resize - try s.resize(1, 5); + // Going backward + { + var sel = s.selectWord(s.pages.pin(.{ .active = .{ + .x = 2, + .y = 0, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); + } + // Going forward and backward { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + var sel = s.selectWord(s.pages.pin(.{ .active = .{ + .x = 1, + .y = 0, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } + + // Whitespace { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "5EFGH"; - try testing.expectEqualStrings(expected, contents); + var sel = s.selectWord(s.pages.pin(.{ .active = .{ + .x = 3, + .y = 0, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); + } + + // Whitespace single char + { + var sel = s.selectWord(s.pages.pin(.{ .active = .{ + .x = 0, + .y = 1, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.end()).?); + } + + // End of screen + { + var sel = s.selectWord(s.pages.pin(.{ .active = .{ + .x = 1, + .y = 2, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 2, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } -test "Screen: resize less rows with full scrollback" { +test "Screen: selectWord across soft-wrap" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 3); + var s = try init(alloc, 5, 10, 0); defer s.deinit(); - const str = "00000\n1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; - try s.testWriteString(str); + try s.testWriteString(" 1234012\n 123"); + { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - const expected = "3IJKL\n4ABCD\n5EFGH"; - try testing.expectEqualStrings(expected, contents); + try testing.expectEqualStrings(" 1234\n012\n 123", contents); } - const cursor = s.cursor; - try testing.expectEqual(Cursor{ .x = 4, .y = 2 }, cursor); - - // Resize - try s.resize(2, 5); - - // Cursor should stay in the same relative place (bottom of the - // screen, same character). - try testing.expectEqual(Cursor{ .x = 4, .y = 1 }, s.cursor); + // Going forward + { + var sel = s.selectWord(s.pages.pin(.{ .active = .{ + .x = 1, + .y = 0, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.end()).?); + } + // Going backward { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - const expected = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; - try testing.expectEqualStrings(expected, contents); + var sel = s.selectWord(s.pages.pin(.{ .active = .{ + .x = 1, + .y = 1, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } + + // Going forward and backward { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "4ABCD\n5EFGH"; - try testing.expectEqualStrings(expected, contents); + var sel = s.selectWord(s.pages.pin(.{ .active = .{ + .x = 3, + .y = 0, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } -test "Screen: resize less cols no reflow" { +test "Screen: selectWord whitespace across soft-wrap" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 5, 10, 0); defer s.deinit(); - const str = "1AB\n2EF\n3IJ"; - try s.testWriteString(str); - s.cursor.x = 0; - s.cursor.y = 0; - const cursor = s.cursor; - try s.resize(3, 3); + try s.testWriteString("1 1\n 123"); - // Cursor should not move - try testing.expectEqual(cursor, s.cursor); + // Going forward + { + var sel = s.selectWord(s.pages.pin(.{ .active = .{ + .x = 1, + .y = 0, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.end()).?); + } + // Going backward { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + var sel = s.selectWord(s.pages.pin(.{ .active = .{ + .x = 1, + .y = 1, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } + + // Going forward and backward { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + var sel = s.selectWord(s.pages.pin(.{ .active = .{ + .x = 3, + .y = 0, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } -test "Screen: resize less cols trailing background colors" { +test "Screen: selectWord with character boundary" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 10, 0); - defer s.deinit(); - const str = "1AB"; - try s.testWriteString(str); - const cursor = s.cursor; + const cases = [_][]const u8{ + " 'abc' \n123", + " \"abc\" \n123", + " │abc│ \n123", + " `abc` \n123", + " |abc| \n123", + " :abc: \n123", + " ,abc, \n123", + " (abc( \n123", + " )abc) \n123", + " [abc[ \n123", + " ]abc] \n123", + " {abc{ \n123", + " }abc} \n123", + " abc> \n123", + }; - // Color our cells red - const pen: Cell = .{ .bg = .{ .rgb = .{ .r = 0xFF } } }; - for (s.cursor.x..s.cols) |x| { - const row = s.getRow(.{ .active = s.cursor.y }); - const cell = row.getCellPtr(x); - cell.* = pen; - } - for ((s.cursor.y + 1)..s.rows) |y| { - const row = s.getRow(.{ .active = y }); - row.fill(pen); + for (cases) |case| { + var s = try init(alloc, 20, 10, 0); + defer s.deinit(); + try s.testWriteString(case); + + // Inside character forward + { + var sel = s.selectWord(s.pages.pin(.{ .active = .{ + .x = 2, + .y = 0, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); + } + + // Inside character backward + { + var sel = s.selectWord(s.pages.pin(.{ .active = .{ + .x = 4, + .y = 0, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); + } + + // Inside character bidirectional + { + var sel = s.selectWord(s.pages.pin(.{ .active = .{ + .x = 3, + .y = 0, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); + } + + // On quote + // NOTE: this behavior is not ideal, so we can change this one day, + // but I think its also not that important compared to the above. + { + var sel = s.selectWord(s.pages.pin(.{ .active = .{ + .x = 1, + .y = 0, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); + } } +} - try s.resize(3, 5); +test "Screen: selectOutput" { + const testing = std.testing; + const alloc = testing.allocator; - // Cursor should not move - try testing.expectEqual(cursor, s.cursor); + var s = try init(alloc, 10, 15, 0); + defer s.deinit(); + // zig fmt: off { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + // line number: + try s.testWriteString("output1\n"); // 0 + try s.testWriteString("output1\n"); // 1 + try s.testWriteString("prompt2\n"); // 2 + try s.testWriteString("input2\n"); // 3 + try s.testWriteString("output2\n"); // 4 + try s.testWriteString("output2\n"); // 5 + try s.testWriteString("prompt3$ input3\n"); // 6 + try s.testWriteString("output3\n"); // 7 + try s.testWriteString("output3\n"); // 8 + try s.testWriteString("output3"); // 9 } + // zig fmt: on + { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + const pin = s.pages.pin(.{ .screen = .{ .y = 2 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .prompt; + } + { + const pin = s.pages.pin(.{ .screen = .{ .y = 3 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .input; + } + { + const pin = s.pages.pin(.{ .screen = .{ .y = 4 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .command; + } + { + const pin = s.pages.pin(.{ .screen = .{ .y = 6 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .input; + } + { + const pin = s.pages.pin(.{ .screen = .{ .y = 7 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .command; } - // Verify all our trailing cells have the color - for (s.cursor.x..s.cols) |x| { - const row = s.getRow(.{ .active = s.cursor.y }); - const cell = row.getCellPtr(x); - try testing.expectEqual(pen, cell.*); + // No start marker, should select from the beginning + { + var sel = s.selectOutput(s.pages.pin(.{ .active = .{ + .x = 1, + .y = 1, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 9, + .y = 1, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } + // Both start and end markers, should select between them + { + var sel = s.selectOutput(s.pages.pin(.{ .active = .{ + .x = 3, + .y = 5, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 4, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 9, + .y = 5, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } + // No end marker, should select till the end + { + var sel = s.selectOutput(s.pages.pin(.{ .active = .{ + .x = 2, + .y = 7, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 7, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 9, + .y = 10, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } + // input / prompt at y = 0, pt.y = 0 + { + s.deinit(); + s = try init(alloc, 10, 5, 0); + try s.testWriteString("prompt1$ input1\n"); + try s.testWriteString("output1\n"); + try s.testWriteString("prompt2\n"); + { + const pin = s.pages.pin(.{ .screen = .{ .y = 0 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .input; + } + { + const pin = s.pages.pin(.{ .screen = .{ .y = 1 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .command; + } + try testing.expect(s.selectOutput(s.pages.pin(.{ .active = .{ + .x = 2, + .y = 0, + } }).?) == null); } } -test "Screen: resize less cols with graphemes" { +test "Screen: selectPrompt basics" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 10, 15, 0); defer s.deinit(); - const str = "1AB\n2EF\n3IJ"; - try s.testWriteString(str); - // Attach graphemes to all the columns + // zig fmt: off { - var iter = s.rowIterator(.viewport); - while (iter.next()) |row| { - var col: usize = 0; - while (col < 3) : (col += 1) { - try row.attachGrapheme(col, 0xFE0F); - } - } + // line number: + try s.testWriteString("output1\n"); // 0 + try s.testWriteString("output1\n"); // 1 + try s.testWriteString("prompt2\n"); // 2 + try s.testWriteString("input2\n"); // 3 + try s.testWriteString("output2\n"); // 4 + try s.testWriteString("output2\n"); // 5 + try s.testWriteString("prompt3$ input3\n"); // 6 + try s.testWriteString("output3\n"); // 7 + try s.testWriteString("output3\n"); // 8 + try s.testWriteString("output3"); // 9 } + // zig fmt: on - s.cursor.x = 0; - s.cursor.y = 0; - const cursor = s.cursor; - try s.resize(3, 3); + { + const pin = s.pages.pin(.{ .screen = .{ .y = 2 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .prompt; + } + { + const pin = s.pages.pin(.{ .screen = .{ .y = 3 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .input; + } + { + const pin = s.pages.pin(.{ .screen = .{ .y = 4 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .command; + } + { + const pin = s.pages.pin(.{ .screen = .{ .y = 6 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .input; + } + { + const pin = s.pages.pin(.{ .screen = .{ .y = 7 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .command; + } - // Cursor should not move - try testing.expectEqual(cursor, s.cursor); + // Not at a prompt + { + const sel = s.selectPrompt(s.pages.pin(.{ .active = .{ + .x = 0, + .y = 1, + } }).?); + try testing.expect(sel == null); + } + { + const sel = s.selectPrompt(s.pages.pin(.{ .active = .{ + .x = 0, + .y = 8, + } }).?); + try testing.expect(sel == null); + } + // Single line prompt { - const expected = "1️A️B️\n2️E️F️\n3️I️J️"; - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(expected, contents); + var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ + .x = 1, + .y = 6, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 6, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 9, + .y = 6, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } + + // Multi line prompt { - const expected = "1️A️B️\n2️E️F️\n3️I️J️"; - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(expected, contents); + var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ + .x = 1, + .y = 3, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 9, + .y = 3, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } -test "Screen: resize less cols no reflow preserves semantic prompt" { +test "Screen: selectPrompt prompt at start" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 10, 15, 0); defer s.deinit(); - const str = "1AB\n2EF\n3IJ"; - try s.testWriteString(str); - // Set one of the rows to be a prompt + // zig fmt: off { - const row = s.getRow(.{ .active = 1 }); - row.setSemanticPrompt(.prompt); - } - - s.cursor.x = 0; - s.cursor.y = 0; - const cursor = s.cursor; - try s.resize(3, 3); - - // Cursor should not move - try testing.expectEqual(cursor, s.cursor); + // line number: + try s.testWriteString("prompt1\n"); // 0 + try s.testWriteString("input1\n"); // 1 + try s.testWriteString("output2\n"); // 2 + try s.testWriteString("output2\n"); // 3 + } + // zig fmt: on { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + const pin = s.pages.pin(.{ .screen = .{ .y = 0 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .prompt; } { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + const pin = s.pages.pin(.{ .screen = .{ .y = 1 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .input; } - - // Our one row should still be a semantic prompt, the others should not. { - const row = s.getRow(.{ .active = 0 }); - try testing.expect(row.getSemanticPrompt() == .unknown); + const pin = s.pages.pin(.{ .screen = .{ .y = 2 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .command; } + + // Not at a prompt { - const row = s.getRow(.{ .active = 1 }); - try testing.expect(row.getSemanticPrompt() == .prompt); + const sel = s.selectPrompt(s.pages.pin(.{ .active = .{ + .x = 0, + .y = 3, + } }).?); + try testing.expect(sel == null); } + + // Multi line prompt { - const row = s.getRow(.{ .active = 2 }); - try testing.expect(row.getSemanticPrompt() == .unknown); + var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ + .x = 1, + .y = 1, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 9, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } -test "Screen: resize less cols with reflow but row space" { +test "Screen: selectPrompt prompt at end" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 10, 15, 0); defer s.deinit(); - const str = "1ABCD"; - try s.testWriteString(str); - // Put our cursor on the end - s.cursor.x = 4; - s.cursor.y = 0; - try testing.expectEqual(@as(u32, 'D'), s.getCell(.active, s.cursor.y, s.cursor.x).char); + // zig fmt: off + { + // line number: + try s.testWriteString("output2\n"); // 0 + try s.testWriteString("output2\n"); // 1 + try s.testWriteString("prompt1\n"); // 2 + try s.testWriteString("input1\n"); // 3 + } + // zig fmt: on - try s.resize(3, 3); { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "1AB\nCD"; - try testing.expectEqualStrings(expected, contents); + const pin = s.pages.pin(.{ .screen = .{ .y = 2 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .prompt; } { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - const expected = "1AB\nCD"; - try testing.expectEqualStrings(expected, contents); + const pin = s.pages.pin(.{ .screen = .{ .y = 3 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .input; } - // Cursor should be on the last line - try testing.expectEqual(@as(usize, 1), s.cursor.x); - try testing.expectEqual(@as(usize, 1), s.cursor.y); -} - -test "Screen: resize less cols with reflow with trimmed rows" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 3, 5, 0); - defer s.deinit(); - const str = "3IJKL\n4ABCD\n5EFGH"; - try s.testWriteString(str); - try s.resize(3, 3); - + // Not at a prompt { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "CD\n5EF\nGH"; - try testing.expectEqualStrings(expected, contents); + const sel = s.selectPrompt(s.pages.pin(.{ .active = .{ + .x = 0, + .y = 1, + } }).?); + try testing.expect(sel == null); } + + // Multi line prompt { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - const expected = "CD\n5EF\nGH"; - try testing.expectEqualStrings(expected, contents); + var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ + .x = 1, + .y = 2, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 9, + .y = 3, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } -test "Screen: resize less cols with reflow with trimmed rows and scrollback" { +test "Screen: promptPath" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 1); + var s = try init(alloc, 10, 15, 0); defer s.deinit(); - const str = "3IJKL\n4ABCD\n5EFGH"; - try s.testWriteString(str); - try s.resize(3, 3); + // zig fmt: off { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "CD\n5EF\nGH"; - try testing.expectEqualStrings(expected, contents); + // line number: + try s.testWriteString("output1\n"); // 0 + try s.testWriteString("output1\n"); // 1 + try s.testWriteString("prompt2\n"); // 2 + try s.testWriteString("input2\n"); // 3 + try s.testWriteString("output2\n"); // 4 + try s.testWriteString("output2\n"); // 5 + try s.testWriteString("prompt3$ input3\n"); // 6 + try s.testWriteString("output3\n"); // 7 + try s.testWriteString("output3\n"); // 8 + try s.testWriteString("output3"); // 9 } + // zig fmt: on + { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - const expected = "4AB\nCD\n5EF\nGH"; - try testing.expectEqualStrings(expected, contents); + const pin = s.pages.pin(.{ .screen = .{ .y = 2 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .prompt; } -} - -test "Screen: resize less cols with reflow previously wrapped" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 3, 5, 0); - defer s.deinit(); - const str = "3IJKL4ABCD5EFGH"; - try s.testWriteString(str); - - // Check { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - const expected = "3IJKL\n4ABCD\n5EFGH"; - try testing.expectEqualStrings(expected, contents); + const pin = s.pages.pin(.{ .screen = .{ .y = 3 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .input; } - - try s.resize(3, 3); - - // { - // const contents = try s.testString(alloc, .viewport); - // defer alloc.free(contents); - // const expected = "CD\n5EF\nGH"; - // try testing.expectEqualStrings(expected, contents); - // } { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - const expected = "ABC\nD5E\nFGH"; - try testing.expectEqualStrings(expected, contents); + const pin = s.pages.pin(.{ .screen = .{ .y = 4 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .command; + } + { + const pin = s.pages.pin(.{ .screen = .{ .y = 6 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .input; + } + { + const pin = s.pages.pin(.{ .screen = .{ .y = 7 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .command; } -} - -test "Screen: resize less cols with reflow and scrollback" { - const testing = std.testing; - const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 5); - defer s.deinit(); - const str = "1A\n2B\n3C\n4D\n5E"; - try s.testWriteString(str); + // From is not in the prompt + { + const path = s.promptPath( + s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, + s.pages.pin(.{ .active = .{ .x = 0, .y = 2 } }).?, + ); + try testing.expectEqual(@as(isize, 0), path.x); + try testing.expectEqual(@as(isize, 0), path.y); + } - // Put our cursor on the end - s.cursor.x = 1; - s.cursor.y = s.rows - 1; - try testing.expectEqual(@as(u32, 'E'), s.getCell(.active, s.cursor.y, s.cursor.x).char); + // Same line + { + const path = s.promptPath( + s.pages.pin(.{ .active = .{ .x = 6, .y = 2 } }).?, + s.pages.pin(.{ .active = .{ .x = 3, .y = 2 } }).?, + ); + try testing.expectEqual(@as(isize, -3), path.x); + try testing.expectEqual(@as(isize, 0), path.y); + } - try s.resize(3, 3); + // Different lines + { + const path = s.promptPath( + s.pages.pin(.{ .active = .{ .x = 6, .y = 2 } }).?, + s.pages.pin(.{ .active = .{ .x = 3, .y = 3 } }).?, + ); + try testing.expectEqual(@as(isize, -3), path.x); + try testing.expectEqual(@as(isize, 1), path.y); + } + // To is out of bounds before { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "3C\n4D\n5E"; - try testing.expectEqualStrings(expected, contents); + const path = s.promptPath( + s.pages.pin(.{ .active = .{ .x = 6, .y = 2 } }).?, + s.pages.pin(.{ .active = .{ .x = 3, .y = 1 } }).?, + ); + try testing.expectEqual(@as(isize, -6), path.x); + try testing.expectEqual(@as(isize, 0), path.y); } - // Cursor should be on the last line - try testing.expectEqual(@as(usize, 1), s.cursor.x); - try testing.expectEqual(@as(usize, 2), s.cursor.y); + // To is out of bounds after + { + const path = s.promptPath( + s.pages.pin(.{ .active = .{ .x = 6, .y = 2 } }).?, + s.pages.pin(.{ .active = .{ .x = 3, .y = 9 } }).?, + ); + try testing.expectEqual(@as(isize, 3), path.x); + try testing.expectEqual(@as(isize, 1), path.y); + } } -test "Screen: resize less cols with reflow previously wrapped and scrollback" { +test "Screen: selectionString basic" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 2); + var s = try init(alloc, 5, 3, 0); defer s.deinit(); - const str = "1ABCD2EFGH3IJKL4ABCD5EFGH"; + const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); - // Check - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "3IJKL\n4ABCD\n5EFGH"; - try testing.expectEqualStrings(expected, contents); - } - - // Put our cursor on the end - s.cursor.x = s.cols - 1; - s.cursor.y = s.rows - 1; - try testing.expectEqual(@as(u32, 'H'), s.getCell(.active, s.cursor.y, s.cursor.x).char); - - try s.resize(3, 3); - - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "CD5\nEFG\nH"; - try testing.expectEqualStrings(expected, contents); - } { - const contents = try s.testString(alloc, .screen); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 0, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, + false, + ); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = true, + }); defer alloc.free(contents); - const expected = "JKL\n4AB\nCD5\nEFG\nH"; + const expected = "2EFGH\n3IJ"; try testing.expectEqualStrings(expected, contents); } - - // Cursor should be on the last line - try testing.expectEqual(@as(u32, 'H'), s.getCell(.active, s.cursor.y, s.cursor.x).char); - try testing.expectEqual(@as(usize, 0), s.cursor.x); - try testing.expectEqual(@as(usize, 2), s.cursor.y); } -test "Screen: resize less cols with scrollback keeps cursor row" { +test "Screen: selectionString start outside of written area" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 5); + var s = try init(alloc, 5, 10, 0); defer s.deinit(); - const str = "1A\n2B\n3C\n4D\n5E"; + const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); - // Lets do a scroll and clear operation - try s.scroll(.{ .clear = {} }); - - // Move our cursor to the beginning - s.cursor.x = 0; - s.cursor.y = 0; - - try s.resize(3, 3); - { - const contents = try s.testString(alloc, .viewport); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 6 } }).?, + false, + ); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = true, + }); defer alloc.free(contents); const expected = ""; try testing.expectEqualStrings(expected, contents); } - - // Cursor should be on the last line - try testing.expectEqual(@as(usize, 0), s.cursor.x); - try testing.expectEqual(@as(usize, 0), s.cursor.y); } -test "Screen: resize more rows, less cols with reflow with scrollback" { +test "Screen: selectionString end outside of written area" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 3); + var s = try init(alloc, 5, 10, 0); defer s.deinit(); - const str = "1ABCD\n2EFGH3IJKL\n4MNOP"; + const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - const expected = "1ABCD\n2EFGH\n3IJKL\n4MNOP"; - try testing.expectEqualStrings(expected, contents); - } - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "2EFGH\n3IJKL\n4MNOP"; - try testing.expectEqualStrings(expected, contents); - } - - try s.resize(10, 2); - - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "BC\nD\n2E\nFG\nH3\nIJ\nKL\n4M\nNO\nP"; - try testing.expectEqualStrings(expected, contents); - } - { - const contents = try s.testString(alloc, .screen); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 0, .y = 2 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 6 } }).?, + false, + ); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = true, + }); defer alloc.free(contents); - const expected = "1A\nBC\nD\n2E\nFG\nH3\nIJ\nKL\n4M\nNO\nP"; + const expected = "3IJKL"; try testing.expectEqualStrings(expected, contents); } } -// This seems like it should work fine but for some reason in practice -// in the initial implementation I found this bug! This is a regression -// test for that. -test "Screen: resize more rows then shrink again" { +test "Screen: selectionString trim space" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 10); + var s = try init(alloc, 5, 3, 0); defer s.deinit(); - const str = "1ABC"; + const str = "1AB \n2EFGH\n3IJKL"; try s.testWriteString(str); - // Grow - try s.resize(10, 5); - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, + false, + ); - // Shrink - try s.resize(3, 5); - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } { - const contents = try s.testString(alloc, .viewport); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = true, + }); defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + const expected = "1AB\n2EF"; + try testing.expectEqualStrings(expected, contents); } - // Grow again - try s.resize(10, 5); - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } + // No trim { - const contents = try s.testString(alloc, .screen); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + const expected = "1AB \n2EF"; + try testing.expectEqualStrings(expected, contents); } } -test "Screen: resize less cols to eliminate wide char" { +test "Screen: selectionString trim empty line" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 1, 2, 0); + var s = try init(alloc, 5, 5, 0); defer s.deinit(); - const str = "😀"; + const str = "1AB \n\n2EFGH\n3IJKL"; try s.testWriteString(str); + + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, + false, + ); + { - const contents = try s.testString(alloc, .screen); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = true, + }); defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const cell = s.getCell(.screen, 0, 0); - try testing.expectEqual(@as(u32, '😀'), cell.char); - try testing.expect(cell.attrs.wide); + const expected = "1AB\n\n2EF"; + try testing.expectEqualStrings(expected, contents); } - // Resize to 1 column can't fit a wide char. So it should be deleted. - try s.resize(1, 1); + // No trim { - const contents = try s.testString(alloc, .screen); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); defer alloc.free(contents); - try testing.expectEqualStrings(" ", contents); + const expected = "1AB \n \n2EF"; + try testing.expectEqualStrings(expected, contents); } - - const cell = s.getCell(.screen, 0, 0); - try testing.expectEqual(@as(u32, ' '), cell.char); - try testing.expect(!cell.attrs.wide); - try testing.expect(!cell.attrs.wide_spacer_tail); - try testing.expect(!cell.attrs.wide_spacer_head); } -test "Screen: resize less cols to wrap wide char" { +test "Screen: selectionString soft wrap" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 3, 0); + var s = try init(alloc, 5, 3, 0); defer s.deinit(); - const str = "x😀"; + const str = "1ABCD2EFGH3IJKL"; try s.testWriteString(str); - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const cell = s.getCell(.screen, 0, 1); - try testing.expectEqual(@as(u32, '😀'), cell.char); - try testing.expect(cell.attrs.wide); - try testing.expect(s.getCell(.screen, 0, 2).attrs.wide_spacer_tail); - } - try s.resize(3, 2); { - const contents = try s.testString(alloc, .screen); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 0, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, + false, + ); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = true, + }); defer alloc.free(contents); - try testing.expectEqualStrings("x\n😀", contents); - } - { - const cell = s.getCell(.screen, 0, 1); - try testing.expectEqual(@as(u32, ' '), cell.char); - try testing.expect(!cell.attrs.wide); - try testing.expect(!cell.attrs.wide_spacer_tail); - try testing.expect(cell.attrs.wide_spacer_head); + const expected = "2EFGH3IJ"; + try testing.expectEqualStrings(expected, contents); } } -test "Screen: resize less cols to eliminate wide char with row space" { +test "Screen: selectionString wide char" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 2, 2, 0); + var s = try init(alloc, 5, 3, 0); defer s.deinit(); - const str = "😀"; + const str = "1A⚡"; try s.testWriteString(str); + { - const contents = try s.testString(alloc, .screen); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 0 } }).?, + false, + ); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = true, + }); defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const cell = s.getCell(.screen, 0, 0); - try testing.expectEqual(@as(u32, '😀'), cell.char); - try testing.expect(cell.attrs.wide); - try testing.expect(s.getCell(.screen, 0, 1).attrs.wide_spacer_tail); + const expected = str; + try testing.expectEqualStrings(expected, contents); } - try s.resize(2, 1); { - const contents = try s.testString(alloc, .screen); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 0 } }).?, + false, + ); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = true, + }); defer alloc.free(contents); - try testing.expectEqualStrings(" \n ", contents); + const expected = str; + try testing.expectEqualStrings(expected, contents); } + { - const cell = s.getCell(.screen, 0, 0); - try testing.expectEqual(@as(u32, ' '), cell.char); - try testing.expect(!cell.attrs.wide); - try testing.expect(!cell.attrs.wide_spacer_tail); - try testing.expect(!cell.attrs.wide_spacer_head); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 3, .y = 0 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 0 } }).?, + false, + ); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = true, + }); + defer alloc.free(contents); + const expected = "⚡"; + try testing.expectEqualStrings(expected, contents); } } -test "Screen: resize more cols with wide spacer head" { +test "Screen: selectionString wide char with header" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 2, 3, 0); + var s = try init(alloc, 5, 3, 0); defer s.deinit(); - const str = " 😀"; + const str = "1ABC⚡"; try s.testWriteString(str); - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(" \n😀", contents); - } - - // So this is the key point: we end up with a wide spacer head at - // the end of row 1, then the emoji, then a wide spacer tail on row 2. - // We should expect that if we resize to more cols, the wide spacer - // head is replaced with the emoji. - { - const cell = s.getCell(.screen, 0, 2); - try testing.expectEqual(@as(u32, ' '), cell.char); - try testing.expect(cell.attrs.wide_spacer_head); - try testing.expect(s.getCell(.screen, 1, 0).attrs.wide); - try testing.expect(s.getCell(.screen, 1, 1).attrs.wide_spacer_tail); - } - try s.resize(2, 4); { - const contents = try s.testString(alloc, .screen); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?, + s.pages.pin(.{ .screen = .{ .x = 4, .y = 0 } }).?, + false, + ); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = true, + }); defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const cell = s.getCell(.screen, 0, 2); - try testing.expectEqual(@as(u32, '😀'), cell.char); - try testing.expect(!cell.attrs.wide_spacer_head); - try testing.expect(cell.attrs.wide); - try testing.expect(s.getCell(.screen, 0, 3).attrs.wide_spacer_tail); + const expected = str; + try testing.expectEqualStrings(expected, contents); } } -test "Screen: resize less cols preserves grapheme cluster" { +// https://github.com/mitchellh/ghostty/issues/289 +test "Screen: selectionString empty with soft wrap" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 1, 5, 0); + var s = try init(alloc, 5, 2, 0); defer s.deinit(); - const str: []const u8 = &.{ 0x43, 0xE2, 0x83, 0x90 }; // C⃐ (C with combining left arrow) - try s.testWriteString(str); - // We should have a single cell with all the codepoints - { - const row = s.getRow(.{ .screen = 0 }); - try testing.expectEqual(@as(usize, 2), row.codepointLen(0)); - } - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } + // Let me describe the situation that caused this because this + // test is not obvious. By writing an emoji below, we introduce + // one cell with the emoji and one cell as a "wide char spacer". + // We then soft wrap the line by writing spaces. + // + // By selecting only the tail, we'd select nothing and we had + // a logic error that would cause a crash. + try s.testWriteString("👨"); + try s.testWriteString(" "); - // Resize to less columns. No wrapping, but we should still have - // the same grapheme cluster. - try s.resize(1, 4); { - const contents = try s.testString(alloc, .screen); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 0 } }).?, + false, + ); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = true, + }); defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + const expected = "👨"; + try testing.expectEqualStrings(expected, contents); } } -test "Screen: resize more cols with wide spacer head multiple lines" { +test "Screen: selectionString with zero width joiner" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 3, 0); + var s = try init(alloc, 10, 1, 0); defer s.deinit(); - const str = "xxxyy😀"; + const str = "👨‍"; // this has a ZWJ try s.testWriteString(str); - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings("xxx\nyy\n😀", contents); - } - // Similar to the "wide spacer head" test, but this time we'er going - // to increase our columns such that multiple rows are unwrapped. + // Integrity check { - const cell = s.getCell(.screen, 1, 2); - try testing.expectEqual(@as(u32, ' '), cell.char); - try testing.expect(cell.attrs.wide_spacer_head); - try testing.expect(s.getCell(.screen, 2, 0).attrs.wide); - try testing.expect(s.getCell(.screen, 2, 1).attrs.wide_spacer_tail); + const pin = s.pages.pin(.{ .screen = .{ .y = 0, .x = 0 } }).?; + const cell = pin.rowAndCell().cell; + try testing.expectEqual(@as(u21, 0x1F468), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + const cps = pin.page.data.lookupGrapheme(cell).?; + try testing.expectEqual(@as(usize, 1), cps.len); } - try s.resize(2, 8); + // The real test { - const contents = try s.testString(alloc, .screen); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?, + false, + ); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = true, + }); defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const cell = s.getCell(.screen, 0, 5); - try testing.expect(!cell.attrs.wide_spacer_head); - try testing.expectEqual(@as(u32, '😀'), cell.char); - try testing.expect(cell.attrs.wide); - try testing.expect(s.getCell(.screen, 0, 6).attrs.wide_spacer_tail); + const expected = "👨‍"; + try testing.expectEqualStrings(expected, contents); } } -test "Screen: resize more cols requiring a wide spacer head" { +test "Screen: selectionString, rectangle, basic" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 2, 2, 0); + var s = try init(alloc, 30, 5, 0); defer s.deinit(); - const str = "xx😀"; + const str = + \\Lorem ipsum dolor + \\sit amet, consectetur + \\adipiscing elit, sed do + \\eiusmod tempor incididunt + \\ut labore et dolore + ; + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 6, .y = 3 } }).?, + true, + ); + const expected = + \\t ame + \\ipisc + \\usmod + ; try s.testWriteString(str); - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings("xx\n😀", contents); - } - { - try testing.expect(s.getCell(.screen, 1, 0).attrs.wide); - try testing.expect(s.getCell(.screen, 1, 1).attrs.wide_spacer_tail); - } - // This resizes to 3 columns, which isn't enough space for our wide - // char to enter row 1. But we need to mark the wide spacer head on the - // end of the first row since we're wrapping to the next row. - try s.resize(2, 3); - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings("xx\n😀", contents); - } - { - const cell = s.getCell(.screen, 0, 2); - try testing.expectEqual(@as(u32, ' '), cell.char); - try testing.expect(cell.attrs.wide_spacer_head); - try testing.expect(s.getCell(.screen, 1, 0).attrs.wide); - try testing.expect(s.getCell(.screen, 1, 1).attrs.wide_spacer_tail); - } - { - const cell = s.getCell(.screen, 1, 0); - try testing.expectEqual(@as(u32, '😀'), cell.char); - try testing.expect(cell.attrs.wide); - try testing.expect(s.getCell(.screen, 1, 1).attrs.wide_spacer_tail); - } + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = true, + }); + defer alloc.free(contents); + try testing.expectEqualStrings(expected, contents); } -test "Screen: jump zero" { +test "Screen: selectionString, rectangle, w/EOL" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 10); + var s = try init(alloc, 30, 5, 0); defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n"); - try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); - try testing.expect(s.viewportIsBottom()); + const str = + \\Lorem ipsum dolor + \\sit amet, consectetur + \\adipiscing elit, sed do + \\eiusmod tempor incididunt + \\ut labore et dolore + ; + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 12, .y = 0 } }).?, + s.pages.pin(.{ .screen = .{ .x = 26, .y = 4 } }).?, + true, + ); + const expected = + \\dolor + \\nsectetur + \\lit, sed do + \\or incididunt + \\ dolore + ; + try s.testWriteString(str); - // Set semantic prompts - { - const row = s.getRow(.{ .screen = 1 }); - row.setSemanticPrompt(.prompt); - } - { - const row = s.getRow(.{ .screen = 5 }); - row.setSemanticPrompt(.prompt); - } + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = 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, 30, 8, 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.init( + s.pages.pin(.{ .screen = .{ .x = 11, .y = 2 } }).?, + s.pages.pin(.{ .screen = .{ .x = 26, .y = 7 } }).?, + true, + ); + const expected = + \\elit, sed do + \\por incididunt + \\t dolore + \\ + \\a. Ut enim + \\niam, quis + ; + try s.testWriteString(str); - try testing.expect(!s.jump(.{ .prompt_delta = 0 })); - try testing.expectEqual(@as(usize, 3), s.viewport); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = true, + }); + defer alloc.free(contents); + try testing.expectEqualStrings(expected, contents); } -test "Screen: jump to prompt" { +test "Screen: lineIterator" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 10); + var s = try init(alloc, 5, 5, 0); defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n"); - try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); - try testing.expect(s.viewportIsBottom()); + const str = "1ABCD\n2EFGH"; + try s.testWriteString(str); - // Set semantic prompts + // Test the line iterator + var iter = s.lineIterator(s.pages.pin(.{ .viewport = .{} }).?); { - const row = s.getRow(.{ .screen = 1 }); - row.setSemanticPrompt(.prompt); + const sel = iter.next().?; + const actual = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + defer alloc.free(actual); + try testing.expectEqualStrings("1ABCD", actual); } { - const row = s.getRow(.{ .screen = 5 }); - row.setSemanticPrompt(.prompt); + const sel = iter.next().?; + const actual = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + defer alloc.free(actual); + try testing.expectEqualStrings("2EFGH", actual); } - - // Jump back - try testing.expect(s.jump(.{ .prompt_delta = -1 })); - try testing.expectEqual(@as(usize, 1), s.viewport); - - // Jump back - try testing.expect(!s.jump(.{ .prompt_delta = -1 })); - try testing.expectEqual(@as(usize, 1), s.viewport); - - // Jump forward - try testing.expect(s.jump(.{ .prompt_delta = 1 })); - try testing.expectEqual(@as(usize, 3), s.viewport); - - // Jump forward - try testing.expect(!s.jump(.{ .prompt_delta = 1 })); - try testing.expectEqual(@as(usize, 3), s.viewport); } -test "Screen: row graphemeBreak" { +test "Screen: lineIterator soft wrap" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 1, 10, 0); + var s = try init(alloc, 5, 5, 0); defer s.deinit(); - try s.testWriteString("x"); - try s.testWriteString("👨‍A"); - - const row = s.getRow(.{ .screen = 0 }); - - // Normal char is a break - try testing.expect(row.graphemeBreak(0)); + const str = "1ABCD2EFGH\n3ABCD"; + try s.testWriteString(str); - // Emoji with ZWJ is not - try testing.expect(!row.graphemeBreak(1)); + // Test the line iterator + var iter = s.lineIterator(s.pages.pin(.{ .viewport = .{} }).?); + { + const sel = iter.next().?; + const actual = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + defer alloc.free(actual); + try testing.expectEqualStrings("1ABCD2EFGH", actual); + } + { + const sel = iter.next().?; + const actual = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + defer alloc.free(actual); + try testing.expectEqualStrings("3ABCD", actual); + } + // try testing.expect(iter.next() == null); } diff --git a/src/terminal/Selection.zig b/src/terminal/Selection.zig index fb83ebbea4..9da0f134ef 100644 --- a/src/terminal/Selection.zig +++ b/src/terminal/Selection.zig @@ -1,48 +1,246 @@ -/// Represents a single selection within the terminal -/// (i.e. a highlight region). +//! Represents a single selection within the terminal (i.e. a highlight region). const Selection = @This(); const std = @import("std"); const assert = std.debug.assert; +const page = @import("page.zig"); const point = @import("point.zig"); +const PageList = @import("PageList.zig"); const Screen = @import("Screen.zig"); -const ScreenPoint = point.ScreenPoint; +const Pin = PageList.Pin; -/// Start and end of the selection. There is no guarantee that -/// start is before end or vice versa. If a user selects backwards, -/// start will be after end, and vice versa. Use the struct functions -/// to not have to worry about this. -start: ScreenPoint, -end: ScreenPoint, +// NOTE(mitchellh): I'm not very happy with how this is implemented, because +// the ordering operations which are used frequently require using +// pointFromPin which -- at the time of writing this -- is slow. The overall +// style of this struct is due to porting it from the previous implementation +// which had an efficient ordering operation. +// +// While reimplementing this, there were too many callers that already +// depended on this behavior so I kept it despite the inefficiency. In the +// future, we should take a look at this again! + +/// The bounds of the selection. +bounds: Bounds, /// 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. -pub fn toViewport(self: Selection, screen: *const Screen) ?Selection { - const top = (point.Viewport{ .x = 0, .y = 0 }).toScreen(screen); - const bot = (point.Viewport{ .x = screen.cols - 1, .y = screen.rows - 1 }).toScreen(screen); - - // If our selection isn't within the viewport, do nothing. - if (!self.within(top, bot)) return null; - - // Convert - const start = self.start.toViewport(screen); - const end = self.end.toViewport(screen); - return Selection{ - .start = .{ .x = if (self.rectangle) self.start.x else start.x, .y = start.y }, - .end = .{ .x = if (self.rectangle) self.end.x else end.x, .y = end.y }, +/// The bounds of the selection. A selection bounds can be either tracked +/// or untracked. Untracked bounds are unsafe beyond the point the terminal +/// screen may be modified, since they may point to invalid memory. Tracked +/// bounds are always valid and will be updated as the screen changes, but +/// are more expensive to exist. +/// +/// In all cases, start and end can be in any order. There is no guarantee that +/// start is before end or vice versa. If a user selects backwards, +/// start will be after end, and vice versa. Use the struct functions +/// to not have to worry about this. +pub const Bounds = union(enum) { + untracked: struct { + start: Pin, + end: Pin, + }, + + tracked: struct { + start: *Pin, + end: *Pin, + }, +}; + +/// Initialize a new selection with the given start and end pins on +/// the screen. The screen will be used for pin tracking. +pub fn init( + start_pin: Pin, + end_pin: Pin, + rect: bool, +) Selection { + return .{ + .bounds = .{ .untracked = .{ + .start = start_pin, + .end = end_pin, + } }, + .rectangle = rect, + }; +} + +pub fn deinit( + self: Selection, + s: *Screen, +) void { + switch (self.bounds) { + .tracked => |v| { + s.pages.untrackPin(v.start); + s.pages.untrackPin(v.end); + }, + + .untracked => {}, + } +} + +/// Returns true if this selection is equal to another selection. +pub fn eql(self: Selection, other: Selection) bool { + return self.start().eql(other.start()) and + self.end().eql(other.end()) and + self.rectangle == other.rectangle; +} + +/// The starting pin of the selection. This is NOT ordered. +pub fn startPtr(self: *Selection) *Pin { + return switch (self.bounds) { + .untracked => |*v| &v.start, + .tracked => |v| v.start, + }; +} + +/// The ending pin of the selection. This is NOT ordered. +pub fn endPtr(self: *Selection) *Pin { + return switch (self.bounds) { + .untracked => |*v| &v.end, + .tracked => |v| v.end, + }; +} + +pub fn start(self: Selection) Pin { + return switch (self.bounds) { + .untracked => |v| v.start, + .tracked => |v| v.start.*, + }; +} + +pub fn end(self: Selection) Pin { + return switch (self.bounds) { + .untracked => |v| v.end, + .tracked => |v| v.end.*, + }; +} + +/// Returns true if this is a tracked selection. +pub fn tracked(self: *const Selection) bool { + return switch (self.bounds) { + .untracked => false, + .tracked => true, + }; +} + +/// Convert this selection a tracked selection. It is asserted this is +/// an untracked selection. The tracked selection is returned. +pub fn track(self: *const Selection, s: *Screen) !Selection { + assert(!self.tracked()); + + // Track our pins + const start_pin = self.bounds.untracked.start; + const end_pin = self.bounds.untracked.end; + const tracked_start = try s.pages.trackPin(start_pin); + errdefer s.pages.untrackPin(tracked_start); + const tracked_end = try s.pages.trackPin(end_pin); + errdefer s.pages.untrackPin(tracked_end); + + return .{ + .bounds = .{ .tracked = .{ + .start = tracked_start, + .end = tracked_end, + } }, .rectangle = self.rectangle, }; } -/// 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 the top left point of the selection. +pub fn topLeft(self: Selection, s: *const Screen) Pin { + return switch (self.order(s)) { + .forward => self.start(), + .reverse => self.end(), + .mirrored_forward => pin: { + var p = self.start(); + p.x = self.end().x; + break :pin p; + }, + .mirrored_reverse => pin: { + var p = self.end(); + p.x = self.start().x; + break :pin p; + }, + }; +} + +/// Returns the bottom right point of the selection. +pub fn bottomRight(self: Selection, s: *const Screen) Pin { + return switch (self.order(s)) { + .forward => self.end(), + .reverse => self.start(), + .mirrored_forward => pin: { + var p = self.end(); + p.x = self.start().x; + break :pin p; + }, + .mirrored_reverse => pin: { + var p = self.start(); + p.x = self.end().x; + break :pin p; + }, + }; +} + +/// The order of the selection: +/// +/// * forward: start(x, y) is before end(x, y) (top-left to bottom-right). +/// * reverse: end(x, y) is before start(x, y) (bottom-right to top-left). +/// * mirrored_[forward|reverse]: special, rectangle selections only (see below). +/// +/// For regular selections, the above also holds for top-right to bottom-left +/// (forward) and bottom-left to top-right (reverse). However, for rectangle +/// selections, both of these selections are *mirrored* as orientation +/// operations only flip the x or y axis, not both. Depending on the y axis +/// direction, this is either mirrored_forward or mirrored_reverse. +/// +pub const Order = enum { forward, reverse, mirrored_forward, mirrored_reverse }; + +pub fn order(self: Selection, s: *const Screen) Order { + const start_pt = s.pages.pointFromPin(.screen, self.start()).?.screen; + const end_pt = s.pages.pointFromPin(.screen, self.end()).?.screen; + + if (self.rectangle) { + // Reverse (also handles single-column) + if (start_pt.y > end_pt.y and start_pt.x >= end_pt.x) return .reverse; + if (start_pt.y >= end_pt.y and start_pt.x > end_pt.x) return .reverse; + + // Mirror, bottom-left to top-right + if (start_pt.y > end_pt.y and start_pt.x < end_pt.x) return .mirrored_reverse; + + // Mirror, top-right to bottom-left + if (start_pt.y < end_pt.y and start_pt.x > end_pt.x) return .mirrored_forward; + + // Forward + return .forward; + } + + if (start_pt.y < end_pt.y) return .forward; + if (start_pt.y > end_pt.y) return .reverse; + if (start_pt.x <= end_pt.x) return .forward; + return .reverse; +} + +/// Returns the selection in the given order. +/// +/// The returned selection is always a new untracked selection. +/// +/// Note that only forward and reverse are useful desired orders for this +/// function. All other orders act as if forward order was desired. +pub fn ordered(self: Selection, s: *const Screen, desired: Order) Selection { + if (self.order(s) == desired) return Selection.init( + self.start(), + self.end(), + self.rectangle, + ); + + const tl = self.topLeft(s); + const br = self.bottomRight(s); + return switch (desired) { + .forward => Selection.init(tl, br, self.rectangle), + .reverse => Selection.init(br, tl, self.rectangle), + else => Selection.init(tl, br, self.rectangle), + }; } /// Returns true if the selection contains the given point. @@ -50,12 +248,15 @@ pub fn empty(self: Selection) bool { /// This recalculates top left and bottom right each call. If you have /// many points to check, it is cheaper to do the containment logic /// yourself and cache the topleft/bottomright. -pub fn contains(self: Selection, p: ScreenPoint) bool { - const tl = self.topLeft(); - const br = self.bottomRight(); +pub fn contains(self: Selection, s: *const Screen, pin: Pin) bool { + const tl_pin = self.topLeft(s); + const br_pin = self.bottomRight(s); - // Honestly there is probably way more efficient boolean logic here. - // Look back at this in the future... + // This is definitely not very efficient. Low-hanging fruit to + // improve this. + const tl = s.pages.pointFromPin(.screen, tl_pin).?.screen; + const br = s.pages.pointFromPin(.screen, br_pin).?.screen; + const p = s.pages.pointFromPin(.screen, pin).?.screen; // If we're in rectangle select, we can short-circuit with an easy check // here @@ -77,60 +278,40 @@ pub fn contains(self: Selection, p: ScreenPoint) bool { return p.y > tl.y and p.y < br.y; } -/// Returns true if the selection contains any of the points between -/// (and including) the start and end. The x values are ignored this is -/// just a section match -pub fn within(self: Selection, start: ScreenPoint, end: ScreenPoint) bool { - const tl = self.topLeft(); - const br = self.bottomRight(); - - // Bottom right is before start, no way we are in it. - if (br.y < start.y) return false; - // Bottom right is the first line, only if our x is in it. - if (br.y == start.y) return br.x >= start.x; - - // If top left is beyond the end, we're not in it. - if (tl.y > end.y) return false; - // If top left is on the end, only if our x is in it. - if (tl.y == end.y) return tl.x <= end.x; - - return true; -} - -/// Returns true if the selection contains the row of the given point, -/// regardless of the x value. -pub fn containsRow(self: Selection, p: ScreenPoint) bool { - const tl = self.topLeft(); - const br = self.bottomRight(); - return p.y >= tl.y and p.y <= br.y; -} - /// Get a selection for a single row in the screen. This will return null /// if the row is not included in the selection. -pub fn containedRow(self: Selection, screen: *const Screen, p: ScreenPoint) ?Selection { - const tl = self.topLeft(); - const br = self.bottomRight(); +pub fn containedRow(self: Selection, s: *const Screen, pin: Pin) ?Selection { + const tl_pin = self.topLeft(s); + const br_pin = self.bottomRight(s); + + // This is definitely not very efficient. Low-hanging fruit to + // improve this. + const tl = s.pages.pointFromPin(.screen, tl_pin).?.screen; + const br = s.pages.pointFromPin(.screen, br_pin).?.screen; + const p = s.pages.pointFromPin(.screen, pin).?.screen; + 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 (self.rectangle) return init( + s.pages.pin(.{ .screen = .{ .y = p.y, .x = tl.x } }).?, + s.pages.pin(.{ .screen = .{ .y = p.y, .x = br.x } }).?, + true, + ); if (p.y == tl.y) { // If the selection is JUST this line, return it as-is. if (p.y == br.y) { - return self; + return init(tl_pin, br_pin, false); } // Selection top-left line matches only. - return .{ - .start = tl, - .end = .{ .y = tl.y, .x = screen.cols - 1 }, - }; + return init( + tl_pin, + s.pages.pin(.{ .screen = .{ .y = p.y, .x = s.pages.cols - 1 } }).?, + false, + ); } // Row is our bottom selection, so we return the selection from the @@ -138,88 +319,19 @@ pub fn containedRow(self: Selection, screen: *const Screen, p: ScreenPoint) ?Sel // one line (due to conditionals above) if (p.y == br.y) { assert(p.y != tl.y); - return .{ - .start = .{ .y = br.y, .x = 0 }, - .end = br, - }; + return init( + s.pages.pin(.{ .screen = .{ .y = p.y, .x = 0 } }).?, + br_pin, + false, + ); } // Row is somewhere between our selection lines so we return the full line. - return .{ - .start = .{ .y = p.y, .x = 0 }, - .end = .{ .y = p.y, .x = screen.cols - 1 }, - }; -} - -/// Returns the top left point of the selection. -pub fn topLeft(self: Selection) ScreenPoint { - return switch (self.order()) { - .forward => self.start, - .reverse => self.end, - .mirrored_forward => .{ .x = self.end.x, .y = self.start.y }, - .mirrored_reverse => .{ .x = self.start.x, .y = self.end.y }, - }; -} - -/// Returns the bottom right point of the selection. -pub fn bottomRight(self: Selection) ScreenPoint { - return switch (self.order()) { - .forward => self.end, - .reverse => self.start, - .mirrored_forward => .{ .x = self.start.x, .y = self.end.y }, - .mirrored_reverse => .{ .x = self.end.x, .y = self.start.y }, - }; -} - -/// Returns the selection in the given order. -/// -/// Note that only forward and reverse are useful desired orders for this -/// function. All other orders act as if forward order was desired. -pub fn ordered(self: Selection, desired: Order) Selection { - if (self.order() == desired) return self; - const tl = self.topLeft(); - const br = self.bottomRight(); - return switch (desired) { - .forward => .{ .start = tl, .end = br, .rectangle = self.rectangle }, - .reverse => .{ .start = br, .end = tl, .rectangle = self.rectangle }, - else => .{ .start = tl, .end = br, .rectangle = self.rectangle }, - }; -} - -/// The order of the selection: -/// -/// * forward: start(x, y) is before end(x, y) (top-left to bottom-right). -/// * reverse: end(x, y) is before start(x, y) (bottom-right to top-left). -/// * mirrored_[forward|reverse]: special, rectangle selections only (see below). -/// -/// For regular selections, the above also holds for top-right to bottom-left -/// (forward) and bottom-left to top-right (reverse). However, for rectangle -/// selections, both of these selections are *mirrored* as orientation -/// operations only flip the x or y axis, not both. Depending on the y axis -/// direction, this is either mirrored_forward or mirrored_reverse. -/// -pub const Order = enum { forward, reverse, mirrored_forward, mirrored_reverse }; - -pub fn order(self: Selection) Order { - if (self.rectangle) { - // Reverse (also handles single-column) - if (self.start.y > self.end.y and self.start.x >= self.end.x) return .reverse; - if (self.start.y >= self.end.y and self.start.x > self.end.x) return .reverse; - - // Mirror, bottom-left to top-right - if (self.start.y > self.end.y and self.start.x < self.end.x) return .mirrored_reverse; - - // Mirror, top-right to bottom-left - if (self.start.y < self.end.y and self.start.x > self.end.x) return .mirrored_forward; - - // Forward - return .forward; - } - - if (self.start.y < self.end.y) return .forward; - if (self.start.y > self.end.y) return .reverse; - if (self.start.x <= self.end.x) return .forward; - return .reverse; + return init( + s.pages.pin(.{ .screen = .{ .y = p.y, .x = 0 } }).?, + s.pages.pin(.{ .screen = .{ .y = p.y, .x = s.pages.cols - 1 } }).?, + false, + ); } /// Possible adjustments to the selection. @@ -236,45 +348,46 @@ pub const Adjustment = enum { /// Adjust the selection by some given adjustment. An adjustment allows /// a selection to be expanded slightly left, right, up, down, etc. -pub fn adjust(self: Selection, screen: *Screen, adjustment: Adjustment) Selection { - const screen_end = Screen.RowIndexTag.screen.maxLen(screen) - 1; - - // Make an editable one because its so much easier to use modification - // logic below than it is to reconstruct the selection every time. - var result = self; - +pub fn adjust( + self: *Selection, + s: *const Screen, + adjustment: Adjustment, +) void { // Note that we always adjusts "end" because end always represents // the last point of the selection by mouse, not necessarilly the // top/bottom visually. So this results in the right behavior // whether the user drags up or down. + const end_pin = self.endPtr(); switch (adjustment) { - .up => if (result.end.y == 0) { - result.end.x = 0; + .up => if (end_pin.up(1)) |new_end| { + end_pin.* = new_end; } else { - result.end.y -= 1; + end_pin.x = 0; }, - .down => if (result.end.y >= screen_end) { - result.end.y = screen_end; - result.end.x = screen.cols - 1; - } else { - result.end.y += 1; + .down => { + // Find the next non-blank row + var current = end_pin.*; + while (current.down(1)) |next| : (current = next) { + const rac = next.rowAndCell(); + const cells = next.page.data.getCells(rac.row); + if (page.Cell.hasTextAny(cells)) { + end_pin.* = next; + break; + } + } else { + // If we're at the bottom, just go to the end of the line + end_pin.x = end_pin.page.data.size.cols - 1; + } }, .left => { - // Step left, wrapping to the next row up at the start of each new line, - // until we find a non-empty cell. - // - // This iterator emits the start point first, throw it out. - var iterator = result.end.iterator(screen, .left_up); - _ = iterator.next(); - while (iterator.next()) |next| { - if (screen.getCell( - .screen, - next.y, - next.x, - ).char != 0) { - result.end = next; + var it = end_pin.cellIterator(.left_up, null); + _ = it.next(); + while (it.next()) |next| { + const rac = next.rowAndCell(); + if (rac.cell.hasText()) { + end_pin.* = next; break; } } @@ -283,533 +396,467 @@ pub fn adjust(self: Selection, screen: *Screen, adjustment: Adjustment) Selectio .right => { // Step right, wrapping to the next row down at the start of each new line, // until we find a non-empty cell. - var iterator = result.end.iterator(screen, .right_down); - _ = iterator.next(); - while (iterator.next()) |next| { - if (next.y > screen_end) break; - if (screen.getCell( - .screen, - next.y, - next.x, - ).char != 0) { - if (next.y > screen_end) { - result.end.y = screen_end; - } else { - result.end = next; - } + var it = end_pin.cellIterator(.right_down, null); + _ = it.next(); + while (it.next()) |next| { + const rac = next.rowAndCell(); + if (rac.cell.hasText()) { + end_pin.* = next; break; } } }, - .page_up => if (screen.rows > result.end.y) { - result.end.y = 0; - result.end.x = 0; + .page_up => if (end_pin.up(s.pages.rows)) |new_end| { + end_pin.* = new_end; } else { - result.end.y -= screen.rows; + self.adjust(s, .home); }, - .page_down => if (screen.rows > screen_end - result.end.y) { - result.end.y = screen_end; - result.end.x = screen.cols - 1; + .page_down => if (end_pin.down(s.pages.rows)) |new_end| { + end_pin.* = new_end; } else { - result.end.y += screen.rows; + self.adjust(s, .end); }, - .home => { - result.end.y = 0; - result.end.x = 0; - }, + .home => end_pin.* = s.pages.pin(.{ .screen = .{ + .x = 0, + .y = 0, + } }).?, .end => { - result.end.y = screen_end; - result.end.x = screen.cols - 1; + var it = s.pages.rowIterator( + .left_up, + .{ .screen = .{} }, + null, + ); + while (it.next()) |next| { + const rac = next.rowAndCell(); + const cells = next.page.data.getCells(rac.row); + if (page.Cell.hasTextAny(cells)) { + end_pin.* = next; + end_pin.x = cells.len - 1; + break; + } + } }, } - - return result; } test "Selection: adjust right" { const testing = std.testing; - var screen = try Screen.init(testing.allocator, 5, 10, 0); - defer screen.deinit(); - try screen.testWriteString("A1234\nB5678\nC1234\nD5678"); + var s = try Screen.init(testing.allocator, 5, 10, 0); + defer s.deinit(); + try s.testWriteString("A1234\nB5678\nC1234\nD5678"); // Simple movement right { - const sel = (Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 3, .y = 3 }, - }).adjust(&screen, .right); - - try testing.expectEqual(Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 4, .y = 3 }, - }, sel); + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .right); + + try testing.expectEqual(point.Point{ .screen = .{ + .x = 5, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 3, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Already at end of the line. { - const sel = (Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 4, .y = 2 }, - }).adjust(&screen, .right); - - try testing.expectEqual(Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 0, .y = 3 }, - }, sel); + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 4, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 4, .y = 2 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .right); + + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 3, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Already at end of the screen { - const sel = (Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 4, .y = 3 }, - }).adjust(&screen, .right); - - try testing.expectEqual(Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 4, .y = 3 }, - }, sel); + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 4, .y = 3 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .right); + + try testing.expectEqual(point.Point{ .screen = .{ + .x = 5, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 3, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } test "Selection: adjust left" { const testing = std.testing; - var screen = try Screen.init(testing.allocator, 5, 10, 0); - defer screen.deinit(); - try screen.testWriteString("A1234\nB5678\nC1234\nD5678"); + var s = try Screen.init(testing.allocator, 5, 10, 0); + defer s.deinit(); + try s.testWriteString("A1234\nB5678\nC1234\nD5678"); // Simple movement left { - const sel = (Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 3, .y = 3 }, - }).adjust(&screen, .left); + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .left); // Start line - try testing.expectEqual(Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 2, .y = 3 }, - }, sel); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 5, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 3, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Already at beginning of the line. { - const sel = (Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 0, .y = 3 }, - }).adjust(&screen, .left); + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 0, .y = 3 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .left); // Start line - try testing.expectEqual(Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 4, .y = 2 }, - }, sel); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 5, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 2, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } test "Selection: adjust left skips blanks" { const testing = std.testing; - var screen = try Screen.init(testing.allocator, 5, 10, 0); - defer screen.deinit(); - try screen.testWriteString("A1234\nB5678\nC12\nD56"); + var s = try Screen.init(testing.allocator, 5, 10, 0); + defer s.deinit(); + try s.testWriteString("A1234\nB5678\nC12\nD56"); // Same line { - const sel = (Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 4, .y = 3 }, - }).adjust(&screen, .left); + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 4, .y = 3 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .left); // Start line - try testing.expectEqual(Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 2, .y = 3 }, - }, sel); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 5, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 3, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Edge { - const sel = (Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 0, .y = 3 }, - }).adjust(&screen, .left); + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 0, .y = 3 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .left); // Start line - try testing.expectEqual(Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 2, .y = 2 }, - }, sel); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 5, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 2, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } test "Selection: adjust up" { const testing = std.testing; - var screen = try Screen.init(testing.allocator, 5, 10, 0); - defer screen.deinit(); - try screen.testWriteString("A\nB\nC\nD\nE"); + var s = try Screen.init(testing.allocator, 5, 10, 0); + defer s.deinit(); + try s.testWriteString("A\nB\nC\nD\nE"); // Not on the first line { - const sel = (Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 3, .y = 3 }, - }).adjust(&screen, .up); - - // Start line - try testing.expectEqual(Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 3, .y = 2 }, - }, sel); + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .up); + + try testing.expectEqual(point.Point{ .screen = .{ + .x = 5, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 2, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // On the first line { - const sel = (Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 3, .y = 0 }, - }).adjust(&screen, .up); - - // Start line - try testing.expectEqual(Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 0, .y = 0 }, - }, sel); + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 0 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .up); + + try testing.expectEqual(point.Point{ .screen = .{ + .x = 5, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } test "Selection: adjust down" { const testing = std.testing; - var screen = try Screen.init(testing.allocator, 5, 10, 0); - defer screen.deinit(); - try screen.testWriteString("A\nB\nC\nD\nE"); + var s = try Screen.init(testing.allocator, 5, 10, 0); + defer s.deinit(); + try s.testWriteString("A\nB\nC\nD\nE"); // Not on the first line { - const sel = (Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 3, .y = 3 }, - }).adjust(&screen, .down); - - // Start line - try testing.expectEqual(Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 3, .y = 4 }, - }, sel); + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .down); + + try testing.expectEqual(point.Point{ .screen = .{ + .x = 5, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 4, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // On the last line { - const sel = (Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 3, .y = 4 }, - }).adjust(&screen, .down); - - // Start line - try testing.expectEqual(Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 9, .y = 4 }, - }, sel); + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 4, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 4 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .down); + + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 4, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } test "Selection: adjust down with not full screen" { const testing = std.testing; - var screen = try Screen.init(testing.allocator, 5, 10, 0); - defer screen.deinit(); - try screen.testWriteString("A\nB\nC"); + var s = try Screen.init(testing.allocator, 5, 10, 0); + defer s.deinit(); + try s.testWriteString("A\nB\nC"); // On the last line { - const sel = (Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 3, .y = 2 }, - }).adjust(&screen, .down); + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 4, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 2 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .down); // Start line - try testing.expectEqual(Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 9, .y = 2 }, - }, sel); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 2, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } -test "Selection: contains" { +test "Selection: adjust home" { const testing = std.testing; - { - const sel: Selection = .{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 3, .y = 2 }, - }; - - try testing.expect(sel.contains(.{ .x = 6, .y = 1 })); - try testing.expect(sel.contains(.{ .x = 1, .y = 2 })); - try testing.expect(!sel.contains(.{ .x = 1, .y = 1 })); - try testing.expect(!sel.contains(.{ .x = 5, .y = 2 })); - try testing.expect(!sel.containsRow(.{ .x = 1, .y = 3 })); - try testing.expect(sel.containsRow(.{ .x = 1, .y = 1 })); - try testing.expect(sel.containsRow(.{ .x = 5, .y = 2 })); - } - - // Reverse - { - const sel: Selection = .{ - .start = .{ .x = 3, .y = 2 }, - .end = .{ .x = 5, .y = 1 }, - }; - - try testing.expect(sel.contains(.{ .x = 6, .y = 1 })); - try testing.expect(sel.contains(.{ .x = 1, .y = 2 })); - try testing.expect(!sel.contains(.{ .x = 1, .y = 1 })); - try testing.expect(!sel.contains(.{ .x = 5, .y = 2 })); - } - - // Single line - { - const sel: Selection = .{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 10, .y = 1 }, - }; - - 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 })); - } -} + var s = try Screen.init(testing.allocator, 5, 10, 0); + defer s.deinit(); + try s.testWriteString("A\nB\nC"); -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 + // On the last line { - 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 })); - } + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 4, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 2 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .home); - // 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 })); + // Start line + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } -test "Selection: containedRow" { +test "Selection: adjust end with not full screen" { const testing = std.testing; - var screen = try Screen.init(testing.allocator, 5, 10, 0); - defer screen.deinit(); - - { - const sel: Selection = .{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 3, .y = 3 }, - }; - - // Not contained - try testing.expect(sel.containedRow(&screen, .{ .x = 1, .y = 4 }) == null); + var s = try Screen.init(testing.allocator, 5, 10, 0); + defer s.deinit(); + try s.testWriteString("A\nB\nC"); - // Start line - try testing.expectEqual(Selection{ - .start = sel.start, - .end = .{ .x = screen.cols - 1, .y = 1 }, - }, sel.containedRow(&screen, .{ .x = 1, .y = 1 }).?); - - // End line - try testing.expectEqual(Selection{ - .start = .{ .x = 0, .y = 3 }, - .end = sel.end, - }, sel.containedRow(&screen, .{ .x = 2, .y = 3 }).?); - - // Middle line - try testing.expectEqual(Selection{ - .start = .{ .x = 0, .y = 2 }, - .end = .{ .x = screen.cols - 1, .y = 2 }, - }, sel.containedRow(&screen, .{ .x = 2, .y = 2 }).?); - } - - // Rectangle + // On the last line { - 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); + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 4, .y = 0 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .end); // 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 = .{ - .start = .{ .x = 2, .y = 1 }, - .end = .{ .x = 6, .y = 1 }, - }; - - // Not contained - try testing.expect(sel.containedRow(&screen, .{ .x = 1, .y = 0 }) == null); - try testing.expect(sel.containedRow(&screen, .{ .x = 1, .y = 2 }) == null); - - // Contained - try testing.expectEqual(Selection{ - .start = sel.start, - .end = sel.end, - }, sel.containedRow(&screen, .{ .x = 1, .y = 1 }).?); - } -} - -test "Selection: within" { - const testing = std.testing; - { - const sel: Selection = .{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 3, .y = 2 }, - }; - - // Fully within - try testing.expect(sel.within(.{ .x = 6, .y = 0 }, .{ .x = 6, .y = 3 })); - try testing.expect(sel.within(.{ .x = 3, .y = 1 }, .{ .x = 6, .y = 3 })); - try testing.expect(sel.within(.{ .x = 3, .y = 0 }, .{ .x = 6, .y = 2 })); - - // Partially within - try testing.expect(sel.within(.{ .x = 1, .y = 2 }, .{ .x = 6, .y = 3 })); - try testing.expect(sel.within(.{ .x = 1, .y = 0 }, .{ .x = 6, .y = 1 })); - - // Not within at all - try testing.expect(!sel.within(.{ .x = 0, .y = 0 }, .{ .x = 4, .y = 1 })); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 2, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } test "Selection: order, standard" { const testing = std.testing; + const alloc = testing.allocator; + + var s = try Screen.init(alloc, 100, 100, 1); + defer s.deinit(); + { // forward, multi-line - const sel: Selection = .{ - .start = .{ .x = 2, .y = 1 }, - .end = .{ .x = 2, .y = 2 }, - }; - - try testing.expect(sel.order() == .forward); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, + false, + ); + defer sel.deinit(&s); + + try testing.expect(sel.order(&s) == .forward); } { // reverse, multi-line - const sel: Selection = .{ - .start = .{ .x = 2, .y = 2 }, - .end = .{ .x = 2, .y = 1 }, - }; - - try testing.expect(sel.order() == .reverse); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, + false, + ); + defer sel.deinit(&s); + + try testing.expect(sel.order(&s) == .reverse); } { // forward, same-line - const sel: Selection = .{ - .start = .{ .x = 2, .y = 1 }, - .end = .{ .x = 3, .y = 1 }, - }; - - try testing.expect(sel.order() == .forward); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + false, + ); + defer sel.deinit(&s); + + try testing.expect(sel.order(&s) == .forward); } { // forward, single char - const sel: Selection = .{ - .start = .{ .x = 2, .y = 1 }, - .end = .{ .x = 2, .y = 1 }, - }; - - try testing.expect(sel.order() == .forward); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, + false, + ); + defer sel.deinit(&s); + + try testing.expect(sel.order(&s) == .forward); } { // reverse, single line - const sel: Selection = .{ - .start = .{ .x = 2, .y = 1 }, - .end = .{ .x = 1, .y = 1 }, - }; - - try testing.expect(sel.order() == .reverse); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + false, + ); + defer sel.deinit(&s); + + try testing.expect(sel.order(&s) == .reverse); } } test "Selection: order, rectangle" { const testing = std.testing; + const alloc = testing.allocator; + + var s = try Screen.init(alloc, 100, 100, 1); + defer s.deinit(); + // Conventions: // TL - top left // BL - bottom left @@ -817,336 +864,537 @@ test "Selection: order, rectangle" { // BR - bottom right { // forward (TL -> BR) - const sel: Selection = .{ - .start = .{ .x = 1, .y = 1 }, - .end = .{ .x = 2, .y = 2 }, - .rectangle = true, - }; - - try testing.expect(sel.order() == .forward); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, + true, + ); + defer sel.deinit(&s); + + try testing.expect(sel.order(&s) == .forward); } { // reverse (BR -> TL) - const sel: Selection = .{ - .start = .{ .x = 2, .y = 2 }, - .end = .{ .x = 1, .y = 1 }, - .rectangle = true, - }; - - try testing.expect(sel.order() == .reverse); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + true, + ); + defer sel.deinit(&s); + + try testing.expect(sel.order(&s) == .reverse); } { // mirrored_forward (TR -> BL) - const sel: Selection = .{ - .start = .{ .x = 3, .y = 1 }, - .end = .{ .x = 1, .y = 3 }, - .rectangle = true, - }; - - try testing.expect(sel.order() == .mirrored_forward); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 3 } }).?, + true, + ); + defer sel.deinit(&s); + + try testing.expect(sel.order(&s) == .mirrored_forward); } { // mirrored_reverse (BL -> TR) - const sel: Selection = .{ - .start = .{ .x = 1, .y = 3 }, - .end = .{ .x = 3, .y = 1 }, - .rectangle = true, - }; - - try testing.expect(sel.order() == .mirrored_reverse); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 1, .y = 3 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + true, + ); + defer sel.deinit(&s); + + try testing.expect(sel.order(&s) == .mirrored_reverse); } { // forward, single line (left -> right ) - const sel: Selection = .{ - .start = .{ .x = 1, .y = 1 }, - .end = .{ .x = 3, .y = 1 }, - .rectangle = true, - }; - - try testing.expect(sel.order() == .forward); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + true, + ); + defer sel.deinit(&s); + + try testing.expect(sel.order(&s) == .forward); } { // reverse, single line (right -> left) - const sel: Selection = .{ - .start = .{ .x = 3, .y = 1 }, - .end = .{ .x = 1, .y = 1 }, - .rectangle = true, - }; - - try testing.expect(sel.order() == .reverse); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + true, + ); + defer sel.deinit(&s); + + try testing.expect(sel.order(&s) == .reverse); } { // forward, single column (top -> bottom) - const sel: Selection = .{ - .start = .{ .x = 2, .y = 1 }, - .end = .{ .x = 2, .y = 3 }, - .rectangle = true, - }; - - try testing.expect(sel.order() == .forward); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 3 } }).?, + true, + ); + defer sel.deinit(&s); + + try testing.expect(sel.order(&s) == .forward); } { // reverse, single column (bottom -> top) - const sel: Selection = .{ - .start = .{ .x = 2, .y = 3 }, - .end = .{ .x = 2, .y = 1 }, - .rectangle = true, - }; - - try testing.expect(sel.order() == .reverse); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 2, .y = 3 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, + true, + ); + defer sel.deinit(&s); + + try testing.expect(sel.order(&s) == .reverse); } { // forward, single cell - const sel: Selection = .{ - .start = .{ .x = 1, .y = 1 }, - .end = .{ .x = 1, .y = 1 }, - .rectangle = true, - }; - - try testing.expect(sel.order() == .forward); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + true, + ); + defer sel.deinit(&s); + + try testing.expect(sel.order(&s) == .forward); } } test "topLeft" { const testing = std.testing; + + var s = try Screen.init(testing.allocator, 5, 10, 0); + defer s.deinit(); { // forward - const sel: Selection = .{ - .start = .{ .x = 1, .y = 1 }, - .end = .{ .x = 3, .y = 1 }, - }; - const expected: ScreenPoint = .{ .x = 1, .y = 1 }; - try testing.expectEqual(sel.topLeft(), expected); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + true, + ); + defer sel.deinit(&s); + const tl = sel.topLeft(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 1, + } }, s.pages.pointFromPin(.screen, tl)); } { // reverse - const sel: Selection = .{ - .start = .{ .x = 3, .y = 1 }, - .end = .{ .x = 1, .y = 1 }, - }; - const expected: ScreenPoint = .{ .x = 1, .y = 1 }; - try testing.expectEqual(sel.topLeft(), expected); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + true, + ); + defer sel.deinit(&s); + const tl = sel.topLeft(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 1, + } }, s.pages.pointFromPin(.screen, tl)); } { // mirrored_forward - const sel: Selection = .{ - .start = .{ .x = 3, .y = 1 }, - .end = .{ .x = 1, .y = 3 }, - .rectangle = true, - }; - const expected: ScreenPoint = .{ .x = 1, .y = 1 }; - try testing.expectEqual(sel.topLeft(), expected); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 3 } }).?, + true, + ); + defer sel.deinit(&s); + const tl = sel.topLeft(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 1, + } }, s.pages.pointFromPin(.screen, tl)); } { // mirrored_reverse - const sel: Selection = .{ - .start = .{ .x = 1, .y = 3 }, - .end = .{ .x = 3, .y = 1 }, - .rectangle = true, - }; - const expected: ScreenPoint = .{ .x = 1, .y = 1 }; - try testing.expectEqual(sel.topLeft(), expected); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 1, .y = 3 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + true, + ); + defer sel.deinit(&s); + const tl = sel.topLeft(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 1, + } }, s.pages.pointFromPin(.screen, tl)); } } test "bottomRight" { const testing = std.testing; + + var s = try Screen.init(testing.allocator, 5, 10, 0); + defer s.deinit(); { // forward - const sel: Selection = .{ - .start = .{ .x = 1, .y = 1 }, - .end = .{ .x = 3, .y = 1 }, - }; - const expected: ScreenPoint = .{ .x = 3, .y = 1 }; - try testing.expectEqual(sel.bottomRight(), expected); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + false, + ); + defer sel.deinit(&s); + const br = sel.bottomRight(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 1, + } }, s.pages.pointFromPin(.screen, br)); } { // reverse - const sel: Selection = .{ - .start = .{ .x = 3, .y = 1 }, - .end = .{ .x = 1, .y = 1 }, - }; - const expected: ScreenPoint = .{ .x = 3, .y = 1 }; - try testing.expectEqual(sel.bottomRight(), expected); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + false, + ); + defer sel.deinit(&s); + const br = sel.bottomRight(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 1, + } }, s.pages.pointFromPin(.screen, br)); } { // mirrored_forward - const sel: Selection = .{ - .start = .{ .x = 3, .y = 1 }, - .end = .{ .x = 1, .y = 3 }, - .rectangle = true, - }; - const expected: ScreenPoint = .{ .x = 3, .y = 3 }; - try testing.expectEqual(sel.bottomRight(), expected); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 3 } }).?, + true, + ); + defer sel.deinit(&s); + const br = sel.bottomRight(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 3, + } }, s.pages.pointFromPin(.screen, br)); } { // mirrored_reverse - const sel: Selection = .{ - .start = .{ .x = 1, .y = 3 }, - .end = .{ .x = 3, .y = 1 }, - .rectangle = true, - }; - const expected: ScreenPoint = .{ .x = 3, .y = 3 }; - try testing.expectEqual(sel.bottomRight(), expected); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 1, .y = 3 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + true, + ); + defer sel.deinit(&s); + const br = sel.bottomRight(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 3, + } }, s.pages.pointFromPin(.screen, br)); } } test "ordered" { const testing = std.testing; + + var s = try Screen.init(testing.allocator, 5, 10, 0); + defer s.deinit(); { // forward - const sel: Selection = .{ - .start = .{ .x = 1, .y = 1 }, - .end = .{ .x = 3, .y = 1 }, - }; - const sel_reverse: Selection = .{ - .start = .{ .x = 3, .y = 1 }, - .end = .{ .x = 1, .y = 1 }, - }; - try testing.expectEqual(sel.ordered(.forward), sel); - try testing.expectEqual(sel.ordered(.reverse), sel_reverse); - try testing.expectEqual(sel.ordered(.mirrored_reverse), sel); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + false, + ); + const sel_reverse = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + false, + ); + try testing.expect(sel.ordered(&s, .forward).eql(sel)); + try testing.expect(sel.ordered(&s, .reverse).eql(sel_reverse)); + try testing.expect(sel.ordered(&s, .mirrored_forward).eql(sel)); } { // reverse - const sel: Selection = .{ - .start = .{ .x = 3, .y = 1 }, - .end = .{ .x = 1, .y = 1 }, - }; - const sel_forward: Selection = .{ - .start = .{ .x = 1, .y = 1 }, - .end = .{ .x = 3, .y = 1 }, - }; - try testing.expectEqual(sel.ordered(.forward), sel_forward); - try testing.expectEqual(sel.ordered(.reverse), sel); - try testing.expectEqual(sel.ordered(.mirrored_forward), sel_forward); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + false, + ); + const sel_forward = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + false, + ); + try testing.expect(sel.ordered(&s, .forward).eql(sel_forward)); + try testing.expect(sel.ordered(&s, .reverse).eql(sel)); + try testing.expect(sel.ordered(&s, .mirrored_forward).eql(sel_forward)); } { // mirrored_forward - const sel: Selection = .{ - .start = .{ .x = 3, .y = 1 }, - .end = .{ .x = 1, .y = 3 }, - .rectangle = true, - }; - const sel_forward: Selection = .{ - .start = .{ .x = 1, .y = 1 }, - .end = .{ .x = 3, .y = 3 }, - .rectangle = true, - }; - const sel_reverse: Selection = .{ - .start = .{ .x = 3, .y = 3 }, - .end = .{ .x = 1, .y = 1 }, - .rectangle = true, - }; - try testing.expectEqual(sel.ordered(.forward), sel_forward); - try testing.expectEqual(sel.ordered(.reverse), sel_reverse); - try testing.expectEqual(sel.ordered(.mirrored_reverse), sel_forward); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 3 } }).?, + true, + ); + const sel_forward = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, + true, + ); + const sel_reverse = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + true, + ); + try testing.expect(sel.ordered(&s, .forward).eql(sel_forward)); + try testing.expect(sel.ordered(&s, .reverse).eql(sel_reverse)); + try testing.expect(sel.ordered(&s, .mirrored_reverse).eql(sel_forward)); } { // mirrored_reverse - const sel: Selection = .{ - .start = .{ .x = 1, .y = 3 }, - .end = .{ .x = 3, .y = 1 }, - .rectangle = true, - }; - const sel_forward: Selection = .{ - .start = .{ .x = 1, .y = 1 }, - .end = .{ .x = 3, .y = 3 }, - .rectangle = true, - }; - const sel_reverse: Selection = .{ - .start = .{ .x = 3, .y = 3 }, - .end = .{ .x = 1, .y = 1 }, - .rectangle = true, - }; - try testing.expectEqual(sel.ordered(.forward), sel_forward); - try testing.expectEqual(sel.ordered(.reverse), sel_reverse); - try testing.expectEqual(sel.ordered(.mirrored_forward), sel_forward); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 1, .y = 3 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + true, + ); + const sel_forward = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, + true, + ); + const sel_reverse = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + true, + ); + try testing.expect(sel.ordered(&s, .forward).eql(sel_forward)); + try testing.expect(sel.ordered(&s, .reverse).eql(sel_reverse)); + try testing.expect(sel.ordered(&s, .mirrored_forward).eql(sel_forward)); } } -test "toViewport" { +test "Selection: contains" { const testing = std.testing; - var screen = try Screen.init(testing.allocator, 24, 80, 0); - defer screen.deinit(); - screen.viewport = 11; // Scroll us down a bit + + var s = try Screen.init(testing.allocator, 5, 10, 0); + defer s.deinit(); { - // Not in viewport (null) - const sel: Selection = .{ - .start = .{ .x = 10, .y = 1 }, - .end = .{ .x = 3, .y = 3 }, - .rectangle = false, - }; - try testing.expectEqual(null, sel.toViewport(&screen)); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 2 } }).?, + false, + ); + + try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 6, .y = 1 } }).?)); + try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 1, .y = 2 } }).?)); + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?)); + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 5, .y = 2 } }).?)); } + + // Reverse { - // In viewport - const sel: Selection = .{ - .start = .{ .x = 10, .y = 11 }, - .end = .{ .x = 3, .y = 13 }, - .rectangle = false, - }; - const want: Selection = .{ - .start = .{ .x = 10, .y = 0 }, - .end = .{ .x = 3, .y = 2 }, - .rectangle = false, - }; - try testing.expectEqual(want, sel.toViewport(&screen)); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 3, .y = 2 } }).?, + s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, + false, + ); + + try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 6, .y = 1 } }).?)); + try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 1, .y = 2 } }).?)); + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?)); + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 5, .y = 2 } }).?)); } + + // Single line { - // Top off viewport - const sel: Selection = .{ - .start = .{ .x = 10, .y = 1 }, - .end = .{ .x = 3, .y = 13 }, - .rectangle = false, - }; - const want: Selection = .{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = 3, .y = 2 }, - .rectangle = false, - }; - try testing.expectEqual(want, sel.toViewport(&screen)); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 10, .y = 1 } }).?, + false, + ); + + try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 6, .y = 1 } }).?)); + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?)); + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 12, .y = 1 } }).?)); } +} + +test "Selection: contains, rectangle" { + const testing = std.testing; + + var s = try Screen.init(testing.allocator, 15, 15, 0); + defer s.deinit(); { - // Bottom off viewport - const sel: Selection = .{ - .start = .{ .x = 10, .y = 11 }, - .end = .{ .x = 3, .y = 40 }, - .rectangle = false, - }; - const want: Selection = .{ - .start = .{ .x = 10, .y = 0 }, - .end = .{ .x = 79, .y = 23 }, - .rectangle = false, - }; - try testing.expectEqual(want, sel.toViewport(&screen)); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, + s.pages.pin(.{ .screen = .{ .x = 7, .y = 9 } }).?, + true, + ); + + try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 5, .y = 6 } }).?)); // Center + try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 3, .y = 6 } }).?)); // Left border + try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 7, .y = 6 } }).?)); // Right border + try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 5, .y = 3 } }).?)); // Top border + try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 5, .y = 9 } }).?)); // Bottom border + + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 5, .y = 2 } }).?)); // Above center + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 5, .y = 10 } }).?)); // Below center + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 2, .y = 6 } }).?)); // Left center + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 8, .y = 6 } }).?)); // Right center + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 8, .y = 3 } }).?)); // Just right of top right + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 2, .y = 9 } }).?)); // Just left of bottom left } + + // Reverse { - // Both off viewport - const sel: Selection = .{ - .start = .{ .x = 10, .y = 1 }, - .end = .{ .x = 3, .y = 40 }, - .rectangle = false, - }; - const want: Selection = .{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = 79, .y = 23 }, - .rectangle = false, - }; - try testing.expectEqual(want, sel.toViewport(&screen)); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 7, .y = 9 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, + true, + ); + + try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 5, .y = 6 } }).?)); // Center + try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 3, .y = 6 } }).?)); // Left border + try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 7, .y = 6 } }).?)); // Right border + try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 5, .y = 3 } }).?)); // Top border + try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 5, .y = 9 } }).?)); // Bottom border + + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 5, .y = 2 } }).?)); // Above center + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 5, .y = 10 } }).?)); // Below center + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 2, .y = 6 } }).?)); // Left center + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 8, .y = 6 } }).?)); // Right center + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 8, .y = 3 } }).?)); // Just right of top right + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 2, .y = 9 } }).?)); // Just left of bottom left } + + // Single line + // NOTE: This is the same as normal selection but we just do it for brevity + { + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 10, .y = 1 } }).?, + true, + ); + + try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 6, .y = 1 } }).?)); + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?)); + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 12, .y = 1 } }).?)); + } +} + +test "Selection: containedRow" { + const testing = std.testing; + var s = try Screen.init(testing.allocator, 10, 5, 0); + defer s.deinit(); + + { + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, + false, + ); + + // Not contained + try testing.expect(sel.containedRow( + &s, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 4 } }).?, + ) == null); + + // Start line + try testing.expectEqual(Selection.init( + sel.start(), + s.pages.pin(.{ .screen = .{ .x = s.pages.cols - 1, .y = 1 } }).?, + false, + ), sel.containedRow( + &s, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + ).?); + + // End line + try testing.expectEqual(Selection.init( + s.pages.pin(.{ .screen = .{ .x = 0, .y = 3 } }).?, + sel.end(), + false, + ), sel.containedRow( + &s, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 3 } }).?, + ).?); + + // Middle line + try testing.expectEqual(Selection.init( + s.pages.pin(.{ .screen = .{ .x = 0, .y = 2 } }).?, + s.pages.pin(.{ .screen = .{ .x = s.pages.cols - 1, .y = 2 } }).?, + false, + ), sel.containedRow( + &s, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, + ).?); + } + + // Rectangle + { + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 6, .y = 3 } }).?, + true, + ); + + // Not contained + try testing.expect(sel.containedRow( + &s, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 4 } }).?, + ) == null); + + // Start line + try testing.expectEqual(Selection.init( + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 6, .y = 1 } }).?, + true, + ), sel.containedRow( + &s, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + ).?); + + // End line + try testing.expectEqual(Selection.init( + s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, + s.pages.pin(.{ .screen = .{ .x = 6, .y = 3 } }).?, + true, + ), sel.containedRow( + &s, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 3 } }).?, + ).?); + + // Middle line + try testing.expectEqual(Selection.init( + s.pages.pin(.{ .screen = .{ .x = 3, .y = 2 } }).?, + s.pages.pin(.{ .screen = .{ .x = 6, .y = 2 } }).?, + true, + ), sel.containedRow( + &s, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, + ).?); + } + + // Single-line selection { - // Both off viewport (rectangle) - const sel: Selection = .{ - .start = .{ .x = 10, .y = 1 }, - .end = .{ .x = 3, .y = 40 }, - .rectangle = true, - }; - const want: Selection = .{ - .start = .{ .x = 10, .y = 0 }, - .end = .{ .x = 3, .y = 23 }, - .rectangle = true, - }; - try testing.expectEqual(want, sel.toViewport(&screen)); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 6, .y = 1 } }).?, + false, + ); + + // Not contained + try testing.expect(sel.containedRow( + &s, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?, + ) == null); + try testing.expect(sel.containedRow( + &s, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 2 } }).?, + ) == null); + + // Contained + try testing.expectEqual(sel, sel.containedRow( + &s, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + ).?); } } diff --git a/src/terminal/StringMap.zig b/src/terminal/StringMap.zig index 588013d9db..9892c13df3 100644 --- a/src/terminal/StringMap.zig +++ b/src/terminal/StringMap.zig @@ -7,10 +7,11 @@ const oni = @import("oniguruma"); const point = @import("point.zig"); const Selection = @import("Selection.zig"); const Screen = @import("Screen.zig"); +const Pin = @import("PageList.zig").Pin; const Allocator = std.mem.Allocator; string: [:0]const u8, -map: []point.ScreenPoint, +map: []Pin, pub fn deinit(self: StringMap, alloc: Allocator) void { alloc.free(self.string); @@ -79,11 +80,11 @@ pub const Match = struct { const end_idx: usize = @intCast(self.region.ends()[0] - 1); const start_pt = self.map.map[self.offset + start_idx]; const end_pt = self.map.map[self.offset + end_idx]; - return .{ .start = start_pt, .end = end_pt }; + return Selection.init(start_pt, end_pt, false); } }; -test "searchIterator" { +test "StringMap searchIterator" { const testing = std.testing; const alloc = testing.allocator; @@ -103,8 +104,19 @@ test "searchIterator" { defer s.deinit(); const str = "1ABCD2EFGH\n3IJKL"; try s.testWriteString(str); - const line = s.getLine(.{ .x = 2, .y = 1 }).?; - const map = try line.stringMap(alloc); + const line = s.selectLine(.{ + .pin = s.pages.pin(.{ .active = .{ + .x = 2, + .y = 1, + } }).?, + }).?; + var map: StringMap = undefined; + const sel_str = try s.selectionString(alloc, .{ + .sel = line, + .trim = false, + .map = &map, + }); + alloc.free(sel_str); defer map.deinit(alloc); // Get our iterator @@ -114,10 +126,14 @@ test "searchIterator" { defer match.deinit(); const sel = match.selection(); - try testing.expectEqual(Selection{ - .start = .{ .x = 1, .y = 0 }, - .end = .{ .x = 2, .y = 0 }, - }, sel); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } try testing.expect(try it.next() == null); diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 723c8d97b5..52267fa133 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1,15 +1,13 @@ //! The primary terminal emulation structure. This represents a single -//! //! "terminal" containing a grid of characters and exposes various operations //! on that grid. This also maintains the scrollback buffer. const Terminal = @This(); const std = @import("std"); const builtin = @import("builtin"); -const testing = std.testing; const assert = std.debug.assert; +const testing = std.testing; const Allocator = std.mem.Allocator; -const simd = @import("../simd/main.zig"); const unicode = @import("../unicode/main.zig"); const ansi = @import("ansi.zig"); @@ -20,9 +18,16 @@ const kitty = @import("kitty.zig"); const sgr = @import("sgr.zig"); const Tabstops = @import("Tabstops.zig"); const color = @import("color.zig"); -const Screen = @import("Screen.zig"); const mouse_shape = @import("mouse_shape.zig"); +const size = @import("size.zig"); +const pagepkg = @import("page.zig"); +const style = @import("style.zig"); +const Screen = @import("Screen.zig"); +const Page = pagepkg.Page; +const Cell = pagepkg.Cell; +const Row = pagepkg.Row; + const log = std.log.scoped(.terminal); /// Default tabstop interval @@ -34,18 +39,6 @@ pub const ScreenType = enum { alternate, }; -/// The semantic prompt type. This is used when tracking a line type and -/// requires integration with the shell. By default, we mark a line as "none" -/// meaning we don't know what type it is. -/// -/// See: https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md -pub const SemanticPrompt = enum { - prompt, - prompt_continuation, - input, - command, -}; - /// Screen is the current screen state. The "active_screen" field says what /// the current screen is. The backup screen is the opposite of the active /// screen. @@ -62,8 +55,8 @@ status_display: ansi.StatusDisplay = .main, tabstops: Tabstops, /// The size of the terminal. -rows: usize, -cols: usize, +rows: size.CellCountInt, +cols: size.CellCountInt, /// The size of the screen in pixels. This is used for pty events and images width_px: u32 = 0, @@ -152,26 +145,35 @@ pub const MouseFormat = enum(u3) { pub const ScrollingRegion = struct { // Top and bottom of the scroll region (0-indexed) // Precondition: top < bottom - top: usize, - bottom: usize, + top: size.CellCountInt, + bottom: size.CellCountInt, // Left/right scroll regions. // Precondition: right > left // Precondition: right <= cols - 1 - left: usize, - right: usize, + left: size.CellCountInt, + right: size.CellCountInt, +}; + +pub const Options = struct { + cols: size.CellCountInt, + rows: size.CellCountInt, + max_scrollback: usize = 10_000, }; /// Initialize a new terminal. -pub fn init(alloc: Allocator, cols: usize, rows: usize) !Terminal { +pub fn init( + alloc: Allocator, + opts: Options, +) !Terminal { + const cols = opts.cols; + const rows = opts.rows; return Terminal{ .cols = cols, .rows = rows, .active_screen = .primary, - // TODO: configurable scrollback - .screen = try Screen.init(alloc, rows, cols, 10000), - // No scrollback for the alternate screen - .secondary_screen = try Screen.init(alloc, rows, cols, 0), + .screen = try Screen.init(alloc, cols, rows, opts.max_scrollback), + .secondary_screen = try Screen.init(alloc, cols, rows, 0), .tabstops = try Tabstops.init(alloc, cols, TABSTOP_INTERVAL), .scrolling_region = .{ .top = 0, @@ -191,500 +193,467 @@ pub fn deinit(self: *Terminal, alloc: Allocator) void { self.* = undefined; } -/// Options for switching to the alternate screen. -pub const AlternateScreenOptions = struct { - cursor_save: bool = false, - clear_on_enter: bool = false, - clear_on_exit: bool = false, -}; +/// Print UTF-8 encoded string to the terminal. +pub fn printString(self: *Terminal, str: []const u8) !void { + const view = try std.unicode.Utf8View.init(str); + var it = view.iterator(); + while (it.nextCodepoint()) |cp| { + switch (cp) { + '\n' => { + self.carriageReturn(); + try self.linefeed(); + }, -/// Switch to the alternate screen buffer. -/// -/// The alternate screen buffer: -/// * has its own grid -/// * has its own cursor state (included saved cursor) -/// * does not support scrollback -/// -pub fn alternateScreen( - self: *Terminal, - alloc: Allocator, - options: AlternateScreenOptions, -) void { - //log.info("alt screen active={} options={} cursor={}", .{ self.active_screen, options, self.screen.cursor }); + else => try self.print(cp), + } + } +} - // TODO: test - // TODO(mitchellh): what happens if we enter alternate screen multiple times? - // for now, we ignore... - if (self.active_screen == .alternate) return; +/// Print the previous printed character a repeated amount of times. +pub fn printRepeat(self: *Terminal, count_req: usize) !void { + if (self.previous_char) |c| { + const count = @max(count_req, 1); + for (0..count) |_| try self.print(c); + } +} - // If we requested cursor save, we save the cursor in the primary screen - if (options.cursor_save) self.saveCursor(); +pub fn print(self: *Terminal, c: u21) !void { + // log.debug("print={x} y={} x={}", .{ c, self.screen.cursor.y, self.screen.cursor.x }); - // Switch the screens - const old = self.screen; - self.screen = self.secondary_screen; - self.secondary_screen = old; - self.active_screen = .alternate; + // If we're not on the main display, do nothing for now + if (self.status_display != .main) return; - // Bring our pen with us - self.screen.cursor = old.cursor; + // After doing any printing, wrapping, scrolling, etc. we want to ensure + // that our screen remains in a consistent state. + defer self.screen.assertIntegrity(); - // Bring our charset state with us - self.screen.charset = old.charset; + // Our right margin depends where our cursor is now. + const right_limit = if (self.screen.cursor.x > self.scrolling_region.right) + self.cols + else + self.scrolling_region.right + 1; - // Clear our selection - self.screen.selection = null; + // Perform grapheme clustering if grapheme support is enabled (mode 2027). + // This is MUCH slower than the normal path so the conditional below is + // purposely ordered in least-likely to most-likely so we can drop out + // as quickly as possible. + if (c > 255 and + self.modes.get(.grapheme_cluster) and + self.screen.cursor.x > 0) + grapheme: { + // We need the previous cell to determine if we're at a grapheme + // break or not. If we are NOT, then we are still combining the + // same grapheme. Otherwise, we can stay in this cell. + const Prev = struct { cell: *Cell, left: size.CellCountInt }; + const prev: Prev = prev: { + const left: size.CellCountInt = left: { + // If we have wraparound, then we always use the prev col + if (self.modes.get(.wraparound)) break :left 1; - // Mark kitty images as dirty so they redraw - self.screen.kitty_images.dirty = true; + // If we do not have wraparound, the logic is trickier. If + // we're not on the last column, then we just use the previous + // column. Otherwise, we need to check if there is text to + // figure out if we're attaching to the prev or current. + if (self.screen.cursor.x != right_limit - 1) break :left 1; + break :left @intFromBool(!self.screen.cursor.page_cell.hasText()); + }; - if (options.clear_on_enter) { - self.eraseDisplay(alloc, .complete, false); - } -} + // If the previous cell is a wide spacer tail, then we actually + // want to use the cell before that because that has the actual + // content. + const immediate = self.screen.cursorCellLeft(left); + break :prev switch (immediate.wide) { + else => .{ .cell = immediate, .left = left }, + .spacer_tail => .{ + .cell = self.screen.cursorCellLeft(left + 1), + .left = left + 1, + }, + }; + }; -/// Switch back to the primary screen (reset alternate screen mode). -pub fn primaryScreen( - self: *Terminal, - alloc: Allocator, - options: AlternateScreenOptions, -) void { - //log.info("primary screen active={} options={}", .{ self.active_screen, options }); + // If our cell has no content, then this is a new cell and + // necessarily a grapheme break. + if (!prev.cell.hasText()) break :grapheme; - // TODO: test - // TODO(mitchellh): what happens if we enter alternate screen multiple times? - if (self.active_screen == .primary) return; + const grapheme_break = brk: { + var state: unicode.GraphemeBreakState = .{}; + var cp1: u21 = prev.cell.content.codepoint; + if (prev.cell.hasGrapheme()) { + const cps = self.screen.cursor.page_pin.page.data.lookupGrapheme(prev.cell).?; + for (cps) |cp2| { + // log.debug("cp1={x} cp2={x}", .{ cp1, cp2 }); + assert(!unicode.graphemeBreak(cp1, cp2, &state)); + cp1 = cp2; + } + } - if (options.clear_on_exit) self.eraseDisplay(alloc, .complete, false); + // log.debug("cp1={x} cp2={x} end", .{ cp1, c }); + break :brk unicode.graphemeBreak(cp1, c, &state); + }; - // Switch the screens - const old = self.screen; - self.screen = self.secondary_screen; - self.secondary_screen = old; - self.active_screen = .primary; + // If we can NOT break, this means that "c" is part of a grapheme + // with the previous char. + if (!grapheme_break) { + // If this is an emoji variation selector then we need to modify + // the cell width accordingly. VS16 makes the character wide and + // VS15 makes it narrow. + if (c == 0xFE0F or c == 0xFE0E) { + // This only applies to emoji + const prev_props = unicode.getProperties(prev.cell.content.codepoint); + const emoji = prev_props.grapheme_boundary_class == .extended_pictographic; + if (!emoji) return; - // Clear our selection - self.screen.selection = null; + switch (c) { + 0xFE0F => wide: { + if (prev.cell.wide == .wide) break :wide; - // Mark kitty images as dirty so they redraw - self.screen.kitty_images.dirty = true; + // Move our cursor back to the previous. We'll move + // the cursor within this block to the proper location. + self.screen.cursorLeft(prev.left); - // Restore the cursor from the primary screen - if (options.cursor_save) self.restoreCursor(); -} + // If we don't have space for the wide char, we need + // to insert spacers and wrap. Then we just print the wide + // char as normal. + if (self.screen.cursor.x == right_limit - 1) { + if (!self.modes.get(.wraparound)) return; + self.printCell( + ' ', + if (right_limit == self.cols) .spacer_head else .narrow, + ); + try self.printWrap(); + } -/// The modes for DECCOLM. -pub const DeccolmMode = enum(u1) { - @"80_cols" = 0, - @"132_cols" = 1, -}; + self.printCell(prev.cell.content.codepoint, .wide); -/// DECCOLM changes the terminal width between 80 and 132 columns. This -/// function call will do NOTHING unless `setDeccolmSupported` has been -/// called with "true". -/// -/// This breaks the expectation around modern terminals that they resize -/// with the window. This will fix the grid at either 80 or 132 columns. -/// The rows will continue to be variable. -pub fn deccolm(self: *Terminal, alloc: Allocator, mode: DeccolmMode) !void { - // If DEC mode 40 isn't enabled, then this is ignored. We also make - // sure that we don't have deccolm set because we want to fully ignore - // set mode. - if (!self.modes.get(.enable_mode_3)) { - self.modes.set(.@"132_column", false); - return; - } + // Write our spacer + self.screen.cursorRight(1); + self.printCell(' ', .spacer_tail); - // Enable it - self.modes.set(.@"132_column", mode == .@"132_cols"); + // Move the cursor again so we're beyond our spacer + if (self.screen.cursor.x == right_limit - 1) { + self.screen.cursor.pending_wrap = true; + } else { + self.screen.cursorRight(1); + } + }, - // Resize to the requested size - try self.resize( - alloc, - switch (mode) { - .@"132_cols" => 132, - .@"80_cols" => 80, - }, - self.rows, - ); + 0xFE0E => narrow: { + // Prev cell is no longer wide + if (prev.cell.wide != .wide) break :narrow; + prev.cell.wide = .narrow; - // Erase our display and move our cursor. - self.eraseDisplay(alloc, .complete, false); - self.setCursorPos(1, 1); -} + // Remove the wide spacer tail + const cell = self.screen.cursorCellLeft(prev.left - 1); + cell.wide = .narrow; -/// Resize the underlying terminal. -pub fn resize(self: *Terminal, alloc: Allocator, cols: usize, rows: usize) !void { - // If our cols/rows didn't change then we're done - if (self.cols == cols and self.rows == rows) return; + break :narrow; + }, - // Resize our tabstops - // TODO: use resize, but it doesn't set new tabstops - if (self.cols != cols) { - self.tabstops.deinit(alloc); - self.tabstops = try Tabstops.init(alloc, cols, 8); - } + else => unreachable, + } + } - // If we're making the screen smaller, dealloc the unused items. - if (self.active_screen == .primary) { - self.clearPromptForResize(); - if (self.modes.get(.wraparound)) { - try self.screen.resize(rows, cols); - } else { - try self.screen.resizeWithoutReflow(rows, cols); - } - try self.secondary_screen.resizeWithoutReflow(rows, cols); - } else { - try self.screen.resizeWithoutReflow(rows, cols); - if (self.modes.get(.wraparound)) { - try self.secondary_screen.resize(rows, cols); - } else { - try self.secondary_screen.resizeWithoutReflow(rows, cols); + log.debug("c={x} grapheme attach to left={}", .{ c, prev.left }); + try self.screen.appendGrapheme(prev.cell, c); + return; } } - // Set our size - self.cols = cols; - self.rows = rows; - - // Reset the scrolling region - self.scrolling_region = .{ - .top = 0, - .bottom = rows - 1, - .left = 0, - .right = cols - 1, - }; -} - -/// If shell_redraws_prompt is true and we're on the primary screen, -/// then this will clear the screen from the cursor down if the cursor is -/// on a prompt in order to allow the shell to redraw the prompt. -fn clearPromptForResize(self: *Terminal) void { - assert(self.active_screen == .primary); - - if (!self.flags.shell_redraws_prompt) return; - - // We need to find the first y that is a prompt. If we find any line - // that is NOT a prompt (or input -- which is part of a prompt) then - // we are not at a prompt and we can exit this function. - const prompt_y: usize = prompt_y: { - // Keep track of the found value, because we want to find the START - var found: ?usize = null; - - // Search from the cursor up - var y: usize = 0; - while (y <= self.screen.cursor.y) : (y += 1) { - const real_y = self.screen.cursor.y - y; - const row = self.screen.getRow(.{ .active = real_y }); - switch (row.getSemanticPrompt()) { - // We are at a prompt but we're not at the start of the prompt. - // We mark our found value and continue because the prompt - // may be multi-line. - .input => found = real_y, - - // If we find the prompt then we're done. We are also done - // if we find any prompt continuation, because the shells - // that send this currently (zsh) cannot redraw every line. - .prompt, .prompt_continuation => { - found = real_y; - break; - }, + // Determine the width of this character so we can handle + // non-single-width characters properly. We have a fast-path for + // byte-sized characters since they're so common. We can ignore + // control characters because they're always filtered prior. + const width: usize = if (c <= 0xFF) 1 else @intCast(unicode.table.get(c).width); - // If we have command output, then we're most certainly not - // at a prompt. Break out of the loop. - .command => break, + // Note: it is possible to have a width of "3" and a width of "-1" + // from ziglyph. We should look into those cases and handle them + // appropriately. + assert(width <= 2); + // log.debug("c={x} width={}", .{ c, width }); - // If we don't know, we keep searching. - .unknown => {}, - } - } + // Attach zero-width characters to our cell as grapheme data. + if (width == 0) { + // If we have grapheme clustering enabled, we don't blindly attach + // any zero width character to our cells and we instead just ignore + // it. + if (self.modes.get(.grapheme_cluster)) return; - if (found) |found_y| break :prompt_y found_y; - return; - }; - assert(prompt_y < self.rows); - - // We want to clear all the lines from prompt_y downwards because - // the shell will redraw the prompt. - for (prompt_y..self.rows) |y| { - const row = self.screen.getRow(.{ .active = y }); - row.setWrapped(false); - row.setDirty(true); - row.clear(.{}); - } -} - -/// Return the current string value of the terminal. Newlines are -/// encoded as "\n". This omits any formatting such as fg/bg. -/// -/// The caller must free the string. -pub fn plainString(self: *Terminal, alloc: Allocator) ![]const u8 { - return try self.screen.testString(alloc, .viewport); -} - -/// Save cursor position and further state. -/// -/// The primary and alternate screen have distinct save state. One saved state -/// is kept per screen (main / alternative). If for the current screen state -/// was already saved it is overwritten. -pub fn saveCursor(self: *Terminal) void { - self.screen.saved_cursor = .{ - .x = self.screen.cursor.x, - .y = self.screen.cursor.y, - .pen = self.screen.cursor.pen, - .pending_wrap = self.screen.cursor.pending_wrap, - .origin = self.modes.get(.origin), - .charset = self.screen.charset, - }; -} - -/// Restore cursor position and other state. -/// -/// The primary and alternate screen have distinct save state. -/// If no save was done before values are reset to their initial values. -pub fn restoreCursor(self: *Terminal) void { - const saved: Screen.Cursor.Saved = self.screen.saved_cursor orelse .{ - .x = 0, - .y = 0, - .pen = .{}, - .pending_wrap = false, - .origin = false, - .charset = .{}, - }; - - self.screen.cursor.pen = saved.pen; - self.screen.charset = saved.charset; - self.modes.set(.origin, saved.origin); - self.screen.cursor.x = @min(saved.x, self.cols - 1); - self.screen.cursor.y = @min(saved.y, self.rows - 1); - self.screen.cursor.pending_wrap = saved.pending_wrap; -} - -/// TODO: test -pub fn setAttribute(self: *Terminal, attr: sgr.Attribute) !void { - switch (attr) { - .unset => { - self.screen.cursor.pen.fg = .none; - self.screen.cursor.pen.bg = .none; - self.screen.cursor.pen.attrs = .{}; - }, - - .bold => { - self.screen.cursor.pen.attrs.bold = true; - }, - - .reset_bold => { - // Bold and faint share the same SGR code for this - self.screen.cursor.pen.attrs.bold = false; - self.screen.cursor.pen.attrs.faint = false; - }, - - .italic => { - self.screen.cursor.pen.attrs.italic = true; - }, - - .reset_italic => { - self.screen.cursor.pen.attrs.italic = false; - }, - - .faint => { - self.screen.cursor.pen.attrs.faint = true; - }, - - .underline => |v| { - self.screen.cursor.pen.attrs.underline = v; - }, - - .reset_underline => { - self.screen.cursor.pen.attrs.underline = .none; - }, + // If we're at cell zero, then this is malformed data and we don't + // print anything or even store this. Zero-width characters are ALWAYS + // attached to some other non-zero-width character at the time of + // writing. + if (self.screen.cursor.x == 0) { + log.warn("zero-width character with no prior character, ignoring", .{}); + return; + } - .underline_color => |rgb| { - self.screen.cursor.pen.attrs.underline_color = true; - self.screen.cursor.pen.underline_fg = .{ - .r = rgb.r, - .g = rgb.g, - .b = rgb.b, - }; - }, + // Find our previous cell + const prev = prev: { + const immediate = self.screen.cursorCellLeft(1); + if (immediate.wide != .spacer_tail) break :prev immediate; + break :prev self.screen.cursorCellLeft(2); + }; - .@"256_underline_color" => |idx| { - self.screen.cursor.pen.attrs.underline_color = true; - self.screen.cursor.pen.underline_fg = self.color_palette.colors[idx]; - }, + // If our previous cell has no text, just ignore the zero-width character + if (!prev.hasText()) { + log.warn("zero-width character with no prior character, ignoring", .{}); + return; + } - .reset_underline_color => { - self.screen.cursor.pen.attrs.underline_color = false; - }, + // If this is a emoji variation selector, prev must be an emoji + if (c == 0xFE0F or c == 0xFE0E) { + const prev_props = unicode.getProperties(prev.content.codepoint); + const emoji = prev_props.grapheme_boundary_class == .extended_pictographic; + if (!emoji) return; + } - .blink => { - log.warn("blink requested, but not implemented", .{}); - self.screen.cursor.pen.attrs.blink = true; - }, + try self.screen.appendGrapheme(prev, c); + return; + } - .reset_blink => { - self.screen.cursor.pen.attrs.blink = false; - }, + // We have a printable character, save it + self.previous_char = c; - .inverse => { - self.screen.cursor.pen.attrs.inverse = true; - }, + // If we're soft-wrapping, then handle that first. + if (self.screen.cursor.pending_wrap and self.modes.get(.wraparound)) { + try self.printWrap(); + } - .reset_inverse => { - self.screen.cursor.pen.attrs.inverse = false; - }, + // If we have insert mode enabled then we need to handle that. We + // only do insert mode if we're not at the end of the line. + if (self.modes.get(.insert) and + self.screen.cursor.x + width < self.cols) + { + self.insertBlanks(width); + } - .invisible => { - self.screen.cursor.pen.attrs.invisible = true; - }, + switch (width) { + // Single cell is very easy: just write in the cell + 1 => @call(.always_inline, printCell, .{ self, c, .narrow }), - .reset_invisible => { - self.screen.cursor.pen.attrs.invisible = false; - }, + // Wide character requires a spacer. We print this by + // using two cells: the first is flagged "wide" and has the + // wide char. The second is guaranteed to be a spacer if + // we're not at the end of the line. + 2 => if ((right_limit - self.scrolling_region.left) > 1) { + // If we don't have space for the wide char, we need + // to insert spacers and wrap. Then we just print the wide + // char as normal. + if (self.screen.cursor.x == right_limit - 1) { + // If we don't have wraparound enabled then we don't print + // this character at all and don't move the cursor. This is + // how xterm behaves. + if (!self.modes.get(.wraparound)) return; - .strikethrough => { - self.screen.cursor.pen.attrs.strikethrough = true; - }, + // We only create a spacer head if we're at the real edge + // of the screen. Otherwise, we clear the space with a narrow. + // This allows soft wrapping to work correctly. + self.printCell(' ', if (right_limit == self.cols) .spacer_head else .narrow); + try self.printWrap(); + } - .reset_strikethrough => { - self.screen.cursor.pen.attrs.strikethrough = false; + self.printCell(c, .wide); + self.screen.cursorRight(1); + self.printCell(' ', .spacer_tail); + } else { + // This is pretty broken, terminals should never be only 1-wide. + // We sould prevent this downstream. + self.printCell(' ', .narrow); }, - .direct_color_fg => |rgb| { - self.screen.cursor.pen.fg = .{ - .rgb = .{ - .r = rgb.r, - .g = rgb.g, - .b = rgb.b, - }, - }; - }, + else => unreachable, + } - .direct_color_bg => |rgb| { - self.screen.cursor.pen.bg = .{ - .rgb = .{ - .r = rgb.r, - .g = rgb.g, - .b = rgb.b, - }, - }; - }, + // If we're at the column limit, then we need to wrap the next time. + // In this case, we don't move the cursor. + if (self.screen.cursor.x == right_limit - 1) { + self.screen.cursor.pending_wrap = true; + return; + } - .@"8_fg" => |n| { - self.screen.cursor.pen.fg = .{ .indexed = @intFromEnum(n) }; - }, + // Move the cursor + self.screen.cursorRight(1); +} - .@"8_bg" => |n| { - self.screen.cursor.pen.bg = .{ .indexed = @intFromEnum(n) }; - }, +fn printCell( + self: *Terminal, + unmapped_c: u21, + wide: Cell.Wide, +) void { + defer self.screen.assertIntegrity(); - .reset_fg => self.screen.cursor.pen.fg = .none, + // TODO: spacers should use a bgcolor only cell - .reset_bg => self.screen.cursor.pen.bg = .none, + const c: u21 = c: { + // TODO: non-utf8 handling, gr - .@"8_bright_fg" => |n| { - self.screen.cursor.pen.fg = .{ .indexed = @intFromEnum(n) }; - }, + // If we're single shifting, then we use the key exactly once. + const key = if (self.screen.charset.single_shift) |key_once| blk: { + self.screen.charset.single_shift = null; + break :blk key_once; + } else self.screen.charset.gl; + const set = self.screen.charset.charsets.get(key); - .@"8_bright_bg" => |n| { - self.screen.cursor.pen.bg = .{ .indexed = @intFromEnum(n) }; - }, + // UTF-8 or ASCII is used as-is + if (set == .utf8 or set == .ascii) break :c unmapped_c; - .@"256_fg" => |idx| { - self.screen.cursor.pen.fg = .{ .indexed = idx }; - }, + // If we're outside of ASCII range this is an invalid value in + // this table so we just return space. + if (unmapped_c > std.math.maxInt(u8)) break :c ' '; - .@"256_bg" => |idx| { - self.screen.cursor.pen.bg = .{ .indexed = idx }; - }, + // Get our lookup table and map it + const table = set.table(); + break :c @intCast(table[@intCast(unmapped_c)]); + }; - .unknown => return error.InvalidAttribute, - } -} + const cell = self.screen.cursor.page_cell; + + // If the wide property of this cell is the same, then we don't + // need to do the special handling here because the structure will + // be the same. If it is NOT the same, then we may need to clear some + // cells. + if (cell.wide != wide) { + switch (cell.wide) { + // Previous cell was narrow. Do nothing. + .narrow => {}, + + // Previous cell was wide. We need to clear the tail and head. + .wide => wide: { + if (self.screen.cursor.x >= self.cols - 1) break :wide; + + const spacer_cell = self.screen.cursorCellRight(1); + self.screen.clearCells( + &self.screen.cursor.page_pin.page.data, + self.screen.cursor.page_row, + spacer_cell[0..1], + ); + if (self.screen.cursor.y > 0 and self.screen.cursor.x <= 1) { + const head_cell = self.screen.cursorCellEndOfPrev(); + head_cell.wide = .narrow; + } + }, -/// Print the active attributes as a string. This is used to respond to DECRQSS -/// requests. -/// -/// Boolean attributes are printed first, followed by foreground color, then -/// background color. Each attribute is separated by a semicolon. -pub fn printAttributes(self: *Terminal, buf: []u8) ![]const u8 { - var stream = std.io.fixedBufferStream(buf); - const writer = stream.writer(); + .spacer_tail => { + assert(self.screen.cursor.x > 0); - // The SGR response always starts with a 0. See https://vt100.net/docs/vt510-rm/DECRPSS - try writer.writeByte('0'); + // So integrity checks pass. We fix this up later so we don't + // need to do this without safety checks. + if (comptime std.debug.runtime_safety) { + cell.wide = .narrow; + } - const pen = self.screen.cursor.pen; - var attrs = [_]u8{0} ** 8; - var i: usize = 0; + const wide_cell = self.screen.cursorCellLeft(1); + self.screen.clearCells( + &self.screen.cursor.page_pin.page.data, + self.screen.cursor.page_row, + wide_cell[0..1], + ); + if (self.screen.cursor.y > 0 and self.screen.cursor.x <= 1) { + const head_cell = self.screen.cursorCellEndOfPrev(); + head_cell.wide = .narrow; + } + }, - if (pen.attrs.bold) { - attrs[i] = '1'; - i += 1; + // TODO: this case was not handled in the old terminal implementation + // but it feels like we should do something. investigate other + // terminals (xterm mainly) and see whats up. + .spacer_head => {}, + } } - if (pen.attrs.faint) { - attrs[i] = '2'; - i += 1; + // If the prior value had graphemes, clear those + if (cell.hasGrapheme()) { + self.screen.cursor.page_pin.page.data.clearGrapheme( + self.screen.cursor.page_row, + cell, + ); } - if (pen.attrs.italic) { - attrs[i] = '3'; - i += 1; - } + // Keep track of the previous style so we can decrement the ref count + const prev_style_id = cell.style_id; - if (pen.attrs.underline != .none) { - attrs[i] = '4'; - i += 1; - } + // Write + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = c }, + .style_id = self.screen.cursor.style_id, + .wide = wide, + .protected = self.screen.cursor.protected, + }; - if (pen.attrs.blink) { - attrs[i] = '5'; - i += 1; + if (comptime std.debug.runtime_safety) { + // We've had bugs around this, so let's add an assertion: every + // style we use should be present in the style table. + if (self.screen.cursor.style_id != style.default_id) { + const page = &self.screen.cursor.page_pin.page.data; + if (page.styles.lookupId( + page.memory, + self.screen.cursor.style_id, + ) == null) { + log.err("can't find style page={X} id={}", .{ + @intFromPtr(&self.screen.cursor.page_pin.page.data), + self.screen.cursor.style_id, + }); + @panic("style not found"); + } + } } - if (pen.attrs.inverse) { - attrs[i] = '7'; - i += 1; - } + // Handle the style ref count handling + style_ref: { + if (prev_style_id != style.default_id) { + const row = self.screen.cursor.page_row; + assert(row.styled); + + // If our previous cell had the same style ID as us currently, + // then we don't bother with any ref counts because we're the same. + if (prev_style_id == self.screen.cursor.style_id) break :style_ref; + + // Slow path: we need to lookup this style so we can decrement + // the ref count. Since we've already loaded everything, we also + // just go ahead and GC it if it reaches zero, too. + var page = &self.screen.cursor.page_pin.page.data; + if (page.styles.lookupId(page.memory, prev_style_id)) |prev_style| { + // Below upsert can't fail because it should already be present + const md = page.styles.upsert(page.memory, prev_style.*) catch unreachable; + assert(md.ref > 0); + md.ref -= 1; + if (md.ref == 0) page.styles.remove(page.memory, prev_style_id); + } + } - if (pen.attrs.invisible) { - attrs[i] = '8'; - i += 1; + // If we have a ref-counted style, increase. + if (self.screen.cursor.style_ref) |ref| { + ref.* += 1; + self.screen.cursor.page_row.styled = true; + } } +} - if (pen.attrs.strikethrough) { - attrs[i] = '9'; - i += 1; - } +fn printWrap(self: *Terminal) !void { + // We only mark that we soft-wrapped if we're at the edge of our + // full screen. We don't mark the row as wrapped if we're in the + // middle due to a right margin. + const mark_wrap = self.screen.cursor.x == self.cols - 1; + if (mark_wrap) self.screen.cursor.page_row.wrap = true; - for (attrs[0..i]) |c| { - try writer.print(";{c}", .{c}); - } + // Get the old semantic prompt so we can extend it to the next + // line. We need to do this before we index() because we may + // modify memory. + const old_prompt = self.screen.cursor.page_row.semantic_prompt; - switch (pen.fg) { - .none => {}, - .indexed => |idx| if (idx >= 16) - try writer.print(";38:5:{}", .{idx}) - else if (idx >= 8) - try writer.print(";9{}", .{idx - 8}) - else - try writer.print(";3{}", .{idx}), - .rgb => |rgb| try writer.print(";38:2::{[r]}:{[g]}:{[b]}", rgb), - } + // Move to the next line + try self.index(); + self.screen.cursorHorizontalAbsolute(self.scrolling_region.left); - switch (pen.bg) { - .none => {}, - .indexed => |idx| if (idx >= 16) - try writer.print(";48:5:{}", .{idx}) - else if (idx >= 8) - try writer.print(";10{}", .{idx - 8}) - else - try writer.print(";4{}", .{idx}), - .rgb => |rgb| try writer.print(";48:2::{[r]}:{[g]}:{[b]}", rgb), + if (mark_wrap) { + // New line must inherit semantic prompt of the old line + self.screen.cursor.page_row.semantic_prompt = old_prompt; + self.screen.cursor.page_row.wrap_continuation = true; } - return stream.getWritten(); + // Assure that our screen is consistent + self.screen.assertIntegrity(); } /// Set the charset into the given slot. @@ -712,411 +681,364 @@ pub fn invokeCharset( } } -/// Print UTF-8 encoded string to the terminal. -pub fn printString(self: *Terminal, str: []const u8) !void { - const view = try std.unicode.Utf8View.init(str); - var it = view.iterator(); - while (it.nextCodepoint()) |cp| { - switch (cp) { - '\n' => { - self.carriageReturn(); - try self.linefeed(); - }, +/// Carriage return moves the cursor to the first column. +pub fn carriageReturn(self: *Terminal) void { + // Always reset pending wrap state + self.screen.cursor.pending_wrap = false; - else => try self.print(cp), - } - } -} - -pub fn print(self: *Terminal, c: u21) !void { - // log.debug("print={x} y={} x={}", .{ c, self.screen.cursor.y, self.screen.cursor.x }); - - // If we're not on the main display, do nothing for now - if (self.status_display != .main) return; - - // Our right margin depends where our cursor is now. - const right_limit = if (self.screen.cursor.x > self.scrolling_region.right) - self.cols + // In origin mode we always move to the left margin + self.screen.cursorHorizontalAbsolute(if (self.modes.get(.origin)) + self.scrolling_region.left + else if (self.screen.cursor.x >= self.scrolling_region.left) + self.scrolling_region.left else - self.scrolling_region.right + 1; - - // Perform grapheme clustering if grapheme support is enabled (mode 2027). - // This is MUCH slower than the normal path so the conditional below is - // purposely ordered in least-likely to most-likely so we can drop out - // as quickly as possible. - if (c > 255 and self.modes.get(.grapheme_cluster) and self.screen.cursor.x > 0) grapheme: { - const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); - - // We need the previous cell to determine if we're at a grapheme - // break or not. If we are NOT, then we are still combining the - // same grapheme. Otherwise, we can stay in this cell. - const Prev = struct { cell: *Screen.Cell, x: usize }; - const prev: Prev = prev: { - const x = x: { - // If we have wraparound, then we always use the prev col - if (self.modes.get(.wraparound)) break :x self.screen.cursor.x - 1; - - // If we do not have wraparound, the logic is trickier. If - // we're not on the last column, then we just use the previous - // column. Otherwise, we need to check if there is text to - // figure out if we're attaching to the prev or current. - if (self.screen.cursor.x != right_limit - 1) break :x self.screen.cursor.x - 1; - const current = row.getCellPtr(self.screen.cursor.x); - break :x self.screen.cursor.x - @intFromBool(current.char == 0); - }; - const immediate = row.getCellPtr(x); + 0); +} - // If the previous cell is a wide spacer tail, then we actually - // want to use the cell before that because that has the actual - // content. - if (!immediate.attrs.wide_spacer_tail) break :prev .{ - .cell = immediate, - .x = x, - }; +/// Linefeed moves the cursor to the next line. +pub fn linefeed(self: *Terminal) !void { + try self.index(); + if (self.modes.get(.linefeed)) self.carriageReturn(); +} - break :prev .{ - .cell = row.getCellPtr(x - 1), - .x = x - 1, - }; - }; +/// Backspace moves the cursor back a column (but not less than 0). +pub fn backspace(self: *Terminal) void { + self.cursorLeft(1); +} - // If our cell has no content, then this is a new cell and - // necessarily a grapheme break. - if (prev.cell.char == 0) break :grapheme; +/// Move the cursor up amount lines. If amount is greater than the maximum +/// move distance then it is internally adjusted to the maximum. If amount is +/// 0, adjust it to 1. +pub fn cursorUp(self: *Terminal, count_req: usize) void { + // Always resets pending wrap + self.screen.cursor.pending_wrap = false; - const grapheme_break = brk: { - var state: unicode.GraphemeBreakState = .{}; - var cp1: u21 = @intCast(prev.cell.char); - if (prev.cell.attrs.grapheme) { - var it = row.codepointIterator(prev.x); - while (it.next()) |cp2| { - // log.debug("cp1={x} cp2={x}", .{ cp1, cp2 }); - assert(!unicode.graphemeBreak(cp1, cp2, &state)); - cp1 = cp2; - } - } + // The maximum amount the cursor can move up depends on scrolling regions + const max = if (self.screen.cursor.y >= self.scrolling_region.top) + self.screen.cursor.y - self.scrolling_region.top + else + self.screen.cursor.y; + const count = @min(max, @max(count_req, 1)); - // log.debug("cp1={x} cp2={x} end", .{ cp1, c }); - break :brk unicode.graphemeBreak(cp1, c, &state); - }; + // We can safely intCast below because of the min/max clamping we did above. + self.screen.cursorUp(@intCast(count)); +} - // If we can NOT break, this means that "c" is part of a grapheme - // with the previous char. - if (!grapheme_break) { - // If this is an emoji variation selector then we need to modify - // the cell width accordingly. VS16 makes the character wide and - // VS15 makes it narrow. - if (c == 0xFE0F or c == 0xFE0E) { - // This only applies to emoji - const prev_props = unicode.getProperties(@intCast(prev.cell.char)); - const emoji = prev_props.grapheme_boundary_class == .extended_pictographic; - if (!emoji) return; +/// Move the cursor down amount lines. If amount is greater than the maximum +/// move distance then it is internally adjusted to the maximum. This sequence +/// will not scroll the screen or scroll region. If amount is 0, adjust it to 1. +pub fn cursorDown(self: *Terminal, count_req: usize) void { + // Always resets pending wrap + self.screen.cursor.pending_wrap = false; - switch (c) { - 0xFE0F => wide: { - if (prev.cell.attrs.wide) break :wide; + // The max the cursor can move to depends where the cursor currently is + const max = if (self.screen.cursor.y <= self.scrolling_region.bottom) + self.scrolling_region.bottom - self.screen.cursor.y + else + self.rows - self.screen.cursor.y - 1; + const count = @min(max, @max(count_req, 1)); + self.screen.cursorDown(@intCast(count)); +} - // Move our cursor back to the previous. We'll move - // the cursor within this block to the proper location. - self.screen.cursor.x = prev.x; +/// Move the cursor right amount columns. If amount is greater than the +/// maximum move distance then it is internally adjusted to the maximum. +/// This sequence will not scroll the screen or scroll region. If amount is +/// 0, adjust it to 1. +pub fn cursorRight(self: *Terminal, count_req: usize) void { + // Always resets pending wrap + self.screen.cursor.pending_wrap = false; - // If we don't have space for the wide char, we need - // to insert spacers and wrap. Then we just print the wide - // char as normal. - if (prev.x == right_limit - 1) { - if (!self.modes.get(.wraparound)) return; - const spacer_head = self.printCell(' '); - spacer_head.attrs.wide_spacer_head = true; - try self.printWrap(); - } + // The max the cursor can move to depends where the cursor currently is + const max = if (self.screen.cursor.x <= self.scrolling_region.right) + self.scrolling_region.right - self.screen.cursor.x + else + self.cols - self.screen.cursor.x - 1; + const count = @min(max, @max(count_req, 1)); + self.screen.cursorRight(@intCast(count)); +} - const wide_cell = self.printCell(@intCast(prev.cell.char)); - wide_cell.attrs.wide = true; +/// Move the cursor to the left amount cells. If amount is 0, adjust it to 1. +pub fn cursorLeft(self: *Terminal, count_req: usize) void { + // Wrapping behavior depends on various terminal modes + const WrapMode = enum { none, reverse, reverse_extended }; + const wrap_mode: WrapMode = wrap_mode: { + if (!self.modes.get(.wraparound)) break :wrap_mode .none; + if (self.modes.get(.reverse_wrap_extended)) break :wrap_mode .reverse_extended; + if (self.modes.get(.reverse_wrap)) break :wrap_mode .reverse; + break :wrap_mode .none; + }; - // Write our spacer - self.screen.cursor.x += 1; - const spacer = self.printCell(' '); - spacer.attrs.wide_spacer_tail = true; + var count = @max(count_req, 1); - // Move the cursor again so we're beyond our spacer - self.screen.cursor.x += 1; - if (self.screen.cursor.x == right_limit) { - self.screen.cursor.x -= 1; - self.screen.cursor.pending_wrap = true; - } - }, + // If we are in no wrap mode, then we move the cursor left and exit + // since this is the fastest and most typical path. + if (wrap_mode == .none) { + self.screen.cursorLeft(@min(count, self.screen.cursor.x)); + self.screen.cursor.pending_wrap = false; + return; + } - 0xFE0E => narrow: { - // Prev cell is no longer wide - if (!prev.cell.attrs.wide) break :narrow; - prev.cell.attrs.wide = false; + // If we have a pending wrap state and we are in either reverse wrap + // modes then we decrement the amount we move by one to match xterm. + if (self.screen.cursor.pending_wrap) { + count -= 1; + self.screen.cursor.pending_wrap = false; + } - // Remove the wide spacer tail - const cell = row.getCellPtr(prev.x + 1); - cell.attrs.wide_spacer_tail = false; + // The margins we can move to. + const top = self.scrolling_region.top; + const bottom = self.scrolling_region.bottom; + const right_margin = self.scrolling_region.right; + const left_margin = if (self.screen.cursor.x < self.scrolling_region.left) + 0 + else + self.scrolling_region.left; - break :narrow; - }, + // Handle some edge cases when our cursor is already on the left margin. + if (self.screen.cursor.x == left_margin) { + switch (wrap_mode) { + // In reverse mode, if we're already before the top margin + // then we just set our cursor to the top-left and we're done. + .reverse => if (self.screen.cursor.y <= top) { + self.screen.cursorAbsolute(left_margin, top); + return; + }, - else => unreachable, - } - } + // Handled in while loop + .reverse_extended => {}, - log.debug("c={x} grapheme attach to x={}", .{ c, prev.x }); - try row.attachGrapheme(prev.x, c); - return; + // Handled above + .none => unreachable, } } - // Determine the width of this character so we can handle - // non-single-width characters properly. We have a fast-path for - // byte-sized characters since they're so common. We can ignore - // control characters because they're always filtered prior. - const width: usize = if (c <= 0xFF) 1 else @intCast(unicode.table.get(c).width); + while (true) { + // We can move at most to the left margin. + const max = self.screen.cursor.x - left_margin; - // Note: it is possible to have a width of "3" and a width of "-1" - // from ziglyph. We should look into those cases and handle them - // appropriately. - assert(width <= 2); - // log.debug("c={x} width={}", .{ c, width }); + // We want to move at most the number of columns we have left + // or our remaining count. Do the move. + const amount = @min(max, count); + count -= amount; + self.screen.cursorLeft(amount); - // Attach zero-width characters to our cell as grapheme data. - if (width == 0) { - // If we have grapheme clustering enabled, we don't blindly attach - // any zero width character to our cells and we instead just ignore - // it. - if (self.modes.get(.grapheme_cluster)) return; + // If we have no more to move, then we're done. + if (count == 0) break; - // If we're at cell zero, then this is malformed data and we don't - // print anything or even store this. Zero-width characters are ALWAYS - // attached to some other non-zero-width character at the time of - // writing. - if (self.screen.cursor.x == 0) { - log.warn("zero-width character with no prior character, ignoring", .{}); - return; + // If we are at the top, then we are done. + if (self.screen.cursor.y == top) { + if (wrap_mode != .reverse_extended) break; + + self.screen.cursorAbsolute(right_margin, bottom); + count -= 1; + continue; } - // Find our previous cell - const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); - const prev: usize = prev: { - const x = self.screen.cursor.x - 1; - const immediate = row.getCellPtr(x); - if (!immediate.attrs.wide_spacer_tail) break :prev x; - break :prev x - 1; - }; + // UNDEFINED TERMINAL BEHAVIOR. This situation is not handled in xterm + // and currently results in a crash in xterm. Given no other known + // terminal [to me] implements XTREVWRAP2, I decided to just mimick + // the behavior of xterm up and not including the crash by wrapping + // up to the (0, 0) and stopping there. My reasoning is that for an + // appropriately sized value of "count" this is the behavior that xterm + // would have. This is unit tested. + if (self.screen.cursor.y == 0) { + assert(self.screen.cursor.x == left_margin); + break; + } - // If this is a emoji variation selector, prev must be an emoji - if (c == 0xFE0F or c == 0xFE0E) { - const prev_cell = row.getCellPtr(prev); - const prev_props = unicode.getProperties(@intCast(prev_cell.char)); - const emoji = prev_props.grapheme_boundary_class == .extended_pictographic; - if (!emoji) return; + // If our previous line is not wrapped then we are done. + if (wrap_mode != .reverse_extended) { + const prev_row = self.screen.cursorRowUp(1); + if (!prev_row.wrap) break; } - try row.attachGrapheme(prev, c); - return; + self.screen.cursorAbsolute(right_margin, self.screen.cursor.y - 1); + count -= 1; } +} - // We have a printable character, save it - self.previous_char = c; - - // If we're soft-wrapping, then handle that first. - if (self.screen.cursor.pending_wrap and self.modes.get(.wraparound)) - try self.printWrap(); +/// Save cursor position and further state. +/// +/// The primary and alternate screen have distinct save state. One saved state +/// is kept per screen (main / alternative). If for the current screen state +/// was already saved it is overwritten. +pub fn saveCursor(self: *Terminal) void { + self.screen.saved_cursor = .{ + .x = self.screen.cursor.x, + .y = self.screen.cursor.y, + .style = self.screen.cursor.style, + .protected = self.screen.cursor.protected, + .pending_wrap = self.screen.cursor.pending_wrap, + .origin = self.modes.get(.origin), + .charset = self.screen.charset, + }; +} - // If we have insert mode enabled then we need to handle that. We - // only do insert mode if we're not at the end of the line. - if (self.modes.get(.insert) and - self.screen.cursor.x + width < self.cols) - { - self.insertBlanks(width); - } +/// Restore cursor position and other state. +/// +/// The primary and alternate screen have distinct save state. +/// If no save was done before values are reset to their initial values. +pub fn restoreCursor(self: *Terminal) !void { + const saved: Screen.SavedCursor = self.screen.saved_cursor orelse .{ + .x = 0, + .y = 0, + .style = .{}, + .protected = false, + .pending_wrap = false, + .origin = false, + .charset = .{}, + }; - switch (width) { - // Single cell is very easy: just write in the cell - 1 => _ = @call(.always_inline, printCell, .{ self, c }), + // Set the style first because it can fail + const old_style = self.screen.cursor.style; + self.screen.cursor.style = saved.style; + errdefer self.screen.cursor.style = old_style; + try self.screen.manualStyleUpdate(); - // Wide character requires a spacer. We print this by - // using two cells: the first is flagged "wide" and has the - // wide char. The second is guaranteed to be a spacer if - // we're not at the end of the line. - 2 => if ((right_limit - self.scrolling_region.left) > 1) { - // If we don't have space for the wide char, we need - // to insert spacers and wrap. Then we just print the wide - // char as normal. - if (self.screen.cursor.x == right_limit - 1) { - // If we don't have wraparound enabled then we don't print - // this character at all and don't move the cursor. This is - // how xterm behaves. - if (!self.modes.get(.wraparound)) return; + self.screen.charset = saved.charset; + self.modes.set(.origin, saved.origin); + self.screen.cursor.pending_wrap = saved.pending_wrap; + self.screen.cursor.protected = saved.protected; + self.screen.cursorAbsolute( + @min(saved.x, self.cols - 1), + @min(saved.y, self.rows - 1), + ); - const spacer_head = self.printCell(' '); - spacer_head.attrs.wide_spacer_head = true; - try self.printWrap(); - } + // Ensure our screen is consistent + self.screen.assertIntegrity(); +} - const wide_cell = self.printCell(c); - wide_cell.attrs.wide = true; +/// Set the character protection mode for the terminal. +pub fn setProtectedMode(self: *Terminal, mode: ansi.ProtectedMode) void { + switch (mode) { + .off => { + self.screen.cursor.protected = false; - // Write our spacer - self.screen.cursor.x += 1; - const spacer = self.printCell(' '); - spacer.attrs.wide_spacer_tail = true; - } else { - // This is pretty broken, terminals should never be only 1-wide. - // We sould prevent this downstream. - _ = self.printCell(' '); + // screen.protected_mode is NEVER reset to ".off" because + // logic such as eraseChars depends on knowing what the + // _most recent_ mode was. }, - else => unreachable, - } - - // Move the cursor - self.screen.cursor.x += 1; + .iso => { + self.screen.cursor.protected = true; + self.screen.protected_mode = .iso; + }, - // If we're at the column limit, then we need to wrap the next time. - // This is unlikely so we do the increment above and decrement here - // if we need to rather than check once. - if (self.screen.cursor.x == right_limit) { - self.screen.cursor.x -= 1; - self.screen.cursor.pending_wrap = true; + .dec => { + self.screen.cursor.protected = true; + self.screen.protected_mode = .dec; + }, } } -fn printCell(self: *Terminal, unmapped_c: u21) *Screen.Cell { - const c: u21 = c: { - // TODO: non-utf8 handling, gr - - // If we're single shifting, then we use the key exactly once. - const key = if (self.screen.charset.single_shift) |key_once| blk: { - self.screen.charset.single_shift = null; - break :blk key_once; - } else self.screen.charset.gl; - const set = self.screen.charset.charsets.get(key); - - // UTF-8 or ASCII is used as-is - if (set == .utf8 or set == .ascii) break :c unmapped_c; - - // If we're outside of ASCII range this is an invalid value in - // this table so we just return space. - if (unmapped_c > std.math.maxInt(u8)) break :c ' '; +/// The semantic prompt type. This is used when tracking a line type and +/// requires integration with the shell. By default, we mark a line as "none" +/// meaning we don't know what type it is. +/// +/// See: https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md +pub const SemanticPrompt = enum { + prompt, + prompt_continuation, + input, + command, +}; - // Get our lookup table and map it - const table = set.table(); - break :c @intCast(table[@intCast(unmapped_c)]); +/// Mark the current semantic prompt information. Current escape sequences +/// (OSC 133) only allow setting this for wherever the current active cursor +/// is located. +pub fn markSemanticPrompt(self: *Terminal, p: SemanticPrompt) void { + //log.debug("semantic_prompt y={} p={}", .{ self.screen.cursor.y, p }); + self.screen.cursor.page_row.semantic_prompt = switch (p) { + .prompt => .prompt, + .prompt_continuation => .prompt_continuation, + .input => .input, + .command => .command, }; - - const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); - const cell = row.getCellPtr(self.screen.cursor.x); - - // If this cell is wide char then we need to clear it. - // We ignore wide spacer HEADS because we can just write - // single-width characters into that. - if (cell.attrs.wide) { - const x = self.screen.cursor.x + 1; - if (x < self.cols) { - const spacer_cell = row.getCellPtr(x); - spacer_cell.* = self.screen.cursor.pen; - } - - if (self.screen.cursor.y > 0 and self.screen.cursor.x <= 1) { - self.clearWideSpacerHead(); - } - } else if (cell.attrs.wide_spacer_tail) { - assert(self.screen.cursor.x > 0); - const x = self.screen.cursor.x - 1; - - const wide_cell = row.getCellPtr(x); - wide_cell.* = self.screen.cursor.pen; - - if (self.screen.cursor.y > 0 and self.screen.cursor.x <= 1) { - self.clearWideSpacerHead(); - } - } - - // If the prior value had graphemes, clear those - if (cell.attrs.grapheme) row.clearGraphemes(self.screen.cursor.x); - - // Write - cell.* = self.screen.cursor.pen; - cell.char = @intCast(c); - return cell; } -fn printWrap(self: *Terminal) !void { - const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); - row.setWrapped(true); - - // Get the old semantic prompt so we can extend it to the next - // line. We need to do this before we index() because we may - // modify memory. - const old_prompt = row.getSemanticPrompt(); +/// Returns true if the cursor is currently at a prompt. Another way to look +/// at this is it returns false if the shell is currently outputting something. +/// This requires shell integration (semantic prompt integration). +/// +/// If the shell integration doesn't exist, this will always return false. +pub fn cursorIsAtPrompt(self: *Terminal) bool { + // If we're on the secondary screen, we're never at a prompt. + if (self.active_screen == .alternate) return false; - // Move to the next line - try self.index(); - self.screen.cursor.x = self.scrolling_region.left; + // Reverse through the active + const start_x, const start_y = .{ self.screen.cursor.x, self.screen.cursor.y }; + defer self.screen.cursorAbsolute(start_x, start_y); - // New line must inherit semantic prompt of the old line - const new_row = self.screen.getRow(.{ .active = self.screen.cursor.y }); - new_row.setSemanticPrompt(old_prompt); -} + for (0..start_y + 1) |i| { + if (i > 0) self.screen.cursorUp(1); + switch (self.screen.cursor.page_row.semantic_prompt) { + // If we're at a prompt or input area, then we are at a prompt. + .prompt, + .prompt_continuation, + .input, + => return true, -fn clearWideSpacerHead(self: *Terminal) void { - // TODO: handle deleting wide char on row 0 of active - assert(self.screen.cursor.y >= 1); - const cell = self.screen.getCellPtr( - .active, - self.screen.cursor.y - 1, - self.cols - 1, - ); - cell.attrs.wide_spacer_head = false; -} + // If we have command output, then we're most certainly not + // at a prompt. + .command => return false, -/// Print the previous printed character a repeated amount of times. -pub fn printRepeat(self: *Terminal, count_req: usize) !void { - if (self.previous_char) |c| { - const count = @max(count_req, 1); - for (0..count) |_| try self.print(c); + // If we don't know, we keep searching. + .unknown => {}, + } } + + return false; } -/// Resets all margins and fills the whole screen with the character 'E' -/// -/// Sets the cursor to the top left corner. -pub fn decaln(self: *Terminal) !void { - // Reset margins, also sets cursor to top-left - self.scrolling_region = .{ - .top = 0, - .bottom = self.rows - 1, - .left = 0, - .right = self.cols - 1, - }; +/// Horizontal tab moves the cursor to the next tabstop, clearing +/// the screen to the left the tabstop. +pub fn horizontalTab(self: *Terminal) !void { + while (self.screen.cursor.x < self.scrolling_region.right) { + // Move the cursor right + self.screen.cursorRight(1); - // Origin mode is disabled - self.modes.set(.origin, false); + // If the last cursor position was a tabstop we return. We do + // "last cursor position" because we want a space to be written + // at the tabstop unless we're at the end (the while condition). + if (self.tabstops.get(self.screen.cursor.x)) return; + } +} - // Move our cursor to the top-left - self.setCursorPos(1, 1); +// Same as horizontalTab but moves to the previous tabstop instead of the next. +pub fn horizontalTabBack(self: *Terminal) !void { + // With origin mode enabled, our leftmost limit is the left margin. + const left_limit = if (self.modes.get(.origin)) self.scrolling_region.left else 0; - // Clear our stylistic attributes - self.screen.cursor.pen = .{ - .bg = self.screen.cursor.pen.bg, - .fg = self.screen.cursor.pen.fg, - .attrs = .{ - .protected = self.screen.cursor.pen.attrs.protected, - }, - }; + while (true) { + // If we're already at the edge of the screen, then we're done. + if (self.screen.cursor.x <= left_limit) return; - // Our pen has the letter E - const pen: Screen.Cell = .{ .char = 'E' }; + // Move the cursor left + self.screen.cursorLeft(1); + if (self.tabstops.get(self.screen.cursor.x)) return; + } +} - // Fill with Es, does not move cursor. - for (0..self.rows) |y| { - const filled = self.screen.getRow(.{ .active = y }); - filled.fill(pen); +/// Clear tab stops. +pub fn tabClear(self: *Terminal, cmd: csi.TabClear) void { + switch (cmd) { + .current => self.tabstops.unset(self.screen.cursor.x), + .all => self.tabstops.reset(0), + else => log.warn("invalid or unknown tab clear setting: {}", .{cmd}), } } +/// Set a tab stop on the current cursor. +/// TODO: test +pub fn tabSet(self: *Terminal) void { + self.tabstops.set(self.screen.cursor.x); +} + +/// TODO: test +pub fn tabReset(self: *Terminal) void { + self.tabstops.reset(TABSTOP_INTERVAL); +} + /// Move the cursor to the next line in the scrolling region, possibly scrolling. /// /// If the cursor is outside of the scrolling region: move the cursor one line @@ -1137,7 +1059,12 @@ pub fn index(self: *Terminal) !void { if (self.screen.cursor.y < self.scrolling_region.top or self.screen.cursor.y > self.scrolling_region.bottom) { - self.screen.cursor.y = @min(self.screen.cursor.y + 1, self.rows - 1); + // We only move down if we're not already at the bottom of + // the screen. + if (self.screen.cursor.y < self.rows - 1) { + self.screen.cursorDown(1); + } + return; } @@ -1155,16 +1082,49 @@ pub fn index(self: *Terminal) !void { self.scrolling_region.left == 0 and self.scrolling_region.right == self.cols - 1) { - try self.screen.scroll(.{ .screen = 1 }); + try self.screen.cursorDownScroll(); } else { - try self.scrollUp(1); + // Slow path for left and right scrolling region margins. + if (self.scrolling_region.left != 0 or + self.scrolling_region.right != self.cols - 1) + { + self.scrollUp(1); + return; + } + + // Otherwise use a fast path function from PageList to efficiently + // scroll the contents of the scrolling region. + + // eraseRow and eraseRowBounded will end up moving the cursor pin + // up by 1, so we save its current position and restore it after. + const cursor_x = self.screen.cursor.x; + const cursor_y = self.screen.cursor.y; + defer { + self.screen.cursorAbsolute(cursor_x, cursor_y); + } + + try self.screen.pages.eraseRowBounded( + .{ .active = .{ .y = self.scrolling_region.top } }, + self.scrolling_region.bottom - self.scrolling_region.top + ); + + // The operations above can prune our cursor style so we need to + // update. This should never fail because the above can only FREE + // memory. + self.screen.manualStyleUpdate() catch |err| { + std.log.warn("deleteLines manualStyleUpdate err={}", .{err}); + self.screen.cursor.style = .{}; + self.screen.manualStyleUpdate() catch unreachable; + }; } return; } // Increase cursor by 1, maximum to bottom of scroll region - self.screen.cursor.y = @min(self.screen.cursor.y + 1, self.scrolling_region.bottom); + if (self.screen.cursor.y < self.scrolling_region.bottom) { + self.screen.cursorDown(1); + } } /// Move the cursor to the previous line in the scrolling region, possibly @@ -1179,7 +1139,7 @@ pub fn index(self: *Terminal) !void { /// invoke scroll down with amount=1 /// * If the cursor is not on the top-most line of the scrolling region: /// move the cursor one line up -pub fn reverseIndex(self: *Terminal) !void { +pub fn reverseIndex(self: *Terminal) void { if (self.screen.cursor.y != self.scrolling_region.top or self.screen.cursor.x < self.scrolling_region.left or self.screen.cursor.x > self.scrolling_region.right) @@ -1188,7 +1148,7 @@ pub fn reverseIndex(self: *Terminal) !void { return; } - try self.scrollDown(1); + self.scrollDown(1); } // Set Cursor Position. Move cursor to the position indicated @@ -1206,10 +1166,10 @@ pub fn setCursorPos(self: *Terminal, row_req: usize, col_req: usize) void { // will be moved relative to the left margin column and adjusted to be on // or left of the right margin column. const params: struct { - x_offset: usize = 0, - y_offset: usize = 0, - x_max: usize, - y_max: usize, + x_offset: size.CellCountInt = 0, + y_offset: size.CellCountInt = 0, + x_max: size.CellCountInt, + y_max: size.CellCountInt, } = if (self.modes.get(.origin)) .{ .x_offset = self.scrolling_region.left, .y_offset = self.scrolling_region.top, @@ -1220,241 +1180,481 @@ pub fn setCursorPos(self: *Terminal, row_req: usize, col_req: usize) void { .y_max = self.rows, }; + // Unset pending wrap state + self.screen.cursor.pending_wrap = false; + + // Calculate our new x/y const row = if (row_req == 0) 1 else row_req; const col = if (col_req == 0) 1 else col_req; - self.screen.cursor.x = @min(params.x_max, col + params.x_offset) -| 1; - self.screen.cursor.y = @min(params.y_max, row + params.y_offset) -| 1; + const x = @min(params.x_max, col + params.x_offset) -| 1; + const y = @min(params.y_max, row + params.y_offset) -| 1; + + // If the y is unchanged then this is fast pointer math + if (y == self.screen.cursor.y) { + if (x > self.screen.cursor.x) { + self.screen.cursorRight(x - self.screen.cursor.x); + } else { + self.screen.cursorLeft(self.screen.cursor.x - x); + } + + return; + } + + // If everything changed we do an absolute change which is slightly slower + self.screen.cursorAbsolute(x, y); // log.info("set cursor position: col={} row={}", .{ self.screen.cursor.x, self.screen.cursor.y }); +} - // Unset pending wrap state - self.screen.cursor.pending_wrap = false; +/// Set Top and Bottom Margins If bottom is not specified, 0 or bigger than +/// the number of the bottom-most row, it is adjusted to the number of the +/// bottom most row. +/// +/// If top < bottom set the top and bottom row of the scroll region according +/// to top and bottom and move the cursor to the top-left cell of the display +/// (when in cursor origin mode is set to the top-left cell of the scroll region). +/// +/// Otherwise: Set the top and bottom row of the scroll region to the top-most +/// and bottom-most line of the screen. +/// +/// Top and bottom are 1-indexed. +pub fn setTopAndBottomMargin(self: *Terminal, top_req: usize, bottom_req: usize) void { + const top = @max(1, top_req); + const bottom = @min(self.rows, if (bottom_req == 0) self.rows else bottom_req); + if (top >= bottom) return; + + self.scrolling_region.top = @intCast(top - 1); + self.scrolling_region.bottom = @intCast(bottom - 1); + self.setCursorPos(1, 1); } -/// Erase the display. -pub fn eraseDisplay( - self: *Terminal, - alloc: Allocator, - mode: csi.EraseDisplay, - protected_req: bool, -) void { - // Erasing clears all attributes / colors _except_ the background - const pen: Screen.Cell = switch (self.screen.cursor.pen.bg) { - .none => .{}, - else => |bg| .{ .bg = bg }, - }; +/// DECSLRM +pub fn setLeftAndRightMargin(self: *Terminal, left_req: usize, right_req: usize) void { + // We must have this mode enabled to do anything + if (!self.modes.get(.enable_left_and_right_margin)) return; - // We respect protected attributes if explicitly requested (probably - // a DECSEL sequence) or if our last protected mode was ISO even if its - // not currently set. - const protected = self.screen.protected_mode == .iso or protected_req; + const left = @max(1, left_req); + const right = @min(self.cols, if (right_req == 0) self.cols else right_req); + if (left >= right) return; - switch (mode) { - .scroll_complete => { - self.screen.scroll(.{ .clear = {} }) catch |err| { - log.warn("scroll clear failed, doing a normal clear err={}", .{err}); - self.eraseDisplay(alloc, .complete, protected_req); - return; - }; + self.scrolling_region.left = @intCast(left - 1); + self.scrolling_region.right = @intCast(right - 1); + self.setCursorPos(1, 1); +} - // Unsets pending wrap state - self.screen.cursor.pending_wrap = false; +/// Scroll the text down by one row. +pub fn scrollDown(self: *Terminal, count: usize) void { + // Preserve our x/y to restore. + const old_x = self.screen.cursor.x; + const old_y = self.screen.cursor.y; + const old_wrap = self.screen.cursor.pending_wrap; + defer { + self.screen.cursorAbsolute(old_x, old_y); + self.screen.cursor.pending_wrap = old_wrap; + } - // Clear all Kitty graphics state for this screen - self.screen.kitty_images.delete(alloc, self, .{ .all = true }); - }, + // Move to the top of the scroll region + self.screen.cursorAbsolute(self.scrolling_region.left, self.scrolling_region.top); + self.insertLines(count); +} - .complete => { - // If we're on the primary screen and our last non-empty row is - // a prompt, then we do a scroll_complete instead. This is a - // heuristic to get the generally desirable behavior that ^L - // at a prompt scrolls the screen contents prior to clearing. - // Most shells send `ESC [ H ESC [ 2 J` so we can't just check - // our current cursor position. See #905 - if (self.active_screen == .primary) at_prompt: { - // Go from the bottom of the viewport up and see if we're - // at a prompt. - const viewport_max = Screen.RowIndexTag.viewport.maxLen(&self.screen); - for (0..viewport_max) |y| { - const bottom_y = viewport_max - y - 1; - const row = self.screen.getRow(.{ .viewport = bottom_y }); - if (row.isEmpty()) continue; - switch (row.getSemanticPrompt()) { - // If we're at a prompt or input area, then we are at a prompt. - .prompt, - .prompt_continuation, - .input, - => break, +/// Removes amount lines from the top of the scroll region. The remaining lines +/// to the bottom margin are shifted up and space from the bottom margin up +/// is filled with empty lines. +/// +/// The new lines are created according to the current SGR state. +/// +/// Does not change the (absolute) cursor position. +pub fn scrollUp(self: *Terminal, count: usize) void { + // Preserve our x/y to restore. + const old_x = self.screen.cursor.x; + const old_y = self.screen.cursor.y; + const old_wrap = self.screen.cursor.pending_wrap; + defer { + self.screen.cursorAbsolute(old_x, old_y); + self.screen.cursor.pending_wrap = old_wrap; + } - // If we have command output, then we're most certainly not - // at a prompt. - .command => break :at_prompt, + // Move to the top of the scroll region + self.screen.cursorAbsolute(self.scrolling_region.left, self.scrolling_region.top); + self.deleteLines(count); +} - // If we don't know, we keep searching. - .unknown => {}, - } - } else break :at_prompt; +/// Options for scrolling the viewport of the terminal grid. +pub const ScrollViewport = union(enum) { + /// Scroll to the top of the scrollback + top: void, - self.screen.scroll(.{ .clear = {} }) catch { - // If we fail, we just fall back to doing a normal clear - // so we don't worry about the error. + /// Scroll to the bottom, i.e. the top of the active area + bottom: void, + + /// Scroll by some delta amount, up is negative. + delta: isize, +}; + +/// Scroll the viewport of the terminal grid. +pub fn scrollViewport(self: *Terminal, behavior: ScrollViewport) !void { + self.screen.scroll(switch (behavior) { + .top => .{ .top = {} }, + .bottom => .{ .active = {} }, + .delta => |delta| .{ .delta_row = delta }, + }); +} + +/// Insert amount lines at the current cursor row. The contents of the line +/// at the current cursor row and below (to the bottom-most line in the +/// scrolling region) are shifted down by amount lines. The contents of the +/// amount bottom-most lines in the scroll region are lost. +/// +/// This unsets the pending wrap state without wrapping. If the current cursor +/// position is outside of the current scroll region it does nothing. +/// +/// If amount is greater than the remaining number of lines in the scrolling +/// region it is adjusted down (still allowing for scrolling out every remaining +/// line in the scrolling region) +/// +/// In left and right margin mode the margins are respected; lines are only +/// scrolled in the scroll region. +/// +/// All cleared space is colored according to the current SGR state. +/// +/// Moves the cursor to the left margin. +pub fn insertLines(self: *Terminal, count: usize) void { + // Rare, but happens + if (count == 0) return; + + // If the cursor is outside the scroll region we do nothing. + if (self.screen.cursor.y < self.scrolling_region.top or + self.screen.cursor.y > self.scrolling_region.bottom or + self.screen.cursor.x < self.scrolling_region.left or + self.screen.cursor.x > self.scrolling_region.right) return; + + // Remaining rows from our cursor to the bottom of the scroll region. + const rem = self.scrolling_region.bottom - self.screen.cursor.y + 1; + + // We can only insert lines up to our remaining lines in the scroll + // region. So we take whichever is smaller. + const adjusted_count = @min(count, rem); + + // top is just the cursor position. insertLines starts at the cursor + // so this is our top. We want to shift lines down, down to the bottom + // of the scroll region. + const top = self.screen.cursor.page_pin.*; + + // This is the amount of space at the bottom of the scroll region + // that will NOT be blank, so we need to shift the correct lines down. + // "scroll_amount" is the number of such lines. + const scroll_amount = rem - adjusted_count; + if (scroll_amount > 0) { + // If we have left/right scroll margins we have a slower path. + const left_right = self.scrolling_region.left > 0 or + self.scrolling_region.right < self.cols - 1; + + const bot = top.down(scroll_amount - 1).?; + var it = bot.rowIterator(.left_up, top); + while (it.next()) |p| { + const dst_p = p.down(adjusted_count).?; + const src: *Row = p.rowAndCell().row; + const dst: *Row = dst_p.rowAndCell().row; + + // If our page doesn't match, then we need to do a copy from + // one page to another. This is the slow path. + if (dst_p.page != p.page) { + dst_p.page.data.clonePartialRowFrom( + &p.page.data, + dst, + src, + self.scrolling_region.left, + self.scrolling_region.right + 1, + ) catch |err| { + std.log.warn("TODO: insertLines handle clone error err={}", .{err}); + @panic("TODO"); }; + + // Row never is wrapped if we're full width. + if (!left_right) dst.wrap = false; + + continue; } - var it = self.screen.rowIterator(.active); - while (it.next()) |row| { - row.setWrapped(false); - row.setDirty(true); + if (!left_right) { + // Swap the src/dst cells. This ensures that our dst gets the proper + // shifted rows and src gets non-garbage cell data that we can clear. + const dst_row = dst.*; + dst.* = src.*; + src.* = dst_row; - if (!protected) { - row.clear(pen); - continue; - } + // Row never is wrapped + dst.wrap = false; + src.wrap = false; - // Protected mode erase - for (0..row.lenCells()) |x| { - const cell = row.getCellPtr(x); - if (cell.attrs.protected) continue; - cell.* = pen; - } + // Ensure what we did didn't corrupt the page + p.page.data.assertIntegrity(); + continue; } - // Unsets pending wrap state - self.screen.cursor.pending_wrap = false; + // Left/right scroll margins we have to copy cells, which is much slower... + const page = &p.page.data; + page.moveCells( + src, + self.scrolling_region.left, + dst, + self.scrolling_region.left, + (self.scrolling_region.right - self.scrolling_region.left) + 1, + ); + + // Row never is wrapped + dst.wrap = false; + } - // Clear all Kitty graphics state for this screen - self.screen.kitty_images.delete(alloc, self, .{ .all = true }); - }, + // The operations above can prune our cursor style so we need to + // update. This should never fail because the above can only FREE + // memory. + self.screen.manualStyleUpdate() catch |err| { + std.log.warn("deleteLines manualStyleUpdate err={}", .{err}); + self.screen.cursor.style = .{}; + self.screen.manualStyleUpdate() catch unreachable; + }; + } - .below => { - // All lines to the right (including the cursor) - { - self.eraseLine(.right, protected_req); - const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); - row.setWrapped(false); - row.setDirty(true); - } + // Inserted lines should keep our bg color + const bot = top.down(adjusted_count - 1).?; + var it = top.rowIterator(.right_down, bot); + while (it.next()) |p| { + const row: *Row = p.rowAndCell().row; - // All lines below - for ((self.screen.cursor.y + 1)..self.rows) |y| { - const row = self.screen.getRow(.{ .active = y }); - row.setWrapped(false); - row.setDirty(true); - for (0..self.cols) |x| { - if (row.header().flags.grapheme) row.clearGraphemes(x); - const cell = row.getCellPtr(x); - if (protected and cell.attrs.protected) continue; - cell.* = pen; - cell.char = 0; - } + // Clear the src row. + const page = &p.page.data; + const cells = page.getCells(row); + const cells_write = cells[self.scrolling_region.left .. self.scrolling_region.right + 1]; + self.screen.clearCells(page, row, cells_write); + } + + // Move the cursor to the left margin. But importantly this also + // forces screen.cursor.page_cell to reload because the rows above + // shifted cell ofsets so this will ensure the cursor is pointing + // to the correct cell. + self.screen.cursorAbsolute( + self.scrolling_region.left, + self.screen.cursor.y, + ); + + // Always unset pending wrap + self.screen.cursor.pending_wrap = false; +} + +/// Removes amount lines from the current cursor row down. The remaining lines +/// to the bottom margin are shifted up and space from the bottom margin up is +/// filled with empty lines. +/// +/// If the current cursor position is outside of the current scroll region it +/// does nothing. If amount is greater than the remaining number of lines in the +/// scrolling region it is adjusted down. +/// +/// In left and right margin mode the margins are respected; lines are only +/// scrolled in the scroll region. +/// +/// If the cell movement splits a multi cell character that character cleared, +/// by replacing it by spaces, keeping its current attributes. All other +/// cleared space is colored according to the current SGR state. +/// +/// Moves the cursor to the left margin. +pub fn deleteLines(self: *Terminal, count_req: usize) void { + // Rare, but happens + if (count_req == 0) return; + + // If the cursor is outside the scroll region we do nothing. + if (self.screen.cursor.y < self.scrolling_region.top or + self.screen.cursor.y > self.scrolling_region.bottom or + self.screen.cursor.x < self.scrolling_region.left or + self.screen.cursor.x > self.scrolling_region.right) return; + + // top is just the cursor position. insertLines starts at the cursor + // so this is our top. We want to shift lines down, down to the bottom + // of the scroll region. + const top = self.screen.cursor.page_pin.*; + + // Remaining rows from our cursor to the bottom of the scroll region. + const rem = self.scrolling_region.bottom - self.screen.cursor.y + 1; + + // The maximum we can delete is the remaining lines in the scroll region. + const count = @min(count_req, rem); + + // This is the amount of space at the bottom of the scroll region + // that will NOT be blank, so we need to shift the correct lines down. + // "scroll_amount" is the number of such lines. + const scroll_amount = rem - count; + if (scroll_amount > 0) { + // If we have left/right scroll margins we have a slower path. + const left_right = self.scrolling_region.left > 0 or + self.scrolling_region.right < self.cols - 1; + + const bot = top.down(scroll_amount - 1).?; + var it = top.rowIterator(.right_down, bot); + while (it.next()) |p| { + const src_p = p.down(count).?; + const src: *Row = src_p.rowAndCell().row; + const dst: *Row = p.rowAndCell().row; + + if (src_p.page != p.page) { + p.page.data.clonePartialRowFrom( + &src_p.page.data, + dst, + src, + self.scrolling_region.left, + self.scrolling_region.right + 1, + ) catch |err| { + std.log.warn("TODO: deleteLines handle clone error err={}", .{err}); + @panic("TODO"); + }; + + // Row never is wrapped if we're full width. + if (!left_right) dst.wrap = false; + + continue; } - // Unsets pending wrap state - self.screen.cursor.pending_wrap = false; - }, + if (!left_right) { + // Swap the src/dst cells. This ensures that our dst gets the proper + // shifted rows and src gets non-garbage cell data that we can clear. + const dst_row = dst.*; + dst.* = src.*; + src.* = dst_row; - .above => { - // Erase to the left (including the cursor) - self.eraseLine(.left, protected_req); + // Row never is wrapped + dst.wrap = false; - // All lines above - var y: usize = 0; - while (y < self.screen.cursor.y) : (y += 1) { - var x: usize = 0; - while (x < self.cols) : (x += 1) { - const cell = self.screen.getCellPtr(.active, y, x); - if (protected and cell.attrs.protected) continue; - cell.* = pen; - cell.char = 0; - } + // Ensure what we did didn't corrupt the page + p.page.data.assertIntegrity(); + continue; } - // Unsets pending wrap state - self.screen.cursor.pending_wrap = false; - }, + // Left/right scroll margins we have to copy cells, which is much slower... + const page = &p.page.data; + page.moveCells( + src, + self.scrolling_region.left, + dst, + self.scrolling_region.left, + (self.scrolling_region.right - self.scrolling_region.left) + 1, + ); + + // Row never is wrapped + dst.wrap = false; + } - .scrollback => self.screen.clear(.history) catch |err| { - // This isn't a huge issue, so just log it. - log.err("failed to clear scrollback: {}", .{err}); - }, + // The operations above can prune our cursor style so we need to + // update. This should never fail because the above can only FREE + // memory. + self.screen.manualStyleUpdate() catch |err| { + std.log.warn("deleteLines manualStyleUpdate err={}", .{err}); + self.screen.cursor.style = .{}; + self.screen.manualStyleUpdate() catch unreachable; + }; } -} -/// Erase the line. -pub fn eraseLine( - self: *Terminal, - mode: csi.EraseLine, - protected_req: bool, -) void { - // We always fill with the background - const pen: Screen.Cell = switch (self.screen.cursor.pen.bg) { - .none => .{}, - else => |bg| .{ .bg = bg }, - }; + const clear_top = top.down(scroll_amount).?; + const bot = top.down(rem - 1).?; + var it = clear_top.rowIterator(.right_down, bot); + while (it.next()) |p| { + const row: *Row = p.rowAndCell().row; - // Get our start/end positions depending on mode. - const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); - const start, const end = switch (mode) { - .right => right: { - var x = self.screen.cursor.x; + // Clear the src row. + const page = &p.page.data; + const cells = page.getCells(row); + const cells_write = cells[self.scrolling_region.left .. self.scrolling_region.right + 1]; + self.screen.clearCells(page, row, cells_write); + } - // If our X is a wide spacer tail then we need to erase the - // previous cell too so we don't split a multi-cell character. - if (x > 0) { - const cell = row.getCellPtr(x); - if (cell.attrs.wide_spacer_tail) x -= 1; - } + // Move the cursor to the left margin. But importantly this also + // forces screen.cursor.page_cell to reload because the rows above + // shifted cell ofsets so this will ensure the cursor is pointing + // to the correct cell. + self.screen.cursorAbsolute( + self.scrolling_region.left, + self.screen.cursor.y, + ); - // This resets the soft-wrap of this line - row.setWrapped(false); + // Always unset pending wrap + self.screen.cursor.pending_wrap = false; +} - break :right .{ x, row.lenCells() }; - }, +/// Inserts spaces at current cursor position moving existing cell contents +/// to the right. The contents of the count right-most columns in the scroll +/// region are lost. The cursor position is not changed. +/// +/// This unsets the pending wrap state without wrapping. +/// +/// The inserted cells are colored according to the current SGR state. +pub fn insertBlanks(self: *Terminal, count: usize) void { + // Unset pending wrap state without wrapping. Note: this purposely + // happens BEFORE the scroll region check below, because that's what + // xterm does. + self.screen.cursor.pending_wrap = false; - .left => left: { - var x = self.screen.cursor.x; + // If our cursor is outside the margins then do nothing. We DO reset + // wrap state still so this must remain below the above logic. + if (self.screen.cursor.x < self.scrolling_region.left or + self.screen.cursor.x > self.scrolling_region.right) return; - // If our x is a wide char we need to delete the tail too. - const cell = row.getCellPtr(x); - if (cell.attrs.wide) { - if (row.getCellPtr(x + 1).attrs.wide_spacer_tail) { - x += 1; - } - } + // If our count is larger than the remaining amount, we just erase right. + // We only do this if we can erase the entire line (no right margin). + // if (right_limit == self.cols and + // count > right_limit - self.screen.cursor.x) + // { + // self.eraseLine(.right, false); + // return; + // } - break :left .{ 0, x + 1 }; - }, + // left is just the cursor position but as a multi-pointer + const left: [*]Cell = @ptrCast(self.screen.cursor.page_cell); + var page = &self.screen.cursor.page_pin.page.data; - // Note that it seems like complete should reset the soft-wrap - // state of the line but in xterm it does not. - .complete => .{ 0, row.lenCells() }, + // If our X is a wide spacer tail then we need to erase the + // previous cell too so we don't split a multi-cell character. + if (self.screen.cursor.page_cell.wide == .spacer_tail) { + assert(self.screen.cursor.x > 0); + self.screen.clearCells(page, self.screen.cursor.page_row, (left - 1)[0..2]); + } - else => { - log.err("unimplemented erase line mode: {}", .{mode}); - return; - }, - }; + // Remaining cols from our cursor to the right margin. + const rem = self.scrolling_region.right - self.screen.cursor.x + 1; - // All modes will clear the pending wrap state and we know we have - // a valid mode at this point. - self.screen.cursor.pending_wrap = false; + // We can only insert blanks up to our remaining cols + const adjusted_count = @min(count, rem); - // We respect protected attributes if explicitly requested (probably - // a DECSEL sequence) or if our last protected mode was ISO even if its - // not currently set. - const protected = self.screen.protected_mode == .iso or protected_req; + // This is the amount of space at the right of the scroll region + // that will NOT be blank, so we need to shift the correct cols right. + // "scroll_amount" is the number of such cols. + const scroll_amount = rem - adjusted_count; + if (scroll_amount > 0) { + page.pauseIntegrityChecks(true); + defer page.pauseIntegrityChecks(false); - // If we're not respecting protected attributes, we can use a fast-path - // to fill the entire line. - if (!protected) { - row.fillSlice(self.screen.cursor.pen, start, end); - return; - } + var x: [*]Cell = left + (scroll_amount - 1); - for (start..end) |x| { - const cell = row.getCellPtr(x); - if (cell.attrs.protected) continue; - cell.* = pen; + // If our last cell we're shifting is wide, then we need to clear + // it to be empty so we don't split the multi-cell char. + const end: *Cell = @ptrCast(x); + if (end.wide == .wide) { + const end_multi: [*]Cell = @ptrCast(end); + assert(end_multi[1].wide == .spacer_tail); + self.screen.clearCells( + page, + self.screen.cursor.page_row, + end_multi[0..2], + ); + } + + // We work backwards so we don't overwrite data. + while (@intFromPtr(x) >= @intFromPtr(left)) : (x -= 1) { + const src: *Cell = @ptrCast(x); + const dst: *Cell = @ptrCast(x + adjusted_count); + page.swapCells(src, dst); + } } + + // Insert blanks. The blanks preserve the background color. + self.screen.clearCells(page, self.screen.cursor.page_row, left[0..adjusted_count]); } /// Removes amount characters from the current cursor position to the right. @@ -1465,659 +1665,603 @@ pub fn eraseLine( /// scrolling region, it is adjusted down. /// /// Does not change the cursor position. -pub fn deleteChars(self: *Terminal, count: usize) !void { - if (count == 0) return; +pub fn deleteChars(self: *Terminal, count_req: usize) void { + if (count_req == 0) return; // If our cursor is outside the margins then do nothing. We DO reset // wrap state still so this must remain below the above logic. if (self.screen.cursor.x < self.scrolling_region.left or self.screen.cursor.x > self.scrolling_region.right) return; + // This resets the soft-wrap of this line + self.screen.cursor.page_row.wrap = false; + // This resets the pending wrap state self.screen.cursor.pending_wrap = false; - const pen: Screen.Cell = .{ - .bg = self.screen.cursor.pen.bg, - }; + // left is just the cursor position but as a multi-pointer + const left: [*]Cell = @ptrCast(self.screen.cursor.page_cell); + var page = &self.screen.cursor.page_pin.page.data; // If our X is a wide spacer tail then we need to erase the // previous cell too so we don't split a multi-cell character. - const line = self.screen.getRow(.{ .active = self.screen.cursor.y }); - if (self.screen.cursor.x > 0) { - const cell = line.getCellPtr(self.screen.cursor.x); - if (cell.attrs.wide_spacer_tail) { - line.getCellPtr(self.screen.cursor.x - 1).* = pen; - } + if (self.screen.cursor.page_cell.wide == .spacer_tail) { + assert(self.screen.cursor.x > 0); + self.screen.clearCells(page, self.screen.cursor.page_row, (left - 1)[0..2]); } - // We go from our cursor right to the end and either copy the cell - // "count" away or clear it. - for (self.screen.cursor.x..self.scrolling_region.right + 1) |x| { - const copy_x = x + count; - if (copy_x >= self.scrolling_region.right + 1) { - line.getCellPtr(x).* = pen; - continue; + // Remaining cols from our cursor to the right margin. + const rem = self.scrolling_region.right - self.screen.cursor.x + 1; + + // We can only insert blanks up to our remaining cols + const count = @min(count_req, rem); + + // This is the amount of space at the right of the scroll region + // that will NOT be blank, so we need to shift the correct cols right. + // "scroll_amount" is the number of such cols. + const scroll_amount = rem - count; + var x: [*]Cell = left; + if (scroll_amount > 0) { + page.pauseIntegrityChecks(true); + defer page.pauseIntegrityChecks(false); + + const right: [*]Cell = left + (scroll_amount - 1); + + const end: *Cell = @ptrCast(right + count); + switch (end.wide) { + .narrow, .wide => {}, + + // If our end is a spacer head then we need to clear it since + // spacer heads must be at the end. + .spacer_head => { + self.screen.clearCells(page, self.screen.cursor.page_row, end[0..1]); + }, + + // If our last cell we're shifting is wide, then we need to clear + // it to be empty so we don't split the multi-cell char. + .spacer_tail => { + const wide: [*]Cell = right + count - 1; + assert(wide[0].wide == .wide); + self.screen.clearCells(page, self.screen.cursor.page_row, wide[0..2]); + }, } - const copy_cell = line.getCellPtr(copy_x); - if (x == 0 and copy_cell.attrs.wide_spacer_tail) { - line.getCellPtr(x).* = pen; - continue; + // If our first cell is a wide char then we need to also clear + // the spacer tail following it. + if (x[0].wide == .wide) { + self.screen.clearCells( + page, + self.screen.cursor.page_row, + x[0..2], + ); + } + + while (@intFromPtr(x) <= @intFromPtr(right)) : (x += 1) { + const src: *Cell = @ptrCast(x + count); + const dst: *Cell = @ptrCast(x); + page.swapCells(src, dst); } - line.getCellPtr(x).* = copy_cell.*; - copy_cell.char = 0; } + + // Insert blanks. The blanks preserve the background color. + self.screen.clearCells(page, self.screen.cursor.page_row, x[0 .. rem - scroll_amount]); } pub fn eraseChars(self: *Terminal, count_req: usize) void { const count = @max(count_req, 1); + // This resets the soft-wrap of this line + self.screen.cursor.page_row.wrap = false; + // This resets the pending wrap state self.screen.cursor.pending_wrap = false; // Our last index is at most the end of the number of chars we have // in the current line. - const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); const end = end: { - var end = @min(self.cols, self.screen.cursor.x + count); + const remaining = self.cols - self.screen.cursor.x; + var end = @min(remaining, count); // If our last cell is a wide char then we need to also clear the // cell beyond it since we can't just split a wide char. - if (end != self.cols) { - const last = row.getCellPtr(end - 1); - if (last.attrs.wide) end += 1; + if (end != remaining) { + const last = self.screen.cursorCellRight(end - 1); + if (last.wide == .wide) end += 1; } break :end end; }; - // This resets the soft-wrap of this line - row.setWrapped(false); - - const pen: Screen.Cell = .{ - .bg = self.screen.cursor.pen.bg, - }; + // Clear the cells + const cells: [*]Cell = @ptrCast(self.screen.cursor.page_cell); // If we never had a protection mode, then we can assume no cells // are protected and go with the fast path. If the last protection // mode was not ISO we also always ignore protection attributes. if (self.screen.protected_mode != .iso) { - row.fillSlice(pen, self.screen.cursor.x, end); + self.screen.clearCells( + &self.screen.cursor.page_pin.page.data, + self.screen.cursor.page_row, + cells[0..end], + ); + return; } + // SLOW PATH // We had a protection mode at some point. We must go through each // cell and check its protection attribute. - for (self.screen.cursor.x..end) |x| { - const cell = row.getCellPtr(x); - if (cell.attrs.protected) continue; - cell.* = pen; + for (0..end) |x| { + const cell_multi: [*]Cell = @ptrCast(cells + x); + const cell: *Cell = @ptrCast(&cell_multi[0]); + if (cell.protected) continue; + self.screen.clearCells( + &self.screen.cursor.page_pin.page.data, + self.screen.cursor.page_row, + cell_multi[0..1], + ); } } -/// Move the cursor to the left amount cells. If amount is 0, adjust it to 1. -pub fn cursorLeft(self: *Terminal, count_req: usize) void { - // Wrapping behavior depends on various terminal modes - const WrapMode = enum { none, reverse, reverse_extended }; - const wrap_mode: WrapMode = wrap_mode: { - if (!self.modes.get(.wraparound)) break :wrap_mode .none; - if (self.modes.get(.reverse_wrap_extended)) break :wrap_mode .reverse_extended; - if (self.modes.get(.reverse_wrap)) break :wrap_mode .reverse; - break :wrap_mode .none; - }; - - var count: usize = @max(count_req, 1); - - // If we are in no wrap mode, then we move the cursor left and exit - // since this is the fastest and most typical path. - if (wrap_mode == .none) { - self.screen.cursor.x -|= count; - self.screen.cursor.pending_wrap = false; - return; - } - - // If we have a pending wrap state and we are in either reverse wrap - // modes then we decrement the amount we move by one to match xterm. - if (self.screen.cursor.pending_wrap) { - count -= 1; - self.screen.cursor.pending_wrap = false; - } - - // The margins we can move to. - const top = self.scrolling_region.top; - const bottom = self.scrolling_region.bottom; - const right_margin = self.scrolling_region.right; - const left_margin = if (self.screen.cursor.x < self.scrolling_region.left) - 0 - else - self.scrolling_region.left; - - // Handle some edge cases when our cursor is already on the left margin. - if (self.screen.cursor.x == left_margin) { - switch (wrap_mode) { - // In reverse mode, if we're already before the top margin - // then we just set our cursor to the top-left and we're done. - .reverse => if (self.screen.cursor.y <= top) { - self.screen.cursor.x = left_margin; - self.screen.cursor.y = top; - return; - }, - - // Handled in while loop - .reverse_extended => {}, - - // Handled above - .none => unreachable, - } - } - - while (true) { - // We can move at most to the left margin. - const max = self.screen.cursor.x - left_margin; - - // We want to move at most the number of columns we have left - // or our remaining count. Do the move. - const amount = @min(max, count); - count -= amount; - self.screen.cursor.x -= amount; - - // If we have no more to move, then we're done. - if (count == 0) break; - - // If we are at the top, then we are done. - if (self.screen.cursor.y == top) { - if (wrap_mode != .reverse_extended) break; - - self.screen.cursor.y = bottom; - self.screen.cursor.x = right_margin; - count -= 1; - continue; - } - - // UNDEFINED TERMINAL BEHAVIOR. This situation is not handled in xterm - // and currently results in a crash in xterm. Given no other known - // terminal [to me] implements XTREVWRAP2, I decided to just mimick - // the behavior of xterm up and not including the crash by wrapping - // up to the (0, 0) and stopping there. My reasoning is that for an - // appropriately sized value of "count" this is the behavior that xterm - // would have. This is unit tested. - if (self.screen.cursor.y == 0) { - assert(self.screen.cursor.x == left_margin); - break; - } +/// Erase the line. +pub fn eraseLine( + self: *Terminal, + mode: csi.EraseLine, + protected_req: bool, +) void { + // Get our start/end positions depending on mode. + const start, const end = switch (mode) { + .right => right: { + var x = self.screen.cursor.x; - // If our previous line is not wrapped then we are done. - if (wrap_mode != .reverse_extended) { - const row = self.screen.getRow(.{ .active = self.screen.cursor.y - 1 }); - if (!row.isWrapped()) break; - } + // If our X is a wide spacer tail then we need to erase the + // previous cell too so we don't split a multi-cell character. + if (x > 0 and self.screen.cursor.page_cell.wide == .spacer_tail) { + x -= 1; + } - self.screen.cursor.y -= 1; - self.screen.cursor.x = right_margin; - count -= 1; - } -} + // This resets the soft-wrap of this line + self.screen.cursor.page_row.wrap = false; -/// Move the cursor right amount columns. If amount is greater than the -/// maximum move distance then it is internally adjusted to the maximum. -/// This sequence will not scroll the screen or scroll region. If amount is -/// 0, adjust it to 1. -pub fn cursorRight(self: *Terminal, count_req: usize) void { - // Always resets pending wrap - self.screen.cursor.pending_wrap = false; + break :right .{ x, self.cols }; + }, - // The max the cursor can move to depends where the cursor currently is - const max = if (self.screen.cursor.x <= self.scrolling_region.right) - self.scrolling_region.right - else - self.cols - 1; + .left => left: { + var x = self.screen.cursor.x; - const count = @max(count_req, 1); - self.screen.cursor.x = @min(max, self.screen.cursor.x +| count); -} + // If our x is a wide char we need to delete the tail too. + if (self.screen.cursor.page_cell.wide == .wide) { + x += 1; + } -/// Move the cursor down amount lines. If amount is greater than the maximum -/// move distance then it is internally adjusted to the maximum. This sequence -/// will not scroll the screen or scroll region. If amount is 0, adjust it to 1. -pub fn cursorDown(self: *Terminal, count_req: usize) void { - // Always resets pending wrap - self.screen.cursor.pending_wrap = false; + break :left .{ 0, x + 1 }; + }, - // The max the cursor can move to depends where the cursor currently is - const max = if (self.screen.cursor.y <= self.scrolling_region.bottom) - self.scrolling_region.bottom - else - self.rows - 1; + // Note that it seems like complete should reset the soft-wrap + // state of the line but in xterm it does not. + .complete => .{ 0, self.cols }, - const count = @max(count_req, 1); - self.screen.cursor.y = @min(max, self.screen.cursor.y +| count); -} + else => { + log.err("unimplemented erase line mode: {}", .{mode}); + return; + }, + }; -/// Move the cursor up amount lines. If amount is greater than the maximum -/// move distance then it is internally adjusted to the maximum. If amount is -/// 0, adjust it to 1. -pub fn cursorUp(self: *Terminal, count_req: usize) void { - // Always resets pending wrap + // All modes will clear the pending wrap state and we know we have + // a valid mode at this point. self.screen.cursor.pending_wrap = false; - // The min the cursor can move to depends where the cursor currently is - const min = if (self.screen.cursor.y >= self.scrolling_region.top) - self.scrolling_region.top - else - 0; - - const count = @max(count_req, 1); - self.screen.cursor.y = @max(min, self.screen.cursor.y -| count); -} + // Start of our cells + const cells: [*]Cell = cells: { + const cells: [*]Cell = @ptrCast(self.screen.cursor.page_cell); + break :cells cells - self.screen.cursor.x; + }; -/// Backspace moves the cursor back a column (but not less than 0). -pub fn backspace(self: *Terminal) void { - self.cursorLeft(1); -} + // We respect protected attributes if explicitly requested (probably + // a DECSEL sequence) or if our last protected mode was ISO even if its + // not currently set. + const protected = self.screen.protected_mode == .iso or protected_req; -/// Horizontal tab moves the cursor to the next tabstop, clearing -/// the screen to the left the tabstop. -pub fn horizontalTab(self: *Terminal) !void { - while (self.screen.cursor.x < self.scrolling_region.right) { - // Move the cursor right - self.screen.cursor.x += 1; + // If we're not respecting protected attributes, we can use a fast-path + // to fill the entire line. + if (!protected) { + self.screen.clearCells( + &self.screen.cursor.page_pin.page.data, + self.screen.cursor.page_row, + cells[start..end], + ); + return; + } - // If the last cursor position was a tabstop we return. We do - // "last cursor position" because we want a space to be written - // at the tabstop unless we're at the end (the while condition). - if (self.tabstops.get(self.screen.cursor.x)) return; + for (start..end) |x| { + const cell_multi: [*]Cell = @ptrCast(cells + x); + const cell: *Cell = @ptrCast(&cell_multi[0]); + if (cell.protected) continue; + self.screen.clearCells( + &self.screen.cursor.page_pin.page.data, + self.screen.cursor.page_row, + cell_multi[0..1], + ); } } -// Same as horizontalTab but moves to the previous tabstop instead of the next. -pub fn horizontalTabBack(self: *Terminal) !void { - // With origin mode enabled, our leftmost limit is the left margin. - const left_limit = if (self.modes.get(.origin)) self.scrolling_region.left else 0; +/// Erase the display. +pub fn eraseDisplay( + self: *Terminal, + mode: csi.EraseDisplay, + protected_req: bool, +) void { + // We respect protected attributes if explicitly requested (probably + // a DECSEL sequence) or if our last protected mode was ISO even if its + // not currently set. + const protected = self.screen.protected_mode == .iso or protected_req; - while (true) { - // If we're already at the edge of the screen, then we're done. - if (self.screen.cursor.x <= left_limit) return; + switch (mode) { + .scroll_complete => { + self.screen.scrollClear() catch |err| { + log.warn("scroll clear failed, doing a normal clear err={}", .{err}); + self.eraseDisplay(.complete, protected_req); + return; + }; - // Move the cursor left - self.screen.cursor.x -= 1; - if (self.tabstops.get(self.screen.cursor.x)) return; - } -} + // Unsets pending wrap state + self.screen.cursor.pending_wrap = false; -/// Clear tab stops. -pub fn tabClear(self: *Terminal, cmd: csi.TabClear) void { - switch (cmd) { - .current => self.tabstops.unset(self.screen.cursor.x), - .all => self.tabstops.reset(0), - else => log.warn("invalid or unknown tab clear setting: {}", .{cmd}), - } -} + // Clear all Kitty graphics state for this screen + self.screen.kitty_images.delete( + self.screen.alloc, + self, + .{ .all = true }, + ); + }, -/// Set a tab stop on the current cursor. -/// TODO: test -pub fn tabSet(self: *Terminal) void { - self.tabstops.set(self.screen.cursor.x); -} + .complete => { + // If we're on the primary screen and our last non-empty row is + // a prompt, then we do a scroll_complete instead. This is a + // heuristic to get the generally desirable behavior that ^L + // at a prompt scrolls the screen contents prior to clearing. + // Most shells send `ESC [ H ESC [ 2 J` so we can't just check + // our current cursor position. See #905 + if (self.active_screen == .primary) at_prompt: { + // Go from the bottom of the active up and see if we're + // at a prompt. + const active_br = self.screen.pages.getBottomRight( + .active, + ) orelse break :at_prompt; + var it = active_br.rowIterator( + .left_up, + self.screen.pages.getTopLeft(.active), + ); + while (it.next()) |p| { + const row = p.rowAndCell().row; + switch (row.semantic_prompt) { + // If we're at a prompt or input area, then we are at a prompt. + .prompt, + .prompt_continuation, + .input, + => break, -/// TODO: test -pub fn tabReset(self: *Terminal) void { - self.tabstops.reset(TABSTOP_INTERVAL); -} + // If we have command output, then we're most certainly not + // at a prompt. + .command => break :at_prompt, -/// Carriage return moves the cursor to the first column. -pub fn carriageReturn(self: *Terminal) void { - // Always reset pending wrap state - self.screen.cursor.pending_wrap = false; + // If we don't know, we keep searching. + .unknown => {}, + } + } else break :at_prompt; - // In origin mode we always move to the left margin - self.screen.cursor.x = if (self.modes.get(.origin)) - self.scrolling_region.left - else if (self.screen.cursor.x >= self.scrolling_region.left) - self.scrolling_region.left - else - 0; -} + self.screen.scrollClear() catch { + // If we fail, we just fall back to doing a normal clear + // so we don't worry about the error. + }; + } -/// Linefeed moves the cursor to the next line. -pub fn linefeed(self: *Terminal) !void { - try self.index(); - if (self.modes.get(.linefeed)) self.carriageReturn(); -} + // All active area + self.screen.clearRows( + .{ .active = .{} }, + null, + protected, + ); -/// Inserts spaces at current cursor position moving existing cell contents -/// to the right. The contents of the count right-most columns in the scroll -/// region are lost. The cursor position is not changed. -/// -/// This unsets the pending wrap state without wrapping. -/// -/// The inserted cells are colored according to the current SGR state. -pub fn insertBlanks(self: *Terminal, count: usize) void { - // Unset pending wrap state without wrapping. Note: this purposely - // happens BEFORE the scroll region check below, because that's what - // xterm does. - self.screen.cursor.pending_wrap = false; + // Unsets pending wrap state + self.screen.cursor.pending_wrap = false; - // If our cursor is outside the margins then do nothing. We DO reset - // wrap state still so this must remain below the above logic. - if (self.screen.cursor.x < self.scrolling_region.left or - self.screen.cursor.x > self.scrolling_region.right) return; + // Clear all Kitty graphics state for this screen + self.screen.kitty_images.delete( + self.screen.alloc, + self, + .{ .all = true }, + ); + }, - // The limit we can shift to is our right margin. We add 1 since the - // math around this is 1-indexed. - const right_limit = self.scrolling_region.right + 1; + .below => { + // All lines to the right (including the cursor) + self.eraseLine(.right, protected_req); - // If our count is larger than the remaining amount, we just erase right. - // We only do this if we can erase the entire line (no right margin). - if (right_limit == self.cols and - count > right_limit - self.screen.cursor.x) - { - self.eraseLine(.right, false); - return; - } + // All lines below + if (self.screen.cursor.y + 1 < self.rows) { + self.screen.clearRows( + .{ .active = .{ .y = self.screen.cursor.y + 1 } }, + null, + protected, + ); + } - // Get the current row - const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); + // Unsets pending wrap state. Should be done by eraseLine. + assert(!self.screen.cursor.pending_wrap); + }, + + .above => { + // Erase to the left (including the cursor) + self.eraseLine(.left, protected_req); - // Determine our indexes. - const start = self.screen.cursor.x; - const pivot = @min(self.screen.cursor.x + count, right_limit); + // All lines above + if (self.screen.cursor.y > 0) { + self.screen.clearRows( + .{ .active = .{ .y = 0 } }, + .{ .active = .{ .y = self.screen.cursor.y - 1 } }, + protected, + ); + } - // This is the number of spaces we have left to shift existing data. - // If count is bigger than the available space left after the cursor, - // we may have no space at all for copying. - const copyable = right_limit - pivot; - if (copyable > 0) { - // This is the index of the final copyable value that we need to copy. - const copyable_end = start + copyable - 1; + // Unsets pending wrap state + assert(!self.screen.cursor.pending_wrap); + }, - // If our last cell we're shifting is wide, then we need to clear - // it to be empty so we don't split the multi-cell char. - const cell = row.getCellPtr(copyable_end); - if (cell.attrs.wide) cell.char = 0; - - // Shift count cells. We have to do this backwards since we're not - // allocated new space, otherwise we'll copy duplicates. - var i: usize = 0; - while (i < copyable) : (i += 1) { - const to = right_limit - 1 - i; - const from = copyable_end - i; - const src = row.getCell(from); - const dst = row.getCellPtr(to); - dst.* = src; - } + .scrollback => self.screen.eraseRows(.{ .history = .{} }, null), } - - // Insert blanks. The blanks preserve the background color. - row.fillSlice(.{ - .bg = self.screen.cursor.pen.bg, - }, start, pivot); } -/// Insert amount lines at the current cursor row. The contents of the line -/// at the current cursor row and below (to the bottom-most line in the -/// scrolling region) are shifted down by amount lines. The contents of the -/// amount bottom-most lines in the scroll region are lost. -/// -/// This unsets the pending wrap state without wrapping. If the current cursor -/// position is outside of the current scroll region it does nothing. -/// -/// If amount is greater than the remaining number of lines in the scrolling -/// region it is adjusted down (still allowing for scrolling out every remaining -/// line in the scrolling region) -/// -/// In left and right margin mode the margins are respected; lines are only -/// scrolled in the scroll region. -/// -/// All cleared space is colored according to the current SGR state. +/// Resets all margins and fills the whole screen with the character 'E' /// -/// Moves the cursor to the left margin. -pub fn insertLines(self: *Terminal, count: usize) !void { - // Rare, but happens - if (count == 0) return; +/// Sets the cursor to the top left corner. +pub fn decaln(self: *Terminal) !void { + // Clear our stylistic attributes. This is the only thing that can + // fail so we do it first so we can undo it. + const old_style = self.screen.cursor.style; + self.screen.cursor.style = .{ + .bg_color = self.screen.cursor.style.bg_color, + .fg_color = self.screen.cursor.style.fg_color, + }; + errdefer self.screen.cursor.style = old_style; + try self.screen.manualStyleUpdate(); - // If the cursor is outside the scroll region we do nothing. - if (self.screen.cursor.y < self.scrolling_region.top or - self.screen.cursor.y > self.scrolling_region.bottom or - self.screen.cursor.x < self.scrolling_region.left or - self.screen.cursor.x > self.scrolling_region.right) return; + // Reset margins, also sets cursor to top-left + self.scrolling_region = .{ + .top = 0, + .bottom = self.rows - 1, + .left = 0, + .right = self.cols - 1, + }; - // Move the cursor to the left margin - self.screen.cursor.x = self.scrolling_region.left; - self.screen.cursor.pending_wrap = false; + // Origin mode is disabled + self.modes.set(.origin, false); - // Remaining rows from our cursor - const rem = self.scrolling_region.bottom - self.screen.cursor.y + 1; + // Move our cursor to the top-left + self.setCursorPos(1, 1); - // If count is greater than the amount of rows, adjust down. - const adjusted_count = @min(count, rem); + // Erase the display which will deallocate graphames, styles, etc. + self.eraseDisplay(.complete, false); - // The the top `scroll_amount` lines need to move to the bottom - // scroll area. We may have nothing to scroll if we're clearing. - const scroll_amount = rem - adjusted_count; - var y: usize = self.scrolling_region.bottom; - const top = y - scroll_amount; - - // Ensure we have the lines populated to the end - while (y > top) : (y -= 1) { - const src = self.screen.getRow(.{ .active = y - adjusted_count }); - const dst = self.screen.getRow(.{ .active = y }); - for (self.scrolling_region.left..self.scrolling_region.right + 1) |x| { - try dst.copyCell(src, x); + // Fill with Es by moving the cursor but reset it after. + while (true) { + const page = &self.screen.cursor.page_pin.page.data; + const row = self.screen.cursor.page_row; + const cells_multi: [*]Cell = row.cells.ptr(page.memory); + const cells = cells_multi[0..page.size.cols]; + @memset(cells, .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'E' }, + .style_id = self.screen.cursor.style_id, + .protected = self.screen.cursor.protected, + }); + + // If we have a ref-counted style, increase + if (self.screen.cursor.style_ref) |ref| { + ref.* += @intCast(cells.len); + row.styled = true; } - } - // Insert count blank lines - y = self.screen.cursor.y; - while (y < self.screen.cursor.y + adjusted_count) : (y += 1) { - const row = self.screen.getRow(.{ .active = y }); - row.fillSlice(.{ - .bg = self.screen.cursor.pen.bg, - }, self.scrolling_region.left, self.scrolling_region.right + 1); + if (self.screen.cursor.y == self.rows - 1) break; + self.screen.cursorDown(1); } + + // Reset the cursor to the top-left + self.setCursorPos(1, 1); } -/// Removes amount lines from the current cursor row down. The remaining lines -/// to the bottom margin are shifted up and space from the bottom margin up is -/// filled with empty lines. -/// -/// If the current cursor position is outside of the current scroll region it -/// does nothing. If amount is greater than the remaining number of lines in the -/// scrolling region it is adjusted down. -/// -/// In left and right margin mode the margins are respected; lines are only -/// scrolled in the scroll region. +/// Execute a kitty graphics command. The buf is used to populate with +/// the response that should be sent as an APC sequence. The response will +/// be a full, valid APC sequence. /// -/// If the cell movement splits a multi cell character that character cleared, -/// by replacing it by spaces, keeping its current attributes. All other -/// cleared space is colored according to the current SGR state. +/// If an error occurs, the caller should response to the pty that a +/// an error occurred otherwise the behavior of the graphics protocol is +/// undefined. +pub fn kittyGraphics( + self: *Terminal, + alloc: Allocator, + cmd: *kitty.graphics.Command, +) ?kitty.graphics.Response { + return kitty.graphics.execute(alloc, self, cmd); +} + +/// Set a style attribute. +pub fn setAttribute(self: *Terminal, attr: sgr.Attribute) !void { + try self.screen.setAttribute(attr); +} + +/// Print the active attributes as a string. This is used to respond to DECRQSS +/// requests. /// -/// Moves the cursor to the left margin. -pub fn deleteLines(self: *Terminal, count: usize) !void { - // If the cursor is outside the scroll region we do nothing. - if (self.screen.cursor.y < self.scrolling_region.top or - self.screen.cursor.y > self.scrolling_region.bottom or - self.screen.cursor.x < self.scrolling_region.left or - self.screen.cursor.x > self.scrolling_region.right) return; +/// Boolean attributes are printed first, followed by foreground color, then +/// background color. Each attribute is separated by a semicolon. +pub fn printAttributes(self: *Terminal, buf: []u8) ![]const u8 { + var stream = std.io.fixedBufferStream(buf); + const writer = stream.writer(); - // Move the cursor to the left margin - self.screen.cursor.x = self.scrolling_region.left; - self.screen.cursor.pending_wrap = false; + // The SGR response always starts with a 0. See https://vt100.net/docs/vt510-rm/DECRPSS + try writer.writeByte('0'); - // If this is a full line margin then we can do a faster scroll. - if (self.scrolling_region.left == 0 and - self.scrolling_region.right == self.cols - 1) - { - self.screen.scrollRegionUp( - .{ .active = self.screen.cursor.y }, - .{ .active = self.scrolling_region.bottom }, - @min(count, (self.scrolling_region.bottom - self.screen.cursor.y) + 1), - ); - return; - } + const pen = self.screen.cursor.style; + var attrs = [_]u8{0} ** 8; + var i: usize = 0; - // Left/right margin is set, we need to do a slower scroll. - // Remaining rows from our cursor in the region, 1-indexed. - const rem = self.scrolling_region.bottom - self.screen.cursor.y + 1; + if (pen.flags.bold) { + attrs[i] = '1'; + i += 1; + } - // If our count is greater than the remaining amount, we can just - // clear the region using insertLines. - if (count >= rem) { - try self.insertLines(count); - return; + if (pen.flags.faint) { + attrs[i] = '2'; + i += 1; } - // The amount of lines we need to scroll up. - const scroll_amount = rem - count; - const scroll_end_y = self.screen.cursor.y + scroll_amount; - for (self.screen.cursor.y..scroll_end_y) |y| { - const src = self.screen.getRow(.{ .active = y + count }); - const dst = self.screen.getRow(.{ .active = y }); - for (self.scrolling_region.left..self.scrolling_region.right + 1) |x| { - try dst.copyCell(src, x); - } + if (pen.flags.italic) { + attrs[i] = '3'; + i += 1; } - // Insert blank lines - for (scroll_end_y..self.scrolling_region.bottom + 1) |y| { - const row = self.screen.getRow(.{ .active = y }); - row.setWrapped(false); - row.fillSlice(.{ - .bg = self.screen.cursor.pen.bg, - }, self.scrolling_region.left, self.scrolling_region.right + 1); + if (pen.flags.underline != .none) { + attrs[i] = '4'; + i += 1; } -} -/// Scroll the text down by one row. -pub fn scrollDown(self: *Terminal, count: usize) !void { - // Preserve the cursor - const cursor = self.screen.cursor; - defer self.screen.cursor = cursor; + if (pen.flags.blink) { + attrs[i] = '5'; + i += 1; + } - // Move to the top of the scroll region - self.screen.cursor.y = self.scrolling_region.top; - self.screen.cursor.x = self.scrolling_region.left; - try self.insertLines(count); -} + if (pen.flags.inverse) { + attrs[i] = '7'; + i += 1; + } -/// Removes amount lines from the top of the scroll region. The remaining lines -/// to the bottom margin are shifted up and space from the bottom margin up -/// is filled with empty lines. -/// -/// The new lines are created according to the current SGR state. -/// -/// Does not change the (absolute) cursor position. -pub fn scrollUp(self: *Terminal, count: usize) !void { - // Preserve the cursor - const cursor = self.screen.cursor; - defer self.screen.cursor = cursor; + if (pen.flags.invisible) { + attrs[i] = '8'; + i += 1; + } - // Move to the top of the scroll region - self.screen.cursor.y = self.scrolling_region.top; - self.screen.cursor.x = self.scrolling_region.left; - try self.deleteLines(count); -} + if (pen.flags.strikethrough) { + attrs[i] = '9'; + i += 1; + } -/// Options for scrolling the viewport of the terminal grid. -pub const ScrollViewport = union(enum) { - /// Scroll to the top of the scrollback - top: void, + for (attrs[0..i]) |c| { + try writer.print(";{c}", .{c}); + } - /// Scroll to the bottom, i.e. the top of the active area - bottom: void, + switch (pen.fg_color) { + .none => {}, + .palette => |idx| if (idx >= 16) + try writer.print(";38:5:{}", .{idx}) + else if (idx >= 8) + try writer.print(";9{}", .{idx - 8}) + else + try writer.print(";3{}", .{idx}), + .rgb => |rgb| try writer.print(";38:2::{[r]}:{[g]}:{[b]}", rgb), + } - /// Scroll by some delta amount, up is negative. - delta: isize, -}; + switch (pen.bg_color) { + .none => {}, + .palette => |idx| if (idx >= 16) + try writer.print(";48:5:{}", .{idx}) + else if (idx >= 8) + try writer.print(";10{}", .{idx - 8}) + else + try writer.print(";4{}", .{idx}), + .rgb => |rgb| try writer.print(";48:2::{[r]}:{[g]}:{[b]}", rgb), + } -/// Scroll the viewport of the terminal grid. -pub fn scrollViewport(self: *Terminal, behavior: ScrollViewport) !void { - try self.screen.scroll(switch (behavior) { - .top => .{ .top = {} }, - .bottom => .{ .bottom = {} }, - .delta => |delta| .{ .viewport = delta }, - }); + return stream.getWritten(); } -/// Set Top and Bottom Margins If bottom is not specified, 0 or bigger than -/// the number of the bottom-most row, it is adjusted to the number of the -/// bottom most row. -/// -/// If top < bottom set the top and bottom row of the scroll region according -/// to top and bottom and move the cursor to the top-left cell of the display -/// (when in cursor origin mode is set to the top-left cell of the scroll region). -/// -/// Otherwise: Set the top and bottom row of the scroll region to the top-most -/// and bottom-most line of the screen. -/// -/// Top and bottom are 1-indexed. -pub fn setTopAndBottomMargin(self: *Terminal, top_req: usize, bottom_req: usize) void { - const top = @max(1, top_req); - const bottom = @min(self.rows, if (bottom_req == 0) self.rows else bottom_req); - if (top >= bottom) return; +/// The modes for DECCOLM. +pub const DeccolmMode = enum(u1) { + @"80_cols" = 0, + @"132_cols" = 1, +}; - self.scrolling_region.top = top - 1; - self.scrolling_region.bottom = bottom - 1; - self.setCursorPos(1, 1); -} +/// DECCOLM changes the terminal width between 80 and 132 columns. This +/// function call will do NOTHING unless `setDeccolmSupported` has been +/// called with "true". +/// +/// This breaks the expectation around modern terminals that they resize +/// with the window. This will fix the grid at either 80 or 132 columns. +/// The rows will continue to be variable. +pub fn deccolm(self: *Terminal, alloc: Allocator, mode: DeccolmMode) !void { + // If DEC mode 40 isn't enabled, then this is ignored. We also make + // sure that we don't have deccolm set because we want to fully ignore + // set mode. + if (!self.modes.get(.enable_mode_3)) { + self.modes.set(.@"132_column", false); + return; + } -/// DECSLRM -pub fn setLeftAndRightMargin(self: *Terminal, left_req: usize, right_req: usize) void { - // We must have this mode enabled to do anything - if (!self.modes.get(.enable_left_and_right_margin)) return; + // Enable it + self.modes.set(.@"132_column", mode == .@"132_cols"); - const left = @max(1, left_req); - const right = @min(self.cols, if (right_req == 0) self.cols else right_req); - if (left >= right) return; + // Resize to the requested size + try self.resize( + alloc, + switch (mode) { + .@"132_cols" => 132, + .@"80_cols" => 80, + }, + self.rows, + ); - self.scrolling_region.left = left - 1; - self.scrolling_region.right = right - 1; + // Erase our display and move our cursor. + self.eraseDisplay(.complete, false); self.setCursorPos(1, 1); } -/// Mark the current semantic prompt information. Current escape sequences -/// (OSC 133) only allow setting this for wherever the current active cursor -/// is located. -pub fn markSemanticPrompt(self: *Terminal, p: SemanticPrompt) void { - //log.debug("semantic_prompt y={} p={}", .{ self.screen.cursor.y, p }); - const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); - row.setSemanticPrompt(switch (p) { - .prompt => .prompt, - .prompt_continuation => .prompt_continuation, - .input => .input, - .command => .command, - }); -} - -/// Returns true if the cursor is currently at a prompt. Another way to look -/// at this is it returns false if the shell is currently outputting something. -/// This requires shell integration (semantic prompt integration). -/// -/// If the shell integration doesn't exist, this will always return false. -pub fn cursorIsAtPrompt(self: *Terminal) bool { - // If we're on the secondary screen, we're never at a prompt. - if (self.active_screen == .alternate) return false; +/// Resize the underlying terminal. +pub fn resize( + self: *Terminal, + alloc: Allocator, + cols: size.CellCountInt, + rows: size.CellCountInt, +) !void { + // If our cols/rows didn't change then we're done + if (self.cols == cols and self.rows == rows) return; - var y: usize = 0; - while (y <= self.screen.cursor.y) : (y += 1) { - // We want to go bottom up - const bottom_y = self.screen.cursor.y - y; - const row = self.screen.getRow(.{ .active = bottom_y }); - switch (row.getSemanticPrompt()) { - // If we're at a prompt or input area, then we are at a prompt. - .prompt, - .prompt_continuation, - .input, - => return true, + // Resize our tabstops + if (self.cols != cols) { + self.tabstops.deinit(alloc); + self.tabstops = try Tabstops.init(alloc, cols, 8); + } - // If we have command output, then we're most certainly not - // at a prompt. - .command => return false, + // If we're making the screen smaller, dealloc the unused items. + if (self.active_screen == .primary) { + if (self.flags.shell_redraws_prompt) { + self.screen.clearPrompt(); + } - // If we don't know, we keep searching. - .unknown => {}, + if (self.modes.get(.wraparound)) { + try self.screen.resize(cols, rows); + } else { + try self.screen.resizeWithoutReflow(cols, rows); + } + try self.secondary_screen.resizeWithoutReflow(cols, rows); + } else { + try self.screen.resizeWithoutReflow(cols, rows); + if (self.modes.get(.wraparound)) { + try self.secondary_screen.resize(cols, rows); + } else { + try self.secondary_screen.resizeWithoutReflow(cols, rows); } } - return false; + // Set our size + self.cols = cols; + self.rows = rows; + + // Reset the scrolling region + self.scrolling_region = .{ + .top = 0, + .bottom = rows - 1, + .left = 0, + .right = cols - 1, + }; } /// Set the pwd for the terminal. @@ -2126,62 +2270,136 @@ pub fn setPwd(self: *Terminal, pwd: []const u8) !void { try self.pwd.appendSlice(pwd); } -/// Returns the pwd for the terminal, if any. The memory is owned by the -/// Terminal and is not copied. It is safe until a reset or setPwd. -pub fn getPwd(self: *const Terminal) ?[]const u8 { - if (self.pwd.items.len == 0) return null; - return self.pwd.items; +/// Returns the pwd for the terminal, if any. The memory is owned by the +/// Terminal and is not copied. It is safe until a reset or setPwd. +pub fn getPwd(self: *const Terminal) ?[]const u8 { + if (self.pwd.items.len == 0) return null; + return self.pwd.items; +} + +/// Get the screen pointer for the given type. +pub fn getScreen(self: *Terminal, t: ScreenType) *Screen { + return if (self.active_screen == t) + &self.screen + else + &self.secondary_screen; +} + +/// Options for switching to the alternate screen. +pub const AlternateScreenOptions = struct { + cursor_save: bool = false, + clear_on_enter: bool = false, + clear_on_exit: bool = false, +}; + +/// Switch to the alternate screen buffer. +/// +/// The alternate screen buffer: +/// * has its own grid +/// * has its own cursor state (included saved cursor) +/// * does not support scrollback +/// +pub fn alternateScreen( + self: *Terminal, + options: AlternateScreenOptions, +) void { + //log.info("alt screen active={} options={} cursor={}", .{ self.active_screen, options, self.screen.cursor }); + + // TODO: test + // TODO(mitchellh): what happens if we enter alternate screen multiple times? + // for now, we ignore... + if (self.active_screen == .alternate) return; + + // If we requested cursor save, we save the cursor in the primary screen + if (options.cursor_save) self.saveCursor(); + + // Switch the screens + const old = self.screen; + self.screen = self.secondary_screen; + self.secondary_screen = old; + self.active_screen = .alternate; + + // Bring our charset state with us + self.screen.charset = old.charset; + + // Clear our selection + self.screen.clearSelection(); + + // Mark kitty images as dirty so they redraw + self.screen.kitty_images.dirty = true; + + // Bring our pen with us + self.screen.cursorCopy(old.cursor) catch |err| { + log.warn("cursor copy failed entering alt screen err={}", .{err}); + }; + + if (options.clear_on_enter) { + self.eraseDisplay(.complete, false); + } } -/// Execute a kitty graphics command. The buf is used to populate with -/// the response that should be sent as an APC sequence. The response will -/// be a full, valid APC sequence. -/// -/// If an error occurs, the caller should response to the pty that a -/// an error occurred otherwise the behavior of the graphics protocol is -/// undefined. -pub fn kittyGraphics( +/// Switch back to the primary screen (reset alternate screen mode). +pub fn primaryScreen( self: *Terminal, - alloc: Allocator, - cmd: *kitty.graphics.Command, -) ?kitty.graphics.Response { - return kitty.graphics.execute(alloc, self, cmd); -} + options: AlternateScreenOptions, +) void { + //log.info("primary screen active={} options={}", .{ self.active_screen, options }); -/// Set the character protection mode for the terminal. -pub fn setProtectedMode(self: *Terminal, mode: ansi.ProtectedMode) void { - switch (mode) { - .off => { - self.screen.cursor.pen.attrs.protected = false; + // TODO: test + // TODO(mitchellh): what happens if we enter alternate screen multiple times? + if (self.active_screen == .primary) return; - // screen.protected_mode is NEVER reset to ".off" because - // logic such as eraseChars depends on knowing what the - // _most recent_ mode was. - }, + if (options.clear_on_exit) self.eraseDisplay(.complete, false); - .iso => { - self.screen.cursor.pen.attrs.protected = true; - self.screen.protected_mode = .iso; - }, + // Switch the screens + const old = self.screen; + self.screen = self.secondary_screen; + self.secondary_screen = old; + self.active_screen = .primary; - .dec => { - self.screen.cursor.pen.attrs.protected = true; - self.screen.protected_mode = .dec; - }, - } + // Clear our selection + self.screen.clearSelection(); + + // Mark kitty images as dirty so they redraw + self.screen.kitty_images.dirty = true; + + // Restore the cursor from the primary screen. This should not + // fail because we should not have to allocate memory since swapping + // screens does not create new cursors. + if (options.cursor_save) self.restoreCursor() catch |err| { + log.warn("restore cursor on primary screen failed err={}", .{err}); + }; +} + +/// Return the current string value of the terminal. Newlines are +/// encoded as "\n". This omits any formatting such as fg/bg. +/// +/// The caller must free the string. +pub fn plainString(self: *Terminal, alloc: Allocator) ![]const u8 { + return try self.screen.dumpStringAlloc(alloc, .{ .viewport = .{} }); } /// Full reset -pub fn fullReset(self: *Terminal, alloc: Allocator) void { - self.primaryScreen(alloc, .{ .clear_on_exit = true, .cursor_save = true }); +pub fn fullReset(self: *Terminal) void { + // Switch back to primary screen and clear it. We do not restore cursor + // because see the next step... + self.primaryScreen(.{ .clear_on_exit = true, .cursor_save = false }); + + // We set the saved cursor to null and then restore. This will force + // our cursor to go back to the default which will also move the cursor + // to the top-left. + self.screen.saved_cursor = null; + self.restoreCursor() catch |err| { + log.warn("restore cursor on primary screen failed err={}", .{err}); + }; + self.screen.charset = .{}; self.modes = .{}; self.flags = .{}; self.tabstops.reset(TABSTOP_INTERVAL); - self.screen.cursor = .{}; - self.screen.saved_cursor = null; - self.screen.selection = null; + self.screen.clearSelection(); self.screen.kitty_keyboard = .{}; + self.secondary_screen.kitty_keyboard = .{}; self.screen.protected_mode = .off; self.scrolling_region = .{ .top = 0, @@ -2190,65 +2408,93 @@ pub fn fullReset(self: *Terminal, alloc: Allocator) void { .right = self.cols - 1, }; self.previous_char = null; - self.eraseDisplay(alloc, .scrollback, false); - self.eraseDisplay(alloc, .complete, false); + self.eraseDisplay(.scrollback, false); + self.eraseDisplay(.complete, false); + self.screen.cursorAbsolute(0, 0); self.pwd.clearRetainingCapacity(); self.status_display = .main; } -test "Terminal: fullReset with a non-empty pen" { - var t = try init(testing.allocator, 80, 80); - defer t.deinit(testing.allocator); - - t.screen.cursor.pen.bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x7F } }; - t.screen.cursor.pen.fg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x7F } }; - t.fullReset(testing.allocator); +test "Terminal: input with no control characters" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 40, .rows = 40 }); + defer t.deinit(alloc); - const cell = t.screen.getCell(.active, t.screen.cursor.y, t.screen.cursor.x); - try testing.expect(cell.bg == .none); - try testing.expect(cell.fg == .none); + // Basic grid writing + for ("hello") |c| try t.print(c); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 5), t.screen.cursor.x); + { + const str = try t.plainString(alloc); + defer alloc.free(str); + try testing.expectEqualStrings("hello", str); + } } -test "Terminal: fullReset origin mode" { - var t = try init(testing.allocator, 10, 10); - defer t.deinit(testing.allocator); +test "Terminal: input with basic wraparound" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 5, .rows = 40 }); + defer t.deinit(alloc); - t.setCursorPos(3, 5); - t.modes.set(.origin, true); - t.fullReset(testing.allocator); + // Basic grid writing + for ("helloworldabc12") |c| try t.print(c); + try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); + try testing.expect(t.screen.cursor.pending_wrap); + { + const str = try t.plainString(alloc); + defer alloc.free(str); + try testing.expectEqualStrings("hello\nworld\nabc12", str); + } +} - // Origin mode should be reset and the cursor should be moved - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); +test "Terminal: input that forces scroll" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 1, .rows = 5 }); + defer t.deinit(alloc); + + // Basic grid writing + for ("abcdef") |c| try t.print(c); + try testing.expectEqual(@as(usize, 4), t.screen.cursor.y); try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expect(!t.modes.get(.origin)); + { + const str = try t.plainString(alloc); + defer alloc.free(str); + try testing.expectEqualStrings("b\nc\nd\ne\nf", str); + } } -test "Terminal: fullReset status display" { - var t = try init(testing.allocator, 10, 10); - defer t.deinit(testing.allocator); +test "Terminal: input unique style per cell" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 30, .rows = 30 }); + defer t.deinit(alloc); - t.status_display = .status_line; - t.fullReset(testing.allocator); - try testing.expect(t.status_display == .main); + for (0..t.rows) |y| { + for (0..t.cols) |x| { + t.setCursorPos(y, x); + try t.setAttribute(.{ .direct_color_bg = .{ + .r = @intCast(x), + .g = @intCast(y), + .b = 0, + } }); + try t.print('x'); + } + } } -test "Terminal: input with no control characters" { - var t = try init(testing.allocator, 80, 80); - defer t.deinit(testing.allocator); +test "Terminal: input glitch text" { + const glitch = @embedFile("res/glitch.txt"); + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 30, .rows = 30 }); + defer t.deinit(alloc); - // Basic grid writing - for ("hello") |c| try t.print(c); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 5), t.screen.cursor.x); - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("hello", str); + for (0..100) |_| { + try t.printString(glitch); } } test "Terminal: zero-width character at start" { - var t = try init(testing.allocator, 80, 80); + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // This used to crash the terminal. This is not allowed so we should @@ -2261,16 +2507,89 @@ test "Terminal: zero-width character at start" { // https://github.com/mitchellh/ghostty/issues/1400 test "Terminal: print single very long line" { - var t = try init(testing.allocator, 5, 5); + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); // This would crash for issue 1400. So the assertion here is // that we simply do not crash. - for (0..500) |_| try t.print('x'); + for (0..1000) |_| try t.print('x'); +} + +test "Terminal: print wide char" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + try t.print(0x1F600); // Smiley face + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x1F600), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } +} + +test "Terminal: print wide char at edge creates spacer head" { + var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + t.setCursorPos(1, 10); + try t.print(0x1F600); // Smiley face + try testing.expectEqual(@as(usize, 1), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 9, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); + } + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x1F600), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } +} + +test "Terminal: print wide char with 1-column width" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 1, .rows = 2 }); + defer t.deinit(alloc); + + try t.print('😀'); // 0x1F600 +} + +test "Terminal: print wide char in single-width terminal" { + var t = try init(testing.allocator, .{ .cols = 1, .rows = 80 }); + defer t.deinit(testing.allocator); + + try t.print(0x1F600); // Smiley face + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expect(t.screen.cursor.pending_wrap); + + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, ' '), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } } test "Terminal: print over wide char at 0,0" { - var t = try init(testing.allocator, 80, 80); + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); try t.print(0x1F600); // Smiley face @@ -2280,21 +2599,22 @@ test "Terminal: print over wide char at 0,0" { try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); try testing.expectEqual(@as(usize, 1), t.screen.cursor.x); - const row = t.screen.getRow(.{ .screen = 0 }); { - const cell = row.getCell(0); - try testing.expectEqual(@as(u32, 'A'), cell.char); - try testing.expect(!cell.attrs.wide); - try testing.expectEqual(@as(usize, 1), row.codepointLen(0)); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); } { - const cell = row.getCell(1); - try testing.expect(!cell.attrs.wide_spacer_tail); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); } } test "Terminal: print over wide spacer tail" { - var t = try init(testing.allocator, 5, 5); + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); try t.print('橋'); @@ -2302,111 +2622,149 @@ test "Terminal: print over wide spacer tail" { try t.print('X'); { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" X", str); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); } - - const row = t.screen.getRow(.{ .screen = 0 }); { - const cell = row.getCell(0); - try testing.expectEqual(@as(u32, 0), cell.char); - try testing.expect(!cell.attrs.wide); - try testing.expectEqual(@as(usize, 1), row.codepointLen(0)); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 'X'), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); } + { - const cell = row.getCell(1); - try testing.expectEqual(@as(u32, 'X'), cell.char); - try testing.expect(!cell.attrs.wide_spacer_tail); - try testing.expectEqual(@as(usize, 1), row.codepointLen(1)); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" X", str); } } -test "Terminal: VS15 to make narrow character" { - var t = try init(testing.allocator, 5, 5); +test "Terminal: print over wide char with bold" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); - // Enable grapheme clustering - t.modes.set(.grapheme_cluster, true); - - try t.print(0x26C8); // Thunder cloud and rain - try t.print(0xFE0E); // VS15 to make narrow - + try t.setAttribute(.{ .bold = {} }); + try t.print(0x1F600); // Smiley face + // verify we have styles in our style map { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("⛈︎", str); + const page = t.screen.cursor.page_pin.page.data; + try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); } - const row = t.screen.getRow(.{ .screen = 0 }); + // Go back and overwrite with no style + t.setCursorPos(0, 0); + try t.setAttribute(.{ .unset = {} }); + try t.print('A'); // Smiley face + + // verify our style is gone { - const cell = row.getCell(0); - try testing.expectEqual(@as(u32, 0x26C8), cell.char); - try testing.expect(!cell.attrs.wide); - try testing.expectEqual(@as(usize, 2), row.codepointLen(0)); + const page = t.screen.cursor.page_pin.page.data; + try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); } } -test "Terminal: VS16 to make wide character with mode 2027" { - var t = try init(testing.allocator, 5, 5); +test "Terminal: print over wide char with bg color" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); - // Enable grapheme clustering - t.modes.set(.grapheme_cluster, true); - - try t.print(0x2764); // Heart - try t.print(0xFE0F); // VS16 to make wide - + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + try t.print(0x1F600); // Smiley face + // verify we have styles in our style map { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("❤️", str); + const page = t.screen.cursor.page_pin.page.data; + try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); } - const row = t.screen.getRow(.{ .screen = 0 }); + // Go back and overwrite with no style + t.setCursorPos(0, 0); + try t.setAttribute(.{ .unset = {} }); + try t.print('A'); // Smiley face + + // verify our style is gone { - const cell = row.getCell(0); - try testing.expectEqual(@as(u32, 0x2764), cell.char); - try testing.expect(cell.attrs.wide); - try testing.expectEqual(@as(usize, 2), row.codepointLen(0)); + const page = t.screen.cursor.page_pin.page.data; + try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); } } -test "Terminal: VS16 repeated with mode 2027" { - var t = try init(testing.allocator, 5, 5); +test "Terminal: print multicodepoint grapheme, disabled mode 2027" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); - // Enable grapheme clustering - t.modes.set(.grapheme_cluster, true); + // https://github.com/mitchellh/ghostty/issues/289 + // This is: 👨‍👩‍👧 (which may or may not render correctly) + try t.print(0x1F468); + try t.print(0x200D); + try t.print(0x1F469); + try t.print(0x200D); + try t.print(0x1F467); - try t.print(0x2764); // Heart - try t.print(0xFE0F); // VS16 to make wide - try t.print(0x2764); // Heart - try t.print(0xFE0F); // VS16 to make wide + // We should have 6 cells taken up + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 6), t.screen.cursor.x); + // Assert various properties about our screen to verify + // we have all expected cells. { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("❤️❤️", str); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x1F468), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + const cps = list_cell.page.data.lookupGrapheme(cell).?; + try testing.expectEqual(@as(usize, 1), cps.len); + } + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, ' '), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + try testing.expect(list_cell.page.data.lookupGrapheme(cell) == null); + } + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x1F469), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + const cps = list_cell.page.data.lookupGrapheme(cell).?; + try testing.expectEqual(@as(usize, 1), cps.len); + } + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 3, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, ' '), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + try testing.expect(list_cell.page.data.lookupGrapheme(cell) == null); } - - const row = t.screen.getRow(.{ .screen = 0 }); { - const cell = row.getCell(0); - try testing.expectEqual(@as(u32, 0x2764), cell.char); - try testing.expect(cell.attrs.wide); - try testing.expectEqual(@as(usize, 2), row.codepointLen(0)); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x1F467), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + try testing.expect(list_cell.page.data.lookupGrapheme(cell) == null); } { - const cell = row.getCell(2); - try testing.expectEqual(@as(u32, 0x2764), cell.char); - try testing.expect(cell.attrs.wide); - try testing.expectEqual(@as(usize, 2), row.codepointLen(2)); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 5, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, ' '), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + try testing.expect(list_cell.page.data.lookupGrapheme(cell) == null); } } test "Terminal: VS16 doesn't make character with 2027 disabled" { - var t = try init(testing.allocator, 5, 5); + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); // Disable grapheme clustering @@ -2421,19 +2779,52 @@ test "Terminal: VS16 doesn't make character with 2027 disabled" { try testing.expectEqualStrings("❤️", str); } - const row = t.screen.getRow(.{ .screen = 0 }); { - const cell = row.getCell(0); - try testing.expectEqual(@as(u32, 0x2764), cell.char); - try testing.expect(!cell.attrs.wide); - try testing.expectEqual(@as(usize, 2), row.codepointLen(0)); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + const cps = list_cell.page.data.lookupGrapheme(cell).?; + try testing.expectEqual(@as(usize, 1), cps.len); } } -test "Terminal: print multicodepoint grapheme, disabled mode 2027" { - var t = try init(testing.allocator, 80, 80); +test "Terminal: print invalid VS16 non-grapheme" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // https://github.com/mitchellh/ghostty/issues/1482 + try t.print('x'); + try t.print(0xFE0F); + + // We should have 2 cells taken up. It is one character but "wide". + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screen.cursor.x); + + // Assert various properties about our screen to verify + // we have all expected cells. + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 'x'), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + } +} + +test "Terminal: print multicodepoint grapheme, mode 2027" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + // https://github.com/mitchellh/ghostty/issues/289 // This is: 👨‍👩‍👧 (which may or may not render correctly) try t.print(0x1F468); @@ -2442,114 +2833,124 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { try t.print(0x200D); try t.print(0x1F467); - // We should have 6 cells taken up + // We should have 2 cells taken up. It is one character but "wide". try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 6), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); // Assert various properties about our screen to verify // we have all expected cells. - const row = t.screen.getRow(.{ .screen = 0 }); - { - const cell = row.getCell(0); - try testing.expectEqual(@as(u32, 0x1F468), cell.char); - try testing.expect(cell.attrs.wide); - try testing.expectEqual(@as(usize, 2), row.codepointLen(0)); - } - { - const cell = row.getCell(1); - try testing.expectEqual(@as(u32, ' '), cell.char); - try testing.expect(cell.attrs.wide_spacer_tail); - try testing.expectEqual(@as(usize, 1), row.codepointLen(1)); - } { - const cell = row.getCell(2); - try testing.expectEqual(@as(u32, 0x1F469), cell.char); - try testing.expect(cell.attrs.wide); - try testing.expectEqual(@as(usize, 2), row.codepointLen(2)); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x1F468), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + const cps = list_cell.page.data.lookupGrapheme(cell).?; + try testing.expectEqual(@as(usize, 4), cps.len); } { - const cell = row.getCell(3); - try testing.expectEqual(@as(u32, ' '), cell.char); - try testing.expect(cell.attrs.wide_spacer_tail); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, ' '), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); } +} + +test "Terminal: VS15 to make narrow character" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + try t.print(0x26C8); // Thunder cloud and rain + try t.print(0xFE0E); // VS15 to make narrow + { - const cell = row.getCell(4); - try testing.expectEqual(@as(u32, 0x1F467), cell.char); - try testing.expect(cell.attrs.wide); - try testing.expectEqual(@as(usize, 1), row.codepointLen(4)); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("⛈︎", str); } + { - const cell = row.getCell(5); - try testing.expectEqual(@as(u32, ' '), cell.char); - try testing.expect(cell.attrs.wide_spacer_tail); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x26C8), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + const cps = list_cell.page.data.lookupGrapheme(cell).?; + try testing.expectEqual(@as(usize, 1), cps.len); } } -test "Terminal: print multicodepoint grapheme, mode 2027" { - var t = try init(testing.allocator, 80, 80); +test "Terminal: VS16 to make wide character with mode 2027" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); // Enable grapheme clustering t.modes.set(.grapheme_cluster, true); - // https://github.com/mitchellh/ghostty/issues/289 - // This is: 👨‍👩‍👧 (which may or may not render correctly) - try t.print(0x1F468); - try t.print(0x200D); - try t.print(0x1F469); - try t.print(0x200D); - try t.print(0x1F467); - - // We should have 2 cells taken up. It is one character but "wide". - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + try t.print(0x2764); // Heart + try t.print(0xFE0F); // VS16 to make wide - // Assert various properties about our screen to verify - // we have all expected cells. - const row = t.screen.getRow(.{ .screen = 0 }); { - const cell = row.getCell(0); - try testing.expectEqual(@as(u32, 0x1F468), cell.char); - try testing.expect(cell.attrs.wide); - try testing.expectEqual(@as(usize, 5), row.codepointLen(0)); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("❤️", str); } + { - const cell = row.getCell(1); - try testing.expectEqual(@as(u32, ' '), cell.char); - try testing.expect(cell.attrs.wide_spacer_tail); - try testing.expectEqual(@as(usize, 1), row.codepointLen(1)); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + const cps = list_cell.page.data.lookupGrapheme(cell).?; + try testing.expectEqual(@as(usize, 1), cps.len); } } -test "Terminal: print invalid VS16 non-grapheme" { - var t = try init(testing.allocator, 80, 80); +test "Terminal: VS16 repeated with mode 2027" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); - // https://github.com/mitchellh/ghostty/issues/1482 - try t.print('x'); - try t.print(0xFE0F); + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); - // We should have 2 cells taken up. It is one character but "wide". - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.x); + try t.print(0x2764); // Heart + try t.print(0xFE0F); // VS16 to make wide + try t.print(0x2764); // Heart + try t.print(0xFE0F); // VS16 to make wide + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("❤️❤️", str); + } - // Assert various properties about our screen to verify - // we have all expected cells. - const row = t.screen.getRow(.{ .screen = 0 }); { - const cell = row.getCell(0); - try testing.expectEqual(@as(u32, 'x'), cell.char); - try testing.expect(!cell.attrs.wide); - try testing.expectEqual(@as(usize, 1), row.codepointLen(0)); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + const cps = list_cell.page.data.lookupGrapheme(cell).?; + try testing.expectEqual(@as(usize, 1), cps.len); } { - const cell = row.getCell(1); - try testing.expectEqual(@as(u32, 0), cell.char); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + const cps = list_cell.page.data.lookupGrapheme(cell).?; + try testing.expectEqual(@as(usize, 1), cps.len); } } test "Terminal: print invalid VS16 grapheme" { - var t = try init(testing.allocator, 80, 80); + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // Enable grapheme clustering @@ -2565,21 +2966,23 @@ test "Terminal: print invalid VS16 grapheme" { // Assert various properties about our screen to verify // we have all expected cells. - const row = t.screen.getRow(.{ .screen = 0 }); { - const cell = row.getCell(0); - try testing.expectEqual(@as(u32, 'x'), cell.char); - try testing.expect(!cell.attrs.wide); - try testing.expectEqual(@as(usize, 1), row.codepointLen(0)); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 'x'), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); } { - const cell = row.getCell(1); - try testing.expectEqual(@as(u32, 0), cell.char); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); } } test "Terminal: print invalid VS16 with second char" { - var t = try init(testing.allocator, 80, 80); + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // Enable grapheme clustering @@ -2596,141 +2999,128 @@ test "Terminal: print invalid VS16 with second char" { // Assert various properties about our screen to verify // we have all expected cells. - const row = t.screen.getRow(.{ .screen = 0 }); { - const cell = row.getCell(0); - try testing.expectEqual(@as(u32, 'x'), cell.char); - try testing.expect(!cell.attrs.wide); - try testing.expectEqual(@as(usize, 1), row.codepointLen(0)); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 'x'), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); } + { - const cell = row.getCell(1); - try testing.expectEqual(@as(u32, 'y'), cell.char); - try testing.expect(!cell.attrs.wide); - try testing.expectEqual(@as(usize, 1), row.codepointLen(0)); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 'y'), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); } } -test "Terminal: soft wrap" { - var t = try init(testing.allocator, 3, 80); +test "Terminal: overwrite grapheme should clear grapheme data" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); - // Basic grid writing - for ("hello") |c| try t.print(c); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + try t.print(0x26C8); // Thunder cloud and rain + try t.print(0xFE0E); // VS15 to make narrow + t.setCursorPos(1, 1); + try t.print('A'); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("hel\nlo", str); + try testing.expectEqualStrings("A", str); } -} - -test "Terminal: soft wrap with semantic prompt" { - var t = try init(testing.allocator, 3, 80); - defer t.deinit(testing.allocator); - - t.markSemanticPrompt(.prompt); - for ("hello") |c| try t.print(c); { - const row = t.screen.getRow(.{ .active = 0 }); - try testing.expect(row.getSemanticPrompt() == .prompt); - } - { - const row = t.screen.getRow(.{ .active = 1 }); - try testing.expect(row.getSemanticPrompt() == .prompt); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); } } -test "Terminal: disabled wraparound with wide char and one space" { - var t = try init(testing.allocator, 5, 5); +test "Terminal: overwrite multicodepoint grapheme clears grapheme data" { + var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - t.modes.set(.wraparound, false); - - // This puts our cursor at the end and there is NO SPACE for a - // wide character. - try t.printString("AAAA"); - try t.print(0x1F6A8); // Police car light - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("AAAA", str); - } + // https://github.com/mitchellh/ghostty/issues/289 + // This is: 👨‍👩‍👧 (which may or may not render correctly) + try t.print(0x1F468); + try t.print(0x200D); + try t.print(0x1F469); + try t.print(0x200D); + try t.print(0x1F467); - // Make sure we printed nothing - const row = t.screen.getRow(.{ .screen = 0 }); - { - const cell = row.getCell(4); - try testing.expectEqual(@as(u32, 0), cell.char); - try testing.expect(!cell.attrs.wide); - } -} + // We should have 2 cells taken up. It is one character but "wide". + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); -test "Terminal: disabled wraparound with wide char and no space" { - var t = try init(testing.allocator, 5, 5); - defer t.deinit(testing.allocator); + // We should have one cell with graphemes + const page = t.screen.cursor.page_pin.page.data; + try testing.expectEqual(@as(usize, 1), page.graphemeCount()); - t.modes.set(.wraparound, false); + // Move back and overwrite wide + t.setCursorPos(1, 1); + try t.print('X'); - // This puts our cursor at the end and there is NO SPACE for a - // wide character. - try t.printString("AAAAA"); - try t.print(0x1F6A8); // Police car light try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 1), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), page.graphemeCount()); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("AAAAA", str); - } - - // Make sure we printed nothing - const row = t.screen.getRow(.{ .screen = 0 }); - { - const cell = row.getCell(4); - try testing.expectEqual(@as(u32, 'A'), cell.char); - try testing.expect(!cell.attrs.wide); + try testing.expectEqualStrings("X", str); } } -test "Terminal: disabled wraparound with wide grapheme and half space" { - var t = try init(testing.allocator, 5, 5); +test "Terminal: overwrite multicodepoint grapheme tail clears grapheme data" { + var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); + // Enable grapheme clustering t.modes.set(.grapheme_cluster, true); - t.modes.set(.wraparound, false); - // This puts our cursor at the end and there is NO SPACE for a - // wide character. - try t.printString("AAAA"); - try t.print(0x2764); // Heart - try t.print(0xFE0F); // VS16 to make wide + // https://github.com/mitchellh/ghostty/issues/289 + // This is: 👨‍👩‍👧 (which may or may not render correctly) + try t.print(0x1F468); + try t.print(0x200D); + try t.print(0x1F469); + try t.print(0x200D); + try t.print(0x1F467); + + // We should have 2 cells taken up. It is one character but "wide". try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + + // We should have one cell with graphemes + const page = t.screen.cursor.page_pin.page.data; + try testing.expectEqual(@as(usize, 1), page.graphemeCount()); + + // Move back and overwrite wide + t.setCursorPos(1, 2); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("AAAA❤", str); + try testing.expectEqualStrings(" X", str); } - // Make sure we printed nothing - const row = t.screen.getRow(.{ .screen = 0 }); - { - const cell = row.getCell(4); - try testing.expectEqual(@as(u32, '❤'), cell.char); - try testing.expect(!cell.attrs.wide); - } + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), page.graphemeCount()); } test "Terminal: print writes to bottom if scrolled" { - var t = try init(testing.allocator, 5, 2); + var t = try init(testing.allocator, .{ .cols = 5, .rows = 2 }); defer t.deinit(testing.allocator); // Basic grid writing @@ -2749,7 +3139,7 @@ test "Terminal: print writes to bottom if scrolled" { } // Scroll to the top - try t.scrollViewport(.{ .top = {} }); + t.screen.scroll(.{ .top = {} }); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -2758,7 +3148,7 @@ test "Terminal: print writes to bottom if scrolled" { // Type try t.print('A'); - try t.scrollViewport(.{ .bottom = {} }); + t.screen.scroll(.{ .active = {} }); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -2767,7 +3157,7 @@ test "Terminal: print writes to bottom if scrolled" { } test "Terminal: print charset" { - var t = try init(testing.allocator, 80, 80); + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // G1 should have no effect @@ -2791,7 +3181,7 @@ test "Terminal: print charset" { } test "Terminal: print charset outside of ASCII" { - var t = try init(testing.allocator, 80, 80); + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // G1 should have no effect @@ -2811,7 +3201,7 @@ test "Terminal: print charset outside of ASCII" { } test "Terminal: print invoke charset" { - var t = try init(testing.allocator, 80, 80); + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); t.configureCharset(.G1, .dec_special); @@ -2830,26 +3220,142 @@ test "Terminal: print invoke charset" { } } -test "Terminal: print invoke charset single" { - var t = try init(testing.allocator, 80, 80); +test "Terminal: print invoke charset single" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + t.configureCharset(.G1, .dec_special); + + // Basic grid writing + try t.print('`'); + t.invokeCharset(.GL, .G1, true); + try t.print('`'); + try t.print('`'); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("`◆`", str); + } +} + +test "Terminal: soft wrap" { + var t = try init(testing.allocator, .{ .cols = 3, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Basic grid writing + for ("hello") |c| try t.print(c); + try testing.expectEqual(@as(usize, 1), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("hel\nlo", str); + } +} + +test "Terminal: soft wrap with semantic prompt" { + var t = try init(testing.allocator, .{ .cols = 3, .rows = 80 }); + defer t.deinit(testing.allocator); + + t.markSemanticPrompt(.prompt); + for ("hello") |c| try t.print(c); + + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + try testing.expectEqual(Row.SemanticPrompt.prompt, list_cell.row.semantic_prompt); + } + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; + try testing.expectEqual(Row.SemanticPrompt.prompt, list_cell.row.semantic_prompt); + } +} + +test "Terminal: disabled wraparound with wide char and one space" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + defer t.deinit(testing.allocator); + + t.modes.set(.wraparound, false); + + // This puts our cursor at the end and there is NO SPACE for a + // wide character. + try t.printString("AAAA"); + try t.print(0x1F6A8); // Police car light + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AAAA", str); + } + + // Make sure we printed nothing + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } +} + +test "Terminal: disabled wraparound with wide char and no space" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + defer t.deinit(testing.allocator); + + t.modes.set(.wraparound, false); + + // This puts our cursor at the end and there is NO SPACE for a + // wide character. + try t.printString("AAAAA"); + try t.print(0x1F6A8); // Police car light + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AAAAA", str); + } + + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } +} + +test "Terminal: disabled wraparound with wide grapheme and half space" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); - t.configureCharset(.G1, .dec_special); + t.modes.set(.grapheme_cluster, true); + t.modes.set(.wraparound, false); + + // This puts our cursor at the end and there is NO SPACE for a + // wide character. + try t.printString("AAAA"); + try t.print(0x2764); // Heart + try t.print(0xFE0F); // VS16 to make wide + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); - // Basic grid writing - try t.print('`'); - t.invokeCharset(.GL, .G1, true); - try t.print('`'); - try t.print('`'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("`◆`", str); + try testing.expectEqualStrings("AAAA❤", str); + } + + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, '❤'), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); } } test "Terminal: print right margin wrap" { - var t = try init(testing.allocator, 10, 5); + var t = try init(testing.allocator, .{ .cols = 10, .rows = 5 }); defer t.deinit(testing.allocator); try t.printString("123456789"); @@ -2863,10 +3369,16 @@ test "Terminal: print right margin wrap" { defer testing.allocator.free(str); try testing.expectEqualStrings("1234X6789\n Y", str); } + + { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + const row = list_cell.row; + try testing.expect(!row.wrap); + } } test "Terminal: print right margin outside" { - var t = try init(testing.allocator, 10, 5); + var t = try init(testing.allocator, .{ .cols = 10, .rows = 5 }); defer t.deinit(testing.allocator); try t.printString("123456789"); @@ -2883,7 +3395,7 @@ test "Terminal: print right margin outside" { } test "Terminal: print right margin outside wrap" { - var t = try init(testing.allocator, 10, 5); + var t = try init(testing.allocator, .{ .cols = 10, .rows = 5 }); defer t.deinit(testing.allocator); try t.printString("123456789"); @@ -2899,8 +3411,41 @@ test "Terminal: print right margin outside wrap" { } } +test "Terminal: print wide char at right margin does not create spacer head" { + var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(3, 5); + t.setCursorPos(1, 5); + try t.print(0x1F600); // Smiley face + try testing.expectEqual(@as(usize, 1), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); + + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, ' '), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + + const row = list_cell.row; + try testing.expect(!row.wrap); + } + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 2, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x1F600), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 3, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } +} + test "Terminal: linefeed and carriage return" { - var t = try init(testing.allocator, 80, 80); + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // Basic grid writing @@ -2918,7 +3463,7 @@ test "Terminal: linefeed and carriage return" { } test "Terminal: linefeed unsets pending wrap" { - var t = try init(testing.allocator, 5, 80); + var t = try init(testing.allocator, .{ .cols = 5, .rows = 80 }); defer t.deinit(testing.allocator); // Basic grid writing @@ -2929,7 +3474,7 @@ test "Terminal: linefeed unsets pending wrap" { } test "Terminal: linefeed mode automatic carriage return" { - var t = try init(testing.allocator, 10, 10); + var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); // Basic grid writing @@ -2945,7 +3490,7 @@ test "Terminal: linefeed mode automatic carriage return" { } test "Terminal: carriage return unsets pending wrap" { - var t = try init(testing.allocator, 5, 80); + var t = try init(testing.allocator, .{ .cols = 5, .rows = 80 }); defer t.deinit(testing.allocator); // Basic grid writing @@ -2956,7 +3501,7 @@ test "Terminal: carriage return unsets pending wrap" { } test "Terminal: carriage return origin mode moves to left margin" { - var t = try init(testing.allocator, 5, 80); + var t = try init(testing.allocator, .{ .cols = 5, .rows = 80 }); defer t.deinit(testing.allocator); t.modes.set(.origin, true); @@ -2967,7 +3512,7 @@ test "Terminal: carriage return origin mode moves to left margin" { } test "Terminal: carriage return left of left margin moves to zero" { - var t = try init(testing.allocator, 5, 80); + var t = try init(testing.allocator, .{ .cols = 5, .rows = 80 }); defer t.deinit(testing.allocator); t.screen.cursor.x = 1; @@ -2977,7 +3522,7 @@ test "Terminal: carriage return left of left margin moves to zero" { } test "Terminal: carriage return right of left margin moves to left margin" { - var t = try init(testing.allocator, 5, 80); + var t = try init(testing.allocator, .{ .cols = 5, .rows = 80 }); defer t.deinit(testing.allocator); t.screen.cursor.x = 3; @@ -2987,7 +3532,7 @@ test "Terminal: carriage return right of left margin moves to left margin" { } test "Terminal: backspace" { - var t = try init(testing.allocator, 80, 80); + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // BS @@ -3005,7 +3550,7 @@ test "Terminal: backspace" { test "Terminal: horizontal tabs" { const alloc = testing.allocator; - var t = try init(alloc, 20, 5); + var t = try init(alloc, .{ .cols = 20, .rows = 5 }); defer t.deinit(alloc); // HT @@ -3026,12 +3571,12 @@ test "Terminal: horizontal tabs" { test "Terminal: horizontal tabs starting on tabstop" { const alloc = testing.allocator; - var t = try init(alloc, 20, 5); + var t = try init(alloc, .{ .cols = 20, .rows = 5 }); defer t.deinit(alloc); - t.screen.cursor.x = 8; + t.setCursorPos(t.screen.cursor.y, 9); try t.print('X'); - t.screen.cursor.x = 8; + t.setCursorPos(t.screen.cursor.y, 9); try t.horizontalTab(); try t.print('A'); @@ -3044,12 +3589,12 @@ test "Terminal: horizontal tabs starting on tabstop" { test "Terminal: horizontal tabs with right margin" { const alloc = testing.allocator; - var t = try init(alloc, 20, 5); + var t = try init(alloc, .{ .cols = 20, .rows = 5 }); defer t.deinit(alloc); t.scrolling_region.left = 2; t.scrolling_region.right = 5; - t.screen.cursor.x = 0; + t.setCursorPos(t.screen.cursor.y, 1); try t.print('X'); try t.horizontalTab(); try t.print('A'); @@ -3063,11 +3608,11 @@ test "Terminal: horizontal tabs with right margin" { test "Terminal: horizontal tabs back" { const alloc = testing.allocator; - var t = try init(alloc, 20, 5); + var t = try init(alloc, .{ .cols = 20, .rows = 5 }); defer t.deinit(alloc); // Edge of screen - t.screen.cursor.x = 19; + t.setCursorPos(t.screen.cursor.y, 20); // HT try t.horizontalTabBack(); @@ -3086,12 +3631,12 @@ test "Terminal: horizontal tabs back" { test "Terminal: horizontal tabs back starting on tabstop" { const alloc = testing.allocator; - var t = try init(alloc, 20, 5); + var t = try init(alloc, .{ .cols = 20, .rows = 5 }); defer t.deinit(alloc); - t.screen.cursor.x = 8; + t.setCursorPos(t.screen.cursor.y, 9); try t.print('X'); - t.screen.cursor.x = 8; + t.setCursorPos(t.screen.cursor.y, 9); try t.horizontalTabBack(); try t.print('A'); @@ -3104,13 +3649,13 @@ test "Terminal: horizontal tabs back starting on tabstop" { test "Terminal: horizontal tabs with left margin in origin mode" { const alloc = testing.allocator; - var t = try init(alloc, 20, 5); + var t = try init(alloc, .{ .cols = 20, .rows = 5 }); defer t.deinit(alloc); t.modes.set(.origin, true); t.scrolling_region.left = 2; t.scrolling_region.right = 5; - t.screen.cursor.x = 3; + t.setCursorPos(1, 2); try t.print('X'); try t.horizontalTabBack(); try t.print('A'); @@ -3124,14 +3669,14 @@ test "Terminal: horizontal tabs with left margin in origin mode" { test "Terminal: horizontal tab back with cursor before left margin" { const alloc = testing.allocator; - var t = try init(alloc, 20, 5); + var t = try init(alloc, .{ .cols = 20, .rows = 5 }); defer t.deinit(alloc); t.modes.set(.origin, true); t.saveCursor(); t.modes.set(.enable_left_and_right_margin, true); t.setLeftAndRightMargin(5, 0); - t.restoreCursor(); + try t.restoreCursor(); try t.horizontalTabBack(); try t.print('X'); @@ -3144,7 +3689,7 @@ test "Terminal: horizontal tab back with cursor before left margin" { test "Terminal: cursorPos resets wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -3162,7 +3707,7 @@ test "Terminal: cursorPos resets wrap" { test "Terminal: cursorPos off the screen" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setCursorPos(500, 500); @@ -3177,7 +3722,7 @@ test "Terminal: cursorPos off the screen" { test "Terminal: cursorPos relative to origin" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.scrolling_region.top = 2; @@ -3195,7 +3740,7 @@ test "Terminal: cursorPos relative to origin" { test "Terminal: cursorPos relative to origin with left/right" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.scrolling_region.top = 2; @@ -3215,7 +3760,7 @@ test "Terminal: cursorPos relative to origin with left/right" { test "Terminal: cursorPos limits with full scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.scrolling_region.top = 2; @@ -3233,8 +3778,9 @@ test "Terminal: cursorPos limits with full scroll region" { } } +// Probably outdated, but dates back to the original terminal implementation. test "Terminal: setCursorPos (original test)" { - var t = try init(testing.allocator, 80, 80); + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); @@ -3287,7 +3833,7 @@ test "Terminal: setCursorPos (original test)" { test "Terminal: setTopAndBottomMargin simple" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -3298,7 +3844,7 @@ test "Terminal: setTopAndBottomMargin simple" { try t.linefeed(); try t.printString("GHI"); t.setTopAndBottomMargin(0, 0); - try t.scrollDown(1); + t.scrollDown(1); { const str = try t.plainString(testing.allocator); @@ -3309,7 +3855,7 @@ test "Terminal: setTopAndBottomMargin simple" { test "Terminal: setTopAndBottomMargin top only" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -3320,7 +3866,7 @@ test "Terminal: setTopAndBottomMargin top only" { try t.linefeed(); try t.printString("GHI"); t.setTopAndBottomMargin(2, 0); - try t.scrollDown(1); + t.scrollDown(1); { const str = try t.plainString(testing.allocator); @@ -3331,7 +3877,7 @@ test "Terminal: setTopAndBottomMargin top only" { test "Terminal: setTopAndBottomMargin top and bottom" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -3342,7 +3888,7 @@ test "Terminal: setTopAndBottomMargin top and bottom" { try t.linefeed(); try t.printString("GHI"); t.setTopAndBottomMargin(1, 2); - try t.scrollDown(1); + t.scrollDown(1); { const str = try t.plainString(testing.allocator); @@ -3353,7 +3899,7 @@ test "Terminal: setTopAndBottomMargin top and bottom" { test "Terminal: setTopAndBottomMargin top equal to bottom" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -3364,7 +3910,7 @@ test "Terminal: setTopAndBottomMargin top equal to bottom" { try t.linefeed(); try t.printString("GHI"); t.setTopAndBottomMargin(2, 2); - try t.scrollDown(1); + t.scrollDown(1); { const str = try t.plainString(testing.allocator); @@ -3375,7 +3921,7 @@ test "Terminal: setTopAndBottomMargin top equal to bottom" { test "Terminal: setLeftAndRightMargin simple" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -3398,7 +3944,7 @@ test "Terminal: setLeftAndRightMargin simple" { test "Terminal: setLeftAndRightMargin left only" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -3413,7 +3959,7 @@ test "Terminal: setLeftAndRightMargin left only" { try testing.expectEqual(@as(usize, 1), t.scrolling_region.left); try testing.expectEqual(@as(usize, t.cols - 1), t.scrolling_region.right); t.setCursorPos(1, 2); - try t.insertLines(1); + t.insertLines(1); { const str = try t.plainString(testing.allocator); @@ -3424,7 +3970,7 @@ test "Terminal: setLeftAndRightMargin left only" { test "Terminal: setLeftAndRightMargin left and right" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -3437,7 +3983,7 @@ test "Terminal: setLeftAndRightMargin left and right" { t.modes.set(.enable_left_and_right_margin, true); t.setLeftAndRightMargin(1, 2); t.setCursorPos(1, 2); - try t.insertLines(1); + t.insertLines(1); { const str = try t.plainString(testing.allocator); @@ -3448,7 +3994,7 @@ test "Terminal: setLeftAndRightMargin left and right" { test "Terminal: setLeftAndRightMargin left equal right" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -3461,7 +4007,7 @@ test "Terminal: setLeftAndRightMargin left equal right" { t.modes.set(.enable_left_and_right_margin, true); t.setLeftAndRightMargin(2, 2); t.setCursorPos(1, 2); - try t.insertLines(1); + t.insertLines(1); { const str = try t.plainString(testing.allocator); @@ -3472,7 +4018,7 @@ test "Terminal: setLeftAndRightMargin left equal right" { test "Terminal: setLeftAndRightMargin mode 69 unset" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -3485,7 +4031,7 @@ test "Terminal: setLeftAndRightMargin mode 69 unset" { t.modes.set(.enable_left_and_right_margin, false); t.setLeftAndRightMargin(1, 2); t.setCursorPos(1, 2); - try t.insertLines(1); + t.insertLines(1); { const str = try t.plainString(testing.allocator); @@ -3494,9 +4040,152 @@ test "Terminal: setLeftAndRightMargin mode 69 unset" { } } -test "Terminal: deleteLines" { +test "Terminal: insertLines simple" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setCursorPos(2, 2); + t.insertLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\n\nDEF\nGHI", str); + } +} + +test "Terminal: insertLines colors with bg color" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setCursorPos(2, 2); + + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + t.insertLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\n\nDEF\nGHI", str); + } + + for (0..t.cols) |x| { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = x, .y = 1 } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); + } +} + +test "Terminal: insertLines handles style refs" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + + // For the line being deleted, create a refcounted style + try t.setAttribute(.{ .bold = {} }); + try t.printString("GHI"); + try t.setAttribute(.{ .unset = {} }); + + // verify we have styles in our style map + const page = t.screen.cursor.page_pin.page.data; + try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); + + t.setCursorPos(2, 2); + t.insertLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\n\nDEF", str); + } + + // verify we have no styles in our style map + try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); +} + +test "Terminal: insertLines outside of scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setTopAndBottomMargin(3, 4); + t.setCursorPos(2, 2); + t.insertLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nDEF\nGHI", str); + } +} + +test "Terminal: insertLines top/bottom scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("123"); + t.setTopAndBottomMargin(1, 3); + t.setCursorPos(2, 2); + t.insertLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\n\nDEF\n123", str); + } +} + +test "Terminal: insertLines (legacy test)" { const alloc = testing.allocator; - var t = try init(alloc, 80, 80); + var t = try init(alloc, .{ .cols = 2, .rows = 5 }); defer t.deinit(alloc); // Initial value @@ -3510,28 +4199,36 @@ test "Terminal: deleteLines" { t.carriageReturn(); try t.linefeed(); try t.print('D'); - - t.cursorUp(2); - try t.deleteLines(1); - - try t.print('E'); t.carriageReturn(); try t.linefeed(); + try t.print('E'); - // We should be - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); + // Move to row 2 + t.setCursorPos(2, 1); + + // Insert two lines + t.insertLines(2); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A\nE\nD", str); + try testing.expectEqualStrings("A\n\n\nB\nC", str); } } -test "Terminal: deleteLines with scroll region" { +test "Terminal: insertLines zero" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 2, .rows = 5 }); + defer t.deinit(alloc); + + // This should do nothing + t.setCursorPos(1, 1); + t.insertLines(0); +} + +test "Terminal: insertLines with scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 80, 80); + var t = try init(alloc, .{ .cols = 2, .rows = 6 }); defer t.deinit(alloc); // Initial value @@ -3545,111 +4242,184 @@ test "Terminal: deleteLines with scroll region" { t.carriageReturn(); try t.linefeed(); try t.print('D'); + t.carriageReturn(); + try t.linefeed(); + try t.print('E'); - t.setTopAndBottomMargin(1, 3); + t.setTopAndBottomMargin(1, 2); t.setCursorPos(1, 1); - try t.deleteLines(1); + t.insertLines(1); - try t.print('E'); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X\nA\nC\nD\nE", str); + } +} + +test "Terminal: insertLines more than remaining" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 2, .rows = 5 }); + defer t.deinit(alloc); + + // Initial value + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + try t.print('B'); + t.carriageReturn(); + try t.linefeed(); + try t.print('C'); + t.carriageReturn(); + try t.linefeed(); + try t.print('D'); t.carriageReturn(); try t.linefeed(); + try t.print('E'); - // We should be - // try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - // try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); + // Move to row 2 + t.setCursorPos(2, 1); + + // Insert a bunch of lines + t.insertLines(20); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("E\nC\n\nD", str); + try testing.expectEqualStrings("A", str); } } -test "Terminal: deleteLines with scroll region, large count" { +test "Terminal: insertLines resets pending wrap" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + t.insertLines(1); + try testing.expect(!t.screen.cursor.pending_wrap); + try t.print('B'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("B\nABCDE", str); + } +} + +test "Terminal: insertLines resets wrap" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + + try t.print('1'); + t.carriageReturn(); + try t.linefeed(); + for ("ABCDEF") |c| try t.print(c); + t.setCursorPos(1, 1); + t.insertLines(1); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X\n1\nABC", str); + } + + { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 2 } }).?; + const row = list_cell.row; + try testing.expect(!row.wrap); + } +} + +test "Terminal: insertLines multi-codepoint graphemes" { const alloc = testing.allocator; - var t = try init(alloc, 80, 80); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - // Initial value - try t.print('A'); - t.carriageReturn(); - try t.linefeed(); - try t.print('B'); - t.carriageReturn(); - try t.linefeed(); - try t.print('C'); + // Disable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + try t.printString("ABC"); t.carriageReturn(); try t.linefeed(); - try t.print('D'); - t.setTopAndBottomMargin(1, 3); - t.setCursorPos(1, 1); - try t.deleteLines(5); + // This is: 👨‍👩‍👧 (which may or may not render correctly) + try t.print(0x1F468); + try t.print(0x200D); + try t.print(0x1F469); + try t.print(0x200D); + try t.print(0x1F467); - try t.print('E'); t.carriageReturn(); try t.linefeed(); - - // We should be - // try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - // try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); + try t.printString("GHI"); + t.setCursorPos(2, 2); + t.insertLines(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("E\n\n\nD", str); + try testing.expectEqualStrings("ABC\n\n👨‍👩‍👧\nGHI", str); } } -test "Terminal: deleteLines with scroll region, cursor outside of region" { +test "Terminal: insertLines left/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 80, 80); + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); - // Initial value - try t.print('A'); - t.carriageReturn(); - try t.linefeed(); - try t.print('B'); + try t.printString("ABC123"); t.carriageReturn(); try t.linefeed(); - try t.print('C'); + try t.printString("DEF456"); t.carriageReturn(); try t.linefeed(); - try t.print('D'); - - t.setTopAndBottomMargin(1, 3); - t.setCursorPos(4, 1); - try t.deleteLines(1); + try t.printString("GHI789"); + t.scrolling_region.left = 1; + t.scrolling_region.right = 3; + t.setCursorPos(2, 2); + t.insertLines(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A\nB\nC\nD", str); + try testing.expectEqualStrings("ABC123\nD 56\nGEF489\n HI7", str); } } -test "Terminal: deleteLines resets wrap" { +test "Terminal: scrollUp simple" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); - try t.deleteLines(1); - try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('B'); + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setCursorPos(2, 2); + const cursor = t.screen.cursor; + t.scrollUp(1); + try testing.expectEqual(cursor.x, t.screen.cursor.x); + try testing.expectEqual(cursor.y, t.screen.cursor.y); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("B", str); + try testing.expectEqualStrings("DEF\nGHI", str); } } -test "Terminal: deleteLines simple" { +test "Terminal: scrollUp top/bottom scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -3659,8 +4429,9 @@ test "Terminal: deleteLines simple" { t.carriageReturn(); try t.linefeed(); try t.printString("GHI"); - t.setCursorPos(2, 2); - try t.deleteLines(1); + t.setTopAndBottomMargin(2, 3); + t.setCursorPos(1, 1); + t.scrollUp(1); { const str = try t.plainString(testing.allocator); @@ -3669,9 +4440,9 @@ test "Terminal: deleteLines simple" { } } -test "Terminal: deleteLines left/right scroll region" { +test "Terminal: scrollUp left/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); try t.printString("ABC123"); @@ -3684,85 +4455,80 @@ test "Terminal: deleteLines left/right scroll region" { t.scrolling_region.left = 1; t.scrolling_region.right = 3; t.setCursorPos(2, 2); - try t.deleteLines(1); + const cursor = t.screen.cursor; + t.scrollUp(1); + try testing.expectEqual(cursor.x, t.screen.cursor.x); + try testing.expectEqual(cursor.y, t.screen.cursor.y); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC123\nDHI756\nG 89", str); + try testing.expectEqualStrings("AEF423\nDHI756\nG 89", str); } } -test "Terminal: deleteLines left/right scroll region clears row wrap" { +test "Terminal: scrollUp preserves pending wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - try t.print('0'); - t.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(2, 3); - try t.printRepeat(1000); - for (0..t.rows - 1) |y| { - const row = t.screen.getRow(.{ .active = y }); - try testing.expect(row.isWrapped()); - } + t.setCursorPos(1, 5); + try t.print('A'); + t.setCursorPos(2, 5); + try t.print('B'); + t.setCursorPos(3, 5); + try t.print('C'); + t.scrollUp(1); + try t.print('X'); + { - const row = t.screen.getRow(.{ .active = t.rows - 1 }); - try testing.expect(!row.isWrapped()); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" B\n C\n\nX", str); } } -test "Terminal: deleteLines left/right scroll region from top" { +test "Terminal: scrollUp full top/bottom region" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - try t.printString("ABC123"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF456"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("GHI789"); - t.scrolling_region.left = 1; - t.scrolling_region.right = 3; - t.setCursorPos(1, 2); - try t.deleteLines(1); + try t.printString("top"); + t.setCursorPos(5, 1); + try t.printString("ABCDE"); + t.setTopAndBottomMargin(2, 5); + t.scrollUp(4); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("AEF423\nDHI756\nG 89", str); + try testing.expectEqualStrings("top", str); } } -test "Terminal: deleteLines left/right scroll region high count" { +test "Terminal: scrollUp full top/bottomleft/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - try t.printString("ABC123"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF456"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("GHI789"); - t.scrolling_region.left = 1; - t.scrolling_region.right = 3; - t.setCursorPos(2, 2); - try t.deleteLines(100); + try t.printString("top"); + t.setCursorPos(5, 1); + try t.printString("ABCDE"); + t.modes.set(.enable_left_and_right_margin, true); + t.setTopAndBottomMargin(2, 5); + t.setLeftAndRightMargin(2, 4); + t.scrollUp(4); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC123\nD 56\nG 89", str); + try testing.expectEqualStrings("top\n\n\n\nA E", str); } } -test "Terminal: insertLines simple" { +test "Terminal: scrollDown simple" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -3773,18 +4539,21 @@ test "Terminal: insertLines simple" { try t.linefeed(); try t.printString("GHI"); t.setCursorPos(2, 2); - try t.insertLines(1); + const cursor = t.screen.cursor; + t.scrollDown(1); + try testing.expectEqual(cursor.x, t.screen.cursor.x); + try testing.expectEqual(cursor.y, t.screen.cursor.y); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\n\nDEF\nGHI", str); + try testing.expectEqualStrings("\nABC\nDEF\nGHI", str); } } -test "Terminal: insertLines outside of scroll region" { +test "Terminal: scrollDown outside of scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -3796,44 +4565,48 @@ test "Terminal: insertLines outside of scroll region" { try t.printString("GHI"); t.setTopAndBottomMargin(3, 4); t.setCursorPos(2, 2); - try t.insertLines(1); + const cursor = t.screen.cursor; + t.scrollDown(1); + try testing.expectEqual(cursor.x, t.screen.cursor.x); + try testing.expectEqual(cursor.y, t.screen.cursor.y); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nDEF\nGHI", str); + try testing.expectEqualStrings("ABC\nDEF\n\nGHI", str); } } -test "Terminal: insertLines top/bottom scroll region" { +test "Terminal: scrollDown left/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); - try t.printString("ABC"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF"); + try t.printString("ABC123"); t.carriageReturn(); try t.linefeed(); - try t.printString("GHI"); + try t.printString("DEF456"); t.carriageReturn(); try t.linefeed(); - try t.printString("123"); - t.setTopAndBottomMargin(1, 3); + try t.printString("GHI789"); + t.scrolling_region.left = 1; + t.scrolling_region.right = 3; t.setCursorPos(2, 2); - try t.insertLines(1); + const cursor = t.screen.cursor; + t.scrollDown(1); + try testing.expectEqual(cursor.x, t.screen.cursor.x); + try testing.expectEqual(cursor.y, t.screen.cursor.y); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\n\nDEF\n123", str); + try testing.expectEqualStrings("A 23\nDBC156\nGEF489\n HI7", str); } } -test "Terminal: insertLines left/right scroll region" { +test "Terminal: scrollDown outside of left/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); try t.printString("ABC123"); @@ -3845,146 +4618,273 @@ test "Terminal: insertLines left/right scroll region" { try t.printString("GHI789"); t.scrolling_region.left = 1; t.scrolling_region.right = 3; - t.setCursorPos(2, 2); - try t.insertLines(1); + t.setCursorPos(1, 1); + const cursor = t.screen.cursor; + t.scrollDown(1); + try testing.expectEqual(cursor.x, t.screen.cursor.x); + try testing.expectEqual(cursor.y, t.screen.cursor.y); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A 23\nDBC156\nGEF489\n HI7", str); + } +} + +test "Terminal: scrollDown preserves pending wrap" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 5, .rows = 10 }); + defer t.deinit(alloc); + + t.setCursorPos(1, 5); + try t.print('A'); + t.setCursorPos(2, 5); + try t.print('B'); + t.setCursorPos(3, 5); + try t.print('C'); + t.scrollDown(1); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n A\n B\nX C", str); + } +} + +test "Terminal: eraseChars simple operation" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 1); + t.eraseChars(2); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X C", str); + } +} + +test "Terminal: eraseChars minimum one" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 1); + t.eraseChars(0); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("XBC", str); + } +} + +test "Terminal: eraseChars beyond screen edge" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for (" ABC") |c| try t.print(c); + t.setCursorPos(1, 4); + t.eraseChars(10); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" A", str); + } +} + +test "Terminal: eraseChars wide character" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.print('橋'); + for ("BC") |c| try t.print(c); + t.setCursorPos(1, 1); + t.eraseChars(1); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X BC", str); + } +} + +test "Terminal: eraseChars resets pending wrap" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + t.eraseChars(1); + try testing.expect(!t.screen.cursor.pending_wrap); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC123\nD 56\nGEF489\n HI7", str); + try testing.expectEqualStrings("ABCDX", str); } } -test "Terminal: insertLines" { +test "Terminal: eraseChars resets wrap" { const alloc = testing.allocator; - var t = try init(alloc, 2, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - // Initial value - try t.print('A'); - t.carriageReturn(); - try t.linefeed(); - try t.print('B'); - t.carriageReturn(); - try t.linefeed(); - try t.print('C'); - t.carriageReturn(); - try t.linefeed(); - try t.print('D'); - t.carriageReturn(); - try t.linefeed(); - try t.print('E'); + for ("ABCDE123") |c| try t.print(c); + { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + const row = list_cell.row; + try testing.expect(row.wrap); + } - // Move to row 2 - t.setCursorPos(2, 1); + t.setCursorPos(1, 1); + t.eraseChars(1); - // Insert two lines - try t.insertLines(2); + { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + const row = list_cell.row; + try testing.expect(!row.wrap); + } + + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A\n\n\nB\nC", str); + try testing.expectEqualStrings("XBCDE\n123", str); } } -test "Terminal: insertLines zero" { +test "Terminal: eraseChars preserves background sgr" { const alloc = testing.allocator; - var t = try init(alloc, 2, 5); + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); - // This should do nothing + for ("ABC") |c| try t.print(c); t.setCursorPos(1, 1); - try t.insertLines(0); + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + t.eraseChars(2); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" C", str); + { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); + } + { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 1, .y = 0 } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); + } + } } -test "Terminal: insertLines with scroll region" { +test "Terminal: eraseChars handles refcounted styles" { const alloc = testing.allocator; - var t = try init(alloc, 2, 6); + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); - // Initial value + try t.setAttribute(.{ .bold = {} }); try t.print('A'); - t.carriageReturn(); - try t.linefeed(); try t.print('B'); - t.carriageReturn(); - try t.linefeed(); + try t.setAttribute(.{ .unset = {} }); try t.print('C'); - t.carriageReturn(); - try t.linefeed(); - try t.print('D'); - t.carriageReturn(); - try t.linefeed(); - try t.print('E'); - t.setTopAndBottomMargin(1, 2); + // verify we have styles in our style map + const page = t.screen.cursor.page_pin.page.data; + try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); + t.setCursorPos(1, 1); - try t.insertLines(1); + t.eraseChars(2); - try t.print('X'); + // verify we have no styles in our style map + try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); +} + +test "Terminal: eraseChars protected attributes respected with iso" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 1); + t.eraseChars(2); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("X\nA\nC\nD\nE", str); + try testing.expectEqualStrings("ABC", str); } } -test "Terminal: insertLines more than remaining" { +test "Terminal: eraseChars protected attributes ignored with dec most recent" { const alloc = testing.allocator; - var t = try init(alloc, 2, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - // Initial value - try t.print('A'); - t.carriageReturn(); - try t.linefeed(); - try t.print('B'); - t.carriageReturn(); - try t.linefeed(); - try t.print('C'); - t.carriageReturn(); - try t.linefeed(); - try t.print('D'); - t.carriageReturn(); - try t.linefeed(); - try t.print('E'); - - // Move to row 2 - t.setCursorPos(2, 1); - - // Insert a bunch of lines - try t.insertLines(20); + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); + t.setProtectedMode(.dec); + t.setProtectedMode(.off); + t.setCursorPos(1, 1); + t.eraseChars(2); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A", str); + try testing.expectEqualStrings(" C", str); } } -test "Terminal: insertLines resets wrap" { +test "Terminal: eraseChars protected attributes ignored with dec set" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); - try t.insertLines(1); - try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('B'); + t.setProtectedMode(.dec); + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 1); + t.eraseChars(2); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("B\nABCDE", str); + try testing.expectEqualStrings(" C", str); } } test "Terminal: reverseIndex" { const alloc = testing.allocator; - var t = try init(alloc, 2, 5); + var t = try init(alloc, .{ .cols = 2, .rows = 5 }); defer t.deinit(alloc); // Initial value @@ -3995,7 +4895,7 @@ test "Terminal: reverseIndex" { t.carriageReturn(); try t.linefeed(); try t.print('C'); - try t.reverseIndex(); + t.reverseIndex(); try t.print('D'); t.carriageReturn(); try t.linefeed(); @@ -4011,7 +4911,7 @@ test "Terminal: reverseIndex" { test "Terminal: reverseIndex from the top" { const alloc = testing.allocator; - var t = try init(alloc, 2, 5); + var t = try init(alloc, .{ .cols = 2, .rows = 5 }); defer t.deinit(alloc); try t.print('A'); @@ -4024,13 +4924,13 @@ test "Terminal: reverseIndex from the top" { try t.linefeed(); t.setCursorPos(1, 1); - try t.reverseIndex(); + t.reverseIndex(); try t.print('D'); t.carriageReturn(); try t.linefeed(); t.setCursorPos(1, 1); - try t.reverseIndex(); + t.reverseIndex(); try t.print('E'); t.carriageReturn(); try t.linefeed(); @@ -4044,7 +4944,7 @@ test "Terminal: reverseIndex from the top" { test "Terminal: reverseIndex top of scrolling region" { const alloc = testing.allocator; - var t = try init(alloc, 2, 10); + var t = try init(alloc, .{ .cols = 2, .rows = 10 }); defer t.deinit(alloc); // Initial value @@ -4065,7 +4965,7 @@ test "Terminal: reverseIndex top of scrolling region" { // Set our scroll region t.setTopAndBottomMargin(2, 5); t.setCursorPos(2, 1); - try t.reverseIndex(); + t.reverseIndex(); try t.print('X'); { @@ -4077,7 +4977,7 @@ test "Terminal: reverseIndex top of scrolling region" { test "Terminal: reverseIndex top of screen" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.print('A'); @@ -4086,7 +4986,7 @@ test "Terminal: reverseIndex top of screen" { t.setCursorPos(3, 1); try t.print('C'); t.setCursorPos(1, 1); - try t.reverseIndex(); + t.reverseIndex(); try t.print('X'); { @@ -4098,7 +4998,7 @@ test "Terminal: reverseIndex top of screen" { test "Terminal: reverseIndex not top of screen" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.print('A'); @@ -4107,7 +5007,7 @@ test "Terminal: reverseIndex not top of screen" { t.setCursorPos(3, 1); try t.print('C'); t.setCursorPos(2, 1); - try t.reverseIndex(); + t.reverseIndex(); try t.print('X'); { @@ -4119,7 +5019,7 @@ test "Terminal: reverseIndex not top of screen" { test "Terminal: reverseIndex top/bottom margins" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.print('A'); @@ -4129,7 +5029,7 @@ test "Terminal: reverseIndex top/bottom margins" { try t.print('C'); t.setTopAndBottomMargin(2, 3); t.setCursorPos(2, 1); - try t.reverseIndex(); + t.reverseIndex(); { const str = try t.plainString(testing.allocator); @@ -4140,7 +5040,7 @@ test "Terminal: reverseIndex top/bottom margins" { test "Terminal: reverseIndex outside top/bottom margins" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.print('A'); @@ -4150,7 +5050,7 @@ test "Terminal: reverseIndex outside top/bottom margins" { try t.print('C'); t.setTopAndBottomMargin(2, 3); t.setCursorPos(1, 1); - try t.reverseIndex(); + t.reverseIndex(); { const str = try t.plainString(testing.allocator); @@ -4161,7 +5061,7 @@ test "Terminal: reverseIndex outside top/bottom margins" { test "Terminal: reverseIndex left/right margins" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -4172,7 +5072,7 @@ test "Terminal: reverseIndex left/right margins" { t.modes.set(.enable_left_and_right_margin, true); t.setLeftAndRightMargin(2, 3); t.setCursorPos(1, 2); - try t.reverseIndex(); + t.reverseIndex(); { const str = try t.plainString(testing.allocator); @@ -4183,7 +5083,7 @@ test "Terminal: reverseIndex left/right margins" { test "Terminal: reverseIndex outside left/right margins" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -4194,7 +5094,7 @@ test "Terminal: reverseIndex outside left/right margins" { t.modes.set(.enable_left_and_right_margin, true); t.setLeftAndRightMargin(2, 3); t.setCursorPos(1, 1); - try t.reverseIndex(); + t.reverseIndex(); { const str = try t.plainString(testing.allocator); @@ -4205,7 +5105,7 @@ test "Terminal: reverseIndex outside left/right margins" { test "Terminal: index" { const alloc = testing.allocator; - var t = try init(alloc, 2, 5); + var t = try init(alloc, .{ .cols = 2, .rows = 5 }); defer t.deinit(alloc); try t.index(); @@ -4220,7 +5120,7 @@ test "Terminal: index" { test "Terminal: index from the bottom" { const alloc = testing.allocator; - var t = try init(alloc, 2, 5); + var t = try init(alloc, .{ .cols = 2, .rows = 5 }); defer t.deinit(alloc); t.setCursorPos(5, 1); @@ -4239,7 +5139,7 @@ test "Terminal: index from the bottom" { test "Terminal: index outside of scrolling region" { const alloc = testing.allocator; - var t = try init(alloc, 2, 5); + var t = try init(alloc, .{ .cols = 2, .rows = 5 }); defer t.deinit(alloc); try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); @@ -4250,7 +5150,7 @@ test "Terminal: index outside of scrolling region" { test "Terminal: index from the bottom outside of scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 2, 5); + var t = try init(alloc, .{ .cols = 2, .rows = 5 }); defer t.deinit(alloc); t.setTopAndBottomMargin(1, 2); @@ -4268,7 +5168,7 @@ test "Terminal: index from the bottom outside of scroll region" { test "Terminal: index no scroll region, top of screen" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.print('A'); @@ -4284,7 +5184,7 @@ test "Terminal: index no scroll region, top of screen" { test "Terminal: index bottom of primary screen" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setCursorPos(5, 1); @@ -4301,16 +5201,16 @@ test "Terminal: index bottom of primary screen" { test "Terminal: index bottom of primary screen background sgr" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - const pen: Screen.Cell = .{ - .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, - }; - t.setCursorPos(5, 1); try t.print('A'); - t.screen.cursor.pen = pen; + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); try t.index(); { @@ -4318,15 +5218,20 @@ test "Terminal: index bottom of primary screen background sgr" { defer testing.allocator.free(str); try testing.expectEqualStrings("\n\n\nA", str); for (0..5) |x| { - const cell = t.screen.getCell(.active, 4, x); - try testing.expectEqual(pen, cell); + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = x, .y = 4 } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); } } } test "Terminal: index inside scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setTopAndBottomMargin(1, 3); @@ -4341,29 +5246,9 @@ test "Terminal: index inside scroll region" { } } -test "Terminal: index bottom of scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.setTopAndBottomMargin(1, 3); - t.setCursorPos(4, 1); - try t.print('B'); - t.setCursorPos(3, 1); - try t.print('A'); - try t.index(); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\nA\n X\nB", str); - } -} - test "Terminal: index bottom of primary screen with scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setTopAndBottomMargin(1, 3); @@ -4384,7 +5269,7 @@ test "Terminal: index bottom of primary screen with scroll region" { test "Terminal: index outside left/right margin" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); t.setTopAndBottomMargin(1, 3); @@ -4405,7 +5290,7 @@ test "Terminal: index outside left/right margin" { test "Terminal: index inside left/right margin" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); try t.printString("AAAAAA"); @@ -4431,2796 +5316,3086 @@ test "Terminal: index inside left/right margin" { } } -test "Terminal: DECALN" { +test "Terminal: index bottom of scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 2, 2); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - // Initial value - try t.print('A'); - t.carriageReturn(); - try t.linefeed(); + t.setTopAndBottomMargin(1, 3); + t.setCursorPos(4, 1); try t.print('B'); - try t.decaln(); + t.setCursorPos(3, 1); + try t.print('A'); + try t.index(); + try t.print('X'); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\nA\n X\nB", str); + } +} + +test "Terminal: cursorUp basic" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setCursorPos(3, 1); + try t.print('A'); + t.cursorUp(10); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("EE\nEE", str); + try testing.expectEqualStrings(" X\n\nA", str); } } -test "Terminal: decaln reset margins" { +test "Terminal: cursorUp below top scroll margin" { const alloc = testing.allocator; - var t = try init(alloc, 3, 3); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - // Initial value - t.modes.set(.origin, true); - t.setTopAndBottomMargin(2, 3); - try t.decaln(); - try t.scrollDown(1); + t.setTopAndBottomMargin(2, 4); + t.setCursorPos(3, 1); + try t.print('A'); + t.cursorUp(5); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("\nEEE\nEEE", str); + try testing.expectEqualStrings("\n X\nA", str); } } -test "Terminal: decaln preserves color" { +test "Terminal: cursorUp above top scroll margin" { const alloc = testing.allocator; - var t = try init(alloc, 3, 3); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - const pen: Screen.Cell = .{ - .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, - }; + t.setTopAndBottomMargin(3, 5); + t.setCursorPos(3, 1); + try t.print('A'); + t.setCursorPos(2, 1); + t.cursorUp(10); + try t.print('X'); - // Initial value - t.screen.cursor.pen = pen; - t.modes.set(.origin, true); - t.setTopAndBottomMargin(2, 3); - try t.decaln(); - try t.scrollDown(1); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X\n\nA", str); + } +} + +test "Terminal: cursorUp resets wrap" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + t.cursorUp(1); + try testing.expect(!t.screen.cursor.pending_wrap); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("\nEEE\nEEE", str); - const cell = t.screen.getCell(.active, 0, 0); - try testing.expectEqual(pen, cell); + try testing.expectEqualStrings("ABCDX", str); } } -test "Terminal: insertBlanks" { - // NOTE: this is not verified with conformance tests, so these - // tests might actually be verifying wrong behavior. +test "Terminal: cursorLeft no wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 2); + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); try t.print('A'); + t.carriageReturn(); + try t.linefeed(); try t.print('B'); - try t.print('C'); - t.screen.cursor.pen.attrs.bold = true; - t.setCursorPos(1, 1); - t.insertBlanks(2); + t.cursorLeft(10); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" ABC", str); - const cell = t.screen.getCell(.active, 0, 0); - try testing.expect(!cell.attrs.bold); + try testing.expectEqualStrings("A\nB", str); } } -test "Terminal: insertBlanks pushes off end" { - // NOTE: this is not verified with conformance tests, so these - // tests might actually be verifying wrong behavior. +test "Terminal: cursorLeft unsets pending wrap state" { const alloc = testing.allocator; - var t = try init(alloc, 3, 2); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - try t.print('A'); - try t.print('B'); - try t.print('C'); - t.setCursorPos(1, 1); - t.insertBlanks(2); + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + t.cursorLeft(1); + try testing.expect(!t.screen.cursor.pending_wrap); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCXE", str); + } +} + +test "Terminal: cursorLeft unsets pending wrap state with longer jump" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + t.cursorLeft(3); + try testing.expect(!t.screen.cursor.pending_wrap); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" A", str); + try testing.expectEqualStrings("AXCDE", str); } } -test "Terminal: insertBlanks more than size" { - // NOTE: this is not verified with conformance tests, so these - // tests might actually be verifying wrong behavior. +test "Terminal: cursorLeft reverse wrap with pending wrap state" { const alloc = testing.allocator; - var t = try init(alloc, 3, 2); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - try t.print('A'); - try t.print('B'); - try t.print('C'); - t.setCursorPos(1, 1); - t.insertBlanks(5); + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap, true); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + t.cursorLeft(1); + try testing.expect(!t.screen.cursor.pending_wrap); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("", str); + try testing.expectEqualStrings("ABCDX", str); } } -test "Terminal: insertBlanks no scroll region, fits" { +test "Terminal: cursorLeft reverse wrap extended with pending wrap state" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - for ("ABC") |c| try t.print(c); - t.setCursorPos(1, 1); - t.insertBlanks(2); + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap_extended, true); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + t.cursorLeft(1); + try testing.expect(!t.screen.cursor.pending_wrap); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" ABC", str); + try testing.expectEqualStrings("ABCDX", str); } } -test "Terminal: insertBlanks preserves background sgr" { +test "Terminal: cursorLeft reverse wrap" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - const pen: Screen.Cell = .{ - .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, - }; + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap, true); - for ("ABC") |c| try t.print(c); - t.setCursorPos(1, 1); - t.screen.cursor.pen = pen; - t.insertBlanks(2); + for ("ABCDE1") |c| try t.print(c); + t.cursorLeft(2); + try t.print('X'); + try testing.expect(t.screen.cursor.pending_wrap); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" ABC", str); - const cell = t.screen.getCell(.active, 0, 0); - try testing.expectEqual(pen, cell); + try testing.expectEqualStrings("ABCDX\n1", str); } } -test "Terminal: insertBlanks shift off screen" { +test "Terminal: cursorLeft reverse wrap with no soft wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 10); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - for (" ABC") |c| try t.print(c); - t.setCursorPos(1, 3); - t.insertBlanks(2); + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap, true); + + for ("ABCDE") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + try t.print('1'); + t.cursorLeft(2); try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" X A", str); + try testing.expectEqualStrings("ABCDE\nX", str); } } -test "Terminal: insertBlanks split multi-cell character" { +test "Terminal: cursorLeft reverse wrap before left margin" { const alloc = testing.allocator; - var t = try init(alloc, 5, 10); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - for ("123") |c| try t.print(c); - try t.print('橋'); - t.setCursorPos(1, 1); - t.insertBlanks(1); + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap, true); + t.setTopAndBottomMargin(3, 0); + t.cursorLeft(1); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" 123", str); + try testing.expectEqualStrings("\n\nX", str); } } -test "Terminal: insertBlanks inside left/right scroll region" { +test "Terminal: cursorLeft extended reverse wrap" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - t.scrolling_region.left = 2; - t.scrolling_region.right = 4; - t.setCursorPos(1, 3); - for ("ABC") |c| try t.print(c); - t.setCursorPos(1, 3); - t.insertBlanks(2); + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap_extended, true); + + for ("ABCDE") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + try t.print('1'); + t.cursorLeft(2); try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" X A", str); + try testing.expectEqualStrings("ABCDX\n1", str); } } -test "Terminal: insertBlanks outside left/right scroll region" { +test "Terminal: cursorLeft extended reverse wrap bottom wraparound" { const alloc = testing.allocator; - var t = try init(alloc, 6, 10); + var t = try init(alloc, .{ .cols = 5, .rows = 3 }); defer t.deinit(alloc); - t.setCursorPos(1, 4); - for ("ABC") |c| try t.print(c); - t.scrolling_region.left = 2; - t.scrolling_region.right = 4; - try testing.expect(t.screen.cursor.pending_wrap); - t.insertBlanks(2); - try testing.expect(!t.screen.cursor.pending_wrap); + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap_extended, true); + + for ("ABCDE") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + try t.print('1'); + t.cursorLeft(1 + t.cols + 1); try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" ABX", str); + try testing.expectEqualStrings("ABCDE\n1\n X", str); } } -test "Terminal: insertBlanks left/right scroll region large count" { +test "Terminal: cursorLeft extended reverse wrap is priority if both set" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, .{ .cols = 5, .rows = 3 }); defer t.deinit(alloc); - t.modes.set(.origin, true); - t.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(3, 5); - t.setCursorPos(1, 1); - t.insertBlanks(140); + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap, true); + t.modes.set(.reverse_wrap_extended, true); + + for ("ABCDE") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + try t.print('1'); + t.cursorLeft(1 + t.cols + 1); try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" X", str); + try testing.expectEqualStrings("ABCDE\n1\n X", str); } } -test "Terminal: insert mode with space" { +test "Terminal: cursorLeft extended reverse wrap above top scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 10, 2); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - for ("hello") |c| try t.print(c); + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap_extended, true); + + t.setTopAndBottomMargin(3, 0); + t.setCursorPos(2, 1); + t.cursorLeft(1000); + + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); +} + +test "Terminal: cursorLeft reverse wrap on first row" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap, true); + + t.setTopAndBottomMargin(3, 0); t.setCursorPos(1, 2); - t.modes.set(.insert, true); + t.cursorLeft(1000); + + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); +} + +test "Terminal: cursorDown basic" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.print('A'); + t.cursorDown(10); try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("hXello", str); + try testing.expectEqualStrings("A\n\n\n\n X", str); } } -test "Terminal: insert mode doesn't wrap pushed characters" { +test "Terminal: cursorDown above bottom scroll margin" { const alloc = testing.allocator; - var t = try init(alloc, 5, 2); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - for ("hello") |c| try t.print(c); - t.setCursorPos(1, 2); - t.modes.set(.insert, true); + t.setTopAndBottomMargin(1, 3); + try t.print('A'); + t.cursorDown(10); try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("hXell", str); + try testing.expectEqualStrings("A\n\n X", str); } } -test "Terminal: insert mode does nothing at the end of the line" { +test "Terminal: cursorDown below bottom scroll margin" { const alloc = testing.allocator; - var t = try init(alloc, 5, 2); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - for ("hello") |c| try t.print(c); - t.modes.set(.insert, true); + t.setTopAndBottomMargin(1, 3); + try t.print('A'); + t.setCursorPos(4, 1); + t.cursorDown(10); try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("hello\nX", str); + try testing.expectEqualStrings("A\n\n\n\nX", str); } } -test "Terminal: insert mode with wide characters" { +test "Terminal: cursorDown resets wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 2); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - for ("hello") |c| try t.print(c); - t.setCursorPos(1, 2); - t.modes.set(.insert, true); - try t.print('😀'); // 0x1F600 + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + t.cursorDown(1); + try testing.expect(!t.screen.cursor.pending_wrap); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("h😀el", str); + try testing.expectEqualStrings("ABCDE\n X", str); } } -test "Terminal: insert mode with wide characters at end" { +test "Terminal: cursorRight resets wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 2); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - for ("well") |c| try t.print(c); - t.modes.set(.insert, true); - try t.print('😀'); // 0x1F600 + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + t.cursorRight(1); + try testing.expect(!t.screen.cursor.pending_wrap); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("well\n😀", str); + try testing.expectEqualStrings("ABCDX", str); } } -test "Terminal: insert mode pushing off wide character" { +test "Terminal: cursorRight to the edge of screen" { const alloc = testing.allocator; - var t = try init(alloc, 5, 2); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - for ("123") |c| try t.print(c); - try t.print('😀'); // 0x1F600 - t.modes.set(.insert, true); - t.setCursorPos(1, 1); + t.cursorRight(100); try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("X123", str); + try testing.expectEqualStrings(" X", str); } } -test "Terminal: cursorIsAtPrompt" { +test "Terminal: cursorRight left of right margin" { const alloc = testing.allocator; - var t = try init(alloc, 3, 2); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - try testing.expect(!t.cursorIsAtPrompt()); - t.markSemanticPrompt(.prompt); - try testing.expect(t.cursorIsAtPrompt()); - - // Input is also a prompt - t.markSemanticPrompt(.input); - try testing.expect(t.cursorIsAtPrompt()); - - // Newline -- we expect we're still at a prompt if we received - // prompt stuff before. - try t.linefeed(); - try testing.expect(t.cursorIsAtPrompt()); - - // But once we say we're starting output, we're not a prompt - t.markSemanticPrompt(.command); - try testing.expect(!t.cursorIsAtPrompt()); - try t.linefeed(); - try testing.expect(!t.cursorIsAtPrompt()); - - // Until we know we're at a prompt again - try t.linefeed(); - t.markSemanticPrompt(.prompt); - try testing.expect(t.cursorIsAtPrompt()); + t.scrolling_region.right = 2; + t.cursorRight(100); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" X", str); + } } -test "Terminal: cursorIsAtPrompt alternate screen" { +test "Terminal: cursorRight right of right margin" { const alloc = testing.allocator; - var t = try init(alloc, 3, 2); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - try testing.expect(!t.cursorIsAtPrompt()); - t.markSemanticPrompt(.prompt); - try testing.expect(t.cursorIsAtPrompt()); + t.scrolling_region.right = 2; + t.setCursorPos(1, 4); + t.cursorRight(100); + try t.print('X'); - // Secondary screen is never a prompt - t.alternateScreen(alloc, .{}); - try testing.expect(!t.cursorIsAtPrompt()); - t.markSemanticPrompt(.prompt); - try testing.expect(!t.cursorIsAtPrompt()); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" X", str); + } } -test "Terminal: print wide char with 1-column width" { +test "Terminal: deleteLines simple" { const alloc = testing.allocator; - var t = try init(alloc, 1, 2); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - try t.print('😀'); // 0x1F600 + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setCursorPos(2, 2); + t.deleteLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nGHI", str); + } } -test "Terminal: deleteChars" { +test "Terminal: deleteLines colors with bg color" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - for ("ABCDE") |c| try t.print(c); - t.setCursorPos(1, 2); + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setCursorPos(2, 2); - // the cells that shifted in should not have this attribute set - t.screen.cursor.pen = .{ .attrs = .{ .bold = true } }; + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + t.deleteLines(1); - try t.deleteChars(2); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ADE", str); + try testing.expectEqualStrings("ABC\nGHI", str); + } - const cell = t.screen.getCell(.active, 0, 4); - try testing.expect(!cell.attrs.bold); + for (0..t.cols) |x| { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = x, .y = 4 } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); } } -test "Terminal: deleteChars zero count" { +test "Terminal: deleteLines (legacy)" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 80, .rows = 80 }); defer t.deinit(alloc); - for ("ABCDE") |c| try t.print(c); - t.setCursorPos(1, 2); + // Initial value + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + try t.print('B'); + t.carriageReturn(); + try t.linefeed(); + try t.print('C'); + t.carriageReturn(); + try t.linefeed(); + try t.print('D'); + + t.cursorUp(2); + t.deleteLines(1); + + try t.print('E'); + t.carriageReturn(); + try t.linefeed(); + + // We should be + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); - try t.deleteChars(0); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDE", str); + try testing.expectEqualStrings("A\nE\nD", str); } } -test "Terminal: deleteChars more than half" { +test "Terminal: deleteLines with scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 80, .rows = 80 }); defer t.deinit(alloc); - for ("ABCDE") |c| try t.print(c); - t.setCursorPos(1, 2); + // Initial value + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + try t.print('B'); + t.carriageReturn(); + try t.linefeed(); + try t.print('C'); + t.carriageReturn(); + try t.linefeed(); + try t.print('D'); + + t.setTopAndBottomMargin(1, 3); + t.setCursorPos(1, 1); + t.deleteLines(1); + + try t.print('E'); + t.carriageReturn(); + try t.linefeed(); + + // We should be + // try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + // try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); - try t.deleteChars(3); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("AE", str); + try testing.expectEqualStrings("E\nC\n\nD", str); } } -test "Terminal: deleteChars more than line width" { +// X +test "Terminal: deleteLines with scroll region, large count" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 80, .rows = 80 }); defer t.deinit(alloc); - for ("ABCDE") |c| try t.print(c); - t.setCursorPos(1, 2); + // Initial value + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + try t.print('B'); + t.carriageReturn(); + try t.linefeed(); + try t.print('C'); + t.carriageReturn(); + try t.linefeed(); + try t.print('D'); + + t.setTopAndBottomMargin(1, 3); + t.setCursorPos(1, 1); + t.deleteLines(5); + + try t.print('E'); + t.carriageReturn(); + try t.linefeed(); + + // We should be + // try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + // try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); - try t.deleteChars(10); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A", str); + try testing.expectEqualStrings("E\n\n\nD", str); } } -test "Terminal: deleteChars should shift left" { +// X +test "Terminal: deleteLines with scroll region, cursor outside of region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 80, .rows = 80 }); defer t.deinit(alloc); - for ("ABCDE") |c| try t.print(c); - t.setCursorPos(1, 2); + // Initial value + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + try t.print('B'); + t.carriageReturn(); + try t.linefeed(); + try t.print('C'); + t.carriageReturn(); + try t.linefeed(); + try t.print('D'); + + t.setTopAndBottomMargin(1, 3); + t.setCursorPos(4, 1); + t.deleteLines(1); - try t.deleteChars(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ACDE", str); + try testing.expectEqualStrings("A\nB\nC\nD", str); } } -test "Terminal: deleteChars resets wrap" { +test "Terminal: deleteLines resets pending wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); try testing.expect(t.screen.cursor.pending_wrap); - try t.deleteChars(1); + t.deleteLines(1); try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('X'); + try t.print('B'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDX", str); + try testing.expectEqualStrings("B", str); } } -test "Terminal: deleteChars simple operation" { +test "Terminal: deleteLines resets wrap" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, .{ .rows = 3, .cols = 3 }); defer t.deinit(alloc); - try t.printString("ABC123"); - t.setCursorPos(1, 3); - try t.deleteChars(2); + try t.print('1'); + t.carriageReturn(); + try t.linefeed(); + for ("ABCDEF") |c| try t.print(c); + + t.setTopAndBottomMargin(1, 2); + t.setCursorPos(1, 1); + t.deleteLines(1); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("AB23", str); + try testing.expectEqualStrings("XBC\n\nDEF", str); + } + + for (0..t.rows) |y| { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = y } }).?; + const row = list_cell.row; + try testing.expect(!row.wrap); } } -test "Terminal: deleteChars background sgr" { +test "Terminal: deleteLines left/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); - const pen: Screen.Cell = .{ - .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, - }; - try t.printString("ABC123"); - t.setCursorPos(1, 3); - t.screen.cursor.pen = pen; - try t.deleteChars(2); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF456"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI789"); + t.scrolling_region.left = 1; + t.scrolling_region.right = 3; + t.setCursorPos(2, 2); + t.deleteLines(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("AB23", str); - for (t.cols - 2..t.cols) |x| { - const cell = t.screen.getCell(.active, 0, x); - try testing.expectEqual(pen, cell); - } + try testing.expectEqualStrings("ABC123\nDHI756\nG 89", str); } } -test "Terminal: deleteChars outside scroll region" { +test "Terminal: deleteLines left/right scroll region from top" { const alloc = testing.allocator; - var t = try init(alloc, 6, 10); + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); try t.printString("ABC123"); - t.scrolling_region.left = 2; - t.scrolling_region.right = 4; - try testing.expect(t.screen.cursor.pending_wrap); - try t.deleteChars(2); - try testing.expect(t.screen.cursor.pending_wrap); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF456"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI789"); + t.scrolling_region.left = 1; + t.scrolling_region.right = 3; + t.setCursorPos(1, 2); + t.deleteLines(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC123", str); + try testing.expectEqualStrings("AEF423\nDHI756\nG 89", str); } } -test "Terminal: deleteChars inside scroll region" { +test "Terminal: deleteLines left/right scroll region high count" { const alloc = testing.allocator; - var t = try init(alloc, 6, 10); + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); try t.printString("ABC123"); - t.scrolling_region.left = 2; - t.scrolling_region.right = 4; - t.setCursorPos(1, 4); - try t.deleteChars(1); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF456"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI789"); + t.scrolling_region.left = 1; + t.scrolling_region.right = 3; + t.setCursorPos(2, 2); + t.deleteLines(100); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC2 3", str); + try testing.expectEqualStrings("ABC123\nD 56\nG 89", str); } } -test "Terminal: deleteChars split wide character" { +test "Terminal: deleteLines zero" { const alloc = testing.allocator; - var t = try init(alloc, 6, 10); + var t = try init(alloc, .{ .cols = 2, .rows = 5 }); defer t.deinit(alloc); - try t.printString("A橋123"); - t.setCursorPos(1, 3); - try t.deleteChars(1); + // This should do nothing + t.setCursorPos(1, 1); + t.deleteLines(0); +} + +test "Terminal: default style is empty" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.print('A'); { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A 123", str); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); + try testing.expectEqual(@as(style.Id, 0), cell.style_id); } } -test "Terminal: deleteChars split wide character tail" { +test "Terminal: bold style" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - t.setCursorPos(1, t.cols - 1); - try t.print(0x6A4B); // 橋 - t.carriageReturn(); - try t.deleteChars(t.cols - 1); - try t.print('0'); + try t.setAttribute(.{ .bold = {} }); + try t.print('A'); { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("0", str); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); + try testing.expect(cell.style_id != 0); + try testing.expect(t.screen.cursor.style_ref.?.* > 0); } } -test "Terminal: eraseChars resets pending wrap" { +test "Terminal: garbage collect overwritten" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); - t.eraseChars(1); - try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('X'); + try t.setAttribute(.{ .bold = {} }); + try t.print('A'); + t.setCursorPos(1, 1); + try t.setAttribute(.{ .unset = {} }); + try t.print('B'); { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDX", str); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 'B'), cell.content.codepoint); + try testing.expect(cell.style_id == 0); } + + // verify we have no styles in our style map + const page = t.screen.cursor.page_pin.page.data; + try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); } -test "Terminal: eraseChars resets wrap" { +test "Terminal: do not garbage collect old styles in use" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - for ("ABCDE123") |c| try t.print(c); + try t.setAttribute(.{ .bold = {} }); + try t.print('A'); + try t.setAttribute(.{ .unset = {} }); + try t.print('B'); + { - const row = t.screen.getRow(.{ .active = 0 }); - try testing.expect(row.isWrapped()); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 'B'), cell.content.codepoint); + try testing.expect(cell.style_id == 0); } - t.setCursorPos(1, 1); - t.eraseChars(1); + // verify we have no styles in our style map + const page = t.screen.cursor.page_pin.page.data; + try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); +} - { - const row = t.screen.getRow(.{ .active = 0 }); - try testing.expect(!row.isWrapped()); - } - try t.print('X'); +test "Terminal: print with style marks the row as styled" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.setAttribute(.{ .bold = {} }); + try t.print('A'); + try t.setAttribute(.{ .unset = {} }); + try t.print('B'); { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("XBCDE\n123", str); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + try testing.expect(list_cell.row.styled); } } -test "Terminal: eraseChars simple operation" { +test "Terminal: DECALN" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 2, .rows = 2 }); defer t.deinit(alloc); - for ("ABC") |c| try t.print(c); - t.setCursorPos(1, 1); - t.eraseChars(2); - try t.print('X'); + // Initial value + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + try t.print('B'); + try t.decaln(); + + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("X C", str); + try testing.expectEqualStrings("EE\nEE", str); } } -test "Terminal: eraseChars minimum one" { +test "Terminal: decaln reset margins" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 3, .rows = 3 }); defer t.deinit(alloc); - for ("ABC") |c| try t.print(c); - t.setCursorPos(1, 1); - t.eraseChars(0); - try t.print('X'); + // Initial value + t.modes.set(.origin, true); + t.setTopAndBottomMargin(2, 3); + try t.decaln(); + t.scrollDown(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("XBC", str); + try testing.expectEqualStrings("\nEEE\nEEE", str); } } -test "Terminal: eraseChars beyond screen edge" { +test "Terminal: decaln preserves color" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 3, .rows = 3 }); defer t.deinit(alloc); - for (" ABC") |c| try t.print(c); - t.setCursorPos(1, 4); - t.eraseChars(10); + // Initial value + try t.setAttribute(.{ .direct_color_bg = .{ .r = 0xFF, .g = 0, .b = 0 } }); + t.modes.set(.origin, true); + t.setTopAndBottomMargin(2, 3); + try t.decaln(); + t.scrollDown(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" A", str); + try testing.expectEqualStrings("\nEEE\nEEE", str); + } + + { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); } } -test "Terminal: eraseChars preserves background sgr" { +test "Terminal: insertBlanks" { + // NOTE: this is not verified with conformance tests, so these + // tests might actually be verifying wrong behavior. const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, .{ .cols = 5, .rows = 2 }); defer t.deinit(alloc); - const pen: Screen.Cell = .{ - .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, - }; - - for ("ABC") |c| try t.print(c); + try t.print('A'); + try t.print('B'); + try t.print('C'); t.setCursorPos(1, 1); - t.screen.cursor.pen = pen; - t.eraseChars(2); + t.insertBlanks(2); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" C", str); - { - const cell = t.screen.getCell(.active, 0, 0); - try testing.expectEqual(pen, cell); - } - { - const cell = t.screen.getCell(.active, 0, 1); - try testing.expectEqual(pen, cell); - } + try testing.expectEqualStrings(" ABC", str); } } -test "Terminal: eraseChars wide character" { +test "Terminal: insertBlanks pushes off end" { + // NOTE: this is not verified with conformance tests, so these + // tests might actually be verifying wrong behavior. const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 3, .rows = 2 }); defer t.deinit(alloc); - try t.print('橋'); - for ("BC") |c| try t.print(c); + try t.print('A'); + try t.print('B'); + try t.print('C'); t.setCursorPos(1, 1); - t.eraseChars(1); - try t.print('X'); + t.insertBlanks(2); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("X BC", str); + try testing.expectEqualStrings(" A", str); } } -test "Terminal: eraseChars protected attributes respected with iso" { +test "Terminal: insertBlanks more than size" { + // NOTE: this is not verified with conformance tests, so these + // tests might actually be verifying wrong behavior. const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 3, .rows = 2 }); defer t.deinit(alloc); - t.setProtectedMode(.iso); - for ("ABC") |c| try t.print(c); + try t.print('A'); + try t.print('B'); + try t.print('C'); t.setCursorPos(1, 1); - t.eraseChars(2); + t.insertBlanks(5); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC", str); + try testing.expectEqualStrings("", str); } } -test "Terminal: eraseChars protected attributes ignored with dec most recent" { +test "Terminal: insertBlanks no scroll region, fits" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); - t.setProtectedMode(.iso); for ("ABC") |c| try t.print(c); - t.setProtectedMode(.dec); - t.setProtectedMode(.off); t.setCursorPos(1, 1); - t.eraseChars(2); + t.insertBlanks(2); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" C", str); + try testing.expectEqualStrings(" ABC", str); } } -test "Terminal: eraseChars protected attributes ignored with dec set" { +test "Terminal: insertBlanks preserves background sgr" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); - t.setProtectedMode(.dec); for ("ABC") |c| try t.print(c); t.setCursorPos(1, 1); - t.eraseChars(2); + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + t.insertBlanks(2); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" C", str); + try testing.expectEqualStrings(" ABC", str); + } + { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); } } -// https://github.com/mitchellh/ghostty/issues/272 -// This is also tested in depth in screen resize tests but I want to keep -// this test around to ensure we don't regress at multiple layers. -test "Terminal: resize less cols with wide char then print" { - const alloc = testing.allocator; - var t = try init(alloc, 3, 3); - defer t.deinit(alloc); - - try t.print('x'); - try t.print('😀'); // 0x1F600 - try t.resize(alloc, 2, 3); - t.setCursorPos(1, 2); - try t.print('😀'); // 0x1F600 -} - -// https://github.com/mitchellh/ghostty/issues/723 -// This was found via fuzzing so its highly specific. -test "Terminal: resize with left and right margin set" { - const alloc = testing.allocator; - const cols = 70; - const rows = 23; - var t = try init(alloc, cols, rows); - defer t.deinit(alloc); - - t.modes.set(.enable_left_and_right_margin, true); - try t.print('0'); - t.modes.set(.enable_mode_3, true); - try t.resize(alloc, cols, rows); - t.setLeftAndRightMargin(2, 0); - try t.printRepeat(1850); - _ = t.modes.restore(.enable_mode_3); - try t.resize(alloc, cols, rows); -} - -// https://github.com/mitchellh/ghostty/issues/1343 -test "Terminal: resize with wraparound off" { - const alloc = testing.allocator; - const cols = 4; - const rows = 2; - var t = try init(alloc, cols, rows); - defer t.deinit(alloc); - - t.modes.set(.wraparound, false); - try t.print('0'); - try t.print('1'); - try t.print('2'); - try t.print('3'); - const new_cols = 2; - try t.resize(alloc, new_cols, rows); - - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("01", str); -} - -test "Terminal: resize with wraparound on" { +test "Terminal: insertBlanks shift off screen" { const alloc = testing.allocator; - const cols = 4; - const rows = 2; - var t = try init(alloc, cols, rows); + var t = try init(alloc, .{ .cols = 5, .rows = 10 }); defer t.deinit(alloc); - t.modes.set(.wraparound, true); - try t.print('0'); - try t.print('1'); - try t.print('2'); - try t.print('3'); - const new_cols = 2; - try t.resize(alloc, new_cols, rows); + for (" ABC") |c| try t.print(c); + t.setCursorPos(1, 3); + t.insertBlanks(2); + try t.print('X'); - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("01\n23", str); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" X A", str); + } } -test "Terminal: saveCursor" { +test "Terminal: insertBlanks split multi-cell character" { const alloc = testing.allocator; - var t = try init(alloc, 3, 3); + var t = try init(alloc, .{ .cols = 5, .rows = 10 }); defer t.deinit(alloc); - t.screen.cursor.pen.attrs.bold = true; - t.screen.charset.gr = .G3; - t.modes.set(.origin, true); - t.saveCursor(); - t.screen.charset.gr = .G0; - t.screen.cursor.pen.attrs.bold = false; - t.modes.set(.origin, false); - t.restoreCursor(); - try testing.expect(t.screen.cursor.pen.attrs.bold); - try testing.expect(t.screen.charset.gr == .G3); - try testing.expect(t.modes.get(.origin)); -} - -test "Terminal: saveCursor with screen change" { - const alloc = testing.allocator; - var t = try init(alloc, 3, 3); - defer t.deinit(alloc); + for ("123") |c| try t.print(c); + try t.print('橋'); + t.setCursorPos(1, 1); + t.insertBlanks(1); - t.screen.cursor.pen.attrs.bold = true; - t.screen.cursor.x = 2; - t.screen.charset.gr = .G3; - t.modes.set(.origin, true); - t.alternateScreen(alloc, .{ - .cursor_save = true, - .clear_on_enter = true, - }); - // make sure our cursor and charset have come with us - try testing.expect(t.screen.cursor.pen.attrs.bold); - try testing.expect(t.screen.cursor.x == 2); - try testing.expect(t.screen.charset.gr == .G3); - try testing.expect(t.modes.get(.origin)); - t.screen.charset.gr = .G0; - t.screen.cursor.pen.attrs.bold = false; - t.modes.set(.origin, false); - t.primaryScreen(alloc, .{ - .cursor_save = true, - .clear_on_enter = true, - }); - try testing.expect(t.screen.cursor.pen.attrs.bold); - try testing.expect(t.screen.charset.gr == .G3); - try testing.expect(t.modes.get(.origin)); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" 123", str); + } } -test "Terminal: saveCursor position" { +test "Terminal: insertBlanks inside left/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); - t.setCursorPos(1, 5); - try t.print('A'); - t.saveCursor(); - t.setCursorPos(1, 1); - try t.print('B'); - t.restoreCursor(); + t.scrolling_region.left = 2; + t.scrolling_region.right = 4; + t.setCursorPos(1, 3); + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 3); + t.insertBlanks(2); try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("B AX", str); + try testing.expectEqualStrings(" X A", str); } } -test "Terminal: saveCursor pending wrap state" { +test "Terminal: insertBlanks outside left/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 6, .rows = 10 }); defer t.deinit(alloc); - t.setCursorPos(1, 5); - try t.print('A'); - t.saveCursor(); - t.setCursorPos(1, 1); - try t.print('B'); - t.restoreCursor(); + t.setCursorPos(1, 4); + for ("ABC") |c| try t.print(c); + t.scrolling_region.left = 2; + t.scrolling_region.right = 4; + try testing.expect(t.screen.cursor.pending_wrap); + t.insertBlanks(2); + try testing.expect(!t.screen.cursor.pending_wrap); try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("B A\nX", str); + try testing.expectEqualStrings(" ABX", str); } } -test "Terminal: saveCursor origin mode" { +test "Terminal: insertBlanks left/right scroll region large count" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); t.modes.set(.origin, true); - t.saveCursor(); t.modes.set(.enable_left_and_right_margin, true); t.setLeftAndRightMargin(3, 5); - t.setTopAndBottomMargin(2, 4); - t.restoreCursor(); + t.setCursorPos(1, 1); + t.insertBlanks(140); try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("X", str); + try testing.expectEqualStrings(" X", str); } } -test "Terminal: saveCursor resize" { +test "Terminal: insertBlanks deleting graphemes" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - t.setCursorPos(1, 10); - t.saveCursor(); - try t.resize(alloc, 5, 5); - t.restoreCursor(); - try t.print('X'); + // Disable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + try t.printString("ABC"); + + // This is: 👨‍👩‍👧 (which may or may not render correctly) + try t.print(0x1F468); + try t.print(0x200D); + try t.print(0x1F469); + try t.print(0x200D); + try t.print(0x1F467); + + // We should have one cell with graphemes + const page = t.screen.cursor.page_pin.page.data; + try testing.expectEqual(@as(usize, 1), page.graphemeCount()); + + t.setCursorPos(1, 1); + t.insertBlanks(4); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" X", str); + try testing.expectEqualStrings(" A", str); } + + // We should have no graphemes + try testing.expectEqual(@as(usize, 0), page.graphemeCount()); } -test "Terminal: setProtectedMode" { +test "Terminal: insertBlanks shift graphemes" { const alloc = testing.allocator; - var t = try init(alloc, 3, 3); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - try testing.expect(!t.screen.cursor.pen.attrs.protected); - t.setProtectedMode(.off); - try testing.expect(!t.screen.cursor.pen.attrs.protected); - t.setProtectedMode(.iso); - try testing.expect(t.screen.cursor.pen.attrs.protected); - t.setProtectedMode(.dec); - try testing.expect(t.screen.cursor.pen.attrs.protected); - t.setProtectedMode(.off); - try testing.expect(!t.screen.cursor.pen.attrs.protected); + // Disable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + try t.printString("A"); + + // This is: 👨‍👩‍👧 (which may or may not render correctly) + try t.print(0x1F468); + try t.print(0x200D); + try t.print(0x1F469); + try t.print(0x200D); + try t.print(0x1F467); + + // We should have one cell with graphemes + const page = t.screen.cursor.page_pin.page.data; + try testing.expectEqual(@as(usize, 1), page.graphemeCount()); + + t.setCursorPos(1, 1); + t.insertBlanks(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" A👨‍👩‍👧", str); + } + + // We should have no graphemes + try testing.expectEqual(@as(usize, 1), page.graphemeCount()); } -test "Terminal: eraseLine simple erase right" { +test "Terminal: insertBlanks split multi-cell character from tail" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 5, .rows = 10 }); defer t.deinit(alloc); - for ("ABCDE") |c| try t.print(c); - t.setCursorPos(1, 3); - t.eraseLine(.right, false); + try t.printString("橋123"); + t.setCursorPos(1, 2); + t.insertBlanks(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("AB", str); + try testing.expectEqualStrings(" 12", str); } } -test "Terminal: eraseLine resets pending wrap" { +test "Terminal: insert mode with space" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 10, .rows = 2 }); defer t.deinit(alloc); - for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); - t.eraseLine(.right, false); - try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('B'); + for ("hello") |c| try t.print(c); + t.setCursorPos(1, 2); + t.modes.set(.insert, true); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDB", str); + try testing.expectEqualStrings("hXello", str); } } -test "Terminal: eraseLine resets wrap" { +test "Terminal: insert mode doesn't wrap pushed characters" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 5, .rows = 2 }); defer t.deinit(alloc); - for ("ABCDE123") |c| try t.print(c); + for ("hello") |c| try t.print(c); + t.setCursorPos(1, 2); + t.modes.set(.insert, true); + try t.print('X'); + { - const row = t.screen.getRow(.{ .active = 0 }); - try testing.expect(row.isWrapped()); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("hXell", str); } +} - t.setCursorPos(1, 1); - t.eraseLine(.right, false); +test "Terminal: insert mode does nothing at the end of the line" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 5, .rows = 2 }); + defer t.deinit(alloc); - { - const row = t.screen.getRow(.{ .active = 0 }); - try testing.expect(!row.isWrapped()); - } + for ("hello") |c| try t.print(c); + t.modes.set(.insert, true); try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("X\n123", str); + try testing.expectEqualStrings("hello\nX", str); } } -test "Terminal: eraseLine right preserves background sgr" { +test "Terminal: insert mode with wide characters" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 5, .rows = 2 }); defer t.deinit(alloc); - const pen: Screen.Cell = .{ - .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, - }; - - for ("ABCDE") |c| try t.print(c); + for ("hello") |c| try t.print(c); t.setCursorPos(1, 2); - t.screen.cursor.pen = pen; - t.eraseLine(.right, false); + t.modes.set(.insert, true); + try t.print('😀'); // 0x1F600 { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A", str); - for (1..5) |x| { - const cell = t.screen.getCell(.active, 0, x); - try testing.expectEqual(pen, cell); - } + try testing.expectEqualStrings("h😀el", str); } } -test "Terminal: eraseLine right wide character" { +test "Terminal: insert mode with wide characters at end" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, .{ .cols = 5, .rows = 2 }); defer t.deinit(alloc); - for ("AB") |c| try t.print(c); - try t.print('橋'); - for ("DE") |c| try t.print(c); - t.setCursorPos(1, 4); - t.eraseLine(.right, false); + for ("well") |c| try t.print(c); + t.modes.set(.insert, true); + try t.print('😀'); // 0x1F600 { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("AB", str); + try testing.expectEqualStrings("well\n😀", str); } } -test "Terminal: eraseLine right protected attributes respected with iso" { +test "Terminal: insert mode pushing off wide character" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 5, .rows = 2 }); defer t.deinit(alloc); - t.setProtectedMode(.iso); - for ("ABC") |c| try t.print(c); + for ("123") |c| try t.print(c); + try t.print('😀'); // 0x1F600 + t.modes.set(.insert, true); t.setCursorPos(1, 1); - t.eraseLine(.right, false); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC", str); + try testing.expectEqualStrings("X123", str); } } -test "Terminal: eraseLine right protected attributes ignored with dec most recent" { +test "Terminal: deleteChars" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - t.setProtectedMode(.iso); - for ("ABC") |c| try t.print(c); - t.setProtectedMode(.dec); - t.setProtectedMode(.off); + for ("ABCDE") |c| try t.print(c); t.setCursorPos(1, 2); - t.eraseLine(.right, false); + t.deleteChars(2); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A", str); + try testing.expectEqualStrings("ADE", str); } } -test "Terminal: eraseLine right protected attributes ignored with dec set" { +test "Terminal: deleteChars zero count" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - t.setProtectedMode(.dec); - for ("ABC") |c| try t.print(c); + for ("ABCDE") |c| try t.print(c); t.setCursorPos(1, 2); - t.eraseLine(.right, false); + t.deleteChars(0); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A", str); + try testing.expectEqualStrings("ABCDE", str); } } -test "Terminal: eraseLine right protected requested" { +test "Terminal: deleteChars more than half" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - for ("12345678") |c| try t.print(c); - t.setCursorPos(t.screen.cursor.y + 1, 6); - t.setProtectedMode(.dec); - try t.print('X'); - t.setCursorPos(t.screen.cursor.y + 1, 4); - t.eraseLine(.right, true); + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 2); + t.deleteChars(3); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("123 X", str); + try testing.expectEqualStrings("AE", str); } } -test "Terminal: eraseLine simple erase left" { +test "Terminal: deleteChars more than line width" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); - t.setCursorPos(1, 3); - t.eraseLine(.left, false); + t.setCursorPos(1, 2); + t.deleteChars(10); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" DE", str); + try testing.expectEqualStrings("A", str); } } -test "Terminal: eraseLine left resets wrap" { +test "Terminal: deleteChars should shift left" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 2); + + t.deleteChars(1); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ACDE", str); + } +} + +test "Terminal: deleteChars resets pending wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); try testing.expect(t.screen.cursor.pending_wrap); - t.eraseLine(.left, false); + t.deleteChars(1); try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('B'); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" B", str); + try testing.expectEqualStrings("ABCDX", str); } } -test "Terminal: eraseLine left preserves background sgr" { +test "Terminal: deleteChars resets wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - const pen: Screen.Cell = .{ - .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, - }; + for ("ABCDE123") |c| try t.print(c); + { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + const row = list_cell.row; + try testing.expect(row.wrap); + } + t.setCursorPos(1, 1); + t.deleteChars(1); - for ("ABCDE") |c| try t.print(c); - t.setCursorPos(1, 2); - t.screen.cursor.pen = pen; - t.eraseLine(.left, false); + { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + const row = list_cell.row; + try testing.expect(!row.wrap); + } + + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" CDE", str); - for (0..2) |x| { - const cell = t.screen.getCell(.active, 0, x); - try testing.expectEqual(pen, cell); - } + try testing.expectEqualStrings("XCDE\n123", str); } } -test "Terminal: eraseLine left wide character" { +test "Terminal: deleteChars simple operation" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); - for ("AB") |c| try t.print(c); - try t.print('橋'); - for ("DE") |c| try t.print(c); + try t.printString("ABC123"); t.setCursorPos(1, 3); - t.eraseLine(.left, false); + t.deleteChars(2); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" DE", str); + try testing.expectEqualStrings("AB23", str); } } -test "Terminal: eraseLine left protected attributes respected with iso" { +test "Terminal: deleteChars preserves background sgr" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); - t.setProtectedMode(.iso); - for ("ABC") |c| try t.print(c); - t.setCursorPos(1, 1); - t.eraseLine(.left, false); + for ("ABC123") |c| try t.print(c); + t.setCursorPos(1, 3); + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + t.deleteChars(2); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC", str); + try testing.expectEqualStrings("AB23", str); + } + for (t.cols - 2..t.cols) |x| { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = x, .y = 0 } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); } } -test "Terminal: eraseLine left protected attributes ignored with dec most recent" { +test "Terminal: deleteChars outside scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 6, .rows = 10 }); defer t.deinit(alloc); - t.setProtectedMode(.iso); - for ("ABC") |c| try t.print(c); - t.setProtectedMode(.dec); - t.setProtectedMode(.off); - t.setCursorPos(1, 2); - t.eraseLine(.left, false); + try t.printString("ABC123"); + t.scrolling_region.left = 2; + t.scrolling_region.right = 4; + try testing.expect(t.screen.cursor.pending_wrap); + t.deleteChars(2); + try testing.expect(t.screen.cursor.pending_wrap); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" C", str); + try testing.expectEqualStrings("ABC123", str); } } -test "Terminal: eraseLine left protected attributes ignored with dec set" { +test "Terminal: deleteChars inside scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 6, .rows = 10 }); defer t.deinit(alloc); - t.setProtectedMode(.dec); - for ("ABC") |c| try t.print(c); - t.setCursorPos(1, 2); - t.eraseLine(.left, false); + try t.printString("ABC123"); + t.scrolling_region.left = 2; + t.scrolling_region.right = 4; + t.setCursorPos(1, 4); + t.deleteChars(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" C", str); + try testing.expectEqualStrings("ABC2 3", str); } } -test "Terminal: eraseLine left protected requested" { +test "Terminal: deleteChars split wide character from spacer tail" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, .{ .cols = 6, .rows = 10 }); defer t.deinit(alloc); - for ("123456789") |c| try t.print(c); - t.setCursorPos(t.screen.cursor.y + 1, 6); - t.setProtectedMode(.dec); - try t.print('X'); - t.setCursorPos(t.screen.cursor.y + 1, 8); - t.eraseLine(.left, true); + try t.printString("A橋123"); + t.setCursorPos(1, 3); + t.deleteChars(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" X 9", str); + try testing.expectEqualStrings("A 123", str); } } -test "Terminal: eraseLine complete preserves background sgr" { +test "Terminal: deleteChars split wide character from wide" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 6, .rows = 10 }); defer t.deinit(alloc); - const pen: Screen.Cell = .{ - .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, - }; - - for ("ABCDE") |c| try t.print(c); - t.setCursorPos(1, 2); - t.screen.cursor.pen = pen; - t.eraseLine(.complete, false); + try t.printString("橋123"); + t.setCursorPos(1, 1); + t.deleteChars(1); { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("", str); - for (0..5) |x| { - const cell = t.screen.getCell(.active, 0, x); - try testing.expectEqual(pen, cell); - } + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, '1'), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); } } -test "Terminal: eraseLine complete protected attributes respected with iso" { +test "Terminal: deleteChars split wide character from end" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 6, .rows = 10 }); defer t.deinit(alloc); - t.setProtectedMode(.iso); - for ("ABC") |c| try t.print(c); + try t.printString("A橋123"); t.setCursorPos(1, 1); - t.eraseLine(.complete, false); + t.deleteChars(1); { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC", str); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x6A4B), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, ' '), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); } } -test "Terminal: eraseLine complete protected attributes ignored with dec most recent" { +test "Terminal: deleteChars with a spacer head at the end" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 5, .rows = 10 }); defer t.deinit(alloc); - t.setProtectedMode(.iso); - for ("ABC") |c| try t.print(c); - t.setProtectedMode(.dec); - t.setProtectedMode(.off); - t.setCursorPos(1, 2); - t.eraseLine(.complete, false); + try t.printString("0123橋123"); + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; + const row = list_cell.row; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); + try testing.expect(row.wrap); + } + + t.setCursorPos(1, 1); + t.deleteChars(1); { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("", str); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 3, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); } } -test "Terminal: eraseLine complete protected attributes ignored with dec set" { +test "Terminal: deleteChars split wide character tail" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - t.setProtectedMode(.dec); - for ("ABC") |c| try t.print(c); - t.setCursorPos(1, 2); - t.eraseLine(.complete, false); + t.setCursorPos(1, t.cols - 1); + try t.print(0x6A4B); // 橋 + t.carriageReturn(); + t.deleteChars(t.cols - 1); + try t.print('0'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("", str); + try testing.expectEqualStrings("0", str); } } -test "Terminal: eraseLine complete protected requested" { +test "Terminal: saveCursor" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, .{ .cols = 3, .rows = 3 }); defer t.deinit(alloc); - for ("123456789") |c| try t.print(c); - t.setCursorPos(t.screen.cursor.y + 1, 6); - t.setProtectedMode(.dec); + try t.setAttribute(.{ .bold = {} }); + t.screen.charset.gr = .G3; + t.modes.set(.origin, true); + t.saveCursor(); + t.screen.charset.gr = .G0; + try t.setAttribute(.{ .unset = {} }); + t.modes.set(.origin, false); + try t.restoreCursor(); + try testing.expect(t.screen.cursor.style.flags.bold); + try testing.expect(t.screen.charset.gr == .G3); + try testing.expect(t.modes.get(.origin)); +} + +test "Terminal: saveCursor with screen change" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 3, .rows = 3 }); + defer t.deinit(alloc); + + try t.setAttribute(.{ .bold = {} }); + t.setCursorPos(t.screen.cursor.y + 1, 3); + try testing.expect(t.screen.cursor.x == 2); + t.screen.charset.gr = .G3; + t.modes.set(.origin, true); + t.alternateScreen(.{ + .cursor_save = true, + .clear_on_enter = true, + }); + // make sure our cursor and charset have come with us + try testing.expect(t.screen.cursor.style.flags.bold); + try testing.expect(t.screen.cursor.x == 2); + try testing.expect(t.screen.charset.gr == .G3); + try testing.expect(t.modes.get(.origin)); + t.screen.charset.gr = .G0; + try t.setAttribute(.{ .reset_bold = {} }); + t.modes.set(.origin, false); + t.primaryScreen(.{ + .cursor_save = true, + .clear_on_enter = true, + }); + try testing.expect(t.screen.cursor.style.flags.bold); + try testing.expect(t.screen.charset.gr == .G3); + try testing.expect(t.modes.get(.origin)); +} + +test "Terminal: saveCursor position" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + t.setCursorPos(1, 5); + try t.print('A'); + t.saveCursor(); + t.setCursorPos(1, 1); + try t.print('B'); + try t.restoreCursor(); try t.print('X'); - t.setCursorPos(t.screen.cursor.y + 1, 8); - t.eraseLine(.complete, true); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" X", str); + try testing.expectEqualStrings("B AX", str); } } -test "Terminal: eraseDisplay simple erase below" { +test "Terminal: saveCursor pending wrap state" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - for ("ABC") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("DEF") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("GHI") |c| try t.print(c); - t.setCursorPos(2, 2); - t.eraseDisplay(alloc, .below, false); + t.setCursorPos(1, 5); + try t.print('A'); + t.saveCursor(); + t.setCursorPos(1, 1); + try t.print('B'); + try t.restoreCursor(); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nD", str); + try testing.expectEqualStrings("B A\nX", str); } } -test "Terminal: eraseDisplay erase below preserves SGR bg" { +test "Terminal: saveCursor origin mode" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); - for ("ABC") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("DEF") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("GHI") |c| try t.print(c); - t.setCursorPos(2, 2); - - const pen: Screen.Cell = .{ - .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, - }; - - t.screen.cursor.pen = pen; - t.eraseDisplay(alloc, .below, false); + t.modes.set(.origin, true); + t.saveCursor(); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(3, 5); + t.setTopAndBottomMargin(2, 4); + try t.restoreCursor(); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nD", str); - for (1..5) |x| { - const cell = t.screen.getCell(.active, 1, x); - try testing.expectEqual(pen, cell); - } + try testing.expectEqualStrings("X", str); } } -test "Terminal: eraseDisplay below split multi-cell" { +test "Terminal: saveCursor resize" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); - try t.printString("AB橋C"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DE橋F"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("GH橋I"); - t.setCursorPos(2, 4); - t.eraseDisplay(alloc, .below, false); + t.setCursorPos(1, 10); + t.saveCursor(); + try t.resize(alloc, 5, 5); + try t.restoreCursor(); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("AB橋C\nDE", str); + try testing.expectEqualStrings(" X", str); } } -test "Terminal: eraseDisplay below protected attributes respected with iso" { +test "Terminal: saveCursor protected pen" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); t.setProtectedMode(.iso); - for ("ABC") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("DEF") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("GHI") |c| try t.print(c); - t.setCursorPos(2, 2); - t.eraseDisplay(alloc, .below, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nDEF\nGHI", str); - } + try testing.expect(t.screen.cursor.protected); + t.setCursorPos(1, 10); + t.saveCursor(); + t.setProtectedMode(.off); + try testing.expect(!t.screen.cursor.protected); + try t.restoreCursor(); + try testing.expect(t.screen.cursor.protected); } -test "Terminal: eraseDisplay below protected attributes ignored with dec most recent" { +test "Terminal: setProtectedMode" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 3, .rows = 3 }); defer t.deinit(alloc); + try testing.expect(!t.screen.cursor.protected); + t.setProtectedMode(.off); + try testing.expect(!t.screen.cursor.protected); t.setProtectedMode(.iso); - for ("ABC") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("DEF") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("GHI") |c| try t.print(c); + try testing.expect(t.screen.cursor.protected); t.setProtectedMode(.dec); + try testing.expect(t.screen.cursor.protected); t.setProtectedMode(.off); - t.setCursorPos(2, 2); - t.eraseDisplay(alloc, .below, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nD", str); - } + try testing.expect(!t.screen.cursor.protected); } -test "Terminal: eraseDisplay below protected attributes ignored with dec set" { +test "Terminal: eraseLine simple erase right" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - t.setProtectedMode(.dec); - for ("ABC") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("DEF") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("GHI") |c| try t.print(c); - t.setCursorPos(2, 2); - t.eraseDisplay(alloc, .below, false); + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 3); + t.eraseLine(.right, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nD", str); + try testing.expectEqualStrings("AB", str); } } -test "Terminal: eraseDisplay simple erase above" { +test "Terminal: eraseLine resets pending wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - for ("ABC") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("DEF") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("GHI") |c| try t.print(c); - t.setCursorPos(2, 2); - t.eraseDisplay(alloc, .above, false); + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + t.eraseLine(.right, false); + try testing.expect(!t.screen.cursor.pending_wrap); + try t.print('B'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("\n F\nGHI", str); + try testing.expectEqualStrings("ABCDB", str); } } -test "Terminal: eraseDisplay below protected attributes respected with force" { +test "Terminal: eraseLine resets wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - t.setProtectedMode(.dec); - for ("ABC") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("DEF") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("GHI") |c| try t.print(c); - t.setCursorPos(2, 2); - t.eraseDisplay(alloc, .below, true); + for ("ABCDE123") |c| try t.print(c); + { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + try testing.expect(list_cell.row.wrap); + } + + t.setCursorPos(1, 1); + t.eraseLine(.right, false); + + { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + try testing.expect(!list_cell.row.wrap); + } + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nDEF\nGHI", str); + try testing.expectEqualStrings("X\n123", str); } } -test "Terminal: eraseDisplay erase above preserves SGR bg" { +test "Terminal: eraseLine right preserves background sgr" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - for ("ABC") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("DEF") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("GHI") |c| try t.print(c); - t.setCursorPos(2, 2); - - const pen: Screen.Cell = .{ - .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, - }; - - t.screen.cursor.pen = pen; - t.eraseDisplay(alloc, .above, false); + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 2); + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + t.eraseLine(.right, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("\n F\nGHI", str); - for (0..2) |x| { - const cell = t.screen.getCell(.active, 1, x); - try testing.expectEqual(pen, cell); + try testing.expectEqualStrings("A", str); + for (1..5) |x| { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = x, .y = 0 } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); } } } -test "Terminal: eraseDisplay above split multi-cell" { +test "Terminal: eraseLine right wide character" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); - try t.printString("AB橋C"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DE橋F"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("GH橋I"); - t.setCursorPos(2, 3); - t.eraseDisplay(alloc, .above, false); + for ("AB") |c| try t.print(c); + try t.print('橋'); + for ("DE") |c| try t.print(c); + t.setCursorPos(1, 4); + t.eraseLine(.right, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("\n F\nGH橋I", str); + try testing.expectEqualStrings("AB", str); } } -test "Terminal: eraseDisplay above protected attributes respected with iso" { +test "Terminal: eraseLine right protected attributes respected with iso" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.iso); for ("ABC") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("DEF") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("GHI") |c| try t.print(c); - t.setCursorPos(2, 2); - t.eraseDisplay(alloc, .above, false); + t.setCursorPos(1, 1); + t.eraseLine(.right, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nDEF\nGHI", str); + try testing.expectEqualStrings("ABC", str); } } -test "Terminal: eraseDisplay above protected attributes ignored with dec most recent" { +test "Terminal: eraseLine right protected attributes ignored with dec most recent" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.iso); for ("ABC") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("DEF") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("GHI") |c| try t.print(c); t.setProtectedMode(.dec); t.setProtectedMode(.off); - t.setCursorPos(2, 2); - t.eraseDisplay(alloc, .above, false); + t.setCursorPos(1, 2); + t.eraseLine(.right, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("\n F\nGHI", str); + try testing.expectEqualStrings("A", str); } } -test "Terminal: eraseDisplay above protected attributes ignored with dec set" { +test "Terminal: eraseLine right protected attributes ignored with dec set" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.dec); for ("ABC") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("DEF") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("GHI") |c| try t.print(c); - t.setCursorPos(2, 2); - t.eraseDisplay(alloc, .above, false); + t.setCursorPos(1, 2); + t.eraseLine(.right, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("\n F\nGHI", str); + try testing.expectEqualStrings("A", str); } } -test "Terminal: eraseDisplay above protected attributes respected with force" { +test "Terminal: eraseLine right protected requested" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); + for ("12345678") |c| try t.print(c); + t.setCursorPos(t.screen.cursor.y + 1, 6); t.setProtectedMode(.dec); - for ("ABC") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("DEF") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("GHI") |c| try t.print(c); - t.setCursorPos(2, 2); - t.eraseDisplay(alloc, .above, true); + try t.print('X'); + t.setCursorPos(t.screen.cursor.y + 1, 4); + t.eraseLine(.right, true); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nDEF\nGHI", str); + try testing.expectEqualStrings("123 X", str); } } -test "Terminal: eraseDisplay above" { - var t = try init(testing.allocator, 80, 80); - defer t.deinit(testing.allocator); - const pink = color.RGB{ .r = 0xFF, .g = 0x00, .b = 0x7F }; - t.screen.cursor.pen = Screen.Cell{ - .char = 'a', - .bg = .{ .rgb = pink }, - .fg = .{ .rgb = pink }, - .attrs = .{ .bold = true }, - }; - const cell_ptr = t.screen.getCellPtr(.active, 0, 0); - cell_ptr.* = t.screen.cursor.pen; - // verify the cell was set - var cell = t.screen.getCell(.active, 0, 0); - try testing.expect(cell.bg.rgb.eql(pink)); - try testing.expect(cell.fg.rgb.eql(pink)); - try testing.expect(cell.char == 'a'); - try testing.expect(cell.attrs.bold); - // move the cursor below it - t.screen.cursor.y = 40; - t.screen.cursor.x = 40; - // erase above the cursor - t.eraseDisplay(testing.allocator, .above, false); - // check it was erased - cell = t.screen.getCell(.active, 0, 0); - try testing.expect(cell.bg.rgb.eql(pink)); - try testing.expect(cell.fg == .none); - try testing.expect(cell.char == 0); - try testing.expect(!cell.attrs.bold); - - // Check that our pen hasn't changed - try testing.expect(t.screen.cursor.pen.attrs.bold); - - // check that another cell got the correct bg - cell = t.screen.getCell(.active, 0, 1); - try testing.expect(cell.bg.rgb.eql(pink)); -} - -test "Terminal: eraseDisplay below" { - var t = try init(testing.allocator, 80, 80); - defer t.deinit(testing.allocator); +test "Terminal: eraseLine simple erase left" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); - const pink = color.RGB{ .r = 0xFF, .g = 0x00, .b = 0x7F }; - t.screen.cursor.pen = Screen.Cell{ - .char = 'a', - .bg = .{ .rgb = pink }, - .fg = .{ .rgb = pink }, - .attrs = .{ .bold = true }, - }; - const cell_ptr = t.screen.getCellPtr(.active, 60, 60); - cell_ptr.* = t.screen.cursor.pen; - // verify the cell was set - var cell = t.screen.getCell(.active, 60, 60); - try testing.expect(cell.bg.rgb.eql(pink)); - try testing.expect(cell.fg.rgb.eql(pink)); - try testing.expect(cell.char == 'a'); - try testing.expect(cell.attrs.bold); - // erase below the cursor - t.eraseDisplay(testing.allocator, .below, false); - // check it was erased - cell = t.screen.getCell(.active, 60, 60); - try testing.expect(cell.bg.rgb.eql(pink)); - try testing.expect(cell.fg == .none); - try testing.expect(cell.char == 0); - try testing.expect(!cell.attrs.bold); - - // check that another cell got the correct bg - cell = t.screen.getCell(.active, 0, 1); - try testing.expect(cell.bg.rgb.eql(pink)); -} - -test "Terminal: eraseDisplay complete" { - var t = try init(testing.allocator, 80, 80); - defer t.deinit(testing.allocator); + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 3); + t.eraseLine(.left, false); - const pink = color.RGB{ .r = 0xFF, .g = 0x00, .b = 0x7F }; - t.screen.cursor.pen = Screen.Cell{ - .char = 'a', - .bg = .{ .rgb = pink }, - .fg = .{ .rgb = pink }, - .attrs = .{ .bold = true }, - }; - var cell_ptr = t.screen.getCellPtr(.active, 60, 60); - cell_ptr.* = t.screen.cursor.pen; - cell_ptr = t.screen.getCellPtr(.active, 0, 0); - cell_ptr.* = t.screen.cursor.pen; - // verify the cell was set - var cell = t.screen.getCell(.active, 60, 60); - try testing.expect(cell.bg.rgb.eql(pink)); - try testing.expect(cell.fg.rgb.eql(pink)); - try testing.expect(cell.char == 'a'); - try testing.expect(cell.attrs.bold); - // verify the cell was set - cell = t.screen.getCell(.active, 0, 0); - try testing.expect(cell.bg.rgb.eql(pink)); - try testing.expect(cell.fg.rgb.eql(pink)); - try testing.expect(cell.char == 'a'); - try testing.expect(cell.attrs.bold); - // position our cursor between the cells - t.screen.cursor.y = 30; - // erase everything - t.eraseDisplay(testing.allocator, .complete, false); - // check they were erased - cell = t.screen.getCell(.active, 60, 60); - try testing.expect(cell.bg.rgb.eql(pink)); - try testing.expect(cell.fg == .none); - try testing.expect(cell.char == 0); - try testing.expect(!cell.attrs.bold); - cell = t.screen.getCell(.active, 0, 0); - try testing.expect(cell.bg.rgb.eql(pink)); - try testing.expect(cell.fg == .none); - try testing.expect(cell.char == 0); - try testing.expect(!cell.attrs.bold); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" DE", str); + } } -test "Terminal: eraseDisplay protected complete" { +test "Terminal: eraseLine left resets wrap" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - try t.print('A'); - t.carriageReturn(); - try t.linefeed(); - for ("123456789") |c| try t.print(c); - t.setCursorPos(t.screen.cursor.y + 1, 6); - t.setProtectedMode(.dec); - try t.print('X'); - t.setCursorPos(t.screen.cursor.y + 1, 4); - t.eraseDisplay(alloc, .complete, true); + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + t.eraseLine(.left, false); + try testing.expect(!t.screen.cursor.pending_wrap); + try t.print('B'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("\n X", str); + try testing.expectEqualStrings(" B", str); } } -test "Terminal: eraseDisplay protected below" { +test "Terminal: eraseLine left preserves background sgr" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - try t.print('A'); - t.carriageReturn(); - try t.linefeed(); - for ("123456789") |c| try t.print(c); - t.setCursorPos(t.screen.cursor.y + 1, 6); - t.setProtectedMode(.dec); - try t.print('X'); - t.setCursorPos(t.screen.cursor.y + 1, 4); - t.eraseDisplay(alloc, .below, true); + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 2); + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + t.eraseLine(.left, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A\n123 X", str); + try testing.expectEqualStrings(" CDE", str); + for (0..2) |x| { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = x, .y = 0 } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); + } } } -test "Terminal: eraseDisplay protected above" { +test "Terminal: eraseLine left wide character" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); - try t.print('A'); - t.carriageReturn(); - try t.linefeed(); - t.eraseDisplay(alloc, .scroll_complete, false); + for ("AB") |c| try t.print(c); + try t.print('橋'); + for ("DE") |c| try t.print(c); + t.setCursorPos(1, 3); + t.eraseLine(.left, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("", str); + try testing.expectEqualStrings(" DE", str); } } -test "Terminal: eraseDisplay scroll complete" { +test "Terminal: eraseLine left protected attributes respected with iso" { const alloc = testing.allocator; - var t = try init(alloc, 10, 3); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - try t.print('A'); - t.carriageReturn(); - try t.linefeed(); - for ("123456789") |c| try t.print(c); - t.setCursorPos(t.screen.cursor.y + 1, 6); - t.setProtectedMode(.dec); - try t.print('X'); - t.setCursorPos(t.screen.cursor.y + 1, 8); - t.eraseDisplay(alloc, .above, true); + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 1); + t.eraseLine(.left, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("\n X 9", str); + try testing.expectEqualStrings("ABC", str); } } -test "Terminal: cursorLeft no wrap" { +test "Terminal: eraseLine left protected attributes ignored with dec most recent" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - try t.print('A'); - t.carriageReturn(); - try t.linefeed(); - try t.print('B'); - t.cursorLeft(10); + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); + t.setProtectedMode(.dec); + t.setProtectedMode(.off); + t.setCursorPos(1, 2); + t.eraseLine(.left, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A\nB", str); + try testing.expectEqualStrings(" C", str); } } -test "Terminal: cursorLeft unsets pending wrap state" { +test "Terminal: eraseLine left protected attributes ignored with dec set" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); - t.cursorLeft(1); - try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('X'); + t.setProtectedMode(.dec); + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 2); + t.eraseLine(.left, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCXE", str); + try testing.expectEqualStrings(" C", str); } } -test "Terminal: cursorLeft unsets pending wrap state with longer jump" { +test "Terminal: eraseLine left protected requested" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); - for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); - t.cursorLeft(3); - try testing.expect(!t.screen.cursor.pending_wrap); + for ("123456789") |c| try t.print(c); + t.setCursorPos(t.screen.cursor.y + 1, 6); + t.setProtectedMode(.dec); try t.print('X'); + t.setCursorPos(t.screen.cursor.y + 1, 8); + t.eraseLine(.left, true); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("AXCDE", str); + try testing.expectEqualStrings(" X 9", str); } } -test "Terminal: cursorLeft reverse wrap with pending wrap state" { +test "Terminal: eraseLine complete preserves background sgr" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - t.modes.set(.wraparound, true); - t.modes.set(.reverse_wrap, true); - for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); - t.cursorLeft(1); - try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('X'); + t.setCursorPos(1, 2); + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + t.eraseLine(.complete, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDX", str); + try testing.expectEqualStrings("", str); + for (0..5) |x| { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = x, .y = 0 } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); + } } } -test "Terminal: cursorLeft reverse wrap extended with pending wrap state" { +test "Terminal: eraseLine complete protected attributes respected with iso" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - t.modes.set(.wraparound, true); - t.modes.set(.reverse_wrap_extended, true); - - for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); - t.cursorLeft(1); - try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('X'); + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 1); + t.eraseLine(.complete, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDX", str); + try testing.expectEqualStrings("ABC", str); } } -test "Terminal: cursorLeft reverse wrap" { +test "Terminal: eraseLine complete protected attributes ignored with dec most recent" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - t.modes.set(.wraparound, true); - t.modes.set(.reverse_wrap, true); - - for ("ABCDE1") |c| try t.print(c); - t.cursorLeft(2); - try t.print('X'); - try testing.expect(t.screen.cursor.pending_wrap); + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); + t.setProtectedMode(.dec); + t.setProtectedMode(.off); + t.setCursorPos(1, 2); + t.eraseLine(.complete, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDX\n1", str); + try testing.expectEqualStrings("", str); } } -test "Terminal: cursorLeft reverse wrap with no soft wrap" { +test "Terminal: eraseLine complete protected attributes ignored with dec set" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - t.modes.set(.wraparound, true); - t.modes.set(.reverse_wrap, true); - - for ("ABCDE") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - try t.print('1'); - t.cursorLeft(2); - try t.print('X'); + t.setProtectedMode(.dec); + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 2); + t.eraseLine(.complete, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDE\nX", str); + try testing.expectEqualStrings("", str); } } -test "Terminal: cursorLeft reverse wrap before left margin" { +test "Terminal: eraseLine complete protected requested" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); - t.modes.set(.wraparound, true); - t.modes.set(.reverse_wrap, true); - t.setTopAndBottomMargin(3, 0); - t.cursorLeft(1); + for ("123456789") |c| try t.print(c); + t.setCursorPos(t.screen.cursor.y + 1, 6); + t.setProtectedMode(.dec); try t.print('X'); + t.setCursorPos(t.screen.cursor.y + 1, 8); + t.eraseLine(.complete, true); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("\n\nX", str); + try testing.expectEqualStrings(" X", str); } } -test "Terminal: cursorLeft extended reverse wrap" { +test "Terminal: tabClear single" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 30, .rows = 5 }); defer t.deinit(alloc); - t.modes.set(.wraparound, true); - t.modes.set(.reverse_wrap_extended, true); + try t.horizontalTab(); + t.tabClear(.current); + t.setCursorPos(1, 1); + try t.horizontalTab(); + try testing.expectEqual(@as(usize, 16), t.screen.cursor.x); +} - for ("ABCDE") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - try t.print('1'); - t.cursorLeft(2); - try t.print('X'); +test "Terminal: tabClear all" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 30, .rows = 5 }); + defer t.deinit(alloc); + + t.tabClear(.all); + t.setCursorPos(1, 1); + try t.horizontalTab(); + try testing.expectEqual(@as(usize, 29), t.screen.cursor.x); +} + +test "Terminal: printRepeat simple" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString("A"); + try t.printRepeat(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDX\n1", str); + try testing.expectEqualStrings("AA", str); } } -test "Terminal: cursorLeft extended reverse wrap bottom wraparound" { +test "Terminal: printRepeat wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 3); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - t.modes.set(.wraparound, true); - t.modes.set(.reverse_wrap_extended, true); - - for ("ABCDE") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - try t.print('1'); - t.cursorLeft(1 + t.cols + 1); - try t.print('X'); + try t.printString(" A"); + try t.printRepeat(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDE\n1\n X", str); + try testing.expectEqualStrings(" A\nA", str); } } -test "Terminal: cursorLeft extended reverse wrap is priority if both set" { +test "Terminal: printRepeat no previous character" { const alloc = testing.allocator; - var t = try init(alloc, 5, 3); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - t.modes.set(.wraparound, true); - t.modes.set(.reverse_wrap, true); - t.modes.set(.reverse_wrap_extended, true); - - for ("ABCDE") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - try t.print('1'); - t.cursorLeft(1 + t.cols + 1); - try t.print('X'); + try t.printRepeat(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDE\n1\n X", str); + try testing.expectEqualStrings("", str); } } -test "Terminal: cursorLeft extended reverse wrap above top scroll region" { +test "Terminal: printAttributes" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - t.modes.set(.wraparound, true); - t.modes.set(.reverse_wrap_extended, true); - - t.setTopAndBottomMargin(3, 0); - t.setCursorPos(2, 1); - t.cursorLeft(1000); + var storage: [64]u8 = undefined; - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); -} + { + try t.setAttribute(.{ .direct_color_fg = .{ .r = 1, .g = 2, .b = 3 } }); + defer t.setAttribute(.unset) catch unreachable; + const buf = try t.printAttributes(&storage); + try testing.expectEqualStrings("0;38:2::1:2:3", buf); + } -test "Terminal: cursorLeft reverse wrap on first row" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); + { + try t.setAttribute(.bold); + try t.setAttribute(.{ .direct_color_bg = .{ .r = 1, .g = 2, .b = 3 } }); + defer t.setAttribute(.unset) catch unreachable; + const buf = try t.printAttributes(&storage); + try testing.expectEqualStrings("0;1;48:2::1:2:3", buf); + } - t.modes.set(.wraparound, true); - t.modes.set(.reverse_wrap, true); + { + try t.setAttribute(.bold); + try t.setAttribute(.faint); + try t.setAttribute(.italic); + try t.setAttribute(.{ .underline = .single }); + try t.setAttribute(.blink); + try t.setAttribute(.inverse); + try t.setAttribute(.invisible); + try t.setAttribute(.strikethrough); + try t.setAttribute(.{ .direct_color_fg = .{ .r = 100, .g = 200, .b = 255 } }); + try t.setAttribute(.{ .direct_color_bg = .{ .r = 101, .g = 102, .b = 103 } }); + defer t.setAttribute(.unset) catch unreachable; + const buf = try t.printAttributes(&storage); + try testing.expectEqualStrings("0;1;2;3;4;5;7;8;9;38:2::100:200:255;48:2::101:102:103", buf); + } - t.setTopAndBottomMargin(3, 0); - t.setCursorPos(1, 2); - t.cursorLeft(1000); + { + try t.setAttribute(.{ .underline = .single }); + defer t.setAttribute(.unset) catch unreachable; + const buf = try t.printAttributes(&storage); + try testing.expectEqualStrings("0;4", buf); + } - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + { + const buf = try t.printAttributes(&storage); + try testing.expectEqualStrings("0", buf); + } } -test "Terminal: cursorDown basic" { +test "Terminal: eraseDisplay simple erase below" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - try t.print('A'); - t.cursorDown(10); - try t.print('X'); + for ("ABC") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + t.eraseDisplay(.below, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A\n\n\n\n X", str); + try testing.expectEqualStrings("ABC\nD", str); } } -test "Terminal: cursorDown above bottom scroll margin" { +test "Terminal: eraseDisplay erase below preserves SGR bg" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - t.setTopAndBottomMargin(1, 3); - try t.print('A'); - t.cursorDown(10); - try t.print('X'); + for ("ABC") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + t.eraseDisplay(.below, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A\n\n X", str); + try testing.expectEqualStrings("ABC\nD", str); + for (1..5) |x| { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = x, .y = 1 } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); + } } } -test "Terminal: cursorDown below bottom scroll margin" { +test "Terminal: eraseDisplay below split multi-cell" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - t.setTopAndBottomMargin(1, 3); - try t.print('A'); - t.setCursorPos(4, 1); - t.cursorDown(10); - try t.print('X'); + try t.printString("AB橋C"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DE橋F"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GH橋I"); + t.setCursorPos(2, 4); + t.eraseDisplay(.below, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A\n\n\n\nX", str); + try testing.expectEqualStrings("AB橋C\nDE", str); } } -test "Terminal: cursorDown resets wrap" { +test "Terminal: eraseDisplay below protected attributes respected with iso" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); - t.cursorDown(1); - try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('X'); + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + t.eraseDisplay(.below, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDE\n X", str); + try testing.expectEqualStrings("ABC\nDEF\nGHI", str); } } -test "Terminal: cursorUp basic" { +test "Terminal: eraseDisplay below protected attributes ignored with dec most recent" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - t.setCursorPos(3, 1); - try t.print('A'); - t.cursorUp(10); - try t.print('X'); + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setProtectedMode(.dec); + t.setProtectedMode(.off); + t.setCursorPos(2, 2); + t.eraseDisplay(.below, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" X\n\nA", str); + try testing.expectEqualStrings("ABC\nD", str); } } -test "Terminal: cursorUp below top scroll margin" { +test "Terminal: eraseDisplay below protected attributes ignored with dec set" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - t.setTopAndBottomMargin(2, 4); - t.setCursorPos(3, 1); - try t.print('A'); - t.cursorUp(5); - try t.print('X'); + t.setProtectedMode(.dec); + for ("ABC") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + t.eraseDisplay(.below, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("\n X\nA", str); + try testing.expectEqualStrings("ABC\nD", str); } } -test "Terminal: cursorUp above top scroll margin" { +test "Terminal: eraseDisplay below protected attributes respected with force" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - t.setTopAndBottomMargin(3, 5); - t.setCursorPos(3, 1); - try t.print('A'); - t.setCursorPos(2, 1); - t.cursorUp(10); - try t.print('X'); + t.setProtectedMode(.dec); + for ("ABC") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + t.eraseDisplay(.below, true); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("X\n\nA", str); + try testing.expectEqualStrings("ABC\nDEF\nGHI", str); } } -test "Terminal: cursorUp resets wrap" { +test "Terminal: eraseDisplay simple erase above" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); - t.cursorUp(1); - try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('X'); + for ("ABC") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + t.eraseDisplay(.above, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDX", str); + try testing.expectEqualStrings("\n F\nGHI", str); } } -test "Terminal: cursorRight resets wrap" { +test "Terminal: eraseDisplay erase above preserves SGR bg" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); - t.cursorRight(1); - try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('X'); + for ("ABC") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + t.eraseDisplay(.above, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDX", str); + try testing.expectEqualStrings("\n F\nGHI", str); + for (0..2) |x| { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = x, .y = 1 } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); + } } } -test "Terminal: cursorRight to the edge of screen" { +test "Terminal: eraseDisplay above split multi-cell" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - t.cursorRight(100); - try t.print('X'); + try t.printString("AB橋C"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DE橋F"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GH橋I"); + t.setCursorPos(2, 3); + t.eraseDisplay(.above, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" X", str); + try testing.expectEqualStrings("\n F\nGH橋I", str); } } -test "Terminal: cursorRight left of right margin" { +test "Terminal: eraseDisplay above protected attributes respected with iso" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - t.scrolling_region.right = 2; - t.cursorRight(100); - try t.print('X'); + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + t.eraseDisplay(.above, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" X", str); + try testing.expectEqualStrings("ABC\nDEF\nGHI", str); } } -test "Terminal: cursorRight right of right margin" { +test "Terminal: eraseDisplay above protected attributes ignored with dec most recent" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - t.scrolling_region.right = 2; - t.screen.cursor.x = 3; - t.cursorRight(100); - try t.print('X'); + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setProtectedMode(.dec); + t.setProtectedMode(.off); + t.setCursorPos(2, 2); + t.eraseDisplay(.above, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" X", str); + try testing.expectEqualStrings("\n F\nGHI", str); } } -test "Terminal: scrollDown simple" { +test "Terminal: eraseDisplay above protected attributes ignored with dec set" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - try t.printString("ABC"); + t.setProtectedMode(.dec); + for ("ABC") |c| try t.print(c); t.carriageReturn(); try t.linefeed(); - try t.printString("DEF"); + for ("DEF") |c| try t.print(c); t.carriageReturn(); try t.linefeed(); - try t.printString("GHI"); + for ("GHI") |c| try t.print(c); t.setCursorPos(2, 2); - const cursor = t.screen.cursor; - try t.scrollDown(1); - try testing.expectEqual(cursor, t.screen.cursor); + t.eraseDisplay(.above, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("\nABC\nDEF\nGHI", str); + try testing.expectEqualStrings("\n F\nGHI", str); } } -test "Terminal: scrollDown outside of scroll region" { +test "Terminal: eraseDisplay above protected attributes respected with force" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - try t.printString("ABC"); + t.setProtectedMode(.dec); + for ("ABC") |c| try t.print(c); t.carriageReturn(); try t.linefeed(); - try t.printString("DEF"); + for ("DEF") |c| try t.print(c); t.carriageReturn(); try t.linefeed(); - try t.printString("GHI"); - t.setTopAndBottomMargin(3, 4); + for ("GHI") |c| try t.print(c); t.setCursorPos(2, 2); - const cursor = t.screen.cursor; - try t.scrollDown(1); - try testing.expectEqual(cursor, t.screen.cursor); + t.eraseDisplay(.above, true); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nDEF\n\nGHI", str); + try testing.expectEqualStrings("ABC\nDEF\nGHI", str); } } -test "Terminal: scrollDown left/right scroll region" { +test "Terminal: eraseDisplay protected complete" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); - try t.printString("ABC123"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF456"); + try t.print('A'); t.carriageReturn(); try t.linefeed(); - try t.printString("GHI789"); - t.scrolling_region.left = 1; - t.scrolling_region.right = 3; - t.setCursorPos(2, 2); - const cursor = t.screen.cursor; - try t.scrollDown(1); - try testing.expectEqual(cursor, t.screen.cursor); + for ("123456789") |c| try t.print(c); + t.setCursorPos(t.screen.cursor.y + 1, 6); + t.setProtectedMode(.dec); + try t.print('X'); + t.setCursorPos(t.screen.cursor.y + 1, 4); + t.eraseDisplay(.complete, true); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A 23\nDBC156\nGEF489\n HI7", str); + try testing.expectEqualStrings("\n X", str); } } -test "Terminal: scrollDown outside of left/right scroll region" { +test "Terminal: eraseDisplay protected below" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); - try t.printString("ABC123"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF456"); + try t.print('A'); t.carriageReturn(); try t.linefeed(); - try t.printString("GHI789"); - t.scrolling_region.left = 1; - t.scrolling_region.right = 3; - t.setCursorPos(1, 1); - const cursor = t.screen.cursor; - try t.scrollDown(1); - try testing.expectEqual(cursor, t.screen.cursor); + for ("123456789") |c| try t.print(c); + t.setCursorPos(t.screen.cursor.y + 1, 6); + t.setProtectedMode(.dec); + try t.print('X'); + t.setCursorPos(t.screen.cursor.y + 1, 4); + t.eraseDisplay(.below, true); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A 23\nDBC156\nGEF489\n HI7", str); + try testing.expectEqualStrings("A\n123 X", str); } } -test "Terminal: scrollDown preserves pending wrap" { +test "Terminal: eraseDisplay scroll complete" { const alloc = testing.allocator; - var t = try init(alloc, 5, 10); + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); - t.setCursorPos(1, 5); try t.print('A'); - t.setCursorPos(2, 5); - try t.print('B'); - t.setCursorPos(3, 5); - try t.print('C'); - try t.scrollDown(1); - try t.print('X'); + t.carriageReturn(); + try t.linefeed(); + t.eraseDisplay(.scroll_complete, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("\n A\n B\nX C", str); + try testing.expectEqualStrings("", str); } } -test "Terminal: scrollUp simple" { +test "Terminal: eraseDisplay protected above" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 10, .rows = 3 }); defer t.deinit(alloc); - try t.printString("ABC"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF"); + try t.print('A'); t.carriageReturn(); try t.linefeed(); - try t.printString("GHI"); - t.setCursorPos(2, 2); - const cursor = t.screen.cursor; - try t.scrollUp(1); - try testing.expectEqual(cursor, t.screen.cursor); + for ("123456789") |c| try t.print(c); + t.setCursorPos(t.screen.cursor.y + 1, 6); + t.setProtectedMode(.dec); + try t.print('X'); + t.setCursorPos(t.screen.cursor.y + 1, 8); + t.eraseDisplay(.above, true); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("DEF\nGHI", str); + try testing.expectEqualStrings("\n X 9", str); } } -test "Terminal: scrollUp top/bottom scroll region" { +test "Terminal: eraseDisplay complete preserves cursor" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + // Set our cursur + try t.setAttribute(.{ .bold = {} }); + try t.printString("AAAA"); + try testing.expect(t.screen.cursor.style_id != style.default_id); + + // Erasing the display may detect that our style is no longer in use + // and prune our style, which we don't want because its still our + // active cursor. + t.eraseDisplay(.complete, false); + try testing.expect(t.screen.cursor.style_id != style.default_id); +} + +test "Terminal: cursorIsAtPrompt" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 3, .rows = 2 }); + defer t.deinit(alloc); + + try testing.expect(!t.cursorIsAtPrompt()); + t.markSemanticPrompt(.prompt); + try testing.expect(t.cursorIsAtPrompt()); + + // Input is also a prompt + t.markSemanticPrompt(.input); + try testing.expect(t.cursorIsAtPrompt()); + + // Newline -- we expect we're still at a prompt if we received + // prompt stuff before. + try t.linefeed(); + try testing.expect(t.cursorIsAtPrompt()); + + // But once we say we're starting output, we're not a prompt + t.markSemanticPrompt(.command); + try testing.expect(!t.cursorIsAtPrompt()); + try t.linefeed(); + try testing.expect(!t.cursorIsAtPrompt()); + + // Until we know we're at a prompt again + try t.linefeed(); + t.markSemanticPrompt(.prompt); + try testing.expect(t.cursorIsAtPrompt()); +} + +test "Terminal: cursorIsAtPrompt alternate screen" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 3, .rows = 2 }); defer t.deinit(alloc); - try t.printString("ABC"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("GHI"); - t.setTopAndBottomMargin(2, 3); - t.setCursorPos(1, 1); - try t.scrollUp(1); + try testing.expect(!t.cursorIsAtPrompt()); + t.markSemanticPrompt(.prompt); + try testing.expect(t.cursorIsAtPrompt()); + + // Secondary screen is never a prompt + t.alternateScreen(.{}); + try testing.expect(!t.cursorIsAtPrompt()); + t.markSemanticPrompt(.prompt); + try testing.expect(!t.cursorIsAtPrompt()); +} + +test "Terminal: fullReset with a non-empty pen" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + try t.setAttribute(.{ .direct_color_fg = .{ .r = 0xFF, .g = 0, .b = 0x7F } }); + try t.setAttribute(.{ .direct_color_bg = .{ .r = 0xFF, .g = 0, .b = 0x7F } }); + t.fullReset(); { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nGHI", str); + const list_cell = t.screen.pages.getCell(.{ .active = .{ + .x = t.screen.cursor.x, + .y = t.screen.cursor.y, + } }).?; + const cell = list_cell.cell; + try testing.expect(cell.style_id == 0); } + + try testing.expectEqual(@as(style.Id, 0), t.screen.cursor.style_id); } -test "Terminal: scrollUp left/right scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 10, 10); - defer t.deinit(alloc); +test "Terminal: fullReset with a non-empty saved cursor" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); - try t.printString("ABC123"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF456"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("GHI789"); - t.scrolling_region.left = 1; - t.scrolling_region.right = 3; - t.setCursorPos(2, 2); - const cursor = t.screen.cursor; - try t.scrollUp(1); - try testing.expectEqual(cursor, t.screen.cursor); + try t.setAttribute(.{ .direct_color_fg = .{ .r = 0xFF, .g = 0, .b = 0x7F } }); + try t.setAttribute(.{ .direct_color_bg = .{ .r = 0xFF, .g = 0, .b = 0x7F } }); + t.saveCursor(); + t.fullReset(); { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("AEF423\nDHI756\nG 89", str); + const list_cell = t.screen.pages.getCell(.{ .active = .{ + .x = t.screen.cursor.x, + .y = t.screen.cursor.y, + } }).?; + const cell = list_cell.cell; + try testing.expect(cell.style_id == 0); } + + try testing.expectEqual(@as(style.Id, 0), t.screen.cursor.style_id); } -test "Terminal: scrollUp preserves pending wrap" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); +test "Terminal: fullReset origin mode" { + var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); - t.setCursorPos(1, 5); - try t.print('A'); - t.setCursorPos(2, 5); - try t.print('B'); t.setCursorPos(3, 5); - try t.print('C'); - try t.scrollUp(1); - try t.print('X'); + t.modes.set(.origin, true); + t.fullReset(); - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" B\n C\n\nX", str); - } + // Origin mode should be reset and the cursor should be moved + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expect(!t.modes.get(.origin)); } -test "Terminal: scrollUp full top/bottom region" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); +test "Terminal: fullReset status display" { + var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); - try t.printString("top"); - t.setCursorPos(5, 1); - try t.printString("ABCDE"); - t.setTopAndBottomMargin(2, 5); - try t.scrollUp(4); + t.status_display = .status_line; + t.fullReset(); + try testing.expect(t.status_display == .main); +} - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("top", str); - } +// https://github.com/mitchellh/ghostty/issues/1607 +test "Terminal: fullReset clears alt screen kitty keyboard state" { + var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + t.alternateScreen(.{}); + t.screen.kitty_keyboard.push(.{ + .disambiguate = true, + .report_events = false, + .report_alternates = true, + .report_all = true, + .report_associated = true, + }); + t.primaryScreen(.{}); + + t.fullReset(); + try testing.expectEqual(0, t.secondary_screen.kitty_keyboard.current().int()); } -test "Terminal: scrollUp full top/bottomleft/right scroll region" { +// https://github.com/mitchellh/ghostty/issues/272 +// This is also tested in depth in screen resize tests but I want to keep +// this test around to ensure we don't regress at multiple layers. +test "Terminal: resize less cols with wide char then print" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 3, .rows = 3 }); defer t.deinit(alloc); - try t.printString("top"); - t.setCursorPos(5, 1); - try t.printString("ABCDE"); - t.modes.set(.enable_left_and_right_margin, true); - t.setTopAndBottomMargin(2, 5); - t.setLeftAndRightMargin(2, 4); - try t.scrollUp(4); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("top\n\n\n\nA E", str); - } + try t.print('x'); + try t.print('😀'); // 0x1F600 + try t.resize(alloc, 2, 3); + t.setCursorPos(1, 2); + try t.print('😀'); // 0x1F600 } -test "Terminal: tabClear single" { +// https://github.com/mitchellh/ghostty/issues/723 +// This was found via fuzzing so its highly specific. +test "Terminal: resize with left and right margin set" { const alloc = testing.allocator; - var t = try init(alloc, 30, 5); + const cols = 70; + const rows = 23; + var t = try init(alloc, .{ .cols = cols, .rows = rows }); defer t.deinit(alloc); - try t.horizontalTab(); - t.tabClear(.current); - t.setCursorPos(1, 1); - try t.horizontalTab(); - try testing.expectEqual(@as(usize, 16), t.screen.cursor.x); + t.modes.set(.enable_left_and_right_margin, true); + try t.print('0'); + t.modes.set(.enable_mode_3, true); + try t.resize(alloc, cols, rows); + t.setLeftAndRightMargin(2, 0); + try t.printRepeat(1850); + _ = t.modes.restore(.enable_mode_3); + try t.resize(alloc, cols, rows); } -test "Terminal: tabClear all" { +// https://github.com/mitchellh/ghostty/issues/1343 +test "Terminal: resize with wraparound off" { const alloc = testing.allocator; - var t = try init(alloc, 30, 5); + const cols = 4; + const rows = 2; + var t = try init(alloc, .{ .cols = cols, .rows = rows }); defer t.deinit(alloc); - t.tabClear(.all); - t.setCursorPos(1, 1); - try t.horizontalTab(); - try testing.expectEqual(@as(usize, 29), t.screen.cursor.x); + t.modes.set(.wraparound, false); + try t.print('0'); + try t.print('1'); + try t.print('2'); + try t.print('3'); + const new_cols = 2; + try t.resize(alloc, new_cols, rows); + + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("01", str); } -test "Terminal: printRepeat simple" { +test "Terminal: resize with wraparound on" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + const cols = 4; + const rows = 2; + var t = try init(alloc, .{ .cols = cols, .rows = rows }); defer t.deinit(alloc); - try t.printString("A"); - try t.printRepeat(1); + t.modes.set(.wraparound, true); + try t.print('0'); + try t.print('1'); + try t.print('2'); + try t.print('3'); + const new_cols = 2; + try t.resize(alloc, new_cols, rows); - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("AA", str); - } + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("01\n23", str); } -test "Terminal: printRepeat wrap" { +test "Terminal: resize with high unique style per cell" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 30, .rows = 30 }); defer t.deinit(alloc); - try t.printString(" A"); - try t.printRepeat(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" A\nA", str); + for (0..t.rows) |y| { + for (0..t.cols) |x| { + t.setCursorPos(y, x); + try t.setAttribute(.{ .direct_color_bg = .{ + .r = @intCast(x), + .g = @intCast(y), + .b = 0, + } }); + try t.print('x'); + } } + + try t.resize(alloc, 60, 30); } -test "Terminal: printRepeat no previous character" { +test "Terminal: resize with high unique style per cell with wrapping" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .cols = 30, .rows = 30 }); defer t.deinit(alloc); - try t.printRepeat(1); + const cell_count: u16 = @intCast(t.rows * t.cols); + for (0..cell_count) |i| { + const r: u8 = @intCast(i >> 8); + const g: u8 = @intCast(i & 0xFF); - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("", str); + try t.setAttribute(.{ .direct_color_bg = .{ + .r = r, + .g = g, + .b = 0, + } }); + try t.print('x'); } + + try t.resize(alloc, 60, 30); } test "Terminal: DECCOLM without DEC mode 40" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.modes.set(.@"132_column", true); @@ -7232,7 +8407,7 @@ test "Terminal: DECCOLM without DEC mode 40" { test "Terminal: DECCOLM unset" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.modes.set(.enable_mode_3, true); @@ -7243,7 +8418,7 @@ test "Terminal: DECCOLM unset" { test "Terminal: DECCOLM resets pending wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -7258,26 +8433,31 @@ test "Terminal: DECCOLM resets pending wrap" { test "Terminal: DECCOLM preserves SGR bg" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - const pen: Screen.Cell = .{ - .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, - }; - - t.screen.cursor.pen = pen; + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); t.modes.set(.enable_mode_3, true); try t.deccolm(alloc, .@"80_cols"); { - const cell = t.screen.getCell(.active, 0, 0); - try testing.expectEqual(pen, cell); + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); } } test "Terminal: DECCOLM resets scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.modes.set(.enable_left_and_right_margin, true); @@ -7293,79 +8473,3 @@ test "Terminal: DECCOLM resets scroll region" { try testing.expectEqual(@as(usize, 0), t.scrolling_region.left); try testing.expectEqual(@as(usize, 79), t.scrolling_region.right); } - -test "Terminal: printAttributes" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - var storage: [64]u8 = undefined; - - { - try t.setAttribute(.{ .direct_color_fg = .{ .r = 1, .g = 2, .b = 3 } }); - defer t.setAttribute(.unset) catch unreachable; - const buf = try t.printAttributes(&storage); - try testing.expectEqualStrings("0;38:2::1:2:3", buf); - } - - { - try t.setAttribute(.bold); - try t.setAttribute(.{ .direct_color_bg = .{ .r = 1, .g = 2, .b = 3 } }); - defer t.setAttribute(.unset) catch unreachable; - const buf = try t.printAttributes(&storage); - try testing.expectEqualStrings("0;1;48:2::1:2:3", buf); - } - - { - try t.setAttribute(.bold); - try t.setAttribute(.faint); - try t.setAttribute(.italic); - try t.setAttribute(.{ .underline = .single }); - try t.setAttribute(.blink); - try t.setAttribute(.inverse); - try t.setAttribute(.invisible); - try t.setAttribute(.strikethrough); - try t.setAttribute(.{ .direct_color_fg = .{ .r = 100, .g = 200, .b = 255 } }); - try t.setAttribute(.{ .direct_color_bg = .{ .r = 101, .g = 102, .b = 103 } }); - defer t.setAttribute(.unset) catch unreachable; - const buf = try t.printAttributes(&storage); - try testing.expectEqualStrings("0;1;2;3;4;5;7;8;9;38:2::100:200:255;48:2::101:102:103", buf); - } - - { - try t.setAttribute(.{ .underline = .single }); - defer t.setAttribute(.unset) catch unreachable; - const buf = try t.printAttributes(&storage); - try testing.expectEqualStrings("0;4", buf); - } - - { - const buf = try t.printAttributes(&storage); - try testing.expectEqualStrings("0", buf); - } -} - -test "Terminal: preserve grapheme cluster on large scrollback" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 3); - defer t.deinit(alloc); - - // This is the label emoji + the VS16 variant selector - const label = "\u{1F3F7}\u{FE0F}"; - - // This bug required a certain behavior around scrollback interacting - // with the circular buffer that we use at the time of writing this test. - // Mainly, we want to verify that in certain scroll scenarios we preserve - // grapheme clusters. This test is admittedly somewhat brittle but we - // should keep it around to prevent this regression. - for (0..t.screen.max_scrollback * 2) |_| { - try t.printString(label ++ "\n"); - } - - try t.scrollViewport(.{ .delta = -1 }); - { - const str = try t.screen.testString(alloc, .viewport); - defer testing.allocator.free(str); - try testing.expectEqualStrings("🏷️\n🏷️\n🏷️", str); - } -} diff --git a/src/terminal/bitmap_allocator.zig b/src/terminal/bitmap_allocator.zig new file mode 100644 index 0000000000..550a504168 --- /dev/null +++ b/src/terminal/bitmap_allocator.zig @@ -0,0 +1,474 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const size = @import("size.zig"); +const getOffset = size.getOffset; +const Offset = size.Offset; +const OffsetBuf = size.OffsetBuf; +const alignForward = std.mem.alignForward; + +/// A relatively naive bitmap allocator that uses memory offsets against +/// a fixed backing buffer so that the backing buffer can be easily moved +/// without having to update pointers. +/// +/// The chunk size determines the size of each chunk in bytes. This is the +/// minimum distributed unit of memory. For example, if you request a +/// 1-byte allocation, you'll use a chunk of chunk_size bytes. Likewise, +/// if your chunk size is 4, and you request a 5-byte allocation, you'll +/// use 2 chunks. +/// +/// The allocator is susceptible to fragmentation. If you allocate and free +/// memory in a way that leaves small holes in the memory, you may not be +/// able to allocate large chunks of memory even if there is enough free +/// memory in aggregate. To avoid fragmentation, use a chunk size that is +/// large enough to cover most of your allocations. +/// +// Notes for contributors: this is highly contributor friendly part of +// the code. If you can improve this, add tests, show benchmarks, then +// please do so! +pub fn BitmapAllocator(comptime chunk_size: comptime_int) type { + return struct { + const Self = @This(); + + comptime { + assert(std.math.isPowerOfTwo(chunk_size)); + } + + pub const base_align = @alignOf(u64); + pub const bitmap_bit_size = @bitSizeOf(u64); + + /// The bitmap of available chunks. Each bit represents a chunk. A + /// 1 means the chunk is free and a 0 means it's used. We use 1 + /// for free since it makes it very slightly faster to find free + /// chunks. + bitmap: Offset(u64), + bitmap_count: usize, + + /// The contiguous buffer of chunks. + chunks: Offset(u8), + + /// Initialize the allocator map with a given buf and memory layout. + pub fn init(buf: OffsetBuf, l: Layout) Self { + assert(@intFromPtr(buf.start()) % base_align == 0); + + // Initialize our bitmaps to all 1s to note that all chunks are free. + const bitmap = buf.member(u64, l.bitmap_start); + const bitmap_ptr = bitmap.ptr(buf); + @memset(bitmap_ptr[0..l.bitmap_count], std.math.maxInt(u64)); + + return .{ + .bitmap = bitmap, + .bitmap_count = l.bitmap_count, + .chunks = buf.member(u8, l.chunks_start), + }; + } + + /// Allocate n elements of type T. This will return error.OutOfMemory + /// if there isn't enough space in the backing buffer. + pub fn alloc( + self: *Self, + comptime T: type, + base: anytype, + n: usize, + ) Allocator.Error![]T { + // note: we don't handle alignment yet, we just require that all + // types are properly aligned. This is a limitation that should be + // fixed but we haven't needed it. Contributor friendly: add tests + // and fix this. + assert(chunk_size % @alignOf(T) == 0); + assert(n > 0); + + const byte_count = std.math.mul(usize, @sizeOf(T), n) catch + return error.OutOfMemory; + const chunk_count = std.math.divCeil(usize, byte_count, chunk_size) catch + return error.OutOfMemory; + + // Find the index of the free chunk. This also marks it as used. + const bitmaps = self.bitmap.ptr(base); + const idx = findFreeChunks(bitmaps[0..self.bitmap_count], chunk_count) orelse + return error.OutOfMemory; + + const chunks = self.chunks.ptr(base); + const ptr: [*]T = @alignCast(@ptrCast(&chunks[idx * chunk_size])); + return ptr[0..n]; + } + + pub fn free(self: *Self, base: anytype, slice: anytype) void { + // Convert the slice of whatever type to a slice of bytes. We + // can then use the byte len and chunk size to determine the + // number of chunks that were allocated. + const bytes = std.mem.sliceAsBytes(slice); + const aligned_len = std.mem.alignForward(usize, bytes.len, chunk_size); + const chunk_count = @divExact(aligned_len, chunk_size); + + // From the pointer, we can calculate the exact index. + const chunks = self.chunks.ptr(base); + const chunk_idx = @divExact(@intFromPtr(slice.ptr) - @intFromPtr(chunks), chunk_size); + + // From the chunk index, we can find the starting bitmap index + // and the bit within the last bitmap. + var bitmap_idx = @divFloor(chunk_idx, 64); + const bitmap_bit = chunk_idx % 64; + const bitmaps = self.bitmap.ptr(base); + + // If our chunk count is over 64 then we need to handle the + // case where we have to mark multiple bitmaps. + if (chunk_count > 64) { + const bitmaps_full = @divFloor(chunk_count, 64); + for (0..bitmaps_full) |i| bitmaps[bitmap_idx + i] = std.math.maxInt(u64); + bitmap_idx += bitmaps_full; + } + + // Set the bitmap to mark the chunks as free. Note we have to + // do chunk_count % 64 to handle the case where our chunk count + // is using multiple bitmaps. + const bitmap = &bitmaps[bitmap_idx]; + for (0..chunk_count % 64) |i| { + const mask = @as(u64, 1) << @intCast(bitmap_bit + i); + bitmap.* |= mask; + } + } + + /// For debugging + fn dumpBitmaps(self: *Self, base: anytype) void { + const bitmaps = self.bitmap.ptr(base); + for (bitmaps[0..self.bitmap_count], 0..) |bitmap, idx| { + std.log.warn("bm={b} idx={}", .{ bitmap, idx }); + } + } + + pub const Layout = struct { + total_size: usize, + bitmap_count: usize, + bitmap_start: usize, + chunks_start: usize, + }; + + /// Get the layout for the given capacity. The capacity is in + /// number of bytes, not chunks. The capacity will likely be + /// rounded up to the nearest chunk size and bitmap size so + /// everything is perfectly divisible. + pub fn layout(cap: usize) Layout { + // Align the cap forward to our chunk size so we always have + // a full chunk at the end. + const aligned_cap = alignForward(usize, cap, chunk_size); + + // Calculate the number of bitmaps. We need 1 bitmap per 64 chunks. + // We align the chunk count forward so our bitmaps are full so we + // don't have to handle the case where we have a partial bitmap. + const chunk_count = @divExact(aligned_cap, chunk_size); + const aligned_chunk_count = alignForward(usize, chunk_count, 64); + const bitmap_count = @divExact(aligned_chunk_count, 64); + + const bitmap_start = 0; + const bitmap_end = @sizeOf(u64) * bitmap_count; + const chunks_start = alignForward(usize, bitmap_end, @alignOf(u8)); + const chunks_end = chunks_start + (aligned_cap * chunk_size); + const total_size = chunks_end; + + return Layout{ + .total_size = total_size, + .bitmap_count = bitmap_count, + .bitmap_start = bitmap_start, + .chunks_start = chunks_start, + }; + } + }; +} + +/// Find `n` sequential free chunks in the given bitmaps and return the index +/// of the first chunk. If no chunks are found, return `null`. This also updates +/// the bitmap to mark the chunks as used. +fn findFreeChunks(bitmaps: []u64, n: usize) ?usize { + // NOTE: This is a naive implementation that just iterates through the + // bitmaps. There is very likely a more efficient way to do this but + // I'm not a bit twiddling expert. Perhaps even SIMD could be used here + // but unsure. Contributor friendly: let's benchmark and improve this! + + // Large chunks require special handling. In this case we look for + // divFloor sequential chunks that are maxInt, then look for the mod + // normally in the next bitmap. + if (n > @bitSizeOf(u64)) { + const div = @divFloor(n, @bitSizeOf(u64)); + const mod = n % @bitSizeOf(u64); + var seq: usize = 0; + for (bitmaps, 0..) |*bitmap, idx| { + // If we aren't fully empty then reset the sequence + if (bitmap.* != std.math.maxInt(u64)) { + seq = 0; + continue; + } + + // If we haven't reached the sequence count we're looking for + // then add one and continue, we're still accumulating blanks. + if (seq != div) { + seq += 1; + if (seq != div or mod > 0) continue; + } + + // We've reached the seq count see if this has mod starting empty + // blanks. + if (mod > 0) { + const final = @as(u64, std.math.maxInt(u64)) >> @intCast(64 - mod); + if (bitmap.* & final == 0) { + // No blanks, reset. + seq = 0; + continue; + } + + bitmap.* ^= final; + } + + // Found! Set all in our sequence to full and mask our final. + // The "zero_mod" modifier below handles the case where we have + // a perfectly divisible number of chunks so we don't have to + // mark the trailing bitmap. + const zero_mod = @intFromBool(mod == 0); + const start_idx = idx - (seq - zero_mod); + const end_idx = idx + zero_mod; + for (start_idx..end_idx) |i| bitmaps[i] = 0; + + return (start_idx * 64); + } + + return null; + } + + assert(n <= @bitSizeOf(u64)); + for (bitmaps, 0..) |*bitmap, idx| { + // Shift the bitmap to find `n` sequential free chunks. + var shifted: u64 = bitmap.*; + for (1..n) |i| shifted &= bitmap.* >> @intCast(i); + + // If we have zero then we have no matches + if (shifted == 0) continue; + + // Trailing zeroes gets us the bit 1-indexed + const bit = @ctz(shifted); + + // Calculate the mask so we can mark it as used + for (0..n) |i| { + const mask = @as(u64, 1) << @intCast(bit + i); + bitmap.* ^= mask; + } + + return (idx * 64) + bit; + } + + return null; +} + +test "findFreeChunks single found" { + const testing = std.testing; + + var bitmaps = [_]u64{ + 0b10000000_00000000_00000000_00000000_00000000_00000000_00001110_00000000, + }; + const idx = findFreeChunks(&bitmaps, 2).?; + try testing.expectEqual(@as(usize, 9), idx); + try testing.expectEqual( + 0b10000000_00000000_00000000_00000000_00000000_00000000_00001000_00000000, + bitmaps[0], + ); +} + +test "findFreeChunks single not found" { + const testing = std.testing; + + var bitmaps = [_]u64{0b10000111_00000000_00000000_00000000_00000000_00000000_00000000_00000000}; + const idx = findFreeChunks(&bitmaps, 4); + try testing.expect(idx == null); +} + +test "findFreeChunks multiple found" { + const testing = std.testing; + + var bitmaps = [_]u64{ + 0b10000111_00000000_00000000_00000000_00000000_00000000_00000000_01110000, + 0b10000000_00111110_00000000_00000000_00000000_00000000_00111110_00000000, + }; + const idx = findFreeChunks(&bitmaps, 4).?; + try testing.expectEqual(@as(usize, 73), idx); + try testing.expectEqual( + 0b10000000_00111110_00000000_00000000_00000000_00000000_00100000_00000000, + bitmaps[1], + ); +} + +test "findFreeChunks exactly 64 chunks" { + const testing = std.testing; + + var bitmaps = [_]u64{ + 0b11111111_11111111_11111111_11111111_11111111_11111111_11111111_11111111, + }; + const idx = findFreeChunks(&bitmaps, 64).?; + try testing.expectEqual( + 0b00000000_00000000_00000000_00000000_00000000_00000000_00000000_00000000, + bitmaps[0], + ); + try testing.expectEqual(@as(usize, 0), idx); +} + +test "findFreeChunks larger than 64 chunks" { + const testing = std.testing; + + var bitmaps = [_]u64{ + 0b11111111_11111111_11111111_11111111_11111111_11111111_11111111_11111111, + 0b11111111_11111111_11111111_11111111_11111111_11111111_11111111_11111111, + }; + const idx = findFreeChunks(&bitmaps, 65).?; + try testing.expectEqual( + 0b00000000_00000000_00000000_00000000_00000000_00000000_00000000_00000000, + bitmaps[0], + ); + try testing.expectEqual( + 0b11111111_11111111_11111111_11111111_11111111_11111111_11111111_11111110, + bitmaps[1], + ); + try testing.expectEqual(@as(usize, 0), idx); +} + +test "findFreeChunks larger than 64 chunks not at beginning" { + const testing = std.testing; + + var bitmaps = [_]u64{ + 0b11111111_00000000_00000000_00000000_00000000_00000000_00000000_00000000, + 0b11111111_11111111_11111111_11111111_11111111_11111111_11111111_11111111, + 0b11111111_11111111_11111111_11111111_11111111_11111111_11111111_11111111, + }; + const idx = findFreeChunks(&bitmaps, 65).?; + try testing.expectEqual( + 0b11111111_00000000_00000000_00000000_00000000_00000000_00000000_00000000, + bitmaps[0], + ); + try testing.expectEqual( + 0b00000000_00000000_00000000_00000000_00000000_00000000_00000000_00000000, + bitmaps[1], + ); + try testing.expectEqual( + 0b11111111_11111111_11111111_11111111_11111111_11111111_11111111_11111110, + bitmaps[2], + ); + try testing.expectEqual(@as(usize, 64), idx); +} + +test "findFreeChunks larger than 64 chunks exact" { + const testing = std.testing; + + var bitmaps = [_]u64{ + 0b11111111_11111111_11111111_11111111_11111111_11111111_11111111_11111111, + 0b11111111_11111111_11111111_11111111_11111111_11111111_11111111_11111111, + }; + const idx = findFreeChunks(&bitmaps, 128).?; + try testing.expectEqual( + 0b00000000_00000000_00000000_00000000_00000000_00000000_00000000_00000000, + bitmaps[0], + ); + try testing.expectEqual( + 0b00000000_00000000_00000000_00000000_00000000_00000000_00000000_00000000, + bitmaps[1], + ); + try testing.expectEqual(@as(usize, 0), idx); +} + +test "BitmapAllocator layout" { + const Alloc = BitmapAllocator(4); + const cap = 64 * 4; + + const testing = std.testing; + const layout = Alloc.layout(cap); + + // We expect to use one bitmap since the cap is bytes. + try testing.expectEqual(@as(usize, 1), layout.bitmap_count); +} + +test "BitmapAllocator alloc sequentially" { + const Alloc = BitmapAllocator(4); + const cap = 64; + + const testing = std.testing; + const alloc = testing.allocator; + const layout = Alloc.layout(cap); + const buf = try alloc.alignedAlloc(u8, Alloc.base_align, layout.total_size); + defer alloc.free(buf); + + var bm = Alloc.init(OffsetBuf.init(buf), layout); + const ptr = try bm.alloc(u8, buf, 1); + ptr[0] = 'A'; + + const ptr2 = try bm.alloc(u8, buf, 1); + try testing.expect(@intFromPtr(ptr.ptr) != @intFromPtr(ptr2.ptr)); + + // Should grab the next chunk + try testing.expectEqual(@intFromPtr(ptr.ptr) + 4, @intFromPtr(ptr2.ptr)); + + // Free ptr and next allocation should be back + bm.free(buf, ptr); + const ptr3 = try bm.alloc(u8, buf, 1); + try testing.expectEqual(@intFromPtr(ptr.ptr), @intFromPtr(ptr3.ptr)); +} + +test "BitmapAllocator alloc non-byte" { + const Alloc = BitmapAllocator(4); + const cap = 128; + + const testing = std.testing; + const alloc = testing.allocator; + const layout = Alloc.layout(cap); + const buf = try alloc.alignedAlloc(u8, Alloc.base_align, layout.total_size); + defer alloc.free(buf); + + var bm = Alloc.init(OffsetBuf.init(buf), layout); + const ptr = try bm.alloc(u21, buf, 1); + ptr[0] = 'A'; + + const ptr2 = try bm.alloc(u21, buf, 1); + try testing.expect(@intFromPtr(ptr.ptr) != @intFromPtr(ptr2.ptr)); + try testing.expectEqual(@intFromPtr(ptr.ptr) + 4, @intFromPtr(ptr2.ptr)); + + // Free ptr and next allocation should be back + bm.free(buf, ptr); + const ptr3 = try bm.alloc(u21, buf, 1); + try testing.expectEqual(@intFromPtr(ptr.ptr), @intFromPtr(ptr3.ptr)); +} + +test "BitmapAllocator alloc non-byte multi-chunk" { + const Alloc = BitmapAllocator(4 * @sizeOf(u21)); + const cap = 128; + + const testing = std.testing; + const alloc = testing.allocator; + const layout = Alloc.layout(cap); + const buf = try alloc.alignedAlloc(u8, Alloc.base_align, layout.total_size); + defer alloc.free(buf); + + var bm = Alloc.init(OffsetBuf.init(buf), layout); + const ptr = try bm.alloc(u21, buf, 6); + try testing.expectEqual(@as(usize, 6), ptr.len); + for (ptr) |*v| v.* = 'A'; + + const ptr2 = try bm.alloc(u21, buf, 1); + try testing.expect(@intFromPtr(ptr.ptr) != @intFromPtr(ptr2.ptr)); + try testing.expectEqual(@intFromPtr(ptr.ptr) + (@sizeOf(u21) * 4 * 2), @intFromPtr(ptr2.ptr)); + + // Free ptr and next allocation should be back + bm.free(buf, ptr); + const ptr3 = try bm.alloc(u21, buf, 1); + try testing.expectEqual(@intFromPtr(ptr.ptr), @intFromPtr(ptr3.ptr)); +} + +test "BitmapAllocator alloc large" { + const Alloc = BitmapAllocator(2); + const cap = 256; + + const testing = std.testing; + const alloc = testing.allocator; + const layout = Alloc.layout(cap); + const buf = try alloc.alignedAlloc(u8, Alloc.base_align, layout.total_size); + defer alloc.free(buf); + + var bm = Alloc.init(OffsetBuf.init(buf), layout); + const ptr = try bm.alloc(u8, buf, 129); + ptr[0] = 'A'; + bm.free(buf, ptr); +} diff --git a/src/terminal/hash_map.zig b/src/terminal/hash_map.zig new file mode 100644 index 0000000000..c9c6507845 --- /dev/null +++ b/src/terminal/hash_map.zig @@ -0,0 +1,1513 @@ +//! This file contains a fork of the Zig stdlib HashMap implementation tuned +//! for use with our terminal page representation. +//! +//! The main goal we need to achieve that wasn't possible with the stdlib +//! HashMap is to utilize offsets rather than full pointers so that we can +//! copy around the entire backing memory and keep the hash map working. +//! +//! Additionally, for serialization/deserialization purposes, we need to be +//! able to create a HashMap instance and manually set the offsets up. The +//! stdlib HashMap does not export Metadata so this isn't possible. +//! +//! Also, I want to be able to understand possible capacity for a given K,V +//! type and fixed memory amount. The stdlib HashMap doesn't publish its +//! internal allocation size calculation. +//! +//! Finally, I removed many of the APIs that we'll never require for our +//! usage just so that this file is smaller, easier to understand, and has +//! less opportunity for bugs. +//! +//! Besides these shortcomings, the stdlib HashMap has some great qualities +//! that we want to keep, namely the fact that it is backed by a single large +//! allocation rather than pointers to separate allocations. This is important +//! because our terminal page representation is backed by a single large +//! allocation so we can give the HashMap a slice of memory to operate in. +//! +//! I haven't carefully benchmarked this implementation against other hash +//! map implementations. It's possible using some of the newer variants out +//! there would be better. However, I trust the built-in version is pretty good +//! and its more important to get the terminal page representation working +//! first then we can measure and improve this later if we find it to be a +//! bottleneck. + +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; +const autoHash = std.hash.autoHash; +const math = std.math; +const mem = std.mem; +const Allocator = mem.Allocator; +const Wyhash = std.hash.Wyhash; + +const Offset = @import("size.zig").Offset; +const OffsetBuf = @import("size.zig").OffsetBuf; +const getOffset = @import("size.zig").getOffset; + +pub fn AutoOffsetHashMap(comptime K: type, comptime V: type) type { + return OffsetHashMap(K, V, AutoContext(K)); +} + +fn AutoHashMapUnmanaged(comptime K: type, comptime V: type) type { + return HashMapUnmanaged(K, V, AutoContext(K)); +} + +fn AutoContext(comptime K: type) type { + return struct { + pub const hash = std.hash_map.getAutoHashFn(K, @This()); + pub const eql = std.hash_map.getAutoEqlFn(K, @This()); + }; +} + +/// A HashMap type that uses offsets rather than pointers, making it +/// possible to efficiently move around the backing memory without +/// invalidating the HashMap. +pub fn OffsetHashMap( + comptime K: type, + comptime V: type, + comptime Context: type, +) type { + return struct { + const Self = @This(); + + /// This is the pointer-based map that we're wrapping. + pub const Unmanaged = HashMapUnmanaged(K, V, Context); + pub const Layout = Unmanaged.Layout; + + /// This is the alignment that the base pointer must have. + pub const base_align = Unmanaged.base_align; + + metadata: Offset(Unmanaged.Metadata) = .{}, + + /// Returns the total size of the backing memory required for a + /// HashMap with the given capacity. The base ptr must also be + /// aligned to base_align. + pub fn layout(cap: Unmanaged.Size) Layout { + return Unmanaged.layoutForCapacity(cap); + } + + /// Initialize a new HashMap with the given capacity and backing + /// memory. The backing memory must be aligned to base_align. + pub fn init(buf: OffsetBuf, l: Layout) Self { + assert(@intFromPtr(buf.start()) % base_align == 0); + + const m = Unmanaged.init(buf, l); + return .{ .metadata = getOffset( + Unmanaged.Metadata, + buf, + @ptrCast(m.metadata.?), + ) }; + } + + /// Returns the pointer-based map from a base pointer. + pub fn map(self: Self, base: anytype) Unmanaged { + return .{ .metadata = self.metadata.ptr(base) }; + } + }; +} + +/// Fork of stdlib.HashMap as of Zig 0.12 modified to to use offsets +/// for the key/values pointer. The metadata is still a pointer to limit +/// the amount of arithmetic required to access it. See the file comment +/// for full details. +fn HashMapUnmanaged( + comptime K: type, + comptime V: type, + comptime Context: type, +) type { + return struct { + const Self = @This(); + + comptime { + std.hash_map.verifyContext(Context, K, K, u64, false); + assert(@alignOf(Metadata) == 1); + } + + const header_align = @alignOf(Header); + const key_align = if (@sizeOf(K) == 0) 1 else @alignOf(K); + const val_align = if (@sizeOf(V) == 0) 1 else @alignOf(V); + const base_align = @max(header_align, key_align, val_align); + + // This is actually a midway pointer to the single buffer containing + // a `Header` field, the `Metadata`s and `Entry`s. + // At `-@sizeOf(Header)` is the Header field. + // At `sizeOf(Metadata) * capacity + offset`, which is pointed to by + // self.header().entries, is the array of entries. + // This means that the hashmap only holds one live allocation, to + // reduce memory fragmentation and struct size. + /// Pointer to the metadata. + metadata: ?[*]Metadata = null, + + // This is purely empirical and not a /very smart magic constant™/. + /// Capacity of the first grow when bootstrapping the hashmap. + const minimal_capacity = 8; + + // This hashmap is specially designed for sizes that fit in a u32. + pub const Size = u32; + + // u64 hashes guarantee us that the fingerprint bits will never be used + // to compute the index of a slot, maximizing the use of entropy. + pub const Hash = u64; + + pub const Entry = struct { + key_ptr: *K, + value_ptr: *V, + }; + + pub const KV = struct { + key: K, + value: V, + }; + + const Header = struct { + /// The keys/values offset are relative to the metadata + values: Offset(V), + keys: Offset(K), + capacity: Size, + size: Size, + }; + + /// Metadata for a slot. It can be in three states: empty, used or + /// tombstone. Tombstones indicate that an entry was previously used, + /// they are a simple way to handle removal. + /// To this state, we add 7 bits from the slot's key hash. These are + /// used as a fast way to disambiguate between entries without + /// having to use the equality function. If two fingerprints are + /// different, we know that we don't have to compare the keys at all. + /// The 7 bits are the highest ones from a 64 bit hash. This way, not + /// only we use the `log2(capacity)` lowest bits from the hash to determine + /// a slot index, but we use 7 more bits to quickly resolve collisions + /// when multiple elements with different hashes end up wanting to be in the same slot. + /// Not using the equality function means we don't have to read into + /// the entries array, likely avoiding a cache miss and a potentially + /// costly function call. + const Metadata = packed struct { + const FingerPrint = u7; + + const free: FingerPrint = 0; + const tombstone: FingerPrint = 1; + + fingerprint: FingerPrint = free, + used: u1 = 0, + + const slot_free = @as(u8, @bitCast(Metadata{ .fingerprint = free })); + const slot_tombstone = @as(u8, @bitCast(Metadata{ .fingerprint = tombstone })); + + pub fn isUsed(self: Metadata) bool { + return self.used == 1; + } + + pub fn isTombstone(self: Metadata) bool { + return @as(u8, @bitCast(self)) == slot_tombstone; + } + + pub fn isFree(self: Metadata) bool { + return @as(u8, @bitCast(self)) == slot_free; + } + + pub fn takeFingerprint(hash: Hash) FingerPrint { + const hash_bits = @typeInfo(Hash).Int.bits; + const fp_bits = @typeInfo(FingerPrint).Int.bits; + return @as(FingerPrint, @truncate(hash >> (hash_bits - fp_bits))); + } + + pub fn fill(self: *Metadata, fp: FingerPrint) void { + self.used = 1; + self.fingerprint = fp; + } + + pub fn remove(self: *Metadata) void { + self.used = 0; + self.fingerprint = tombstone; + } + }; + + comptime { + assert(@sizeOf(Metadata) == 1); + assert(@alignOf(Metadata) == 1); + } + + pub const Iterator = struct { + hm: *const Self, + index: Size = 0, + + pub fn next(it: *Iterator) ?Entry { + assert(it.index <= it.hm.capacity()); + if (it.hm.header().size == 0) return null; + + const cap = it.hm.capacity(); + const end = it.hm.metadata.? + cap; + var metadata = it.hm.metadata.? + it.index; + + while (metadata != end) : ({ + metadata += 1; + it.index += 1; + }) { + if (metadata[0].isUsed()) { + const key = &it.hm.keys()[it.index]; + const value = &it.hm.values()[it.index]; + it.index += 1; + return Entry{ .key_ptr = key, .value_ptr = value }; + } + } + + return null; + } + }; + + pub const KeyIterator = FieldIterator(K); + pub const ValueIterator = FieldIterator(V); + + fn FieldIterator(comptime T: type) type { + return struct { + len: usize, + metadata: [*]const Metadata, + items: [*]T, + + pub fn next(self: *@This()) ?*T { + while (self.len > 0) { + self.len -= 1; + const used = self.metadata[0].isUsed(); + const item = &self.items[0]; + self.metadata += 1; + self.items += 1; + if (used) { + return item; + } + } + return null; + } + }; + } + + pub const GetOrPutResult = struct { + key_ptr: *K, + value_ptr: *V, + found_existing: bool, + }; + + /// Initialize a hash map with a given capacity and a buffer. The + /// buffer must fit within the size defined by `layoutForCapacity`. + pub fn init(buf: OffsetBuf, layout: Layout) Self { + assert(@intFromPtr(buf.start()) % base_align == 0); + + // Get all our main pointers + const metadata_buf = buf.rebase(@sizeOf(Header)); + const metadata_ptr: [*]Metadata = @ptrCast(metadata_buf.start()); + + // Build our map + var map: Self = .{ .metadata = metadata_ptr }; + const hdr = map.header(); + hdr.capacity = layout.capacity; + hdr.size = 0; + if (@sizeOf([*]K) != 0) hdr.keys = metadata_buf.member(K, layout.keys_start); + if (@sizeOf([*]V) != 0) hdr.values = metadata_buf.member(V, layout.vals_start); + map.initMetadatas(); + + return map; + } + + pub fn ensureTotalCapacity(self: *Self, new_size: Size) Allocator.Error!void { + if (new_size > self.header().size) { + try self.growIfNeeded(new_size - self.header().size); + } + } + + pub fn ensureUnusedCapacity(self: *Self, additional_size: Size) Allocator.Error!void { + return ensureTotalCapacity(self, self.count() + additional_size); + } + + pub fn clearRetainingCapacity(self: *Self) void { + if (self.metadata) |_| { + self.initMetadatas(); + self.header().size = 0; + } + } + + pub fn count(self: *const Self) Size { + return self.header().size; + } + + fn header(self: *const Self) *Header { + return @ptrCast(@as([*]Header, @ptrCast(@alignCast(self.metadata.?))) - 1); + } + + fn keys(self: *const Self) [*]K { + return self.header().keys.ptr(self.metadata.?); + } + + fn values(self: *const Self) [*]V { + return self.header().values.ptr(self.metadata.?); + } + + pub fn capacity(self: *const Self) Size { + if (self.metadata == null) return 0; + + return self.header().capacity; + } + + pub fn iterator(self: *const Self) Iterator { + return .{ .hm = self }; + } + + pub fn keyIterator(self: *const Self) KeyIterator { + if (self.metadata) |metadata| { + return .{ + .len = self.capacity(), + .metadata = metadata, + .items = self.keys(), + }; + } else { + return .{ + .len = 0, + .metadata = undefined, + .items = undefined, + }; + } + } + + pub fn valueIterator(self: *const Self) ValueIterator { + if (self.metadata) |metadata| { + return .{ + .len = self.capacity(), + .metadata = metadata, + .items = self.values(), + }; + } else { + return .{ + .len = 0, + .metadata = undefined, + .items = undefined, + }; + } + } + + /// Insert an entry in the map. Assumes it is not already present. + pub fn putNoClobber(self: *Self, key: K, value: V) Allocator.Error!void { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call putNoClobberContext instead."); + return self.putNoClobberContext(key, value, undefined); + } + pub fn putNoClobberContext(self: *Self, key: K, value: V, ctx: Context) Allocator.Error!void { + assert(!self.containsContext(key, ctx)); + try self.growIfNeeded(1); + + self.putAssumeCapacityNoClobberContext(key, value, ctx); + } + + /// Asserts there is enough capacity to store the new key-value pair. + /// Clobbers any existing data. To detect if a put would clobber + /// existing data, see `getOrPutAssumeCapacity`. + pub fn putAssumeCapacity(self: *Self, key: K, value: V) void { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call putAssumeCapacityContext instead."); + return self.putAssumeCapacityContext(key, value, undefined); + } + pub fn putAssumeCapacityContext(self: *Self, key: K, value: V, ctx: Context) void { + const gop = self.getOrPutAssumeCapacityContext(key, ctx); + gop.value_ptr.* = value; + } + + /// Insert an entry in the map. Assumes it is not already present, + /// and that no allocation is needed. + pub fn putAssumeCapacityNoClobber(self: *Self, key: K, value: V) void { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call putAssumeCapacityNoClobberContext instead."); + return self.putAssumeCapacityNoClobberContext(key, value, undefined); + } + pub fn putAssumeCapacityNoClobberContext(self: *Self, key: K, value: V, ctx: Context) void { + assert(!self.containsContext(key, ctx)); + + const hash = ctx.hash(key); + const mask = self.capacity() - 1; + var idx = @as(usize, @truncate(hash & mask)); + + var metadata = self.metadata.? + idx; + while (metadata[0].isUsed()) { + idx = (idx + 1) & mask; + metadata = self.metadata.? + idx; + } + + const fingerprint = Metadata.takeFingerprint(hash); + metadata[0].fill(fingerprint); + self.keys()[idx] = key; + self.values()[idx] = value; + self.header().size += 1; + } + + /// Inserts a new `Entry` into the hash map, returning the previous one, if any. + pub fn fetchPut(self: *Self, key: K, value: V) Allocator.Error!?KV { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call fetchPutContext instead."); + return self.fetchPutContext(key, value, undefined); + } + pub fn fetchPutContext(self: *Self, key: K, value: V, ctx: Context) Allocator.Error!?KV { + const gop = try self.getOrPutContext(key, ctx); + var result: ?KV = null; + if (gop.found_existing) { + result = KV{ + .key = gop.key_ptr.*, + .value = gop.value_ptr.*, + }; + } + gop.value_ptr.* = value; + return result; + } + + /// Inserts a new `Entry` into the hash map, returning the previous one, if any. + /// If insertion happens, asserts there is enough capacity without allocating. + pub fn fetchPutAssumeCapacity(self: *Self, key: K, value: V) ?KV { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call fetchPutAssumeCapacityContext instead."); + return self.fetchPutAssumeCapacityContext(key, value, undefined); + } + pub fn fetchPutAssumeCapacityContext(self: *Self, key: K, value: V, ctx: Context) ?KV { + const gop = self.getOrPutAssumeCapacityContext(key, ctx); + var result: ?KV = null; + if (gop.found_existing) { + result = KV{ + .key = gop.key_ptr.*, + .value = gop.value_ptr.*, + }; + } + gop.value_ptr.* = value; + return result; + } + + /// If there is an `Entry` with a matching key, it is deleted from + /// the hash map, and then returned from this function. + pub fn fetchRemove(self: *Self, key: K) ?KV { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call fetchRemoveContext instead."); + return self.fetchRemoveContext(key, undefined); + } + pub fn fetchRemoveContext(self: *Self, key: K, ctx: Context) ?KV { + return self.fetchRemoveAdapted(key, ctx); + } + pub fn fetchRemoveAdapted(self: *Self, key: anytype, ctx: anytype) ?KV { + if (self.getIndex(key, ctx)) |idx| { + const old_key = &self.keys()[idx]; + const old_val = &self.values()[idx]; + const result = KV{ + .key = old_key.*, + .value = old_val.*, + }; + self.metadata.?[idx].remove(); + old_key.* = undefined; + old_val.* = undefined; + self.header().size -= 1; + return result; + } + + return null; + } + + /// Find the index containing the data for the given key. + /// Whether this function returns null is almost always + /// branched on after this function returns, and this function + /// returns null/not null from separate code paths. We + /// want the optimizer to remove that branch and instead directly + /// fuse the basic blocks after the branch to the basic blocks + /// from this function. To encourage that, this function is + /// marked as inline. + inline fn getIndex(self: Self, key: anytype, ctx: anytype) ?usize { + comptime std.hash_map.verifyContext(@TypeOf(ctx), @TypeOf(key), K, Hash, false); + + if (self.header().size == 0) { + return null; + } + + // If you get a compile error on this line, it means that your generic hash + // function is invalid for these parameters. + const hash = ctx.hash(key); + // verifyContext can't verify the return type of generic hash functions, + // so we need to double-check it here. + if (@TypeOf(hash) != Hash) { + @compileError("Context " ++ @typeName(@TypeOf(ctx)) ++ " has a generic hash function that returns the wrong type! " ++ @typeName(Hash) ++ " was expected, but found " ++ @typeName(@TypeOf(hash))); + } + const mask = self.capacity() - 1; + const fingerprint = Metadata.takeFingerprint(hash); + // Don't loop indefinitely when there are no empty slots. + var limit = self.capacity(); + var idx = @as(usize, @truncate(hash & mask)); + + var metadata = self.metadata.? + idx; + while (!metadata[0].isFree() and limit != 0) { + if (metadata[0].isUsed() and metadata[0].fingerprint == fingerprint) { + const test_key = &self.keys()[idx]; + // If you get a compile error on this line, it means that your generic eql + // function is invalid for these parameters. + const eql = ctx.eql(key, test_key.*); + // verifyContext can't verify the return type of generic eql functions, + // so we need to double-check it here. + if (@TypeOf(eql) != bool) { + @compileError("Context " ++ @typeName(@TypeOf(ctx)) ++ " has a generic eql function that returns the wrong type! bool was expected, but found " ++ @typeName(@TypeOf(eql))); + } + if (eql) { + return idx; + } + } + + limit -= 1; + idx = (idx + 1) & mask; + metadata = self.metadata.? + idx; + } + + return null; + } + + pub fn getEntry(self: Self, key: K) ?Entry { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call getEntryContext instead."); + return self.getEntryContext(key, undefined); + } + pub fn getEntryContext(self: Self, key: K, ctx: Context) ?Entry { + return self.getEntryAdapted(key, ctx); + } + pub fn getEntryAdapted(self: Self, key: anytype, ctx: anytype) ?Entry { + if (self.getIndex(key, ctx)) |idx| { + return Entry{ + .key_ptr = &self.keys()[idx], + .value_ptr = &self.values()[idx], + }; + } + return null; + } + + /// Insert an entry if the associated key is not already present, otherwise update preexisting value. + pub fn put(self: *Self, key: K, value: V) Allocator.Error!void { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call putContext instead."); + return self.putContext(key, value, undefined); + } + pub fn putContext(self: *Self, key: K, value: V, ctx: Context) Allocator.Error!void { + const result = try self.getOrPutContext(key, ctx); + result.value_ptr.* = value; + } + + /// Get an optional pointer to the actual key associated with adapted key, if present. + pub fn getKeyPtr(self: Self, key: K) ?*K { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call getKeyPtrContext instead."); + return self.getKeyPtrContext(key, undefined); + } + pub fn getKeyPtrContext(self: Self, key: K, ctx: Context) ?*K { + return self.getKeyPtrAdapted(key, ctx); + } + pub fn getKeyPtrAdapted(self: Self, key: anytype, ctx: anytype) ?*K { + if (self.getIndex(key, ctx)) |idx| { + return &self.keys()[idx]; + } + return null; + } + + /// Get a copy of the actual key associated with adapted key, if present. + pub fn getKey(self: Self, key: K) ?K { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call getKeyContext instead."); + return self.getKeyContext(key, undefined); + } + pub fn getKeyContext(self: Self, key: K, ctx: Context) ?K { + return self.getKeyAdapted(key, ctx); + } + pub fn getKeyAdapted(self: Self, key: anytype, ctx: anytype) ?K { + if (self.getIndex(key, ctx)) |idx| { + return self.keys()[idx]; + } + return null; + } + + /// Get an optional pointer to the value associated with key, if present. + pub fn getPtr(self: Self, key: K) ?*V { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call getPtrContext instead."); + return self.getPtrContext(key, undefined); + } + pub fn getPtrContext(self: Self, key: K, ctx: Context) ?*V { + return self.getPtrAdapted(key, ctx); + } + pub fn getPtrAdapted(self: Self, key: anytype, ctx: anytype) ?*V { + if (self.getIndex(key, ctx)) |idx| { + return &self.values()[idx]; + } + return null; + } + + /// Get a copy of the value associated with key, if present. + pub fn get(self: Self, key: K) ?V { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call getContext instead."); + return self.getContext(key, undefined); + } + pub fn getContext(self: Self, key: K, ctx: Context) ?V { + return self.getAdapted(key, ctx); + } + pub fn getAdapted(self: Self, key: anytype, ctx: anytype) ?V { + if (self.getIndex(key, ctx)) |idx| { + return self.values()[idx]; + } + return null; + } + + pub fn getOrPut(self: *Self, key: K) Allocator.Error!GetOrPutResult { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call getOrPutContext instead."); + return self.getOrPutContext(key, undefined); + } + pub fn getOrPutContext(self: *Self, key: K, ctx: Context) Allocator.Error!GetOrPutResult { + const gop = try self.getOrPutContextAdapted(key, ctx); + if (!gop.found_existing) { + gop.key_ptr.* = key; + } + return gop; + } + pub fn getOrPutAdapted(self: *Self, key: anytype, key_ctx: anytype) Allocator.Error!GetOrPutResult { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call getOrPutContextAdapted instead."); + return self.getOrPutContextAdapted(key, key_ctx); + } + pub fn getOrPutContextAdapted(self: *Self, key: anytype, key_ctx: anytype) Allocator.Error!GetOrPutResult { + self.growIfNeeded(1) catch |err| { + // If allocation fails, try to do the lookup anyway. + // If we find an existing item, we can return it. + // Otherwise return the error, we could not add another. + const index = self.getIndex(key, key_ctx) orelse return err; + return GetOrPutResult{ + .key_ptr = &self.keys()[index], + .value_ptr = &self.values()[index], + .found_existing = true, + }; + }; + return self.getOrPutAssumeCapacityAdapted(key, key_ctx); + } + + pub fn getOrPutAssumeCapacity(self: *Self, key: K) GetOrPutResult { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call getOrPutAssumeCapacityContext instead."); + return self.getOrPutAssumeCapacityContext(key, undefined); + } + pub fn getOrPutAssumeCapacityContext(self: *Self, key: K, ctx: Context) GetOrPutResult { + const result = self.getOrPutAssumeCapacityAdapted(key, ctx); + if (!result.found_existing) { + result.key_ptr.* = key; + } + return result; + } + pub fn getOrPutAssumeCapacityAdapted(self: *Self, key: anytype, ctx: anytype) GetOrPutResult { + comptime std.hash_map.verifyContext(@TypeOf(ctx), @TypeOf(key), K, Hash, false); + + // If you get a compile error on this line, it means that your generic hash + // function is invalid for these parameters. + const hash = ctx.hash(key); + // verifyContext can't verify the return type of generic hash functions, + // so we need to double-check it here. + if (@TypeOf(hash) != Hash) { + @compileError("Context " ++ @typeName(@TypeOf(ctx)) ++ " has a generic hash function that returns the wrong type! " ++ @typeName(Hash) ++ " was expected, but found " ++ @typeName(@TypeOf(hash))); + } + const mask = self.capacity() - 1; + const fingerprint = Metadata.takeFingerprint(hash); + var limit = self.capacity(); + var idx = @as(usize, @truncate(hash & mask)); + + var first_tombstone_idx: usize = self.capacity(); // invalid index + var metadata = self.metadata.? + idx; + while (!metadata[0].isFree() and limit != 0) { + if (metadata[0].isUsed() and metadata[0].fingerprint == fingerprint) { + const test_key = &self.keys()[idx]; + // If you get a compile error on this line, it means that your generic eql + // function is invalid for these parameters. + const eql = ctx.eql(key, test_key.*); + // verifyContext can't verify the return type of generic eql functions, + // so we need to double-check it here. + if (@TypeOf(eql) != bool) { + @compileError("Context " ++ @typeName(@TypeOf(ctx)) ++ " has a generic eql function that returns the wrong type! bool was expected, but found " ++ @typeName(@TypeOf(eql))); + } + if (eql) { + return GetOrPutResult{ + .key_ptr = test_key, + .value_ptr = &self.values()[idx], + .found_existing = true, + }; + } + } else if (first_tombstone_idx == self.capacity() and metadata[0].isTombstone()) { + first_tombstone_idx = idx; + } + + limit -= 1; + idx = (idx + 1) & mask; + metadata = self.metadata.? + idx; + } + + if (first_tombstone_idx < self.capacity()) { + // Cheap try to lower probing lengths after deletions. Recycle a tombstone. + idx = first_tombstone_idx; + metadata = self.metadata.? + idx; + } + + metadata[0].fill(fingerprint); + const new_key = &self.keys()[idx]; + const new_value = &self.values()[idx]; + new_key.* = undefined; + new_value.* = undefined; + self.header().size += 1; + + return GetOrPutResult{ + .key_ptr = new_key, + .value_ptr = new_value, + .found_existing = false, + }; + } + + pub fn getOrPutValue(self: *Self, key: K, value: V) Allocator.Error!Entry { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call getOrPutValueContext instead."); + return self.getOrPutValueContext(key, value, undefined); + } + pub fn getOrPutValueContext(self: *Self, key: K, value: V, ctx: Context) Allocator.Error!Entry { + const res = try self.getOrPutAdapted(key, ctx); + if (!res.found_existing) { + res.key_ptr.* = key; + res.value_ptr.* = value; + } + return Entry{ .key_ptr = res.key_ptr, .value_ptr = res.value_ptr }; + } + + /// Return true if there is a value associated with key in the map. + pub fn contains(self: *const Self, key: K) bool { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call containsContext instead."); + return self.containsContext(key, undefined); + } + pub fn containsContext(self: *const Self, key: K, ctx: Context) bool { + return self.containsAdapted(key, ctx); + } + pub fn containsAdapted(self: *const Self, key: anytype, ctx: anytype) bool { + return self.getIndex(key, ctx) != null; + } + + fn removeByIndex(self: *Self, idx: usize) void { + self.metadata.?[idx].remove(); + self.keys()[idx] = undefined; + self.values()[idx] = undefined; + self.header().size -= 1; + } + + /// If there is an `Entry` with a matching key, it is deleted from + /// the hash map, and this function returns true. Otherwise this + /// function returns false. + pub fn remove(self: *Self, key: K) bool { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call removeContext instead."); + return self.removeContext(key, undefined); + } + pub fn removeContext(self: *Self, key: K, ctx: Context) bool { + return self.removeAdapted(key, ctx); + } + pub fn removeAdapted(self: *Self, key: anytype, ctx: anytype) bool { + if (self.getIndex(key, ctx)) |idx| { + self.removeByIndex(idx); + return true; + } + + return false; + } + + /// Delete the entry with key pointed to by key_ptr from the hash map. + /// key_ptr is assumed to be a valid pointer to a key that is present + /// in the hash map. + pub fn removeByPtr(self: *Self, key_ptr: *K) void { + // TODO: replace with pointer subtraction once supported by zig + // if @sizeOf(K) == 0 then there is at most one item in the hash + // map, which is assumed to exist as key_ptr must be valid. This + // item must be at index 0. + const idx = if (@sizeOf(K) > 0) + (@intFromPtr(key_ptr) - @intFromPtr(self.keys())) / @sizeOf(K) + else + 0; + + self.removeByIndex(idx); + } + + fn initMetadatas(self: *Self) void { + @memset(@as([*]u8, @ptrCast(self.metadata.?))[0 .. @sizeOf(Metadata) * self.capacity()], 0); + } + + fn growIfNeeded(self: *Self, new_count: Size) Allocator.Error!void { + const available = self.capacity() - self.header().size; + if (new_count > available) return error.OutOfMemory; + } + + /// The memory layout for the underlying buffer for a given capacity. + const Layout = struct { + /// The total size of the buffer required. The buffer is expected + /// to be aligned to `base_align`. + total_size: usize, + + /// The offset to the start of the keys data. + keys_start: usize, + + /// The offset to the start of the values data. + vals_start: usize, + + /// The capacity that was used to calculate this layout. + capacity: Size, + }; + + /// Returns the memory layout for the buffer for a given capacity. + /// The actual size may be able to fit more than the given capacity + /// because capacity is rounded up to the next power of two. This is + /// a design requirement for this hash map implementation. + pub fn layoutForCapacity(new_capacity: Size) Layout { + assert(std.math.isPowerOfTwo(new_capacity)); + + // Pack our metadata, keys, and values. + const meta_start = @sizeOf(Header); + const meta_end = @sizeOf(Header) + new_capacity * @sizeOf(Metadata); + const keys_start = std.mem.alignForward(usize, meta_end, key_align); + const keys_end = keys_start + new_capacity * @sizeOf(K); + const vals_start = std.mem.alignForward(usize, keys_end, val_align); + const vals_end = vals_start + new_capacity * @sizeOf(V); + + // Our total memory size required is the end of our values + // aligned to the base required alignment. + const total_size = std.mem.alignForward(usize, vals_end, base_align); + + // The offsets we actually store in the map are from the + // metadata pointer so that we can use self.metadata as + // the base. + const keys_offset = keys_start - meta_start; + const vals_offset = vals_start - meta_start; + + return .{ + .total_size = total_size, + .keys_start = keys_offset, + .vals_start = vals_offset, + .capacity = new_capacity, + }; + } + }; +} + +const testing = std.testing; +const expect = std.testing.expect; +const expectEqual = std.testing.expectEqual; + +test "HashMap basic usage" { + const Map = AutoHashMapUnmanaged(u32, u32); + + const alloc = testing.allocator; + const cap = 16; + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); + defer alloc.free(buf); + + var map = Map.init(OffsetBuf.init(buf), layout); + + const count = 5; + var i: u32 = 0; + var total: u32 = 0; + while (i < count) : (i += 1) { + try map.put(i, i); + total += i; + } + + var sum: u32 = 0; + var it = map.iterator(); + while (it.next()) |kv| { + sum += kv.key_ptr.*; + } + try expectEqual(total, sum); + + i = 0; + sum = 0; + while (i < count) : (i += 1) { + try expectEqual(i, map.get(i).?); + sum += map.get(i).?; + } + try expectEqual(total, sum); +} + +test "HashMap ensureTotalCapacity" { + const Map = AutoHashMapUnmanaged(i32, i32); + const cap = 32; + + const alloc = testing.allocator; + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); + defer alloc.free(buf); + var map = Map.init(OffsetBuf.init(buf), layout); + + const initial_capacity = map.capacity(); + try testing.expect(initial_capacity >= 20); + var i: i32 = 0; + while (i < 20) : (i += 1) { + try testing.expect(map.fetchPutAssumeCapacity(i, i + 10) == null); + } + // shouldn't resize from putAssumeCapacity + try testing.expect(initial_capacity == map.capacity()); +} + +test "HashMap ensureUnusedCapacity with tombstones" { + const Map = AutoHashMapUnmanaged(i32, i32); + const cap = 32; + + const alloc = testing.allocator; + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); + defer alloc.free(buf); + var map = Map.init(OffsetBuf.init(buf), layout); + + var i: i32 = 0; + while (i < 100) : (i += 1) { + try map.ensureUnusedCapacity(1); + map.putAssumeCapacity(i, i); + _ = map.remove(i); + } +} + +test "HashMap clearRetainingCapacity" { + const Map = AutoHashMapUnmanaged(u32, u32); + const cap = 16; + + const alloc = testing.allocator; + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); + defer alloc.free(buf); + var map = Map.init(OffsetBuf.init(buf), layout); + + map.clearRetainingCapacity(); + + try map.put(1, 1); + try expectEqual(map.get(1).?, 1); + try expectEqual(map.count(), 1); + + map.clearRetainingCapacity(); + map.putAssumeCapacity(1, 1); + try expectEqual(map.get(1).?, 1); + try expectEqual(map.count(), 1); + + const actual_cap = map.capacity(); + try expect(actual_cap > 0); + + map.clearRetainingCapacity(); + map.clearRetainingCapacity(); + try expectEqual(map.count(), 0); + try expectEqual(map.capacity(), actual_cap); + try expect(!map.contains(1)); +} + +test "HashMap ensureTotalCapacity with existing elements" { + const Map = AutoHashMapUnmanaged(u32, u32); + const cap = Map.minimal_capacity; + + const alloc = testing.allocator; + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); + defer alloc.free(buf); + var map = Map.init(OffsetBuf.init(buf), layout); + + try map.put(0, 0); + try expectEqual(map.count(), 1); + try expectEqual(map.capacity(), Map.minimal_capacity); + + try testing.expectError(error.OutOfMemory, map.ensureTotalCapacity(65)); + try expectEqual(map.count(), 1); + try expectEqual(map.capacity(), Map.minimal_capacity); +} + +test "HashMap remove" { + const Map = AutoHashMapUnmanaged(u32, u32); + const cap = 32; + + const alloc = testing.allocator; + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); + defer alloc.free(buf); + var map = Map.init(OffsetBuf.init(buf), layout); + + var i: u32 = 0; + while (i < 16) : (i += 1) { + try map.put(i, i); + } + + i = 0; + while (i < 16) : (i += 1) { + if (i % 3 == 0) { + _ = map.remove(i); + } + } + try expectEqual(map.count(), 10); + var it = map.iterator(); + while (it.next()) |kv| { + try expectEqual(kv.key_ptr.*, kv.value_ptr.*); + try expect(kv.key_ptr.* % 3 != 0); + } + + i = 0; + while (i < 16) : (i += 1) { + if (i % 3 == 0) { + try expect(!map.contains(i)); + } else { + try expectEqual(map.get(i).?, i); + } + } +} + +test "HashMap reverse removes" { + const Map = AutoHashMapUnmanaged(u32, u32); + const cap = 32; + + const alloc = testing.allocator; + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); + defer alloc.free(buf); + var map = Map.init(OffsetBuf.init(buf), layout); + + var i: u32 = 0; + while (i < 16) : (i += 1) { + try map.putNoClobber(i, i); + } + + i = 16; + while (i > 0) : (i -= 1) { + _ = map.remove(i - 1); + try expect(!map.contains(i - 1)); + var j: u32 = 0; + while (j < i - 1) : (j += 1) { + try expectEqual(map.get(j).?, j); + } + } + + try expectEqual(map.count(), 0); +} + +test "HashMap multiple removes on same metadata" { + const Map = AutoHashMapUnmanaged(u32, u32); + const cap = 32; + + const alloc = testing.allocator; + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); + defer alloc.free(buf); + var map = Map.init(OffsetBuf.init(buf), layout); + + var i: u32 = 0; + while (i < 16) : (i += 1) { + try map.put(i, i); + } + + _ = map.remove(7); + _ = map.remove(15); + _ = map.remove(14); + _ = map.remove(13); + try expect(!map.contains(7)); + try expect(!map.contains(15)); + try expect(!map.contains(14)); + try expect(!map.contains(13)); + + i = 0; + while (i < 13) : (i += 1) { + if (i == 7) { + try expect(!map.contains(i)); + } else { + try expectEqual(map.get(i).?, i); + } + } + + try map.put(15, 15); + try map.put(13, 13); + try map.put(14, 14); + try map.put(7, 7); + i = 0; + while (i < 16) : (i += 1) { + try expectEqual(map.get(i).?, i); + } +} + +test "HashMap put and remove loop in random order" { + const Map = AutoHashMapUnmanaged(u32, u32); + const cap = 64; + + const alloc = testing.allocator; + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); + defer alloc.free(buf); + var map = Map.init(OffsetBuf.init(buf), layout); + + var keys = std.ArrayList(u32).init(alloc); + defer keys.deinit(); + + const size = 32; + const iterations = 100; + + var i: u32 = 0; + while (i < size) : (i += 1) { + try keys.append(i); + } + var prng = std.Random.DefaultPrng.init(0); + const random = prng.random(); + + while (i < iterations) : (i += 1) { + random.shuffle(u32, keys.items); + + for (keys.items) |key| { + try map.put(key, key); + } + try expectEqual(map.count(), size); + + for (keys.items) |key| { + _ = map.remove(key); + } + try expectEqual(map.count(), 0); + } +} + +test "HashMap put" { + const Map = AutoHashMapUnmanaged(u32, u32); + const cap = 32; + + const alloc = testing.allocator; + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); + defer alloc.free(buf); + var map = Map.init(OffsetBuf.init(buf), layout); + + var i: u32 = 0; + while (i < 16) : (i += 1) { + try map.put(i, i); + } + + i = 0; + while (i < 16) : (i += 1) { + try expectEqual(map.get(i).?, i); + } + + i = 0; + while (i < 16) : (i += 1) { + try map.put(i, i * 16 + 1); + } + + i = 0; + while (i < 16) : (i += 1) { + try expectEqual(map.get(i).?, i * 16 + 1); + } +} + +test "HashMap put full load" { + const Map = AutoHashMapUnmanaged(usize, usize); + const cap = 16; + + const alloc = testing.allocator; + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); + defer alloc.free(buf); + var map = Map.init(OffsetBuf.init(buf), layout); + + for (0..cap) |i| try map.put(i, i); + for (0..cap) |i| try expectEqual(map.get(i).?, i); + + try testing.expectError(error.OutOfMemory, map.put(cap, cap)); +} + +test "HashMap putAssumeCapacity" { + const Map = AutoHashMapUnmanaged(u32, u32); + const cap = 32; + + const alloc = testing.allocator; + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); + defer alloc.free(buf); + var map = Map.init(OffsetBuf.init(buf), layout); + + var i: u32 = 0; + while (i < 20) : (i += 1) { + map.putAssumeCapacityNoClobber(i, i); + } + + i = 0; + var sum = i; + while (i < 20) : (i += 1) { + sum += map.getPtr(i).?.*; + } + try expectEqual(sum, 190); + + i = 0; + while (i < 20) : (i += 1) { + map.putAssumeCapacity(i, 1); + } + + i = 0; + sum = i; + while (i < 20) : (i += 1) { + sum += map.get(i).?; + } + try expectEqual(sum, 20); +} + +test "HashMap repeat putAssumeCapacity/remove" { + const Map = AutoHashMapUnmanaged(u32, u32); + const cap = 32; + + const alloc = testing.allocator; + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); + defer alloc.free(buf); + var map = Map.init(OffsetBuf.init(buf), layout); + + const limit = cap; + + var i: u32 = 0; + while (i < limit) : (i += 1) { + map.putAssumeCapacityNoClobber(i, i); + } + + // Repeatedly delete/insert an entry without resizing the map. + // Put to different keys so entries don't land in the just-freed slot. + i = 0; + while (i < 10 * limit) : (i += 1) { + try testing.expect(map.remove(i)); + if (i % 2 == 0) { + map.putAssumeCapacityNoClobber(limit + i, i); + } else { + map.putAssumeCapacity(limit + i, i); + } + } + + i = 9 * limit; + while (i < 10 * limit) : (i += 1) { + try expectEqual(map.get(limit + i), i); + } + try expectEqual(map.count(), limit); +} + +test "HashMap getOrPut" { + const Map = AutoHashMapUnmanaged(u32, u32); + const cap = 32; + + const alloc = testing.allocator; + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); + defer alloc.free(buf); + var map = Map.init(OffsetBuf.init(buf), layout); + + var i: u32 = 0; + while (i < 10) : (i += 1) { + try map.put(i * 2, 2); + } + + i = 0; + while (i < 20) : (i += 1) { + _ = try map.getOrPutValue(i, 1); + } + + i = 0; + var sum = i; + while (i < 20) : (i += 1) { + sum += map.get(i).?; + } + + try expectEqual(sum, 30); +} + +test "HashMap basic hash map usage" { + const Map = AutoHashMapUnmanaged(i32, i32); + const cap = 32; + + const alloc = testing.allocator; + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); + defer alloc.free(buf); + var map = Map.init(OffsetBuf.init(buf), layout); + + try testing.expect((try map.fetchPut(1, 11)) == null); + try testing.expect((try map.fetchPut(2, 22)) == null); + try testing.expect((try map.fetchPut(3, 33)) == null); + try testing.expect((try map.fetchPut(4, 44)) == null); + + try map.putNoClobber(5, 55); + try testing.expect((try map.fetchPut(5, 66)).?.value == 55); + try testing.expect((try map.fetchPut(5, 55)).?.value == 66); + + const gop1 = try map.getOrPut(5); + try testing.expect(gop1.found_existing == true); + try testing.expect(gop1.value_ptr.* == 55); + gop1.value_ptr.* = 77; + try testing.expect(map.getEntry(5).?.value_ptr.* == 77); + + const gop2 = try map.getOrPut(99); + try testing.expect(gop2.found_existing == false); + gop2.value_ptr.* = 42; + try testing.expect(map.getEntry(99).?.value_ptr.* == 42); + + const gop3 = try map.getOrPutValue(5, 5); + try testing.expect(gop3.value_ptr.* == 77); + + const gop4 = try map.getOrPutValue(100, 41); + try testing.expect(gop4.value_ptr.* == 41); + + try testing.expect(map.contains(2)); + try testing.expect(map.getEntry(2).?.value_ptr.* == 22); + try testing.expect(map.get(2).? == 22); + + const rmv1 = map.fetchRemove(2); + try testing.expect(rmv1.?.key == 2); + try testing.expect(rmv1.?.value == 22); + try testing.expect(map.fetchRemove(2) == null); + try testing.expect(map.remove(2) == false); + try testing.expect(map.getEntry(2) == null); + try testing.expect(map.get(2) == null); + + try testing.expect(map.remove(3) == true); +} + +test "HashMap ensureUnusedCapacity" { + const Map = AutoHashMapUnmanaged(u64, u64); + const cap = 64; + + const alloc = testing.allocator; + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); + defer alloc.free(buf); + var map = Map.init(OffsetBuf.init(buf), layout); + + try map.ensureUnusedCapacity(32); + try testing.expectError(error.OutOfMemory, map.ensureUnusedCapacity(cap + 1)); +} + +test "HashMap removeByPtr" { + const Map = AutoHashMapUnmanaged(i32, u64); + const cap = 64; + + const alloc = testing.allocator; + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); + defer alloc.free(buf); + var map = Map.init(OffsetBuf.init(buf), layout); + + var i: i32 = undefined; + i = 0; + while (i < 10) : (i += 1) { + try map.put(i, 0); + } + + try testing.expect(map.count() == 10); + + i = 0; + while (i < 10) : (i += 1) { + const key_ptr = map.getKeyPtr(i); + try testing.expect(key_ptr != null); + + if (key_ptr) |ptr| { + map.removeByPtr(ptr); + } + } + + try testing.expect(map.count() == 0); +} + +test "HashMap removeByPtr 0 sized key" { + const Map = AutoHashMapUnmanaged(i32, u64); + const cap = 64; + + const alloc = testing.allocator; + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); + defer alloc.free(buf); + var map = Map.init(OffsetBuf.init(buf), layout); + + try map.put(0, 0); + + try testing.expect(map.count() == 1); + + const key_ptr = map.getKeyPtr(0); + try testing.expect(key_ptr != null); + + if (key_ptr) |ptr| { + map.removeByPtr(ptr); + } + + try testing.expect(map.count() == 0); +} + +test "HashMap repeat fetchRemove" { + const Map = AutoHashMapUnmanaged(u64, void); + const cap = 64; + + const alloc = testing.allocator; + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); + defer alloc.free(buf); + var map = Map.init(OffsetBuf.init(buf), layout); + + map.putAssumeCapacity(0, {}); + map.putAssumeCapacity(1, {}); + map.putAssumeCapacity(2, {}); + map.putAssumeCapacity(3, {}); + + // fetchRemove() should make slots available. + var i: usize = 0; + while (i < 10) : (i += 1) { + try testing.expect(map.fetchRemove(3) != null); + map.putAssumeCapacity(3, {}); + } + + try testing.expect(map.get(0) != null); + try testing.expect(map.get(1) != null); + try testing.expect(map.get(2) != null); + try testing.expect(map.get(3) != null); +} + +test "OffsetHashMap basic usage" { + const OffsetMap = AutoOffsetHashMap(u32, u32); + const cap = 16; + + const alloc = testing.allocator; + const layout = OffsetMap.layout(cap); + const buf = try alloc.alignedAlloc(u8, OffsetMap.base_align, layout.total_size); + defer alloc.free(buf); + var offset_map = OffsetMap.init(OffsetBuf.init(buf), layout); + var map = offset_map.map(buf.ptr); + + const count = 5; + var i: u32 = 0; + var total: u32 = 0; + while (i < count) : (i += 1) { + try map.put(i, i); + total += i; + } + + var sum: u32 = 0; + var it = map.iterator(); + while (it.next()) |kv| { + sum += kv.key_ptr.*; + } + try expectEqual(total, sum); + + i = 0; + sum = 0; + while (i < count) : (i += 1) { + try expectEqual(i, map.get(i).?); + sum += map.get(i).?; + } + try expectEqual(total, sum); +} + +test "OffsetHashMap remake map" { + const OffsetMap = AutoOffsetHashMap(u32, u32); + const cap = 16; + + const alloc = testing.allocator; + const layout = OffsetMap.layout(cap); + const buf = try alloc.alignedAlloc(u8, OffsetMap.base_align, layout.total_size); + defer alloc.free(buf); + var offset_map = OffsetMap.init(OffsetBuf.init(buf), layout); + + { + var map = offset_map.map(buf.ptr); + try map.put(5, 5); + } + + { + var map = offset_map.map(buf.ptr); + try expectEqual(5, map.get(5).?); + } +} diff --git a/src/terminal/kitty/graphics_exec.zig b/src/terminal/kitty/graphics_exec.zig index b4047c1d5e..0ea084795a 100644 --- a/src/terminal/kitty/graphics_exec.zig +++ b/src/terminal/kitty/graphics_exec.zig @@ -184,15 +184,19 @@ fn display( // Make sure our response has the image id in case we looked up by number result.id = img.id; - // Determine the screen point for the placement. - const placement_point = (point.Viewport{ - .x = terminal.screen.cursor.x, - .y = terminal.screen.cursor.y, - }).toScreen(&terminal.screen); + // Track a new pin for our cursor. The cursor is always tracked but we + // don't want this one to move with the cursor. + const placement_pin = terminal.screen.pages.trackPin( + terminal.screen.cursor.page_pin.*, + ) catch |err| { + log.warn("failed to create pin for Kitty graphics err={}", .{err}); + result.message = "EINVAL: failed to prepare terminal state"; + return result; + }; // Add the placement const p: ImageStorage.Placement = .{ - .point = placement_point, + .pin = placement_pin, .x_offset = d.x_offset, .y_offset = d.y_offset, .source_x = d.x, @@ -209,6 +213,7 @@ fn display( result.placement_id, p, ) catch |err| { + p.deinit(&terminal.screen); encodeError(&result, err); return result; }; @@ -217,19 +222,16 @@ fn display( switch (d.cursor_movement) { .none => {}, .after => { - const rect = p.rect(img, terminal); - - // We can do better by doing this with pure internal screen state - // but this handles scroll regions. - const height = rect.bottom_right.y - rect.top_left.y; - for (0..height) |_| terminal.index() catch |err| { + // We use terminal.index to properly handle scroll regions. + const size = p.gridSize(img, terminal); + for (0..size.rows) |_| terminal.index() catch |err| { log.warn("failed to move cursor: {}", .{err}); break; }; terminal.setCursorPos( terminal.screen.cursor.y, - rect.bottom_right.x + 1, + p.pin.x + size.cols + 1, ); }, } diff --git a/src/terminal/kitty/graphics_image.zig b/src/terminal/kitty/graphics_image.zig index 067217eaa1..4f3e3e48fe 100644 --- a/src/terminal/kitty/graphics_image.zig +++ b/src/terminal/kitty/graphics_image.zig @@ -7,6 +7,7 @@ const posix = std.posix; const command = @import("graphics_command.zig"); const point = @import("../point.zig"); +const PageList = @import("../PageList.zig"); const internal_os = @import("../../os/main.zig"); const stb = @import("../../stb/main.zig"); @@ -452,16 +453,8 @@ pub const Image = struct { /// be rounded up to the nearest grid cell since we can't place images /// in partial grid cells. pub const Rect = struct { - top_left: point.ScreenPoint = .{}, - bottom_right: point.ScreenPoint = .{}, - - /// True if the rect contains a given screen point. - pub fn contains(self: Rect, p: point.ScreenPoint) bool { - return p.y >= self.top_left.y and - p.y <= self.bottom_right.y and - p.x >= self.top_left.x and - p.x <= self.bottom_right.x; - } + top_left: PageList.Pin, + bottom_right: PageList.Pin, }; /// Easy base64 encoding function. diff --git a/src/terminal/kitty/graphics_storage.zig b/src/terminal/kitty/graphics_storage.zig index 6e4efc55be..1071f065a3 100644 --- a/src/terminal/kitty/graphics_storage.zig +++ b/src/terminal/kitty/graphics_storage.zig @@ -6,12 +6,12 @@ const ArenaAllocator = std.heap.ArenaAllocator; const terminal = @import("../main.zig"); const point = @import("../point.zig"); const command = @import("graphics_command.zig"); +const PageList = @import("../PageList.zig"); const Screen = @import("../Screen.zig"); const LoadingImage = @import("graphics_image.zig").LoadingImage; const Image = @import("graphics_image.zig").Image; const Rect = @import("graphics_image.zig").Rect; const Command = command.Command; -const ScreenPoint = point.ScreenPoint; const log = std.log.scoped(.kitty_gfx); @@ -53,13 +53,18 @@ pub const ImageStorage = struct { total_bytes: usize = 0, total_limit: usize = 320 * 1000 * 1000, // 320MB - pub fn deinit(self: *ImageStorage, alloc: Allocator) void { + pub fn deinit( + self: *ImageStorage, + alloc: Allocator, + s: *terminal.Screen, + ) void { if (self.loading) |loading| loading.destroy(alloc); var it = self.images.iterator(); while (it.next()) |kv| kv.value_ptr.deinit(alloc); self.images.deinit(alloc); + self.clearPlacements(s); self.placements.deinit(alloc); } @@ -72,10 +77,15 @@ pub const ImageStorage = struct { /// can be loaded. If this limit is lower, this will do an eviction /// if necessary. If the value is zero, then Kitty image protocol will /// be disabled. - pub fn setLimit(self: *ImageStorage, alloc: Allocator, limit: usize) !void { + pub fn setLimit( + self: *ImageStorage, + alloc: Allocator, + s: *terminal.Screen, + limit: usize, + ) !void { // Special case disabling by quickly deleting all if (limit == 0) { - self.deinit(alloc); + self.deinit(alloc, s); self.* = .{}; } @@ -170,6 +180,12 @@ pub const ImageStorage = struct { self.dirty = true; } + fn clearPlacements(self: *ImageStorage, s: *terminal.Screen) void { + var it = self.placements.iterator(); + while (it.next()) |entry| entry.value_ptr.deinit(s); + self.placements.clearRetainingCapacity(); + } + /// Get an image by its ID. If the image doesn't exist, null is returned. pub fn imageById(self: *const ImageStorage, image_id: u32) ?Image { return self.images.get(image_id); @@ -197,19 +213,20 @@ pub const ImageStorage = struct { pub fn delete( self: *ImageStorage, alloc: Allocator, - t: *const terminal.Terminal, + t: *terminal.Terminal, cmd: command.Delete, ) void { switch (cmd) { .all => |delete_images| if (delete_images) { // We just reset our entire state. - self.deinit(alloc); + self.deinit(alloc, &t.screen); self.* = .{ .dirty = true, .total_limit = self.total_limit, }; } else { // Delete all our placements + self.clearPlacements(&t.screen); self.placements.deinit(alloc); self.placements = .{}; self.dirty = true; @@ -217,6 +234,7 @@ pub const ImageStorage = struct { .id => |v| self.deleteById( alloc, + &t.screen, v.image_id, v.placement_id, v.delete, @@ -224,29 +242,59 @@ pub const ImageStorage = struct { .newest => |v| newest: { const img = self.imageByNumber(v.image_number) orelse break :newest; - self.deleteById(alloc, img.id, v.placement_id, v.delete); + self.deleteById( + alloc, + &t.screen, + img.id, + v.placement_id, + v.delete, + ); }, .intersect_cursor => |delete_images| { - const target = (point.Viewport{ - .x = t.screen.cursor.x, - .y = t.screen.cursor.y, - }).toScreen(&t.screen); - self.deleteIntersecting(alloc, t, target, delete_images, {}, null); + self.deleteIntersecting( + alloc, + t, + .{ .active = .{ + .x = t.screen.cursor.x, + .y = t.screen.cursor.y, + } }, + delete_images, + {}, + null, + ); }, .intersect_cell => |v| { - const target = (point.Viewport{ .x = v.x, .y = v.y }).toScreen(&t.screen); - self.deleteIntersecting(alloc, t, target, v.delete, {}, null); + self.deleteIntersecting( + alloc, + t, + .{ .active = .{ + .x = v.x, + .y = v.y, + } }, + v.delete, + {}, + null, + ); }, .intersect_cell_z => |v| { - const target = (point.Viewport{ .x = v.x, .y = v.y }).toScreen(&t.screen); - self.deleteIntersecting(alloc, t, target, v.delete, v.z, struct { - fn filter(ctx: i32, p: Placement) bool { - return p.z == ctx; - } - }.filter); + self.deleteIntersecting( + alloc, + t, + .{ .active = .{ + .x = v.x, + .y = v.y, + } }, + v.delete, + v.z, + struct { + fn filter(ctx: i32, p: Placement) bool { + return p.z == ctx; + } + }.filter, + ); }, .column => |v| { @@ -255,6 +303,7 @@ pub const ImageStorage = struct { const img = self.imageById(entry.key_ptr.image_id) orelse continue; const rect = entry.value_ptr.rect(img, t); if (rect.top_left.x <= v.x and rect.bottom_right.x >= v.x) { + entry.value_ptr.deinit(&t.screen); self.placements.removeByPtr(entry.key_ptr); if (v.delete) self.deleteIfUnused(alloc, img.id); } @@ -264,15 +313,24 @@ pub const ImageStorage = struct { self.dirty = true; }, - .row => |v| { - // Get the screenpoint y - const y = (point.Viewport{ .x = 0, .y = v.y }).toScreen(&t.screen).y; + .row => |v| row: { + // v.y is in active coords so we want to convert it to a pin + // so we can compare by page offsets. + const target_pin = t.screen.pages.pin(.{ .active = .{ + .y = v.y, + } }) orelse break :row; var it = self.placements.iterator(); while (it.next()) |entry| { const img = self.imageById(entry.key_ptr.image_id) orelse continue; const rect = entry.value_ptr.rect(img, t); - if (rect.top_left.y <= y and rect.bottom_right.y >= y) { + + // We need to copy our pin to ensure we are at least at + // the top-left x. + var target_pin_copy = target_pin; + target_pin_copy.x = rect.top_left.x; + if (target_pin_copy.isBetween(rect.top_left, rect.bottom_right)) { + entry.value_ptr.deinit(&t.screen); self.placements.removeByPtr(entry.key_ptr); if (v.delete) self.deleteIfUnused(alloc, img.id); } @@ -287,6 +345,7 @@ pub const ImageStorage = struct { while (it.next()) |entry| { if (entry.value_ptr.z == v.z) { const image_id = entry.key_ptr.image_id; + entry.value_ptr.deinit(&t.screen); self.placements.removeByPtr(entry.key_ptr); if (v.delete) self.deleteIfUnused(alloc, image_id); } @@ -305,6 +364,7 @@ pub const ImageStorage = struct { fn deleteById( self: *ImageStorage, alloc: Allocator, + s: *terminal.Screen, image_id: u32, placement_id: u32, delete_unused: bool, @@ -314,14 +374,18 @@ pub const ImageStorage = struct { var it = self.placements.iterator(); while (it.next()) |entry| { if (entry.key_ptr.image_id == image_id) { + entry.value_ptr.deinit(s); self.placements.removeByPtr(entry.key_ptr); } } } else { - _ = self.placements.remove(.{ + if (self.placements.getEntry(.{ .image_id = image_id, .placement_id = .{ .tag = .external, .id = placement_id }, - }); + })) |entry| { + entry.value_ptr.deinit(s); + self.placements.removeByPtr(entry.key_ptr); + } } // If this is specified, then we also delete the image @@ -353,18 +417,22 @@ pub const ImageStorage = struct { fn deleteIntersecting( self: *ImageStorage, alloc: Allocator, - t: *const terminal.Terminal, - p: point.ScreenPoint, + t: *terminal.Terminal, + p: point.Point, delete_unused: bool, filter_ctx: anytype, comptime filter: ?fn (@TypeOf(filter_ctx), Placement) bool, ) void { + // Convert our target point to a pin for comparison. + const target_pin = t.screen.pages.pin(p) orelse return; + var it = self.placements.iterator(); while (it.next()) |entry| { const img = self.imageById(entry.key_ptr.image_id) orelse continue; const rect = entry.value_ptr.rect(img, t); - if (rect.contains(p)) { + if (target_pin.isBetween(rect.top_left, rect.bottom_right)) { if (filter) |f| if (!f(filter_ctx, entry.value_ptr.*)) continue; + entry.value_ptr.deinit(&t.screen); self.placements.removeByPtr(entry.key_ptr); if (delete_unused) self.deleteIfUnused(alloc, img.id); } @@ -486,8 +554,8 @@ pub const ImageStorage = struct { }; pub const Placement = struct { - /// The location of the image on the screen. - point: ScreenPoint, + /// The tracked pin for this placement. + pin: *PageList.Pin, /// Offset of the x/y from the top-left of the cell. x_offset: u32 = 0, @@ -506,23 +574,26 @@ pub const ImageStorage = struct { /// The z-index for this placement. z: i32 = 0, - /// Returns a selection of the entire rectangle this placement - /// occupies within the screen. - pub fn rect( + pub fn deinit( + self: *const Placement, + s: *terminal.Screen, + ) void { + s.pages.untrackPin(self.pin); + } + + /// Returns the size in grid cells that this placement takes up. + pub fn gridSize( self: Placement, image: Image, t: *const terminal.Terminal, - ) Rect { - // If we have columns/rows specified we can simplify this whole thing. - if (self.columns > 0 and self.rows > 0) { - return .{ - .top_left = self.point, - .bottom_right = .{ - .x = @min(self.point.x + self.columns, t.cols - 1), - .y = self.point.y + self.rows, - }, - }; - } + ) struct { + cols: u32, + rows: u32, + } { + if (self.columns > 0 and self.rows > 0) return .{ + .cols = self.columns, + .rows = self.rows, + }; // Calculate our cell size. const terminal_width_f64: f64 = @floatFromInt(t.width_px); @@ -543,30 +614,58 @@ pub const ImageStorage = struct { const height_cells: u32 = @intFromFloat(@ceil(height_f64 / cell_height_f64)); return .{ - .top_left = self.point, - .bottom_right = .{ - .x = @min(self.point.x + width_cells, t.cols - 1), - .y = self.point.y + height_cells, - }, + .cols = width_cells, + .rows = height_cells, + }; + } + + /// Returns a selection of the entire rectangle this placement + /// occupies within the screen. + pub fn rect( + self: Placement, + image: Image, + t: *const terminal.Terminal, + ) Rect { + const grid_size = self.gridSize(image, t); + + var br = switch (self.pin.downOverflow(grid_size.rows)) { + .offset => |v| v, + .overflow => |v| v.end, + }; + br.x = @min(self.pin.x + grid_size.cols, t.cols - 1); + + return .{ + .top_left = self.pin.*, + .bottom_right = br, }; } }; }; +// Our pin for the placement +fn trackPin( + t: *terminal.Terminal, + pt: point.Coordinate, +) !*PageList.Pin { + return try t.screen.pages.trackPin(t.screen.pages.pin(.{ + .active = pt, + }).?); +} + test "storage: add placement with zero placement id" { const testing = std.testing; const alloc = testing.allocator; - var t = try terminal.Terminal.init(alloc, 100, 100); + var t = try terminal.Terminal.init(alloc, .{ .cols = 100, .rows = 100 }); defer t.deinit(alloc); t.width_px = 100; t.height_px = 100; var s: ImageStorage = .{}; - defer s.deinit(alloc); + defer s.deinit(alloc, &t.screen); try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); - try s.addPlacement(alloc, 1, 0, .{ .point = .{ .x = 25, .y = 25 } }); - try s.addPlacement(alloc, 1, 0, .{ .point = .{ .x = 25, .y = 25 } }); + try s.addPlacement(alloc, 1, 0, .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) }); + try s.addPlacement(alloc, 1, 0, .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) }); try testing.expectEqual(@as(usize, 2), s.placements.count()); try testing.expectEqual(@as(usize, 2), s.images.count()); @@ -585,38 +684,41 @@ test "storage: add placement with zero placement id" { test "storage: delete all placements and images" { const testing = std.testing; const alloc = testing.allocator; - var t = try terminal.Terminal.init(alloc, 3, 3); + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); defer t.deinit(alloc); + const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc); + defer s.deinit(alloc, &t.screen); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); - try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 1, .y = 1 } }); - try s.addPlacement(alloc, 2, 1, .{ .point = .{ .x = 1, .y = 1 } }); + try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); + try s.addPlacement(alloc, 2, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); s.dirty = false; s.delete(alloc, &t, .{ .all = true }); try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 0), s.images.count()); try testing.expectEqual(@as(usize, 0), s.placements.count()); + try testing.expectEqual(tracked, t.screen.pages.countTrackedPins()); } test "storage: delete all placements and images preserves limit" { const testing = std.testing; const alloc = testing.allocator; - var t = try terminal.Terminal.init(alloc, 3, 3); + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); defer t.deinit(alloc); + const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc); + defer s.deinit(alloc, &t.screen); s.total_limit = 5000; try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); - try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 1, .y = 1 } }); - try s.addPlacement(alloc, 2, 1, .{ .point = .{ .x = 1, .y = 1 } }); + try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); + try s.addPlacement(alloc, 2, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); s.dirty = false; s.delete(alloc, &t, .{ .all = true }); @@ -624,85 +726,93 @@ test "storage: delete all placements and images preserves limit" { try testing.expectEqual(@as(usize, 0), s.images.count()); try testing.expectEqual(@as(usize, 0), s.placements.count()); try testing.expectEqual(@as(usize, 5000), s.total_limit); + try testing.expectEqual(tracked, t.screen.pages.countTrackedPins()); } test "storage: delete all placements" { const testing = std.testing; const alloc = testing.allocator; - var t = try terminal.Terminal.init(alloc, 3, 3); + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); defer t.deinit(alloc); + const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc); + defer s.deinit(alloc, &t.screen); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); - try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 1, .y = 1 } }); - try s.addPlacement(alloc, 2, 1, .{ .point = .{ .x = 1, .y = 1 } }); + try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); + try s.addPlacement(alloc, 2, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); s.dirty = false; s.delete(alloc, &t, .{ .all = false }); try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 0), s.placements.count()); try testing.expectEqual(@as(usize, 3), s.images.count()); + try testing.expectEqual(tracked, t.screen.pages.countTrackedPins()); } test "storage: delete all placements by image id" { const testing = std.testing; const alloc = testing.allocator; - var t = try terminal.Terminal.init(alloc, 3, 3); + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); defer t.deinit(alloc); + const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc); + defer s.deinit(alloc, &t.screen); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); - try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 1, .y = 1 } }); - try s.addPlacement(alloc, 2, 1, .{ .point = .{ .x = 1, .y = 1 } }); + try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); + try s.addPlacement(alloc, 2, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); s.dirty = false; s.delete(alloc, &t, .{ .id = .{ .image_id = 2 } }); try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 1), s.placements.count()); try testing.expectEqual(@as(usize, 3), s.images.count()); + try testing.expectEqual(tracked + 1, t.screen.pages.countTrackedPins()); } test "storage: delete all placements by image id and unused images" { const testing = std.testing; const alloc = testing.allocator; - var t = try terminal.Terminal.init(alloc, 3, 3); + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); defer t.deinit(alloc); + const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc); + defer s.deinit(alloc, &t.screen); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); - try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 1, .y = 1 } }); - try s.addPlacement(alloc, 2, 1, .{ .point = .{ .x = 1, .y = 1 } }); + try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); + try s.addPlacement(alloc, 2, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); s.dirty = false; s.delete(alloc, &t, .{ .id = .{ .delete = true, .image_id = 2 } }); try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 1), s.placements.count()); try testing.expectEqual(@as(usize, 2), s.images.count()); + try testing.expectEqual(tracked + 1, t.screen.pages.countTrackedPins()); } test "storage: delete placement by specific id" { const testing = std.testing; const alloc = testing.allocator; - var t = try terminal.Terminal.init(alloc, 3, 3); + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); defer t.deinit(alloc); + const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc); + defer s.deinit(alloc, &t.screen); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); - try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 1, .y = 1 } }); - try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 1, .y = 1 } }); - try s.addPlacement(alloc, 2, 1, .{ .point = .{ .x = 1, .y = 1 } }); + try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); + try s.addPlacement(alloc, 1, 2, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); + try s.addPlacement(alloc, 2, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); s.dirty = false; s.delete(alloc, &t, .{ .id = .{ @@ -713,31 +823,33 @@ test "storage: delete placement by specific id" { try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 2), s.placements.count()); try testing.expectEqual(@as(usize, 3), s.images.count()); + try testing.expectEqual(tracked + 2, t.screen.pages.countTrackedPins()); } test "storage: delete intersecting cursor" { const testing = std.testing; const alloc = testing.allocator; - var t = try terminal.Terminal.init(alloc, 100, 100); + var t = try terminal.Terminal.init(alloc, .{ .rows = 100, .cols = 100 }); defer t.deinit(alloc); t.width_px = 100; t.height_px = 100; + const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc); + defer s.deinit(alloc, &t.screen); try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); - try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 0, .y = 0 } }); - try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } }); + try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) }); + try s.addPlacement(alloc, 1, 2, .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) }); - t.screen.cursor.x = 12; - t.screen.cursor.y = 12; + t.screen.cursorAbsolute(12, 12); s.dirty = false; s.delete(alloc, &t, .{ .intersect_cursor = false }); try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 1), s.placements.count()); try testing.expectEqual(@as(usize, 2), s.images.count()); + try testing.expectEqual(tracked + 1, t.screen.pages.countTrackedPins()); // verify the placement is what we expect try testing.expect(s.placements.get(.{ @@ -749,26 +861,27 @@ test "storage: delete intersecting cursor" { test "storage: delete intersecting cursor plus unused" { const testing = std.testing; const alloc = testing.allocator; - var t = try terminal.Terminal.init(alloc, 100, 100); + var t = try terminal.Terminal.init(alloc, .{ .rows = 100, .cols = 100 }); defer t.deinit(alloc); t.width_px = 100; t.height_px = 100; + const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc); + defer s.deinit(alloc, &t.screen); try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); - try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 0, .y = 0 } }); - try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } }); + try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) }); + try s.addPlacement(alloc, 1, 2, .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) }); - t.screen.cursor.x = 12; - t.screen.cursor.y = 12; + t.screen.cursorAbsolute(12, 12); s.dirty = false; s.delete(alloc, &t, .{ .intersect_cursor = true }); try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 1), s.placements.count()); try testing.expectEqual(@as(usize, 2), s.images.count()); + try testing.expectEqual(tracked + 1, t.screen.pages.countTrackedPins()); // verify the placement is what we expect try testing.expect(s.placements.get(.{ @@ -780,42 +893,44 @@ test "storage: delete intersecting cursor plus unused" { test "storage: delete intersecting cursor hits multiple" { const testing = std.testing; const alloc = testing.allocator; - var t = try terminal.Terminal.init(alloc, 100, 100); + var t = try terminal.Terminal.init(alloc, .{ .rows = 100, .cols = 100 }); defer t.deinit(alloc); t.width_px = 100; t.height_px = 100; + const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc); + defer s.deinit(alloc, &t.screen); try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); - try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 0, .y = 0 } }); - try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } }); + try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) }); + try s.addPlacement(alloc, 1, 2, .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) }); - t.screen.cursor.x = 26; - t.screen.cursor.y = 26; + t.screen.cursorAbsolute(26, 26); s.dirty = false; s.delete(alloc, &t, .{ .intersect_cursor = true }); try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 0), s.placements.count()); try testing.expectEqual(@as(usize, 1), s.images.count()); + try testing.expectEqual(tracked, t.screen.pages.countTrackedPins()); } test "storage: delete by column" { const testing = std.testing; const alloc = testing.allocator; - var t = try terminal.Terminal.init(alloc, 100, 100); + var t = try terminal.Terminal.init(alloc, .{ .rows = 100, .cols = 100 }); defer t.deinit(alloc); t.width_px = 100; t.height_px = 100; + const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc); + defer s.deinit(alloc, &t.screen); try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); - try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 0, .y = 0 } }); - try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } }); + try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) }); + try s.addPlacement(alloc, 1, 2, .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) }); s.dirty = false; s.delete(alloc, &t, .{ .column = .{ @@ -825,6 +940,7 @@ test "storage: delete by column" { try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 1), s.placements.count()); try testing.expectEqual(@as(usize, 2), s.images.count()); + try testing.expectEqual(tracked + 1, t.screen.pages.countTrackedPins()); // verify the placement is what we expect try testing.expect(s.placements.get(.{ @@ -836,17 +952,18 @@ test "storage: delete by column" { test "storage: delete by row" { const testing = std.testing; const alloc = testing.allocator; - var t = try terminal.Terminal.init(alloc, 100, 100); + var t = try terminal.Terminal.init(alloc, .{ .rows = 100, .cols = 100 }); defer t.deinit(alloc); t.width_px = 100; t.height_px = 100; + const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc); + defer s.deinit(alloc, &t.screen); try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); - try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 0, .y = 0 } }); - try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } }); + try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) }); + try s.addPlacement(alloc, 1, 2, .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) }); s.dirty = false; s.delete(alloc, &t, .{ .row = .{ @@ -856,6 +973,7 @@ test "storage: delete by row" { try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 1), s.placements.count()); try testing.expectEqual(@as(usize, 2), s.images.count()); + try testing.expectEqual(tracked + 1, t.screen.pages.countTrackedPins()); // verify the placement is what we expect try testing.expect(s.placements.get(.{ diff --git a/src/terminal/main.zig b/src/terminal/main.zig index a4224e63a1..be60aa477c 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -7,6 +7,7 @@ const stream = @import("stream.zig"); const ansi = @import("ansi.zig"); const csi = @import("csi.zig"); const sgr = @import("sgr.zig"); +const style = @import("style.zig"); pub const apc = @import("apc.zig"); pub const dcs = @import("dcs.zig"); pub const osc = @import("osc.zig"); @@ -15,22 +16,31 @@ pub const color = @import("color.zig"); pub const device_status = @import("device_status.zig"); pub const kitty = @import("kitty.zig"); pub const modes = @import("modes.zig"); +pub const page = @import("page.zig"); pub const parse_table = @import("parse_table.zig"); +pub const size = @import("size.zig"); pub const x11_color = @import("x11_color.zig"); pub const Charset = charsets.Charset; pub const CharsetSlot = charsets.Slots; pub const CharsetActiveSlot = charsets.ActiveSlot; +pub const Cell = page.Cell; pub const CSI = Parser.Action.CSI; pub const DCS = Parser.Action.DCS; pub const MouseShape = @import("mouse_shape.zig").MouseShape; -pub const Terminal = @import("Terminal.zig"); +pub const Page = page.Page; +pub const PageList = @import("PageList.zig"); pub const Parser = @import("Parser.zig"); -pub const Selection = @import("Selection.zig"); +pub const Pin = PageList.Pin; pub const Screen = @import("Screen.zig"); +pub const ScreenType = Terminal.ScreenType; +pub const Selection = @import("Selection.zig"); pub const StringMap = @import("StringMap.zig"); +pub const Style = style.Style; +pub const Terminal = @import("Terminal.zig"); pub const Stream = stream.Stream; pub const Cursor = Screen.Cursor; +pub const CursorStyle = Screen.CursorStyle; pub const CursorStyleReq = ansi.CursorStyle; pub const DeviceAttributeReq = ansi.DeviceAttributeReq; pub const Mode = modes.Mode; @@ -43,11 +53,11 @@ pub const EraseLine = csi.EraseLine; pub const TabClear = csi.TabClear; pub const Attribute = sgr.Attribute; -/// If we're targeting wasm then we export some wasm APIs. -pub usingnamespace if (builtin.target.isWasm()) struct { - pub usingnamespace @import("wasm.zig"); -} else struct {}; - test { @import("std").testing.refAllDecls(@This()); + + // Internals + _ = @import("bitmap_allocator.zig"); + _ = @import("hash_map.zig"); + _ = @import("size.zig"); } diff --git a/src/terminal/page.zig b/src/terminal/page.zig new file mode 100644 index 0000000000..d100acc89a --- /dev/null +++ b/src/terminal/page.zig @@ -0,0 +1,2090 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; +const assert = std.debug.assert; +const testing = std.testing; +const posix = std.posix; +const fastmem = @import("../fastmem.zig"); +const color = @import("color.zig"); +const sgr = @import("sgr.zig"); +const style = @import("style.zig"); +const size = @import("size.zig"); +const getOffset = size.getOffset; +const Offset = size.Offset; +const OffsetBuf = size.OffsetBuf; +const BitmapAllocator = @import("bitmap_allocator.zig").BitmapAllocator; +const hash_map = @import("hash_map.zig"); +const AutoOffsetHashMap = hash_map.AutoOffsetHashMap; +const alignForward = std.mem.alignForward; +const alignBackward = std.mem.alignBackward; + +const log = std.log.scoped(.page); + +/// The allocator to use for multi-codepoint grapheme data. We use +/// a chunk size of 4 codepoints. It'd be best to set this empirically +/// but it is currently set based on vibes. My thinking around 4 codepoints +/// is that most skin-tone emoji are <= 4 codepoints, letter combiners +/// are usually <= 4 codepoints, and 4 codepoints is a nice power of two +/// for alignment. +const grapheme_chunk_len = 4; +const grapheme_chunk = grapheme_chunk_len * @sizeOf(u21); +const GraphemeAlloc = BitmapAllocator(grapheme_chunk); +const grapheme_count_default = GraphemeAlloc.bitmap_bit_size; +const grapheme_bytes_default = grapheme_count_default * grapheme_chunk; +const GraphemeMap = AutoOffsetHashMap(Offset(Cell), Offset(u21).Slice); + +/// A page represents a specific section of terminal screen. The primary +/// idea of a page is that it is a fully self-contained unit that can be +/// serialized, copied, etc. as a convenient way to represent a section +/// of the screen. +/// +/// This property is useful for renderers which want to copy just the pages +/// for the visible portion of the screen, or for infinite scrollback where +/// we may want to serialize and store pages that are sufficiently far +/// away from the current viewport. +/// +/// Pages are always backed by a single contiguous block of memory that is +/// aligned on a page boundary. This makes it easy and fast to copy pages +/// around. Within the contiguous block of memory, the contents of a page are +/// thoughtfully laid out to optimize primarily for terminal IO (VT streams) +/// and to minimize memory usage. +pub const Page = struct { + comptime { + // The alignment of our members. We want to ensure that the page + // alignment is always divisible by this. + assert(std.mem.page_size % @max( + @alignOf(Row), + @alignOf(Cell), + style.Set.base_align, + ) == 0); + } + + /// The backing memory for the page. A page is always made up of a + /// a single contiguous block of memory that is aligned on a page + /// boundary and is a multiple of the system page size. + memory: []align(std.mem.page_size) u8, + + /// The array of rows in the page. The rows are always in row order + /// (i.e. index 0 is the top row, index 1 is the row below that, etc.) + rows: Offset(Row), + + /// The array of cells in the page. The cells are NOT in row order, + /// but they are in column order. To determine the mapping of cells + /// to row, you must use the `rows` field. From the pointer to the + /// first column, all cells in that row are laid out in column order. + cells: Offset(Cell), + + /// The multi-codepoint grapheme data for this page. This is where + /// any cell that has more than one codepoint will be stored. This is + /// relatively rare (typically only emoji) so this defaults to a very small + /// size and we force page realloc when it grows. + grapheme_alloc: GraphemeAlloc, + + /// The mapping of cell to grapheme data. The exact mapping is the + /// cell offset to the grapheme data offset. Therefore, whenever a + /// cell is moved (i.e. `erase`) then the grapheme data must be updated. + /// Grapheme data is relatively rare so this is considered a slow + /// path. + grapheme_map: GraphemeMap, + + /// The available set of styles in use on this page. + styles: style.Set, + + /// The current dimensions of the page. The capacity may be larger + /// than this. This allows us to allocate a larger page than necessary + /// and also to resize a page smaller witout reallocating. + size: Size, + + /// The capacity of this page. This is the full size of the backing + /// memory and is fixed at page creation time. + capacity: Capacity, + + /// If this is true then verifyIntegrity will do nothing. This is + /// only present with runtime safety enabled. + pause_integrity_checks: if (std.debug.runtime_safety) usize else void = + if (std.debug.runtime_safety) 0 else {}, + + /// Initialize a new page, allocating the required backing memory. + /// The size of the initialized page defaults to the full capacity. + /// + /// The backing memory is always allocated using mmap directly. + /// You cannot use custom allocators with this structure because + /// it is critical to performance that we use mmap. + pub fn init(cap: Capacity) !Page { + const l = layout(cap); + + // We use mmap directly to avoid Zig allocator overhead + // (small but meaningful for this path) and because a private + // anonymous mmap is guaranteed on Linux and macOS to be zeroed, + // which is a critical property for us. + assert(l.total_size % std.mem.page_size == 0); + const backing = try posix.mmap( + null, + l.total_size, + posix.PROT.READ | posix.PROT.WRITE, + .{ .TYPE = .PRIVATE, .ANONYMOUS = true }, + -1, + 0, + ); + errdefer posix.munmap(backing); + + const buf = OffsetBuf.init(backing); + return initBuf(buf, l); + } + + /// Initialize a new page using the given backing memory. + /// It is up to the caller to not call deinit on these pages. + pub fn initBuf(buf: OffsetBuf, l: Layout) Page { + const cap = l.capacity; + const rows = buf.member(Row, l.rows_start); + const cells = buf.member(Cell, l.cells_start); + + // We need to go through and initialize all the rows so that + // they point to a valid offset into the cells, since the rows + // zero-initialized aren't valid. + const cells_ptr = cells.ptr(buf)[0 .. cap.cols * cap.rows]; + for (rows.ptr(buf)[0..cap.rows], 0..) |*row, y| { + const start = y * cap.cols; + row.* = .{ + .cells = getOffset(Cell, buf, &cells_ptr[start]), + }; + } + + return .{ + .memory = @alignCast(buf.start()[0..l.total_size]), + .rows = rows, + .cells = cells, + .styles = style.Set.init( + buf.add(l.styles_start), + l.styles_layout, + ), + .grapheme_alloc = GraphemeAlloc.init( + buf.add(l.grapheme_alloc_start), + l.grapheme_alloc_layout, + ), + .grapheme_map = GraphemeMap.init( + buf.add(l.grapheme_map_start), + l.grapheme_map_layout, + ), + .size = .{ .cols = cap.cols, .rows = cap.rows }, + .capacity = cap, + }; + } + + /// Deinitialize the page, freeing any backing memory. Do NOT call + /// this if you allocated the backing memory yourself (i.e. you used + /// initBuf). + pub fn deinit(self: *Page) void { + posix.munmap(self.memory); + self.* = undefined; + } + + /// Reinitialize the page with the same capacity. + pub fn reinit(self: *Page) void { + // We zero the page memory as u64 instead of u8 because + // we can and it's empirically quite a bit faster. + @memset(@as([*]u64, @ptrCast(self.memory))[0..self.memory.len / 8], 0); + self.* = initBuf(OffsetBuf.init(self.memory), layout(self.capacity)); + } + + pub const IntegrityError = error{ + ZeroRowCount, + ZeroColCount, + UnmarkedGraphemeRow, + MissingGraphemeData, + InvalidGraphemeCount, + MissingStyle, + UnmarkedStyleRow, + MismatchedStyleRef, + InvalidStyleCount, + InvalidSpacerTailLocation, + InvalidSpacerHeadLocation, + UnwrappedSpacerHead, + }; + + /// Temporarily pause integrity checks. This is useful when you are + /// doing a lot of operations that would trigger integrity check + /// violations but you know the page will end up in a consistent state. + pub fn pauseIntegrityChecks(self: *Page, v: bool) void { + if (comptime std.debug.runtime_safety) { + if (v) { + self.pause_integrity_checks += 1; + } else { + self.pause_integrity_checks -= 1; + } + } + } + + /// A helper that can be used to assert the integrity of the page + /// when runtime safety is enabled. This is a no-op when runtime + /// safety is disabled. This uses the libc allocator. + pub fn assertIntegrity(self: *Page) void { + if (comptime std.debug.runtime_safety) { + self.verifyIntegrity(std.heap.c_allocator) catch unreachable; + } + } + + /// Verifies the integrity of the page data. This is not fast, + /// but it is useful for assertions, deserialization, etc. The + /// allocator is only used for temporary allocations -- all memory + /// is freed before this function returns. + /// + /// Integrity errors are also logged as warnings. + pub fn verifyIntegrity(self: *Page, alloc_gpa: Allocator) !void { + // Some things that seem like we should check but do not: + // + // - We do not check that the style ref count is exact, only that + // it is at least what we see. We do this because some fast paths + // trim rows without clearing data. + // - We do not check that styles seen is exactly the same as the + // styles count in the page for the same reason as above. + // - We only check that we saw less graphemes than the total memory + // used for the same reason as styles above. + // + + if (comptime std.debug.runtime_safety) { + if (self.pause_integrity_checks > 0) return; + } + + if (self.size.rows == 0) { + log.warn("page integrity violation zero row count", .{}); + return IntegrityError.ZeroRowCount; + } + if (self.size.cols == 0) { + log.warn("page integrity violation zero col count", .{}); + return IntegrityError.ZeroColCount; + } + + var arena = ArenaAllocator.init(alloc_gpa); + defer arena.deinit(); + const alloc = arena.allocator(); + + var graphemes_seen: usize = 0; + var styles_seen = std.AutoHashMap(style.Id, usize).init(alloc); + defer styles_seen.deinit(); + + const rows = self.rows.ptr(self.memory)[0..self.size.rows]; + for (rows, 0..) |*row, y| { + const graphemes_start = graphemes_seen; + const cells = row.cells.ptr(self.memory)[0..self.size.cols]; + for (cells, 0..) |*cell, x| { + if (cell.hasGrapheme()) { + // If a cell has grapheme data, it must be present in + // the grapheme map. + _ = self.lookupGrapheme(cell) orelse { + log.warn( + "page integrity violation y={} x={} grapheme data missing", + .{ y, x }, + ); + return IntegrityError.MissingGraphemeData; + }; + + graphemes_seen += 1; + } + + if (cell.style_id != style.default_id) { + // If a cell has a style, it must be present in the styles + // set. + _ = self.styles.lookupId( + self.memory, + cell.style_id, + ) orelse { + log.warn( + "page integrity violation y={} x={} style missing id={}", + .{ y, x, cell.style_id }, + ); + return IntegrityError.MissingStyle; + }; + + if (!row.styled) { + log.warn( + "page integrity violation y={} x={} row not marked as styled", + .{ y, x }, + ); + return IntegrityError.UnmarkedStyleRow; + } + + const gop = try styles_seen.getOrPut(cell.style_id); + if (!gop.found_existing) gop.value_ptr.* = 0; + gop.value_ptr.* += 1; + } + + switch (cell.wide) { + .narrow => {}, + .wide => {}, + + .spacer_tail => { + // Spacer tails can't be at the start because they follow + // a wide char. + if (x == 0) { + log.warn( + "page integrity violation y={} x={} spacer tail at start", + .{ y, x }, + ); + return IntegrityError.InvalidSpacerTailLocation; + } + + // Spacer tails must follow a wide char + const prev = cells[x - 1]; + if (prev.wide != .wide) { + log.warn( + "page integrity violation y={} x={} spacer tail not following wide", + .{ y, x }, + ); + return IntegrityError.InvalidSpacerTailLocation; + } + }, + + .spacer_head => { + // Spacer heads must be at the end + if (x != self.size.cols - 1) { + log.warn( + "page integrity violation y={} x={} spacer head not at end", + .{ y, x }, + ); + return IntegrityError.InvalidSpacerHeadLocation; + } + + // The row must be wrapped + if (!row.wrap) { + log.warn( + "page integrity violation y={} spacer head not wrapped", + .{y}, + ); + return IntegrityError.UnwrappedSpacerHead; + } + }, + } + } + + // Check row grapheme data + if (graphemes_seen > graphemes_start) { + // If a cell in a row has grapheme data, the row must + // be marked as having grapheme data. + if (!row.grapheme) { + log.warn( + "page integrity violation y={} grapheme data but row not marked", + .{y}, + ); + return IntegrityError.UnmarkedGraphemeRow; + } + } + } + + // Our graphemes seen should exactly match the grapheme count + if (graphemes_seen > self.graphemeCount()) { + log.warn( + "page integrity violation grapheme count mismatch expected={} actual={}", + .{ graphemes_seen, self.graphemeCount() }, + ); + return IntegrityError.InvalidGraphemeCount; + } + + // Verify all our styles have the correct ref count. + { + var it = styles_seen.iterator(); + while (it.next()) |entry| { + const style_val = self.styles.lookupId(self.memory, entry.key_ptr.*).?.*; + const md = self.styles.upsert(self.memory, style_val) catch unreachable; + if (md.ref < entry.value_ptr.*) { + log.warn( + "page integrity violation style ref count mismatch id={} expected={} actual={}", + .{ entry.key_ptr.*, entry.value_ptr.*, md.ref }, + ); + return IntegrityError.MismatchedStyleRef; + } + } + } + } + + /// Clone the contents of this page. This will allocate new memory + /// using the page allocator. If you want to manage memory manually, + /// use cloneBuf. + pub fn clone(self: *const Page) !Page { + const backing = try posix.mmap( + null, + self.memory.len, + posix.PROT.READ | posix.PROT.WRITE, + .{ .TYPE = .PRIVATE, .ANONYMOUS = true }, + -1, + 0, + ); + errdefer posix.munmap(backing); + return self.cloneBuf(backing); + } + + /// Clone the entire contents of this page. + /// + /// The buffer must be at least the size of self.memory. + pub fn cloneBuf(self: *const Page, buf: []align(std.mem.page_size) u8) Page { + assert(buf.len >= self.memory.len); + + // The entire concept behind a page is that everything is stored + // as offsets so we can do a simple linear copy of the backing + // memory and copy all the offsets and everything will work. + var result = self.*; + result.memory = buf[0..self.memory.len]; + + // This is a memcpy. We may want to investigate if there are + // faster ways to do this (i.e. copy-on-write tricks) but I suspect + // they'll be slower. I haven't experimented though. + // std.log.warn("copy bytes={}", .{self.memory.len}); + fastmem.copy(u8, result.memory, self.memory); + + return result; + } + + pub const CloneFromError = Allocator.Error || style.Set.UpsertError; + + /// Clone the contents of another page into this page. The capacities + /// can be different, but the size of the other page must fit into + /// this page. + /// + /// The y_start and y_end parameters allow you to clone only a portion + /// of the other page. This is useful for splitting a page into two + /// or more pages. + /// + /// The column count of this page will always be the same as this page. + /// If the other page has more columns, the extra columns will be + /// truncated. If the other page has fewer columns, the extra columns + /// will be zeroed. + pub fn cloneFrom( + self: *Page, + other: *const Page, + y_start: usize, + y_end: usize, + ) CloneFromError!void { + assert(y_start <= y_end); + assert(y_end <= other.size.rows); + assert(y_end - y_start <= self.size.rows); + + const other_rows = other.rows.ptr(other.memory)[y_start..y_end]; + const rows = self.rows.ptr(self.memory)[0 .. y_end - y_start]; + for (rows, other_rows) |*dst_row, *src_row| try self.cloneRowFrom( + other, + dst_row, + src_row, + ); + + // We should remain consistent + self.assertIntegrity(); + } + + /// Clone a single row from another page into this page. + pub fn cloneRowFrom( + self: *Page, + other: *const Page, + dst_row: *Row, + src_row: *const Row, + ) CloneFromError!void { + try self.clonePartialRowFrom( + other, + dst_row, + src_row, + 0, + self.size.cols, + ); + } + + /// Clone a single row from another page into this page, supporting + /// partial copy. cloneRowFrom calls this. + pub fn clonePartialRowFrom( + self: *Page, + other: *const Page, + dst_row: *Row, + src_row: *const Row, + x_start: usize, + x_end_req: usize, + ) CloneFromError!void { + const cell_len = @min(self.size.cols, other.size.cols); + const x_end = @min(x_end_req, cell_len); + assert(x_start <= x_end); + const other_cells = src_row.cells.ptr(other.memory)[x_start..x_end]; + const cells = dst_row.cells.ptr(self.memory)[x_start..x_end]; + + // If our destination has styles or graphemes then we need to + // clear some state. + if (dst_row.grapheme or dst_row.styled) { + self.clearCells(dst_row, x_start, x_end); + } + + // Copy all the row metadata but keep our cells offset + dst_row.* = copy: { + var copy = src_row.*; + + // If we're not copying the full row then we want to preserve + // some original state from our dst row. + if ((x_end - x_start) < self.size.cols) { + copy.wrap = dst_row.wrap; + copy.wrap_continuation = dst_row.wrap_continuation; + copy.grapheme = dst_row.grapheme; + copy.styled = dst_row.styled; + } + + // Our cell offset remains the same + copy.cells = dst_row.cells; + + break :copy copy; + }; + + // If we have no managed memory in the source, then we can just + // copy it directly. + if (!src_row.grapheme and !src_row.styled) { + fastmem.copy(Cell, cells, other_cells); + } else { + // We have managed memory, so we have to do a slower copy to + // get all of that right. + for (cells, other_cells) |*dst_cell, *src_cell| { + dst_cell.* = src_cell.*; + if (src_cell.hasGrapheme()) { + // To prevent integrity checks flipping + if (comptime std.debug.runtime_safety) dst_cell.style_id = style.default_id; + + dst_cell.content_tag = .codepoint; // required for appendGrapheme + const cps = other.lookupGrapheme(src_cell).?; + for (cps) |cp| try self.appendGrapheme(dst_row, dst_cell, cp); + } + if (src_cell.style_id != style.default_id) { + const other_style = other.styles.lookupId(other.memory, src_cell.style_id).?.*; + const md = try self.styles.upsert(self.memory, other_style); + md.ref += 1; + dst_cell.style_id = md.id; + dst_row.styled = true; + } + } + } + + // If we are growing columns, then we need to ensure spacer heads + // are cleared. + if (self.size.cols > other.size.cols) { + const last = &cells[other.size.cols - 1]; + if (last.wide == .spacer_head) { + last.wide = .narrow; + } + } + + // The final page should remain consistent + self.assertIntegrity(); + } + + /// Get a single row. y must be valid. + pub fn getRow(self: *const Page, y: usize) *Row { + assert(y < self.size.rows); + return &self.rows.ptr(self.memory)[y]; + } + + /// Get the cells for a row. + pub fn getCells(self: *const Page, row: *Row) []Cell { + if (comptime std.debug.runtime_safety) { + const rows = self.rows.ptr(self.memory); + const cells = self.cells.ptr(self.memory); + assert(@intFromPtr(row) >= @intFromPtr(rows)); + assert(@intFromPtr(row) < @intFromPtr(cells)); + } + + const cells = row.cells.ptr(self.memory); + return cells[0..self.size.cols]; + } + + /// Get the row and cell for the given X/Y within this page. + pub fn getRowAndCell(self: *const Page, x: usize, y: usize) struct { + row: *Row, + cell: *Cell, + } { + assert(y < self.size.rows); + assert(x < self.size.cols); + + const rows = self.rows.ptr(self.memory); + const row = &rows[y]; + const cell = &row.cells.ptr(self.memory)[x]; + + return .{ .row = row, .cell = cell }; + } + + /// Move a cell from one location to another. This will replace the + /// previous contents with a blank cell. Because this is a move, this + /// doesn't allocate and can't fail. + pub fn moveCells( + self: *Page, + src_row: *Row, + src_left: usize, + dst_row: *Row, + dst_left: usize, + len: usize, + ) void { + defer self.assertIntegrity(); + + const src_cells = src_row.cells.ptr(self.memory)[src_left .. src_left + len]; + const dst_cells = dst_row.cells.ptr(self.memory)[dst_left .. dst_left + len]; + + // Clear our destination now matter what + self.clearCells(dst_row, dst_left, dst_left + len); + + // If src has no graphemes, this is very fast because we can + // just copy the cells directly because every other attribute + // is position-independent. + const src_grapheme = src_row.grapheme or grapheme: { + for (src_cells) |c| if (c.hasGrapheme()) break :grapheme true; + break :grapheme false; + }; + if (!src_grapheme) { + fastmem.copy(Cell, dst_cells, src_cells); + } else { + // Source has graphemes, meaning we have to do a slower + // cell by cell copy. + for (src_cells, dst_cells) |*src, *dst| { + dst.* = src.*; + if (!src.hasGrapheme()) continue; + + // Required for moveGrapheme assertions + dst.content_tag = .codepoint; + self.moveGrapheme(src, dst); + src.content_tag = .codepoint; + dst.content_tag = .codepoint_grapheme; + } + + // The destination row must be marked + dst_row.grapheme = true; + } + + // The destination row has styles if any of the cells are styled + if (!dst_row.styled) dst_row.styled = styled: for (dst_cells) |c| { + if (c.style_id != style.default_id) break :styled true; + } else false; + + // Clear our source row now that the copy is complete. We can NOT + // use clearCells here because clearCells will garbage collect our + // styles and graphames but we moved them above. + // + // Zero the cells as u64s since empirically this seems + // to be a bit faster than using @memset(src_cells, .{}) + @memset(@as([]u64, @ptrCast(src_cells)), 0); + if (src_cells.len == self.size.cols) { + src_row.grapheme = false; + src_row.styled = false; + } + } + + /// Swap two cells within the same row as quickly as possible. + pub fn swapCells( + self: *Page, + src: *Cell, + dst: *Cell, + ) void { + defer self.assertIntegrity(); + + // Graphemes are keyed by cell offset so we do have to move them. + // We do this first so that all our grapheme state is correct. + if (src.hasGrapheme() or dst.hasGrapheme()) { + if (src.hasGrapheme() and !dst.hasGrapheme()) { + self.moveGrapheme(src, dst); + } else if (!src.hasGrapheme() and dst.hasGrapheme()) { + self.moveGrapheme(dst, src); + } else { + // Both had graphemes, so we have to manually swap + const src_offset = getOffset(Cell, self.memory, src); + const dst_offset = getOffset(Cell, self.memory, dst); + var map = self.grapheme_map.map(self.memory); + const src_entry = map.getEntry(src_offset).?; + const dst_entry = map.getEntry(dst_offset).?; + const src_value = src_entry.value_ptr.*; + const dst_value = dst_entry.value_ptr.*; + src_entry.value_ptr.* = dst_value; + dst_entry.value_ptr.* = src_value; + } + } + + // Copy the metadata. Note that we do NOT have to worry about + // styles because styles are keyed by ID and we're preserving the + // exact ref count and row state here. + const old_dst = dst.*; + dst.* = src.*; + src.* = old_dst; + } + + /// Clear the cells in the given row. This will reclaim memory used + /// by graphemes and styles. Note that if the style cleared is still + /// active, Page cannot know this and it will still be ref counted down. + /// The best solution for this is to artificially increment the ref count + /// prior to calling this function. + pub fn clearCells( + self: *Page, + row: *Row, + left: usize, + end: usize, + ) void { + defer self.assertIntegrity(); + + const cells = row.cells.ptr(self.memory)[left..end]; + if (row.grapheme) { + for (cells) |*cell| { + if (cell.hasGrapheme()) self.clearGrapheme(row, cell); + } + } + + if (row.styled) { + for (cells) |*cell| { + if (cell.style_id == style.default_id) continue; + + if (self.styles.lookupId(self.memory, cell.style_id)) |prev_style| { + // Below upsert can't fail because it should already be present + const md = self.styles.upsert(self.memory, prev_style.*) catch unreachable; + assert(md.ref > 0); + md.ref -= 1; + if (md.ref == 0) self.styles.remove(self.memory, cell.style_id); + } + } + + if (cells.len == self.size.cols) row.styled = false; + } + + // Zero the cells as u64s since empirically this seems + // to be a bit faster than using @memset(cells, .{}) + @memset(@as([]u64, @ptrCast(cells)), 0); + } + + /// Append a codepoint to the given cell as a grapheme. + pub fn appendGrapheme(self: *Page, row: *Row, cell: *Cell, cp: u21) Allocator.Error!void { + defer self.assertIntegrity(); + + if (comptime std.debug.runtime_safety) assert(cell.hasText()); + + const cell_offset = getOffset(Cell, self.memory, cell); + var map = self.grapheme_map.map(self.memory); + + // If this cell has no graphemes, we can go faster by knowing we + // need to allocate a new grapheme slice and update the map. + if (cell.content_tag != .codepoint_grapheme) { + const cps = try self.grapheme_alloc.alloc(u21, self.memory, 1); + errdefer self.grapheme_alloc.free(self.memory, cps); + cps[0] = cp; + + try map.putNoClobber(cell_offset, .{ + .offset = getOffset(u21, self.memory, @ptrCast(cps.ptr)), + .len = 1, + }); + errdefer map.remove(cell_offset); + + cell.content_tag = .codepoint_grapheme; + row.grapheme = true; + + return; + } + + // The cell already has graphemes. We need to append to the existing + // grapheme slice and update the map. + assert(row.grapheme); + + const slice = map.getPtr(cell_offset).?; + + // If our slice len doesn't divide evenly by the grapheme chunk + // length then we can utilize the additional chunk space. + if (slice.len % grapheme_chunk_len != 0) { + const cps = slice.offset.ptr(self.memory); + cps[slice.len] = cp; + slice.len += 1; + return; + } + + // We are out of chunk space. There is no fast path here. We need + // to allocate a larger chunk. This is a very slow path. We expect + // most graphemes to fit within our chunk size. + const cps = try self.grapheme_alloc.alloc(u21, self.memory, slice.len + 1); + errdefer self.grapheme_alloc.free(self.memory, cps); + const old_cps = slice.offset.ptr(self.memory)[0..slice.len]; + fastmem.copy(u21, cps[0..old_cps.len], old_cps); + cps[slice.len] = cp; + slice.* = .{ + .offset = getOffset(u21, self.memory, @ptrCast(cps.ptr)), + .len = slice.len + 1, + }; + + // Free our old chunk + self.grapheme_alloc.free(self.memory, old_cps); + } + + /// Returns the codepoints for the given cell. These are the codepoints + /// in addition to the first codepoint. The first codepoint is NOT + /// included since it is on the cell itself. + pub fn lookupGrapheme(self: *const Page, cell: *Cell) ?[]u21 { + const cell_offset = getOffset(Cell, self.memory, cell); + const map = self.grapheme_map.map(self.memory); + const slice = map.get(cell_offset) orelse return null; + return slice.offset.ptr(self.memory)[0..slice.len]; + } + + /// Move the graphemes from one cell to another. This can't fail + /// because we avoid any allocations since we're just moving data. + /// + /// WARNING: This will NOT change the content_tag on the cells because + /// there are scenarios where we want to move graphemes without changing + /// the content tag. Callers beware but assertIntegrity should catch this. + fn moveGrapheme(self: *Page, src: *Cell, dst: *Cell) void { + if (comptime std.debug.runtime_safety) { + assert(src.hasGrapheme()); + assert(!dst.hasGrapheme()); + } + + const src_offset = getOffset(Cell, self.memory, src); + const dst_offset = getOffset(Cell, self.memory, dst); + var map = self.grapheme_map.map(self.memory); + const entry = map.getEntry(src_offset).?; + const value = entry.value_ptr.*; + map.removeByPtr(entry.key_ptr); + map.putAssumeCapacity(dst_offset, value); + } + + /// Clear the graphemes for a given cell. + pub fn clearGrapheme(self: *Page, row: *Row, cell: *Cell) void { + defer self.assertIntegrity(); + if (comptime std.debug.runtime_safety) assert(cell.hasGrapheme()); + + // Get our entry in the map, which must exist + const cell_offset = getOffset(Cell, self.memory, cell); + var map = self.grapheme_map.map(self.memory); + const entry = map.getEntry(cell_offset).?; + + // Free our grapheme data + const cps = entry.value_ptr.offset.ptr(self.memory)[0..entry.value_ptr.len]; + self.grapheme_alloc.free(self.memory, cps); + + // Remove the entry + map.removeByPtr(entry.key_ptr); + + // Mark that we no longer have graphemes, also search the row + // to make sure its state is correct. + cell.content_tag = .codepoint; + const cells = row.cells.ptr(self.memory)[0..self.size.cols]; + for (cells) |c| if (c.hasGrapheme()) return; + row.grapheme = false; + } + + /// Returns the number of graphemes in the page. This isn't the byte + /// size but the total number of unique cells that have grapheme data. + pub fn graphemeCount(self: *const Page) usize { + return self.grapheme_map.map(self.memory).count(); + } + + pub const Layout = struct { + total_size: usize, + rows_start: usize, + rows_size: usize, + cells_start: usize, + cells_size: usize, + styles_start: usize, + styles_layout: style.Set.Layout, + grapheme_alloc_start: usize, + grapheme_alloc_layout: GraphemeAlloc.Layout, + grapheme_map_start: usize, + grapheme_map_layout: GraphemeMap.Layout, + capacity: Capacity, + }; + + /// The memory layout for a page given a desired minimum cols + /// and rows size. + pub fn layout(cap: Capacity) Layout { + const rows_count: usize = @intCast(cap.rows); + const rows_start = 0; + const rows_end: usize = rows_start + (rows_count * @sizeOf(Row)); + + const cells_count: usize = @intCast(cap.cols * cap.rows); + const cells_start = alignForward(usize, rows_end, @alignOf(Cell)); + const cells_end = cells_start + (cells_count * @sizeOf(Cell)); + + const styles_layout = style.Set.layout(cap.styles); + const styles_start = alignForward(usize, cells_end, style.Set.base_align); + const styles_end = styles_start + styles_layout.total_size; + + const grapheme_alloc_layout = GraphemeAlloc.layout(cap.grapheme_bytes); + const grapheme_alloc_start = alignForward(usize, styles_end, GraphemeAlloc.base_align); + const grapheme_alloc_end = grapheme_alloc_start + grapheme_alloc_layout.total_size; + + const grapheme_count = @divFloor(cap.grapheme_bytes, grapheme_chunk); + const grapheme_map_layout = GraphemeMap.layout(@intCast(grapheme_count)); + const grapheme_map_start = alignForward(usize, grapheme_alloc_end, GraphemeMap.base_align); + const grapheme_map_end = grapheme_map_start + grapheme_map_layout.total_size; + + const total_size = alignForward(usize, grapheme_map_end, std.mem.page_size); + + return .{ + .total_size = total_size, + .rows_start = rows_start, + .rows_size = rows_end - rows_start, + .cells_start = cells_start, + .cells_size = cells_end - cells_start, + .styles_start = styles_start, + .styles_layout = styles_layout, + .grapheme_alloc_start = grapheme_alloc_start, + .grapheme_alloc_layout = grapheme_alloc_layout, + .grapheme_map_start = grapheme_map_start, + .grapheme_map_layout = grapheme_map_layout, + .capacity = cap, + }; + } +}; + +/// The standard capacity for a page that doesn't have special +/// requirements. This is enough to support a very large number of cells. +/// The standard capacity is chosen as the fast-path for allocation. +pub const std_capacity: Capacity = .{ + .cols = 215, + .rows = 215, + .styles = 128, + .grapheme_bytes = 8192, +}; + +/// The size of this page. +pub const Size = struct { + cols: size.CellCountInt, + rows: size.CellCountInt, +}; + +/// Capacity of this page. +pub const Capacity = struct { + /// Number of columns and rows we can know about. + cols: size.CellCountInt, + rows: size.CellCountInt, + + /// Number of unique styles that can be used on this page. + styles: u16 = 16, + + /// Number of bytes to allocate for grapheme data. + grapheme_bytes: usize = grapheme_bytes_default, + + pub const Adjustment = struct { + cols: ?size.CellCountInt = null, + }; + + /// Adjust the capacity parameters while retaining the same total size. + /// Adjustments always happen by limiting the rows in the page. Everything + /// else can grow. If it is impossible to achieve the desired adjustment, + /// OutOfMemory is returned. + pub fn adjust(self: Capacity, req: Adjustment) Allocator.Error!Capacity { + var adjusted = self; + if (req.cols) |cols| { + // The math below only works if there is no alignment gap between + // the end of the rows array and the start of the cells array. + // + // To guarantee this, we assert that Row's size is a multiple of + // Cell's alignment, so that any length array of Rows will end on + // a valid alignment for the start of the Cell array. + assert(@sizeOf(Row) % @alignOf(Cell) == 0); + + const layout = Page.layout(self); + + // In order to determine the amount of space in the page available + // for rows & cells (which will allow us to calculate the number of + // rows we can fit at a certain column width) we need to layout the + // "meta" members of the page (i.e. everything else) from the end. + const grapheme_map_start = alignBackward(usize, layout.total_size - layout.grapheme_map_layout.total_size, GraphemeMap.base_align); + const grapheme_alloc_start = alignBackward(usize, grapheme_map_start - layout.grapheme_alloc_layout.total_size, GraphemeAlloc.base_align); + const styles_start = alignBackward(usize, grapheme_alloc_start - layout.styles_layout.total_size, style.Set.base_align); + + const available_size = styles_start; + const size_per_row = @sizeOf(Row) + (@sizeOf(Cell) * @as(usize, @intCast(cols))); + const new_rows = @divFloor(available_size, size_per_row); + + // If our rows go to zero then we can't fit any row metadata + // for the desired number of columns. + if (new_rows == 0) return error.OutOfMemory; + + adjusted.cols = cols; + adjusted.rows = @intCast(new_rows); + } + + return adjusted; + } +}; + +pub const Row = packed struct(u64) { + /// The cells in the row offset from the page. + cells: Offset(Cell), + + /// True if this row is soft-wrapped. The first cell of the next + /// row is a continuation of this row. + wrap: bool = false, + + /// True if the previous row to this one is soft-wrapped and + /// this row is a continuation of that row. + wrap_continuation: bool = false, + + /// True if any of the cells in this row have multi-codepoint + /// grapheme clusters. If this is true, some fast paths are not + /// possible because erasing for example may need to clear existing + /// grapheme data. + grapheme: bool = false, + + /// True if any of the cells in this row have a ref-counted style. + /// This can have false positives but never a false negative. Meaning: + /// this will be set to true the first time a style is used, but it + /// will not be set to false if the style is no longer used, because + /// checking for that condition is too expensive. + /// + /// Why have this weird false positive flag at all? This makes VT operations + /// that erase cells (such as insert lines, delete lines, erase chars, + /// etc.) MUCH MUCH faster in the case that the row was never styled. + /// At the time of writing this, the speed difference is around 4x. + styled: bool = false, + + /// The semantic prompt type for this row as specified by the + /// running program, or "unknown" if it was never set. + semantic_prompt: SemanticPrompt = .unknown, + + _padding: u25 = 0, + + /// Semantic prompt type. + pub const SemanticPrompt = enum(u3) { + /// Unknown, the running application didn't tell us for this line. + unknown = 0, + + /// This is a prompt line, meaning it only contains the shell prompt. + /// For poorly behaving shells, this may also be the input. + prompt = 1, + prompt_continuation = 2, + + /// This line contains the input area. We don't currently track + /// where this actually is in the line, so we just assume it is somewhere. + input = 3, + + /// This line is the start of command output. + command = 4, + + /// True if this is a prompt or input line. + pub fn promptOrInput(self: SemanticPrompt) bool { + return self == .prompt or self == .prompt_continuation or self == .input; + } + }; +}; + +/// A cell represents a single terminal grid cell. +/// +/// The zero value of this struct must be a valid cell representing empty, +/// since we zero initialize the backing memory for a page. +pub const Cell = packed struct(u64) { + /// The content tag dictates the active tag in content and possibly + /// some other behaviors. + content_tag: ContentTag = .codepoint, + + /// The content of the cell. This is a union based on content_tag. + content: packed union { + /// The codepoint that this cell contains. If `grapheme` is false, + /// then this is the only codepoint in the cell. If `grapheme` is + /// true, then this is the first codepoint in the grapheme cluster. + codepoint: u21, + + /// The content is an empty cell with a background color. + color_palette: u8, + color_rgb: RGB, + } = .{ .codepoint = 0 }, + + /// The style ID to use for this cell within the style map. Zero + /// is always the default style so no lookup is required. + style_id: style.Id = 0, + + /// The wide property of this cell, for wide characters. Characters in + /// a terminal grid can only be 1 or 2 cells wide. A wide character + /// is always next to a spacer. This is used to determine both the width + /// and spacer properties of a cell. + wide: Wide = .narrow, + + /// Whether this was written with the protection flag set. + protected: bool = false, + + _padding: u19 = 0, + + pub const ContentTag = enum(u2) { + /// A single codepoint, could be zero to be empty cell. + codepoint = 0, + + /// A codepoint that is part of a multi-codepoint grapheme cluster. + /// The codepoint tag is active in content, but also expect more + /// codepoints in the grapheme data. + codepoint_grapheme = 1, + + /// The cell has no text but only a background color. This is an + /// optimization so that cells with only backgrounds don't take up + /// style map space and also don't require a style map lookup. + bg_color_palette = 2, + bg_color_rgb = 3, + }; + + pub const RGB = packed struct { + r: u8, + g: u8, + b: u8, + }; + + pub const Wide = enum(u2) { + /// Not a wide character, cell width 1. + narrow = 0, + + /// Wide character, cell width 2. + wide = 1, + + /// Spacer after wide character. Do not render. + spacer_tail = 2, + + /// Spacer at the end of a soft-wrapped line to indicate that a wide + /// character is continued on the next line. + spacer_head = 3, + }; + + /// Helper to make a cell that just has a codepoint. + pub fn init(cp: u21) Cell { + return .{ + .content_tag = .codepoint, + .content = .{ .codepoint = cp }, + }; + } + + pub fn hasText(self: Cell) bool { + return switch (self.content_tag) { + .codepoint, + .codepoint_grapheme, + => self.content.codepoint != 0, + + .bg_color_palette, + .bg_color_rgb, + => false, + }; + } + + pub fn codepoint(self: Cell) u21 { + return switch (self.content_tag) { + .codepoint, + .codepoint_grapheme, + => self.content.codepoint, + + .bg_color_palette, + .bg_color_rgb, + => 0, + }; + } + + /// The width in grid cells that this cell takes up. + pub fn gridWidth(self: Cell) u2 { + return switch (self.wide) { + .narrow, .spacer_head, .spacer_tail => 1, + .wide => 2, + }; + } + + pub fn hasStyling(self: Cell) bool { + return self.style_id != style.default_id; + } + + /// Returns true if the cell has no text or styling. + pub fn isEmpty(self: Cell) bool { + return switch (self.content_tag) { + // Textual cells are empty if they have no text and are narrow. + // The "narrow" requirement is because wide spacers are meaningful. + .codepoint, + .codepoint_grapheme, + => !self.hasText() and self.wide == .narrow, + + .bg_color_palette, + .bg_color_rgb, + => false, + }; + } + + pub fn hasGrapheme(self: Cell) bool { + return self.content_tag == .codepoint_grapheme; + } + + /// Returns true if the set of cells has text in it. + pub fn hasTextAny(cells: []const Cell) bool { + for (cells) |cell| { + if (cell.hasText()) return true; + } + + return false; + } +}; + +// Uncomment this when you want to do some math. +// test "Page size calculator" { +// const total_size = alignForward( +// usize, +// Page.layout(.{ +// .cols = 250, +// .rows = 250, +// .styles = 128, +// .grapheme_bytes = 1024, +// }).total_size, +// std.mem.page_size, +// ); +// +// std.log.warn("total_size={} pages={}", .{ +// total_size, +// total_size / std.mem.page_size, +// }); +// } +// +// test "Page std size" { +// // We want to ensure that the standard capacity is what we +// // expect it to be. Changing this is fine but should be done with care +// // so we fail a test if it changes. +// const total_size = Page.layout(std_capacity).total_size; +// try testing.expectEqual(@as(usize, 524_288), total_size); // 512 KiB +// //const pages = total_size / std.mem.page_size; +// } + +test "Page capacity adjust cols down" { + const original = std_capacity; + const original_size = Page.layout(original).total_size; + const adjusted = try original.adjust(.{ .cols = original.cols / 2 }); + const adjusted_size = Page.layout(adjusted).total_size; + try testing.expectEqual(original_size, adjusted_size); + // If we layout a page with 1 more row and it's still the same size + // then adjust is not producing enough rows. + var bigger = adjusted; + bigger.rows += 1; + const bigger_size = Page.layout(bigger).total_size; + try testing.expect(bigger_size > original_size); +} + +test "Page capacity adjust cols down to 1" { + const original = std_capacity; + const original_size = Page.layout(original).total_size; + const adjusted = try original.adjust(.{ .cols = 1 }); + const adjusted_size = Page.layout(adjusted).total_size; + try testing.expectEqual(original_size, adjusted_size); + // If we layout a page with 1 more row and it's still the same size + // then adjust is not producing enough rows. + var bigger = adjusted; + bigger.rows += 1; + const bigger_size = Page.layout(bigger).total_size; + try testing.expect(bigger_size > original_size); +} + +test "Page capacity adjust cols up" { + const original = std_capacity; + const original_size = Page.layout(original).total_size; + const adjusted = try original.adjust(.{ .cols = original.cols * 2 }); + const adjusted_size = Page.layout(adjusted).total_size; + try testing.expectEqual(original_size, adjusted_size); + // If we layout a page with 1 more row and it's still the same size + // then adjust is not producing enough rows. + var bigger = adjusted; + bigger.rows += 1; + const bigger_size = Page.layout(bigger).total_size; + try testing.expect(bigger_size > original_size); +} + +test "Page capacity adjust cols sweep" { + var cap = std_capacity; + const original_cols = cap.cols; + const original_size = Page.layout(cap).total_size; + for (1..original_cols * 2) |c| { + cap = try cap.adjust(.{ .cols = @as(u16, @intCast(c)) }); + const adjusted_size = Page.layout(cap).total_size; + try testing.expectEqual(original_size, adjusted_size); + // If we layout a page with 1 more row and it's still the same size + // then adjust is not producing enough rows. + var bigger = cap; + bigger.rows += 1; + const bigger_size = Page.layout(bigger).total_size; + try testing.expect(bigger_size > original_size); + } +} + +test "Page capacity adjust cols too high" { + const original = std_capacity; + try testing.expectError( + error.OutOfMemory, + original.adjust(.{ .cols = std.math.maxInt(size.CellCountInt) }), + ); +} + +test "Page init" { + var page = try Page.init(.{ + .cols = 120, + .rows = 80, + .styles = 32, + }); + defer page.deinit(); +} + +test "Page read and write cells" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(y) }, + }; + } + + // Read it again + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, @intCast(y)), rac.cell.content.codepoint); + } +} + +test "Page appendGrapheme small" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + const rac = page.getRowAndCell(0, 0); + rac.cell.* = Cell.init(0x09); + + // One + try page.appendGrapheme(rac.row, rac.cell, 0x0A); + try testing.expect(rac.row.grapheme); + try testing.expect(rac.cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{0x0A}, page.lookupGrapheme(rac.cell).?); + + // Two + try page.appendGrapheme(rac.row, rac.cell, 0x0B); + try testing.expect(rac.row.grapheme); + try testing.expect(rac.cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{ 0x0A, 0x0B }, page.lookupGrapheme(rac.cell).?); + + // Clear it + page.clearGrapheme(rac.row, rac.cell); + try testing.expect(!rac.row.grapheme); + try testing.expect(!rac.cell.hasGrapheme()); +} + +test "Page appendGrapheme larger than chunk" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + const rac = page.getRowAndCell(0, 0); + rac.cell.* = Cell.init(0x09); + + const count = grapheme_chunk_len * 10; + for (0..count) |i| { + try page.appendGrapheme(rac.row, rac.cell, @intCast(0x0A + i)); + } + + const cps = page.lookupGrapheme(rac.cell).?; + try testing.expectEqual(@as(usize, count), cps.len); + for (0..count) |i| { + try testing.expectEqual(@as(u21, @intCast(0x0A + i)), cps[i]); + } +} + +test "Page clearGrapheme not all cells" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + const rac = page.getRowAndCell(0, 0); + rac.cell.* = Cell.init(0x09); + try page.appendGrapheme(rac.row, rac.cell, 0x0A); + + const rac2 = page.getRowAndCell(1, 0); + rac2.cell.* = Cell.init(0x09); + try page.appendGrapheme(rac2.row, rac2.cell, 0x0A); + + // Clear it + page.clearGrapheme(rac.row, rac.cell); + try testing.expect(rac.row.grapheme); + try testing.expect(!rac.cell.hasGrapheme()); + try testing.expect(rac2.cell.hasGrapheme()); +} + +test "Page clone" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(y) }, + }; + } + + // Clone + var page2 = try page.clone(); + defer page2.deinit(); + try testing.expectEqual(page2.capacity, page.capacity); + + // Read it again + for (0..page2.capacity.rows) |y| { + const rac = page2.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, @intCast(y)), rac.cell.content.codepoint); + } + + // Write again + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0 }, + }; + } + + // Read it again, should be unchanged + for (0..page2.capacity.rows) |y| { + const rac = page2.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, @intCast(y)), rac.cell.content.codepoint); + } + + // Read the original + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint); + } +} + +test "Page cloneFrom" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(y) }, + }; + } + + // Clone + var page2 = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page2.deinit(); + try page2.cloneFrom(&page, 0, page.size.rows); + + // Read it again + for (0..page2.capacity.rows) |y| { + const rac = page2.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, @intCast(y)), rac.cell.content.codepoint); + } + + // Write again + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0 }, + }; + } + + // Read it again, should be unchanged + for (0..page2.capacity.rows) |y| { + const rac = page2.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, @intCast(y)), rac.cell.content.codepoint); + } + + // Read the original + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint); + } +} + +test "Page cloneFrom shrink columns" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(y) }, + }; + } + + // Clone + var page2 = try Page.init(.{ + .cols = 5, + .rows = 10, + .styles = 8, + }); + defer page2.deinit(); + try page2.cloneFrom(&page, 0, page.size.rows); + try testing.expectEqual(@as(size.CellCountInt, 5), page2.size.cols); + + // Read it again + for (0..page2.capacity.rows) |y| { + const rac = page2.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, @intCast(y)), rac.cell.content.codepoint); + } +} + +test "Page cloneFrom partial" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(y) }, + }; + } + + // Clone + var page2 = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page2.deinit(); + try page2.cloneFrom(&page, 0, 5); + + // Read it again + for (0..5) |y| { + const rac = page2.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, @intCast(y)), rac.cell.content.codepoint); + } + for (5..page2.size.rows) |y| { + const rac = page2.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint); + } +} + +test "Page cloneFrom graphemes" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(y + 1) }, + }; + try page.appendGrapheme(rac.row, rac.cell, 0x0A); + } + + // Clone + var page2 = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page2.deinit(); + try page2.cloneFrom(&page, 0, page.size.rows); + + // Read it again + for (0..page2.capacity.rows) |y| { + const rac = page2.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, @intCast(y + 1)), rac.cell.content.codepoint); + try testing.expect(rac.row.grapheme); + try testing.expect(rac.cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{0x0A}, page2.lookupGrapheme(rac.cell).?); + } + + // Write again + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + page.clearGrapheme(rac.row, rac.cell); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0 }, + }; + } + + // Read it again, should be unchanged + for (0..page2.capacity.rows) |y| { + const rac = page2.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, @intCast(y + 1)), rac.cell.content.codepoint); + try testing.expect(rac.row.grapheme); + try testing.expect(rac.cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{0x0A}, page2.lookupGrapheme(rac.cell).?); + } + + // Read the original + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint); + } +} + +test "Page cloneFrom frees dst graphemes" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(y + 1) }, + }; + } + + // Clone + var page2 = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page2.deinit(); + for (0..page2.capacity.rows) |y| { + const rac = page2.getRowAndCell(1, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(y + 1) }, + }; + try page2.appendGrapheme(rac.row, rac.cell, 0x0A); + } + + // Clone from page which has no graphemes. + try page2.cloneFrom(&page, 0, page.size.rows); + + // Read it again + for (0..page2.capacity.rows) |y| { + const rac = page2.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, @intCast(y + 1)), rac.cell.content.codepoint); + try testing.expect(!rac.row.grapheme); + try testing.expect(!rac.cell.hasGrapheme()); + } + try testing.expectEqual(@as(usize, 0), page2.graphemeCount()); +} + +test "Page cloneRowFrom partial" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + { + const y = 0; + for (0..page.size.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x + 1) }, + }; + } + } + + // Clone + var page2 = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page2.deinit(); + try page2.clonePartialRowFrom( + &page, + page2.getRow(0), + page.getRow(0), + 2, + 8, + ); + + // Read it again + { + const y = 0; + for (0..page2.size.cols) |x| { + const expected: u21 = if (x >= 2 and x < 8) @intCast(x + 1) else 0; + const rac = page2.getRowAndCell(x, y); + try testing.expectEqual(expected, rac.cell.content.codepoint); + } + } +} + +test "Page cloneRowFrom partial grapheme in non-copied source region" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + { + const y = 0; + for (0..page.size.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x + 1) }, + }; + } + { + const rac = page.getRowAndCell(0, y); + try page.appendGrapheme(rac.row, rac.cell, 0x0A); + } + { + const rac = page.getRowAndCell(9, y); + try page.appendGrapheme(rac.row, rac.cell, 0x0A); + } + } + try testing.expectEqual(@as(usize, 2), page.graphemeCount()); + + // Clone + var page2 = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page2.deinit(); + try page2.clonePartialRowFrom( + &page, + page2.getRow(0), + page.getRow(0), + 2, + 8, + ); + + // Read it again + { + const y = 0; + for (0..page2.size.cols) |x| { + const expected: u21 = if (x >= 2 and x < 8) @intCast(x + 1) else 0; + const rac = page2.getRowAndCell(x, y); + try testing.expectEqual(expected, rac.cell.content.codepoint); + try testing.expect(!rac.cell.hasGrapheme()); + } + { + const rac = page2.getRowAndCell(9, y); + try testing.expect(!rac.row.grapheme); + } + } + try testing.expectEqual(@as(usize, 0), page2.graphemeCount()); +} + +test "Page cloneRowFrom partial grapheme in non-copied dest region" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + { + const y = 0; + for (0..page.size.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x + 1) }, + }; + } + } + try testing.expectEqual(@as(usize, 0), page.graphemeCount()); + + // Clone + var page2 = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page2.deinit(); + { + const y = 0; + for (0..page2.size.cols) |x| { + const rac = page2.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0xBB }, + }; + } + { + const rac = page2.getRowAndCell(0, y); + try page2.appendGrapheme(rac.row, rac.cell, 0x0A); + } + { + const rac = page2.getRowAndCell(9, y); + try page2.appendGrapheme(rac.row, rac.cell, 0x0A); + } + } + try page2.clonePartialRowFrom( + &page, + page2.getRow(0), + page.getRow(0), + 2, + 8, + ); + + // Read it again + { + const y = 0; + for (0..page2.size.cols) |x| { + const expected: u21 = if (x >= 2 and x < 8) @intCast(x + 1) else 0xBB; + const rac = page2.getRowAndCell(x, y); + try testing.expectEqual(expected, rac.cell.content.codepoint); + } + { + const rac = page2.getRowAndCell(9, y); + try testing.expect(rac.row.grapheme); + } + } + try testing.expectEqual(@as(usize, 2), page2.graphemeCount()); +} + +test "Page moveCells text-only" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + for (0..page.capacity.cols) |x| { + const rac = page.getRowAndCell(x, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x + 1) }, + }; + } + + const src = page.getRow(0); + const dst = page.getRow(1); + page.moveCells(src, 0, dst, 0, page.capacity.cols); + + // New rows should have text + for (0..page.capacity.cols) |x| { + const rac = page.getRowAndCell(x, 1); + try testing.expectEqual( + @as(u21, @intCast(x + 1)), + rac.cell.content.codepoint, + ); + } + + // Old row should be blank + for (0..page.capacity.cols) |x| { + const rac = page.getRowAndCell(x, 0); + try testing.expectEqual( + @as(u21, 0), + rac.cell.content.codepoint, + ); + } +} + +test "Page moveCells graphemes" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + for (0..page.size.cols) |x| { + const rac = page.getRowAndCell(x, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x + 1) }, + }; + try page.appendGrapheme(rac.row, rac.cell, 0x0A); + } + const original_count = page.graphemeCount(); + + const src = page.getRow(0); + const dst = page.getRow(1); + page.moveCells(src, 0, dst, 0, page.size.cols); + try testing.expectEqual(original_count, page.graphemeCount()); + + // New rows should have text + for (0..page.size.cols) |x| { + const rac = page.getRowAndCell(x, 1); + try testing.expectEqual( + @as(u21, @intCast(x + 1)), + rac.cell.content.codepoint, + ); + try testing.expectEqualSlices( + u21, + &.{0x0A}, + page.lookupGrapheme(rac.cell).?, + ); + } + + // Old row should be blank + for (0..page.size.cols) |x| { + const rac = page.getRowAndCell(x, 0); + try testing.expectEqual( + @as(u21, 0), + rac.cell.content.codepoint, + ); + } +} + +test "Page verifyIntegrity graphemes good" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + for (0..page.size.cols) |x| { + const rac = page.getRowAndCell(x, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x + 1) }, + }; + try page.appendGrapheme(rac.row, rac.cell, 0x0A); + } + + try page.verifyIntegrity(testing.allocator); +} + +test "Page verifyIntegrity grapheme row not marked" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + for (0..page.size.cols) |x| { + const rac = page.getRowAndCell(x, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x + 1) }, + }; + try page.appendGrapheme(rac.row, rac.cell, 0x0A); + } + + // Make invalid by unmarking the row + page.getRow(0).grapheme = false; + + try testing.expectError( + Page.IntegrityError.UnmarkedGraphemeRow, + page.verifyIntegrity(testing.allocator), + ); +} + +test "Page verifyIntegrity styles good" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Upsert a style we'll use + const md = try page.styles.upsert(page.memory, .{ .flags = .{ + .bold = true, + } }); + + // Write + for (0..page.size.cols) |x| { + const rac = page.getRowAndCell(x, 0); + rac.row.styled = true; + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x + 1) }, + .style_id = md.id, + }; + md.ref += 1; + } + + try page.verifyIntegrity(testing.allocator); +} + +test "Page verifyIntegrity styles ref count mismatch" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Upsert a style we'll use + const md = try page.styles.upsert(page.memory, .{ .flags = .{ + .bold = true, + } }); + + // Write + for (0..page.size.cols) |x| { + const rac = page.getRowAndCell(x, 0); + rac.row.styled = true; + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x + 1) }, + .style_id = md.id, + }; + md.ref += 1; + } + + // Miss a ref + md.ref -= 1; + + try testing.expectError( + Page.IntegrityError.MismatchedStyleRef, + page.verifyIntegrity(testing.allocator), + ); +} + +test "Page verifyIntegrity zero rows" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + page.size.rows = 0; + try testing.expectError( + Page.IntegrityError.ZeroRowCount, + page.verifyIntegrity(testing.allocator), + ); +} + +test "Page verifyIntegrity zero cols" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + page.size.cols = 0; + try testing.expectError( + Page.IntegrityError.ZeroColCount, + page.verifyIntegrity(testing.allocator), + ); +} diff --git a/src/terminal/point.zig b/src/terminal/point.zig index 8c694f992c..41b7a35585 100644 --- a/src/terminal/point.zig +++ b/src/terminal/point.zig @@ -1,254 +1,74 @@ 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. - -/// Active is a point within the active part of the screen. -pub const Active = struct { - x: usize = 0, - y: usize = 0, - - pub fn toScreen(self: Active, screen: *const Screen) ScreenPoint { - return .{ - .x = self.x, - .y = screen.history + self.y, - }; - } - - test "toScreen with scrollback" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try Screen.init(alloc, 3, 5, 3); - defer s.deinit(); - const str = "1\n2\n3\n4\n5\n6\n7\n8"; - try s.testWriteString(str); - - try testing.expectEqual(ScreenPoint{ - .x = 1, - .y = 5, - }, (Active{ .x = 1, .y = 2 }).toScreen(&s)); - } +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; + +/// 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" + /// part of the screen. + /// + /// The bottom-right of the active tag differs from all other tags + /// because it includes the full height (rows) of the screen, including + /// rows that may not be written yet. This is required because the active + /// area is fully "addressable" by the running program (see below) whereas + /// the other tags are used primarliy for reading/modifying past-written + /// data so they can't address unwritten rows. + /// + /// Note for those less familiar with terminal functionality: there + /// are escape sequences to move the cursor to any position on + /// the screen, but it is limited to the size of the viewport and + /// the bottommost part of the screen. Terminal programs can't -- + /// with sequences at the time of writing this comment -- modify + /// anything in the scrollback, visible viewport (if it differs + /// from the active area), etc. + active, + + /// Top-left is the visible viewport. This means that if the user + /// has scrolled in any direction, top-left changes. The bottom-right + /// is the last written row from the top-left. + viewport, + + /// Top-left is the furthest back in the scrollback history + /// supported by the screen and the bottom-right is the bottom-right + /// of the last written row. Note this last point is important: the + /// bottom right is NOT necessarilly the same as "active" because + /// "active" always allows referencing the full rows tall of the + /// screen whereas "screen" only contains written rows. + screen, + + /// The top-left is the same as "screen" but the bottom-right is + /// the line just before the top of "active". This contains only + /// the scrollback history. + history, }; -/// 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, +/// An x/y point in the terminal for some definition of location (tag). +pub const Point = union(Tag) { + active: Coordinate, + viewport: Coordinate, + screen: Coordinate, + history: Coordinate, + + pub fn coord(self: Point) Coordinate { + return switch (self) { + .active, + .viewport, + .screen, + .history, + => |v| v, }; } - - 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 { +pub const Coordinate = 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 { + pub fn eql(self: Coordinate, other: Coordinate) 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()); -} diff --git a/src/terminal/res/glitch.txt b/src/terminal/res/glitch.txt new file mode 100644 index 0000000000..f401c08800 --- /dev/null +++ b/src/terminal/res/glitch.txt @@ -0,0 +1 @@ +Ḡ̴͎͍̜͎͔͕̩̗͕͖̟̜͑̊̌̇̑͒͋͑̄̈́͐̈́́̽͌͂̎̀̔͋̓́̅̌̇͘̕͜͝͝h̶̡̞̫͉̳̬̜̱̥͕͑̾͛̒̆̒̉̒̑͂̄͘ͅǫ̷̨̥͔͔͖̭͚͙̯̟̭̘͇̫̰͚̺̳̙̳̟͚̫̱̹̱͒̂̑͒͜͠ͅş̴̖̰̜̱̹͙̅͒̀̏͆̐̋͂̓͋̃̈̔̂̈͛̐̿̔́̔̄͑̇͑̋̈́͌͋̾̃̽̈́̕͘̚͘͘͘͠͠t̵̢̜̱̦͇͉̬̮̼͖̳̗̥̝̬͇͕̥̜͕̳̱̥̮͉̮̩̘̰̪̤͉͎̲͈͍̳̟̠͈̝̫͋̊̀͐̍̅̀̄̃̈́̔̇̈́̄̃̽̂̌̅̄̋͒̃̈́̍̀̍̇̽̐͊̾̆̅̈̿̓͒̄̾͌̚͝͝͝͝͝t̴̥̼̳̗̬̬͔͎̯͉͇̮̰͖͇̝͔̳̳̗̰͇͎͉̬͇̝̺̯͎͖͔̍͆͒̊̒̔̊̈́̿̊̅͂̐͋̿͂̈̒̄͜͠͠ÿ̴̢̗̜̥͇͖̰͎̝̹̗̪̙̞̣̳͎̯̹͚̲̝̗̳̳̗̖͎̗̬͈͙̝̟͍̥̤͖͇̰͈̺͛̒̂͌̌̏̈̾̓̈́̿͐̂̓̔̓̂̈́͑͛͊͋̔̿̊͑͌̊̏͘͘̕͘͠͝ diff --git a/src/terminal/simdvt.zig b/src/terminal/simdvt.zig deleted file mode 100644 index be5e4fcb70..0000000000 --- a/src/terminal/simdvt.zig +++ /dev/null @@ -1,5 +0,0 @@ -pub usingnamespace @import("simdvt/parser.zig"); - -test { - @import("std").testing.refAllDecls(@This()); -} diff --git a/src/terminal/size.zig b/src/terminal/size.zig new file mode 100644 index 0000000000..fead2b469f --- /dev/null +++ b/src/terminal/size.zig @@ -0,0 +1,199 @@ +const std = @import("std"); +const assert = std.debug.assert; + +/// The maximum size of a page in bytes. We use a u16 here because any +/// smaller bit size by Zig is upgraded anyways to a u16 on mainstream +/// CPU architectures, and because 65KB is a reasonable page size. To +/// support better configurability, we derive everything from this. +pub const max_page_size = std.math.maxInt(u32); + +/// The int type that can contain the maximum memory offset in bytes, +/// derived from the maximum terminal page size. +pub const OffsetInt = std.math.IntFittingRange(0, max_page_size - 1); + +/// The int type that can contain the maximum number of cells in a page. +pub const CellCountInt = u16; // TODO: derive +// +/// The offset from the base address of the page to the start of some data. +/// This is typed for ease of use. +/// +/// This is a packed struct so we can attach methods to an int. +pub fn Offset(comptime T: type) type { + return packed struct(OffsetInt) { + const Self = @This(); + + offset: OffsetInt = 0, + + /// A slice of type T that stores via a base offset and len. + pub const Slice = struct { + offset: Self, + len: usize, + }; + + /// Returns a pointer to the start of the data, properly typed. + pub fn ptr(self: Self, base: anytype) [*]T { + // The offset must be properly aligned for the type since + // our return type is naturally aligned. We COULD modify this + // to return arbitrary alignment, but its not something we need. + const addr = intFromBase(base) + self.offset; + assert(addr % @alignOf(T) == 0); + return @ptrFromInt(addr); + } + }; +} + +/// Represents a buffer that is offset from some base pointer. +/// Offset-based structures should use this as their initialization +/// parameter so that they can know what segment of memory they own +/// while at the same time initializing their offset fields to be +/// against the true base. +/// +/// The term "true base" is used to describe the base address of +/// the allocation, which i.e. can include memory that you do NOT +/// own and is used by some other structures. All offsets are against +/// this "true base" so that to determine addresses structures don't +/// need to add up all the intermediary offsets. +pub const OffsetBuf = struct { + /// The true base pointer to the backing memory. This is + /// "byte zero" of the allocation. This plus the offset make + /// it easy to pass in the base pointer in all usage to this + /// structure and the offsets are correct. + base: [*]u8, + + /// Offset from base where the beginning of /this/ data + /// structure is located. We use this so that we can slowly + /// build up a chain of offset-based structures but always + /// have the base pointer sent into functions be the true base. + offset: usize = 0, + + /// Initialize a zero-offset buffer from a base. + pub fn init(base: anytype) OffsetBuf { + return initOffset(base, 0); + } + + /// Initialize from some base pointer and offset. + pub fn initOffset(base: anytype, offset: usize) OffsetBuf { + return .{ + .base = @ptrFromInt(intFromBase(base)), + .offset = offset, + }; + } + + /// The base address for the start of the data for the user + /// of this OffsetBuf. This is where your data structure should + /// begin; anything before this is NOT your memory. + pub fn start(self: OffsetBuf) [*]u8 { + const ptr = self.base + self.offset; + return @ptrCast(ptr); + } + + /// Returns an Offset calculation for some child member of + /// your struct. The offset is against the true base pointer + /// so that future callers can pass that in as the base. + pub fn member( + self: OffsetBuf, + comptime T: type, + len: usize, + ) Offset(T) { + return .{ .offset = @intCast(self.offset + len) }; + } + + /// Add an offset to the current offset. + pub fn add(self: OffsetBuf, offset: usize) OffsetBuf { + return .{ + .base = self.base, + .offset = self.offset + offset, + }; + } + + /// Rebase the offset to have a zero offset by rebasing onto start. + /// This is similar to `add` but all of the offsets are merged into base. + pub fn rebase(self: OffsetBuf, offset: usize) OffsetBuf { + return .{ + .base = self.start() + offset, + .offset = 0, + }; + } +}; + +/// Get the offset for a given type from some base pointer to the +/// actual pointer to the type. +pub fn getOffset( + comptime T: type, + base: anytype, + ptr: *const T, +) Offset(T) { + const base_int = intFromBase(base); + const ptr_int = @intFromPtr(ptr); + const offset = ptr_int - base_int; + return .{ .offset = @intCast(offset) }; +} + +fn intFromBase(base: anytype) usize { + const T = @TypeOf(base); + return switch (@typeInfo(T)) { + .Pointer => |v| switch (v.size) { + .One, + .Many, + .C, + => @intFromPtr(base), + + .Slice => @intFromPtr(base.ptr), + }, + + else => switch (T) { + OffsetBuf => @intFromPtr(base.base), + + else => @compileError("invalid base type"), + }, + }; +} + +test "Offset" { + // This test is here so that if Offset changes, we can be very aware + // of this effect and think about the implications of it. + const testing = std.testing; + try testing.expect(OffsetInt == u32); +} + +test "Offset ptr u8" { + const testing = std.testing; + const offset: Offset(u8) = .{ .offset = 42 }; + const base_int: usize = @intFromPtr(&offset); + const actual = offset.ptr(&offset); + try testing.expectEqual(@as(usize, base_int + 42), @intFromPtr(actual)); +} + +test "Offset ptr structural" { + const Struct = struct { x: u32, y: u32 }; + const testing = std.testing; + const offset: Offset(Struct) = .{ .offset = @alignOf(Struct) * 4 }; + const base_int: usize = std.mem.alignForward(usize, @intFromPtr(&offset), @alignOf(Struct)); + const base: [*]u8 = @ptrFromInt(base_int); + const actual = offset.ptr(base); + try testing.expectEqual(@as(usize, base_int + offset.offset), @intFromPtr(actual)); +} + +test "getOffset bytes" { + const testing = std.testing; + var widgets: []const u8 = "ABCD"; + const offset = getOffset(u8, widgets.ptr, &widgets[2]); + try testing.expectEqual(@as(OffsetInt, 2), offset.offset); +} + +test "getOffset structs" { + const testing = std.testing; + const Widget = struct { x: u32, y: u32 }; + const widgets: []const Widget = &.{ + .{ .x = 1, .y = 2 }, + .{ .x = 3, .y = 4 }, + .{ .x = 5, .y = 6 }, + .{ .x = 7, .y = 8 }, + .{ .x = 9, .y = 10 }, + }; + const offset = getOffset(Widget, widgets.ptr, &widgets[2]); + try testing.expectEqual( + @as(OffsetInt, @sizeOf(Widget) * 2), + offset.offset, + ); +} diff --git a/src/terminal/style.zig b/src/terminal/style.zig new file mode 100644 index 0000000000..4011b42984 --- /dev/null +++ b/src/terminal/style.zig @@ -0,0 +1,332 @@ +const std = @import("std"); +const assert = std.debug.assert; +const color = @import("color.zig"); +const sgr = @import("sgr.zig"); +const page = @import("page.zig"); +const size = @import("size.zig"); +const Offset = size.Offset; +const OffsetBuf = size.OffsetBuf; +const hash_map = @import("hash_map.zig"); +const AutoOffsetHashMap = hash_map.AutoOffsetHashMap; + +/// The unique identifier for a style. This is at most the number of cells +/// that can fit into a terminal page. +pub const Id = size.CellCountInt; + +/// The Id to use for default styling. +pub const default_id: Id = 0; + +/// The style attributes for a cell. +pub const Style = struct { + /// Various colors, all self-explanatory. + fg_color: Color = .none, + bg_color: Color = .none, + underline_color: Color = .none, + + /// On/off attributes that don't require much bit width so we use + /// a packed struct to make this take up significantly less space. + flags: packed struct { + bold: bool = false, + italic: bool = false, + faint: bool = false, + blink: bool = false, + inverse: bool = false, + invisible: bool = false, + strikethrough: bool = false, + underline: sgr.Attribute.Underline = .none, + } = .{}, + + /// The color for an SGR attribute. A color can come from multiple + /// sources so we use this to track the source plus color value so that + /// we can properly react to things like palette changes. + pub const Color = union(enum) { + none: void, + palette: u8, + rgb: color.RGB, + }; + + /// True if the style is the default style. + pub fn default(self: Style) bool { + return std.meta.eql(self, .{}); + } + + /// True if the style is equal to another style. + pub fn eql(self: Style, other: Style) bool { + return std.meta.eql(self, other); + } + + /// Returns the bg color for a cell with this style given the cell + /// that has this style and the palette to use. + /// + /// Note that generally if a cell is a color-only cell, it SHOULD + /// only have the default style, but this is meant to work with the + /// default style as well. + pub fn bg( + self: Style, + cell: *const page.Cell, + palette: *const color.Palette, + ) ?color.RGB { + return switch (cell.content_tag) { + .bg_color_palette => palette[cell.content.color_palette], + .bg_color_rgb => rgb: { + const rgb = cell.content.color_rgb; + break :rgb .{ .r = rgb.r, .g = rgb.g, .b = rgb.b }; + }, + + else => switch (self.bg_color) { + .none => null, + .palette => |idx| palette[idx], + .rgb => |rgb| rgb, + }, + }; + } + + /// Returns the fg color for a cell with this style given the palette. + pub fn fg( + self: Style, + palette: *const color.Palette, + ) ?color.RGB { + return switch (self.fg_color) { + .none => null, + .palette => |idx| palette[idx], + .rgb => |rgb| rgb, + }; + } + + /// Returns the underline color for this style. + pub fn underlineColor( + self: Style, + palette: *const color.Palette, + ) ?color.RGB { + return switch (self.underline_color) { + .none => null, + .palette => |idx| palette[idx], + .rgb => |rgb| rgb, + }; + } + + /// Returns a bg-color only cell from this style, if it exists. + pub fn bgCell(self: Style) ?page.Cell { + return switch (self.bg_color) { + .none => null, + .palette => |idx| .{ + .content_tag = .bg_color_palette, + .content = .{ .color_palette = idx }, + }, + .rgb => |rgb| .{ + .content_tag = .bg_color_rgb, + .content = .{ .color_rgb = .{ + .r = rgb.r, + .g = rgb.g, + .b = rgb.b, + } }, + }, + }; + } + + test { + // The size of the struct so we can be aware of changes. + const testing = std.testing; + try testing.expectEqual(@as(usize, 14), @sizeOf(Style)); + } +}; + +/// A set of styles. +/// +/// This set is created with some capacity in mind. You can determine +/// the exact memory requirement for a capacity by calling `layout` +/// and checking the total size. +/// +/// When the set exceeds capacity, `error.OutOfMemory` is returned +/// from memory-using methods. The caller is responsible for determining +/// a path forward. +/// +/// The general idea behind this structure is that it is optimized for +/// the scenario common in terminals where there aren't many unique +/// styles, and many cells are usually drawn with a single style before +/// changing styles. +/// +/// Callers should call `upsert` when a new style is set. This will +/// return a stable pointer to metadata. You should use this metadata +/// to keep a ref count of the style usage. When it falls to zero you +/// can remove it. +pub const Set = struct { + pub const base_align = @max(MetadataMap.base_align, IdMap.base_align); + + /// The mapping of a style to associated metadata. This is + /// the map that contains the actual style definitions + /// (in the form of the key). + styles: MetadataMap, + + /// The mapping from ID to style. + id_map: IdMap, + + /// The next ID to use for a style that isn't in the set. + /// When this overflows we'll begin returning an IdOverflow + /// error and the caller must manually compact the style + /// set. + /// + /// Id zero is reserved and always is the default style. The + /// default style isn't present in the map, its dependent on + /// the terminal configuration. + next_id: Id = 1, + + /// Maps a style definition to metadata about that style. + const MetadataMap = AutoOffsetHashMap(Style, Metadata); + + /// Maps the unique style ID to the concrete style definition. + const IdMap = AutoOffsetHashMap(Id, Offset(Style)); + + /// Returns the memory layout for the given base offset and + /// desired capacity. The layout can be used by the caller to + /// determine how much memory to allocate, and the layout must + /// be used to initialize the set so that the set knows all + /// the offsets for the various buffers. + pub fn layout(cap: usize) Layout { + const md_layout = MetadataMap.layout(@intCast(cap)); + const md_start = 0; + const md_end = md_start + md_layout.total_size; + + const id_layout = IdMap.layout(@intCast(cap)); + const id_start = std.mem.alignForward(usize, md_end, IdMap.base_align); + const id_end = id_start + id_layout.total_size; + + const total_size = id_end; + + return .{ + .md_start = md_start, + .md_layout = md_layout, + .id_start = id_start, + .id_layout = id_layout, + .total_size = total_size, + }; + } + + pub const Layout = struct { + md_start: usize, + md_layout: MetadataMap.Layout, + id_start: usize, + id_layout: IdMap.Layout, + total_size: usize, + }; + + pub fn init(base: OffsetBuf, l: Layout) Set { + const styles_buf = base.add(l.md_start); + const id_buf = base.add(l.id_start); + return .{ + .styles = MetadataMap.init(styles_buf, l.md_layout), + .id_map = IdMap.init(id_buf, l.id_layout), + }; + } + + /// Possible errors for upsert. + pub const UpsertError = error{ + /// No more space in the backing buffer. Remove styles or + /// grow and reinitialize. + OutOfMemory, + + /// No more available IDs. Perform a garbage collection + /// operation to compact ID space. + Overflow, + }; + + /// Upsert a style into the set and return a pointer to the metadata + /// for that style. The pointer is valid for the lifetime of the set + /// so long as the style is not removed. + /// + /// The ref count for new styles is initialized to zero and + /// for existing styles remains unmodified. + pub fn upsert(self: *Set, base: anytype, style: Style) UpsertError!*Metadata { + // If we already have the style in the map, this is fast. + var map = self.styles.map(base); + const gop = try map.getOrPut(style); + if (gop.found_existing) return gop.value_ptr; + + // New style, we need to setup all the metadata. First thing, + // let's get the ID we'll assign, because if we're out of space + // we need to fail early. + errdefer map.removeByPtr(gop.key_ptr); + const id = self.next_id; + self.next_id = try std.math.add(Id, self.next_id, 1); + errdefer self.next_id -= 1; + gop.value_ptr.* = .{ .id = id }; + + // Setup our ID mapping + var id_map = self.id_map.map(base); + const id_gop = try id_map.getOrPut(id); + errdefer id_map.removeByPtr(id_gop.key_ptr); + assert(!id_gop.found_existing); + id_gop.value_ptr.* = size.getOffset(Style, base, gop.key_ptr); + return gop.value_ptr; + } + + /// Lookup a style by its unique identifier. + pub fn lookupId(self: *const Set, base: anytype, id: Id) ?*Style { + const id_map = self.id_map.map(base); + const offset = id_map.get(id) orelse return null; + return @ptrCast(offset.ptr(base)); + } + + /// Remove a style by its id. + pub fn remove(self: *Set, base: anytype, id: Id) void { + // Lookup by ID, if it doesn't exist then we return. We use + // getEntry so that we can make removal faster later by using + // the entry's key pointer. + var id_map = self.id_map.map(base); + const id_entry = id_map.getEntry(id) orelse return; + + var style_map = self.styles.map(base); + const style_ptr: *Style = @ptrCast(id_entry.value_ptr.ptr(base)); + + id_map.removeByPtr(id_entry.key_ptr); + style_map.removeByPtr(style_ptr); + } + + /// Return the number of styles currently in the set. + pub fn count(self: *const Set, base: anytype) usize { + return self.id_map.map(base).count(); + } +}; + +/// Metadata about a style. This is used to track the reference count +/// and the unique identifier for a style. The unique identifier is used +/// to track the style in the full style map. +pub const Metadata = struct { + ref: size.CellCountInt = 0, + id: Id = 0, +}; + +test "Set basic usage" { + const testing = std.testing; + const alloc = testing.allocator; + const layout = Set.layout(16); + const buf = try alloc.alignedAlloc(u8, Set.base_align, layout.total_size); + defer alloc.free(buf); + + const style: Style = .{ .flags = .{ .bold = true } }; + + var set = Set.init(OffsetBuf.init(buf), layout); + + // Upsert + const meta = try set.upsert(buf, style); + try testing.expect(meta.id > 0); + + // Second upsert should return the same metadata. + { + const meta2 = try set.upsert(buf, style); + try testing.expectEqual(meta.id, meta2.id); + } + + // Look it up + { + const v = set.lookupId(buf, meta.id).?; + try testing.expect(v.flags.bold); + + const v2 = set.lookupId(buf, meta.id).?; + try testing.expectEqual(v, v2); + } + + // Removal + set.remove(buf, meta.id); + try testing.expect(set.lookupId(buf, meta.id) == null); +} diff --git a/src/terminal/wasm.zig b/src/terminal/wasm.zig deleted file mode 100644 index 3450a6829d..0000000000 --- a/src/terminal/wasm.zig +++ /dev/null @@ -1,32 +0,0 @@ -// This is the C-ABI API for the terminal package. This isn't used -// by other Zig programs but by C or WASM interfacing. -// -// NOTE: This is far, far from complete. We did a very minimal amount to -// prove that compilation works, but we haven't completed coverage yet. - -const std = @import("std"); -const builtin = @import("builtin"); -const Allocator = std.mem.Allocator; -const Terminal = @import("main.zig").Terminal; -const wasm = @import("../os/wasm.zig"); -const alloc = wasm.alloc; - -export fn terminal_new(cols: usize, rows: usize) ?*Terminal { - const term = Terminal.init(alloc, cols, rows) catch return null; - const result = alloc.create(Terminal) catch return null; - result.* = term; - return result; -} - -export fn terminal_free(ptr: ?*Terminal) void { - if (ptr) |v| { - v.deinit(alloc); - alloc.destroy(v); - } -} - -export fn terminal_print(ptr: ?*Terminal, char: u32) void { - if (ptr) |t| { - t.print(@intCast(char)) catch return; - } -} diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 3d1277a8a1..43708ed2be 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -78,7 +78,7 @@ pub const DerivedConfig = struct { palette: terminal.color.Palette, image_storage_limit: usize, - cursor_style: terminal.Cursor.Style, + cursor_style: terminal.CursorStyle, cursor_blink: ?bool, cursor_color: ?configpkg.Config.Color, foreground: configpkg.Config.Color, @@ -128,11 +128,11 @@ pub const DerivedConfig = struct { /// process. pub fn init(alloc: Allocator, opts: termio.Options) !Exec { // Create our terminal - var term = try terminal.Terminal.init( - alloc, - opts.grid_size.columns, - opts.grid_size.rows, - ); + var term = try terminal.Terminal.init(alloc, .{ + .cols = opts.grid_size.columns, + .rows = opts.grid_size.rows, + .max_scrollback = opts.full_config.@"scrollback-limit", + }); errdefer term.deinit(alloc); term.default_palette = opts.config.palette; term.color_palette.colors = opts.config.palette; @@ -145,8 +145,16 @@ pub fn init(alloc: Allocator, opts: termio.Options) !Exec { } // Set the image size limits - try term.screen.kitty_images.setLimit(alloc, opts.config.image_storage_limit); - try term.secondary_screen.kitty_images.setLimit(alloc, opts.config.image_storage_limit); + try term.screen.kitty_images.setLimit( + alloc, + &term.screen, + opts.config.image_storage_limit, + ); + try term.secondary_screen.kitty_images.setLimit( + alloc, + &term.secondary_screen, + opts.config.image_storage_limit, + ); // Set default cursor blink settings term.modes.set( @@ -155,7 +163,7 @@ pub fn init(alloc: Allocator, opts: termio.Options) !Exec { ); // Set our default cursor style - term.screen.cursor.style = opts.config.cursor_style; + term.screen.cursor.cursor_style = opts.config.cursor_style; var subprocess = try Subprocess.init(alloc, opts); errdefer subprocess.deinit(); @@ -395,10 +403,12 @@ pub fn changeConfig(self: *Exec, config: *DerivedConfig) !void { // Set the image size limits try self.terminal.screen.kitty_images.setLimit( self.alloc, + &self.terminal.screen, config.image_storage_limit, ); try self.terminal.secondary_screen.kitty_images.setLimit( self.alloc, + &self.terminal.secondary_screen, config.image_storage_limit, ); } @@ -464,11 +474,17 @@ pub fn clearScreen(self: *Exec, history: bool) !void { if (self.terminal.active_screen == .alternate) return; // Clear our scrollback - if (history) self.terminal.eraseDisplay(self.alloc, .scrollback, false); + if (history) self.terminal.eraseDisplay(.scrollback, false); // If we're not at a prompt, we just delete above the cursor. if (!self.terminal.cursorIsAtPrompt()) { - try self.terminal.screen.clear(.above_cursor); + if (self.terminal.screen.cursor.y > 0) { + self.terminal.screen.eraseRows( + .{ .active = .{ .y = 0 } }, + .{ .active = .{ .y = self.terminal.screen.cursor.y - 1 } }, + ); + } + return; } @@ -478,7 +494,7 @@ pub fn clearScreen(self: *Exec, history: bool) !void { // clear the full screen in the next eraseDisplay call. self.terminal.markSemanticPrompt(.command); assert(!self.terminal.cursorIsAtPrompt()); - self.terminal.eraseDisplay(self.alloc, .complete, false); + self.terminal.eraseDisplay(.complete, false); } // If we reached here it means we're at a prompt, so we send a form-feed. @@ -494,17 +510,13 @@ pub fn scrollViewport(self: *Exec, scroll: terminal.Terminal.ScrollViewport) !vo /// Jump the viewport to the prompt. pub fn jumpToPrompt(self: *Exec, delta: isize) !void { - const wakeup: bool = wakeup: { + { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - break :wakeup self.terminal.screen.jump(.{ - .prompt_delta = delta, - }); - }; - - if (wakeup) { - try self.renderer_wakeup.notify(); + self.terminal.screen.scroll(.{ .delta_prompt = delta }); } + + try self.renderer_wakeup.notify(); } /// Called when the child process exited abnormally but before @@ -1666,7 +1678,7 @@ const StreamHandler = struct { /// The default cursor state. This is used with CSI q. This is /// set to true when we're currently in the default cursor state. default_cursor: bool = true, - default_cursor_style: terminal.Cursor.Style, + default_cursor_style: terminal.CursorStyle, default_cursor_blink: ?bool, default_cursor_color: ?terminal.color.RGB, @@ -1843,7 +1855,7 @@ const StreamHandler = struct { .decscusr => { const blink = self.terminal.modes.get(.cursor_blinking); - const style: u8 = switch (self.terminal.screen.cursor.style) { + const style: u8 = switch (self.terminal.screen.cursor.cursor_style) { .block => if (blink) 1 else 2, .underline => if (blink) 3 else 4, .bar => if (blink) 5 else 6, @@ -2007,7 +2019,7 @@ const StreamHandler = struct { try self.queueRender(); } - self.terminal.eraseDisplay(self.alloc, mode, protected); + self.terminal.eraseDisplay(mode, protected); } pub fn eraseLine(self: *StreamHandler, mode: terminal.EraseLine, protected: bool) !void { @@ -2015,7 +2027,7 @@ const StreamHandler = struct { } pub fn deleteChars(self: *StreamHandler, count: usize) !void { - try self.terminal.deleteChars(count); + self.terminal.deleteChars(count); } pub fn eraseChars(self: *StreamHandler, count: usize) !void { @@ -2023,7 +2035,7 @@ const StreamHandler = struct { } pub fn insertLines(self: *StreamHandler, count: usize) !void { - try self.terminal.insertLines(count); + self.terminal.insertLines(count); } pub fn insertBlanks(self: *StreamHandler, count: usize) !void { @@ -2031,11 +2043,11 @@ const StreamHandler = struct { } pub fn deleteLines(self: *StreamHandler, count: usize) !void { - try self.terminal.deleteLines(count); + self.terminal.deleteLines(count); } pub fn reverseIndex(self: *StreamHandler) !void { - try self.terminal.reverseIndex(); + self.terminal.reverseIndex(); } pub fn index(self: *StreamHandler) !void { @@ -2183,9 +2195,9 @@ const StreamHandler = struct { }; if (enabled) - self.terminal.alternateScreen(self.alloc, opts) + self.terminal.alternateScreen(opts) else - self.terminal.primaryScreen(self.alloc, opts); + self.terminal.primaryScreen(opts); // Schedule a render since we changed screens try self.queueRender(); @@ -2198,9 +2210,9 @@ const StreamHandler = struct { }; if (enabled) - self.terminal.alternateScreen(self.alloc, opts) + self.terminal.alternateScreen(opts) else - self.terminal.primaryScreen(self.alloc, opts); + self.terminal.primaryScreen(opts); // Schedule a render since we changed screens try self.queueRender(); @@ -2358,7 +2370,7 @@ const StreamHandler = struct { switch (style) { .default => { self.default_cursor = true; - self.terminal.screen.cursor.style = self.default_cursor_style; + self.terminal.screen.cursor.cursor_style = self.default_cursor_style; self.terminal.modes.set( .cursor_blinking, self.default_cursor_blink orelse true, @@ -2366,32 +2378,32 @@ const StreamHandler = struct { }, .blinking_block => { - self.terminal.screen.cursor.style = .block; + self.terminal.screen.cursor.cursor_style = .block; self.terminal.modes.set(.cursor_blinking, true); }, .steady_block => { - self.terminal.screen.cursor.style = .block; + self.terminal.screen.cursor.cursor_style = .block; self.terminal.modes.set(.cursor_blinking, false); }, .blinking_underline => { - self.terminal.screen.cursor.style = .underline; + self.terminal.screen.cursor.cursor_style = .underline; self.terminal.modes.set(.cursor_blinking, true); }, .steady_underline => { - self.terminal.screen.cursor.style = .underline; + self.terminal.screen.cursor.cursor_style = .underline; self.terminal.modes.set(.cursor_blinking, false); }, .blinking_bar => { - self.terminal.screen.cursor.style = .bar; + self.terminal.screen.cursor.cursor_style = .bar; self.terminal.modes.set(.cursor_blinking, true); }, .steady_bar => { - self.terminal.screen.cursor.style = .bar; + self.terminal.screen.cursor.cursor_style = .bar; self.terminal.modes.set(.cursor_blinking, false); }, @@ -2424,7 +2436,7 @@ const StreamHandler = struct { } pub fn restoreCursor(self: *StreamHandler) !void { - self.terminal.restoreCursor(); + try self.terminal.restoreCursor(); } pub fn enquiry(self: *StreamHandler) !void { @@ -2433,11 +2445,11 @@ const StreamHandler = struct { } pub fn scrollDown(self: *StreamHandler, count: usize) !void { - try self.terminal.scrollDown(count); + self.terminal.scrollDown(count); } pub fn scrollUp(self: *StreamHandler, count: usize) !void { - try self.terminal.scrollUp(count); + self.terminal.scrollUp(count); } pub fn setActiveStatusDisplay( @@ -2467,7 +2479,7 @@ const StreamHandler = struct { pub fn fullReset( self: *StreamHandler, ) !void { - self.terminal.fullReset(self.alloc); + self.terminal.fullReset(); try self.setMouseShape(.text); } diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index 450f322957..45b9fd1ae4 100644 --- a/src/termio/Thread.zig +++ b/src/termio/Thread.zig @@ -177,7 +177,7 @@ pub fn threadMain(self: *Thread) void { \\Please free up some pty devices and try again. ; - t.eraseDisplay(alloc, .complete, false); + t.eraseDisplay(.complete, false); t.printString(str) catch {}; }, @@ -197,7 +197,7 @@ pub fn threadMain(self: *Thread) void { \\Out of memory. This terminal is non-functional. Please close it and try again. ; - t.eraseDisplay(alloc, .complete, false); + t.eraseDisplay(.complete, false); t.printString(str) catch {}; }, }