From 1092204df19bf56eb5b983dcc44394a1855f111e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 26 Jul 2026 20:08:30 -0700 Subject: [PATCH] config: support unlimited scrollback limits --- src/config/Config.zig | 68 ++++++++++++++++++++----- src/config/limit.zig | 115 ++++++++++++++++++++++++++++++++++++++++++ src/termio/Termio.zig | 4 +- 3 files changed, 171 insertions(+), 16 deletions(-) create mode 100644 src/config/limit.zig diff --git a/src/config/Config.zig b/src/config/Config.zig index 3d762760f..b47d287dc 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -41,6 +41,7 @@ const ClipboardCodepointMap = @import("ClipboardCodepointMap.zig"); const KeyRemapSet = @import("../input/key_mods.zig").RemapSet; pub const WindowPaddingBalance = @import("../renderer/size.zig").PaddingBalance; const string = @import("string.zig"); +const Limit = @import("limit.zig").Limit; // We do this instead of importing all of terminal/main.zig to // limit the dependency graph. This is important because some things @@ -1383,10 +1384,10 @@ input: RepeatableReadableIO = .{}, /// are set, then the first one reached will determine when scrollback is /// removed. /// -/// It is not currently possible to set an unlimited byte limit. +/// The default is 50 MB. Set this to `unlimited` to remove the byte limit. /// /// This can be changed at runtime but will only affect new terminal surfaces. -@"scrollback-limit-bytes": usize = 50_000_000, // 50MB +@"scrollback-limit-bytes": Limit(usize, 50_000_000) = .default, /// The maximum number of lines of scrollback to retain. This excludes /// the active screen. Soft-wrapped lines count as multiple lines. @@ -1399,12 +1400,12 @@ input: RepeatableReadableIO = .{}, /// This means that the actual limited lines will likely be slightly /// higher in practice. /// -/// If this is unset, then no line limit will be applied. A separate maximum -/// can be set with `scrollback-limit-bytes`; if both limits are set, then the -/// first one reached will determine when scrollback is removed. +/// The default is `unlimited`. A separate maximum can be set with +/// `scrollback-limit-bytes`; if both limits are set, then the first one reached +/// will determine when scrollback is removed. /// /// This can be changed at runtime but will only affect new terminal surfaces. -@"scrollback-limit-lines": ?usize = null, +@"scrollback-limit-lines": Limit(usize, std.math.maxInt(usize)) = .default, /// Whether to compress scrollback pages while the terminal is idle. /// @@ -10856,6 +10857,15 @@ test "scrollback limits" { var cfg = try Config.default(alloc); defer cfg.deinit(); + try testing.expectEqual( + @as(usize, 50_000_000), + cfg.@"scrollback-limit-bytes".value, + ); + try testing.expectEqual( + std.math.maxInt(usize), + cfg.@"scrollback-limit-lines".value, + ); + var it: TestIterator = .{ .data = &.{ "--scrollback-limit-bytes=1234", "--scrollback-limit-lines=567", @@ -10864,21 +10874,41 @@ test "scrollback limits" { try testing.expectEqual( @as(usize, 1234), - cfg.@"scrollback-limit-bytes", + cfg.@"scrollback-limit-bytes".value, ); try testing.expectEqual( - @as(?usize, 567), - cfg.@"scrollback-limit-lines", + @as(usize, 567), + cfg.@"scrollback-limit-lines".value, ); - var unset_it: TestIterator = .{ .data = &.{ + var unlimited_it: TestIterator = .{ .data = &.{ + "--scrollback-limit-bytes=unlimited", + "--scrollback-limit-lines=unlimited", + } }; + try cfg.loadIter(alloc, &unlimited_it); + + try testing.expectEqual( + std.math.maxInt(usize), + cfg.@"scrollback-limit-bytes".value, + ); + try testing.expectEqual( + std.math.maxInt(usize), + cfg.@"scrollback-limit-lines".value, + ); + + var reset_it: TestIterator = .{ .data = &.{ + "--scrollback-limit-bytes=", "--scrollback-limit-lines=", } }; - try cfg.loadIter(alloc, &unset_it); + try cfg.loadIter(alloc, &reset_it); try testing.expectEqual( - @as(?usize, null), - cfg.@"scrollback-limit-lines", + @as(usize, 50_000_000), + cfg.@"scrollback-limit-bytes".value, + ); + try testing.expectEqual( + std.math.maxInt(usize), + cfg.@"scrollback-limit-lines".value, ); } @@ -10895,7 +10925,17 @@ test "compatibility: scrollback-limit renamed to bytes" { try testing.expectEqual( @as(usize, 1234), - cfg.@"scrollback-limit-bytes", + cfg.@"scrollback-limit-bytes".value, + ); + + var unlimited_it: TestIterator = .{ .data = &.{ + "--scrollback-limit=unlimited", + } }; + try cfg.loadIter(alloc, &unlimited_it); + + try testing.expectEqual( + std.math.maxInt(usize), + cfg.@"scrollback-limit-bytes".value, ); } diff --git a/src/config/limit.zig b/src/config/limit.zig new file mode 100644 index 000000000..ace78676a --- /dev/null +++ b/src/config/limit.zig @@ -0,0 +1,115 @@ +const std = @import("std"); +const formatterpkg = @import("formatter.zig"); + +/// A configurable integer limit with a declared default and support for the +/// special value `unlimited`. +/// +/// Unlimited is stored as the maximum value of the integer type so the value +/// remains directly comparable without optional unwrapping. +pub fn Limit(comptime T: type, comptime default_value: T) type { + comptime std.debug.assert(@typeInfo(T) == .int); + + return struct { + const Self = @This(); + + value: T = default_value, + + /// The limit initialized with its declared default value. + pub const default: Self = .{}; + + /// Parses an integer limit or the special value "unlimited". + pub fn parseCLI(input_: ?[]const u8) !Self { + const input = input_ orelse return error.ValueRequired; + if (std.mem.eql(u8, input, "unlimited")) { + return .{ .value = std.math.maxInt(T) }; + } + + return .{ + .value = std.fmt.parseInt(T, input, 0) catch + return error.InvalidValue, + }; + } + + /// Formats the maximum integer value as "unlimited" and all other + /// values as integers. + pub fn formatEntry( + self: Self, + formatter: formatterpkg.EntryFormatter, + ) !void { + if (self.value == std.math.maxInt(T)) { + try formatter.formatEntry([]const u8, "unlimited"); + } else { + try formatter.formatEntry(T, self.value); + } + } + + /// Returns the configured value, or null for the unlimited sentinel. + pub fn optional(self: Self) ?T { + if (self.value == std.math.maxInt(T)) return null; + return self.value; + } + + /// Returns an independent copy of this value for Config cloning. + pub fn clone( + self: Self, + _: std.mem.Allocator, + ) std.mem.Allocator.Error!Self { + return self; + } + }; +} + +test "Limit default and parsing" { + const testing = std.testing; + const TestLimit = Limit(usize, 42); + + try testing.expectEqual(@as(usize, 42), TestLimit.default.value); + try testing.expectEqual( + @as(usize, 123), + (try TestLimit.parseCLI("123")).value, + ); + try testing.expectEqual( + std.math.maxInt(usize), + (try TestLimit.parseCLI("unlimited")).value, + ); + try testing.expectError(error.InvalidValue, TestLimit.parseCLI("invalid")); +} + +test "Limit optional" { + const testing = std.testing; + const TestLimit = Limit(u16, 42); + + try testing.expectEqual(@as(?u16, 42), (TestLimit{}).optional()); + try testing.expectEqual( + @as(?u16, null), + (TestLimit{ .value = std.math.maxInt(u16) }).optional(), + ); +} + +test "Limit formatting" { + const testing = std.testing; + const TestLimit = Limit(usize, 42); + + { + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + try (TestLimit{ .value = 123 }).formatEntry( + formatterpkg.entryFormatter("limit", &buf.writer), + ); + try testing.expectEqualStrings("limit = 123\n", buf.written()); + } + + { + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + try (TestLimit{ .value = std.math.maxInt(usize) }).formatEntry( + formatterpkg.entryFormatter("limit", &buf.writer), + ); + try testing.expectEqualStrings( + "limit = unlimited\n", + buf.written(), + ); + } +} diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 96c5acc8a..883d8d5de 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -246,8 +246,8 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { break :opts .{ .cols = grid_size.columns, .rows = grid_size.rows, - .max_scrollback_bytes = opts.full_config.@"scrollback-limit-bytes", - .max_scrollback_lines = opts.full_config.@"scrollback-limit-lines", + .max_scrollback_bytes = opts.full_config.@"scrollback-limit-bytes".optional(), + .max_scrollback_lines = opts.full_config.@"scrollback-limit-lines".optional(), .default_modes = default_modes, .default_cursor_style = opts.config.cursor_style, .default_cursor_blink = opts.config.cursor_blink,