diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 807935806..4e4d3167f 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -59,6 +59,11 @@ class QuickTerminalController: BaseTerminalController { selector: #selector(ghosttyConfigDidChange(_:)), name: .ghosttyConfigDidChange, object: nil) + center.addObserver( + self, + selector: #selector(onNewTab), + name: Ghostty.Notification.ghosttyNewTab, + object: nil) } required init?(coder: NSCoder) { @@ -437,6 +442,16 @@ class QuickTerminalController: BaseTerminalController { } } + private func showNoNewTabAlert() { + guard let window else { return } + let alert = NSAlert() + alert.messageText = "Cannot Create New Tab" + alert.informativeText = "Tabs aren't supported in the Quick Terminal." + alert.addButton(withTitle: "OK") + alert.alertStyle = .warning + alert.beginSheetModal(for: window) + } + // MARK: First Responder @IBAction override func closeWindow(_ sender: Any) { @@ -445,13 +460,7 @@ class QuickTerminalController: BaseTerminalController { } @IBAction func newTab(_ sender: Any?) { - guard let window else { return } - let alert = NSAlert() - alert.messageText = "Cannot Create New Tab" - alert.informativeText = "Tabs aren't supported in the Quick Terminal." - alert.addButton(withTitle: "OK") - alert.alertStyle = .warning - alert.beginSheetModal(for: window) + showNoNewTabAlert() } @IBAction func toggleGhosttyFullScreen(_ sender: Any) { @@ -492,6 +501,14 @@ class QuickTerminalController: BaseTerminalController { syncAppearance() } + @objc private func onNewTab(notification: SwiftUI.Notification) { + guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } + guard let window = surfaceView.window else { return } + guard window.windowController is QuickTerminalController else { return } + // Tabs aren't supported with Quick Terminals or derivatives + showNoNewTabAlert() + } + private struct DerivedConfig { let quickTerminalScreen: QuickTerminalScreen let quickTerminalAnimationDuration: Double diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift index a75ee78f8..07735cb58 100644 --- a/macos/Sources/Features/Terminal/TerminalManager.swift +++ b/macos/Sources/Features/Terminal/TerminalManager.swift @@ -125,6 +125,9 @@ class TerminalManager { } private func newTab(to parent: NSWindow, withBaseConfig base: Ghostty.SurfaceConfiguration?) { + // Making sure that we're dealing with a TerminalController + guard parent.windowController is TerminalController else { return } + // If our parent is in non-native fullscreen, then new tabs do not work. // See: https://github.com/mitchellh/ghostty/issues/392 if let controller = parent.windowController as? TerminalController, diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 6e01f687d..eae9603cd 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -7,13 +7,15 @@ const Window = @This(); const std = @import("std"); const builtin = @import("builtin"); - -const gtk = @import("gtk"); -const gobject = @import("gobject"); - -const build_config = @import("../../build_config.zig"); const Allocator = std.mem.Allocator; const assert = std.debug.assert; + +const gio = @import("gio"); +const glib = @import("glib"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const build_config = @import("../../build_config.zig"); const configpkg = @import("../../config.zig"); const font = @import("../../font/main.zig"); const input = @import("../../input.zig"); @@ -496,36 +498,38 @@ fn toggleCssClass( /// menus and such. The menu is defined in App.zig but the action is defined /// here. The string name binds them. fn initActions(self: *Window) void { + // FIXME: when rest of file is converted to gobject + const window: *gtk.ApplicationWindow = @ptrCast(@alignCast(self.window)); + const action_map = window.as(gio.ActionMap); const actions = .{ - .{ "about", >kActionAbout }, - .{ "close", >kActionClose }, - .{ "new-window", >kActionNewWindow }, - .{ "new-tab", >kActionNewTab }, - .{ "close-tab", >kActionCloseTab }, - .{ "split-right", >kActionSplitRight }, - .{ "split-down", >kActionSplitDown }, - .{ "split-left", >kActionSplitLeft }, - .{ "split-up", >kActionSplitUp }, - .{ "toggle-inspector", >kActionToggleInspector }, - .{ "copy", >kActionCopy }, - .{ "paste", >kActionPaste }, - .{ "reset", >kActionReset }, - .{ "clear", >kActionClear }, - .{ "prompt-title", >kActionPromptTitle }, + .{ "about", gtkActionAbout }, + .{ "close", gtkActionClose }, + .{ "new-window", gtkActionNewWindow }, + .{ "new-tab", gtkActionNewTab }, + .{ "close-tab", gtkActionCloseTab }, + .{ "split-right", gtkActionSplitRight }, + .{ "split-down", gtkActionSplitDown }, + .{ "split-left", gtkActionSplitLeft }, + .{ "split-up", gtkActionSplitUp }, + .{ "toggle-inspector", gtkActionToggleInspector }, + .{ "copy", gtkActionCopy }, + .{ "paste", gtkActionPaste }, + .{ "reset", gtkActionReset }, + .{ "clear", gtkActionClear }, + .{ "prompt-title", gtkActionPromptTitle }, }; inline for (actions) |entry| { - const action = c.g_simple_action_new(entry[0], null); - defer c.g_object_unref(action); - _ = c.g_signal_connect_data( + const action = gio.SimpleAction.new(entry[0], null); + defer action.unref(); + _ = gio.SimpleAction.signals.activate.connect( action, - "activate", - c.G_CALLBACK(entry[1]), + *Window, + entry[1], self, - null, - c.G_CONNECT_DEFAULT, + .{}, ); - c.g_action_map_add_action(@ptrCast(self.window), @ptrCast(action)); + action_map.addAction(action.as(gio.Action)); } } @@ -900,12 +904,10 @@ fn gtkKeyPressed( } fn gtkActionAbout( - _: *c.GSimpleAction, - _: *c.GVariant, - ud: ?*anyopaque, + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, ) callconv(.C) void { - const self: *Window = @ptrCast(@alignCast(ud orelse return)); - const name = "Ghostty"; const icon = "com.mitchellh.ghostty"; const website = "https://ghostty.org"; @@ -946,20 +948,18 @@ fn gtkActionAbout( } fn gtkActionClose( - _: *c.GSimpleAction, - _: *c.GVariant, - ud: ?*anyopaque, + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, ) callconv(.C) void { - const self: *Window = @ptrCast(@alignCast(ud orelse return)); c.gtk_window_destroy(self.window); } fn gtkActionNewWindow( - _: *c.GSimpleAction, - _: *c.GVariant, - ud: ?*anyopaque, + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, ) callconv(.C) void { - const self: *Window = @ptrCast(@alignCast(ud orelse return)); const surface = self.actionSurface() orelse return; _ = surface.performBindingAction(.{ .new_window = {} }) catch |err| { log.warn("error performing binding action error={}", .{err}); @@ -968,20 +968,19 @@ fn gtkActionNewWindow( } fn gtkActionNewTab( - _: *c.GSimpleAction, - _: *c.GVariant, - ud: ?*anyopaque, + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, ) callconv(.C) void { // We can use undefined because the button is not used. - gtkTabNewClick(undefined, ud); + gtkTabNewClick(undefined, self); } fn gtkActionCloseTab( - _: *c.GSimpleAction, - _: *c.GVariant, - ud: ?*anyopaque, + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, ) callconv(.C) void { - const self: *Window = @ptrCast(@alignCast(ud orelse return)); const surface = self.actionSurface() orelse return; _ = surface.performBindingAction(.{ .close_tab = {} }) catch |err| { log.warn("error performing binding action error={}", .{err}); @@ -990,11 +989,10 @@ fn gtkActionCloseTab( } fn gtkActionSplitRight( - _: *c.GSimpleAction, - _: *c.GVariant, - ud: ?*anyopaque, + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, ) callconv(.C) void { - const self: *Window = @ptrCast(@alignCast(ud orelse return)); const surface = self.actionSurface() orelse return; _ = surface.performBindingAction(.{ .new_split = .right }) catch |err| { log.warn("error performing binding action error={}", .{err}); @@ -1003,11 +1001,10 @@ fn gtkActionSplitRight( } fn gtkActionSplitDown( - _: *c.GSimpleAction, - _: *c.GVariant, - ud: ?*anyopaque, + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, ) callconv(.C) void { - const self: *Window = @ptrCast(@alignCast(ud orelse return)); const surface = self.actionSurface() orelse return; _ = surface.performBindingAction(.{ .new_split = .down }) catch |err| { log.warn("error performing binding action error={}", .{err}); @@ -1016,11 +1013,10 @@ fn gtkActionSplitDown( } fn gtkActionSplitLeft( - _: *c.GSimpleAction, - _: *c.GVariant, - ud: ?*anyopaque, + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, ) callconv(.C) void { - const self: *Window = @ptrCast(@alignCast(ud orelse return)); const surface = self.actionSurface() orelse return; _ = surface.performBindingAction(.{ .new_split = .left }) catch |err| { log.warn("error performing binding action error={}", .{err}); @@ -1029,11 +1025,10 @@ fn gtkActionSplitLeft( } fn gtkActionSplitUp( - _: *c.GSimpleAction, - _: *c.GVariant, - ud: ?*anyopaque, + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, ) callconv(.C) void { - const self: *Window = @ptrCast(@alignCast(ud orelse return)); const surface = self.actionSurface() orelse return; _ = surface.performBindingAction(.{ .new_split = .up }) catch |err| { log.warn("error performing binding action error={}", .{err}); @@ -1042,11 +1037,10 @@ fn gtkActionSplitUp( } fn gtkActionToggleInspector( - _: *c.GSimpleAction, - _: *c.GVariant, - ud: ?*anyopaque, + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, ) callconv(.C) void { - const self: *Window = @ptrCast(@alignCast(ud orelse return)); const surface = self.actionSurface() orelse return; _ = surface.performBindingAction(.{ .inspector = .toggle }) catch |err| { log.warn("error performing binding action error={}", .{err}); @@ -1055,11 +1049,10 @@ fn gtkActionToggleInspector( } fn gtkActionCopy( - _: *c.GSimpleAction, - _: *c.GVariant, - ud: ?*anyopaque, + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, ) callconv(.C) void { - const self: *Window = @ptrCast(@alignCast(ud orelse return)); const surface = self.actionSurface() orelse return; _ = surface.performBindingAction(.{ .copy_to_clipboard = {} }) catch |err| { log.warn("error performing binding action error={}", .{err}); @@ -1068,11 +1061,10 @@ fn gtkActionCopy( } fn gtkActionPaste( - _: *c.GSimpleAction, - _: *c.GVariant, - ud: ?*anyopaque, + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, ) callconv(.C) void { - const self: *Window = @ptrCast(@alignCast(ud orelse return)); const surface = self.actionSurface() orelse return; _ = surface.performBindingAction(.{ .paste_from_clipboard = {} }) catch |err| { log.warn("error performing binding action error={}", .{err}); @@ -1081,11 +1073,10 @@ fn gtkActionPaste( } fn gtkActionReset( - _: *c.GSimpleAction, - _: *c.GVariant, - ud: ?*anyopaque, + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, ) callconv(.C) void { - const self: *Window = @ptrCast(@alignCast(ud orelse return)); const surface = self.actionSurface() orelse return; _ = surface.performBindingAction(.{ .reset = {} }) catch |err| { log.warn("error performing binding action error={}", .{err}); @@ -1094,11 +1085,10 @@ fn gtkActionReset( } fn gtkActionClear( - _: *c.GSimpleAction, - _: *c.GVariant, - ud: ?*anyopaque, + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, ) callconv(.C) void { - const self: *Window = @ptrCast(@alignCast(ud orelse return)); const surface = self.actionSurface() orelse return; _ = surface.performBindingAction(.{ .clear_screen = {} }) catch |err| { log.warn("error performing binding action error={}", .{err}); @@ -1107,11 +1097,10 @@ fn gtkActionClear( } fn gtkActionPromptTitle( - _: *c.GSimpleAction, - _: *c.GVariant, - ud: ?*anyopaque, + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, ) callconv(.C) void { - const self: *Window = @ptrCast(@alignCast(ud orelse return)); const surface = self.actionSurface() orelse return; _ = surface.performBindingAction(.{ .prompt_surface_title = {} }) catch |err| { log.warn("error performing binding action error={}", .{err}); diff --git a/src/apprt/gtk/blueprint_compiler.zig b/src/apprt/gtk/blueprint_compiler.zig index f1d42c43d..15dd574e5 100644 --- a/src/apprt/gtk/blueprint_compiler.zig +++ b/src/apprt/gtk/blueprint_compiler.zig @@ -47,11 +47,23 @@ pub fn main() !void { alloc, ); - const term = try compiler.spawnAndWait(); + const term = compiler.spawnAndWait() catch |err| switch (err) { + error.FileNotFound => { + std.log.err( + \\`blueprint-compiler` not found. + \\ + \\Ghostty requires `blueprint-compiler` as a build-time dependency starting from version 1.2. + \\Please install it, ensure that it is available on your PATH, and then retry building Ghostty. + , .{}); + std.posix.exit(1); + }, + else => return err, + }; + switch (term) { .Exited => |rc| { - if (rc != 0) std.posix.exit(1); + if (rc != 0) std.process.exit(1); }, - else => std.posix.exit(1), + else => std.process.exit(1), } } diff --git a/src/config/Config.zig b/src/config/Config.zig index 5a47cb1b3..fb6b88fcb 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2060,7 +2060,9 @@ keybind: Keybinds = .{}, /// * `plastic` - A glossy, dark plastic frame. /// * `chrome` - A shiny chrome frame. /// -/// This only has an effect when `macos-icon` is set to `custom-style`. +/// Note: This configuration is required when `macos-icon` is set to +/// `custom-style`. +/// @"macos-icon-frame": MacAppIconFrame = .aluminum, /// The color of the ghost in the macOS app icon. @@ -2068,8 +2070,6 @@ keybind: Keybinds = .{}, /// Note: This configuration is required when `macos-icon` is set to /// `custom-style`. /// -/// This only has an effect when `macos-icon` is set to `custom-style`. -/// /// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. @"macos-icon-ghost-color": ?Color = null, @@ -2084,7 +2084,6 @@ keybind: Keybinds = .{}, /// Note: This configuration is required when `macos-icon` is set to /// `custom-style`. /// -/// This only has an effect when `macos-icon` is set to `custom-style`. @"macos-icon-screen-color": ?ColorList = null, /// Put every surface (tab, split, window) into a dedicated Linux cgroup. diff --git a/src/terminal/kitty/graphics_command.zig b/src/terminal/kitty/graphics_command.zig index d199711d3..840949d74 100644 --- a/src/terminal/kitty/graphics_command.zig +++ b/src/terminal/kitty/graphics_command.zig @@ -801,6 +801,13 @@ pub const Delete = union(enum) { z: i32 = 0, // z }, + // r/R + range: struct { + delete: bool = false, // uppercase + first: u32 = 0, // x + last: u32 = 0, // y + }, + // x/X column: struct { delete: bool = false, // uppercase @@ -885,6 +892,19 @@ pub const Delete = union(enum) { break :blk result; }, + 'r', 'R' => blk: { + const x = kv.get('x') orelse return error.InvalidFormat; + const y = kv.get('y') orelse return error.InvalidFormat; + if (x > y) return error.InvalidFormat; + break :blk .{ + .range = .{ + .delete = what == 'R', + .first = x, + .last = y, + }, + }; + }, + 'x', 'X' => blk: { var result: Delete = .{ .column = .{ .delete = what == 'X' } }; if (kv.get('x')) |v| { @@ -1197,3 +1217,76 @@ test "response: encode with image ID and number" { try r.encode(fbs.writer()); try testing.expectEqualStrings("\x1b_Gi=12,I=4;OK\x1b\\", fbs.getWritten()); } + +test "delete range command 1" { + const testing = std.testing; + const alloc = testing.allocator; + var p = Parser.init(alloc); + defer p.deinit(); + + const input = "a=d,d=r,x=3,y=4"; + for (input) |c| try p.feed(c); + const command = try p.complete(); + defer command.deinit(alloc); + + try testing.expect(command.control == .delete); + const v = command.control.delete; + try testing.expect(v == .range); + const range = v.range; + try testing.expect(!range.delete); + try testing.expectEqual(@as(u32, 3), range.first); + try testing.expectEqual(@as(u32, 4), range.last); +} + +test "delete range command 2" { + const testing = std.testing; + const alloc = testing.allocator; + var p = Parser.init(alloc); + defer p.deinit(); + + const input = "a=d,d=R,x=5,y=11"; + for (input) |c| try p.feed(c); + const command = try p.complete(); + defer command.deinit(alloc); + + try testing.expect(command.control == .delete); + const v = command.control.delete; + try testing.expect(v == .range); + const range = v.range; + try testing.expect(range.delete); + try testing.expectEqual(@as(u32, 5), range.first); + try testing.expectEqual(@as(u32, 11), range.last); +} + +test "delete range command 3" { + const testing = std.testing; + const alloc = testing.allocator; + var p = Parser.init(alloc); + defer p.deinit(); + + const input = "a=d,d=R,x=5,y=4"; + for (input) |c| try p.feed(c); + try testing.expectError(error.InvalidFormat, p.complete()); +} + +test "delete range command 4" { + const testing = std.testing; + const alloc = testing.allocator; + var p = Parser.init(alloc); + defer p.deinit(); + + const input = "a=d,d=R,x=5"; + for (input) |c| try p.feed(c); + try testing.expectError(error.InvalidFormat, p.complete()); +} + +test "delete range command 5" { + const testing = std.testing; + const alloc = testing.allocator; + var p = Parser.init(alloc); + defer p.deinit(); + + const input = "a=d,d=R,y=5"; + for (input) |c| try p.feed(c); + try testing.expectError(error.InvalidFormat, p.complete()); +} diff --git a/src/terminal/kitty/graphics_storage.zig b/src/terminal/kitty/graphics_storage.zig index ffd3aa580..e55dc45c3 100644 --- a/src/terminal/kitty/graphics_storage.zig +++ b/src/terminal/kitty/graphics_storage.zig @@ -397,6 +397,30 @@ pub const ImageStorage = struct { self.dirty = true; }, + .range => |v| range: { + if (v.first <= 0 or v.last <= 0) { + log.warn("delete range values must be greater than zero", .{}); + break :range; + } + if (v.first > v.last) { + log.warn("delete range 'x' ({}) must be less than or equal to 'y' ({})", .{ v.first, v.last }); + break :range; + } + + var it = self.placements.iterator(); + while (it.next()) |entry| { + if (entry.key_ptr.image_id >= v.first or entry.key_ptr.image_id <= v.last) { + 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); + } + } + + // Mark dirty to force redraw + self.dirty = true; + }, + // We don't support animation frames yet so they are successfully // deleted! .animation_frames => {}, @@ -1111,3 +1135,103 @@ test "storage: delete by row 1x1" { .placement_id = .{ .tag = .external, .id = 3 }, }) != null); } + +test "storage: delete images by range 1" { + const testing = std.testing; + const alloc = testing.allocator; + 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, &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, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(alloc, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try testing.expectEqual(@as(usize, 3), s.images.count()); + try testing.expectEqual(@as(usize, 2), s.placements.count()); + + s.dirty = false; + s.delete(alloc, &t, .{ .range = .{ .delete = false, .first = 1, .last = 2 } }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 3), s.images.count()); + try testing.expectEqual(@as(usize, 0), s.placements.count()); + try testing.expectEqual(tracked, t.screen.pages.countTrackedPins()); +} + +test "storage: delete images by range 2" { + const testing = std.testing; + const alloc = testing.allocator; + 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, &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, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(alloc, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try testing.expectEqual(@as(usize, 3), s.images.count()); + try testing.expectEqual(@as(usize, 2), s.placements.count()); + + s.dirty = false; + s.delete(alloc, &t, .{ .range = .{ .delete = true, .first = 1, .last = 2 } }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 1), s.images.count()); + try testing.expectEqual(@as(usize, 0), s.placements.count()); + try testing.expectEqual(tracked, t.screen.pages.countTrackedPins()); +} + +test "storage: delete images by range 3" { + const testing = std.testing; + const alloc = testing.allocator; + 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, &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, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(alloc, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try testing.expectEqual(@as(usize, 3), s.images.count()); + try testing.expectEqual(@as(usize, 2), s.placements.count()); + + s.dirty = false; + s.delete(alloc, &t, .{ .range = .{ .delete = false, .first = 1, .last = 1 } }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 3), s.images.count()); + try testing.expectEqual(@as(usize, 0), s.placements.count()); + try testing.expectEqual(tracked, t.screen.pages.countTrackedPins()); +} + +test "storage: delete images by range 4" { + const testing = std.testing; + const alloc = testing.allocator; + 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, &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, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(alloc, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try testing.expectEqual(@as(usize, 3), s.images.count()); + try testing.expectEqual(@as(usize, 2), s.placements.count()); + + s.dirty = false; + s.delete(alloc, &t, .{ .range = .{ .delete = true, .first = 1, .last = 1 } }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 1), s.images.count()); + try testing.expectEqual(@as(usize, 0), s.placements.count()); + try testing.expectEqual(tracked, t.screen.pages.countTrackedPins()); +}