summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-10-01 19:20:19 -0300
committerGabriel Schneider <[email protected]>2026-10-01 19:57:27 -0300
commit2868516c215a96d9e1e933ded7ac5744ec16c368 (patch)
treef4283b58044fc0b2155f1a0526a87357b0ca62cc
parent5f41a03fdcf0eaa3c1917e0f94243878c481cf8b (diff)
downloadpardes-2868516c215a96d9e1e933ded7ac5744ec16c368.tar.gz
pardes-2868516c215a96d9e1e933ded7ac5744ec16c368.zip
Tab N sets a tab's width in cells, 1 through 16, in every pane, drawn and counted (wrapping, clicks, the cursor); bare Tab says it; the ctl read and a Dump keep it
A session setting, not acme's per window: the width is read by File's pure width functions (~130 callers that are given no pane), so it is one value, config.tab_now, that Runtime.apply sets and a new session resets. A file:L:C column counts runes, as compilers do, and never depended on it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--docs/typ/builtins-gui.json1
-rw-r--r--docs/typ/builtins-tty.json1
-rw-r--r--src/File.zig6
-rw-r--r--src/builtins.zig2
-rw-r--r--src/config.zig22
-rw-r--r--src/exec.zig10
-rw-r--r--src/mini.zig4
-rw-r--r--src/ninep/ctl.zig7
-rw-r--r--src/ninep/events.zig2
-rw-r--r--src/pardes.zig43
-rw-r--r--src/surface.zig4
11 files changed, 89 insertions, 13 deletions
diff --git a/docs/typ/builtins-gui.json b/docs/typ/builtins-gui.json
index 7674ca34..4833360e 100644
--- a/docs/typ/builtins-gui.json
+++ b/docs/typ/builtins-gui.json
@@ -117,6 +117,7 @@
{"name": "Supertypes", "leader": "SPC l t", "also": [], "arg": false, "scope": "pane", "setting": false, "choices": null, "doc": "List the supertypes of the type under the cursor."},
{"name": "Symbols", "leader": "SPC l s", "also": [], "arg": false, "scope": "pane", "setting": false, "choices": null, "doc": "List the file's symbols from the language server."},
{"name": "SyntaxBold", "leader": null, "also": [], "arg": true, "scope": "root", "setting": true, "choices": "on, off", "doc": "Keywords and the like are drawn bold, on or off."},
+ {"name": "Tab", "leader": null, "also": [], "arg": true, "scope": "root", "setting": true, "choices": null, "doc": "A tab's width in cells, 1 through 16, in every pane; bare, it says the width."},
{"name": "Tagbottom", "leader": "SPC t b", "also": [], "arg": true, "scope": "root", "setting": true, "choices": "on, off", "doc": "Each pane's tag goes at its foot rather than its head, on or off."},
{"name": "TaglineSize", "leader": null, "also": [], "arg": true, "scope": "root", "setting": true, "choices": null, "doc": "Scale the tag font, a percentage."},
{"name": "TermImages", "leader": null, "also": [], "arg": true, "scope": "root", "setting": true, "choices": "real, petscii", "doc": "How a new terminal draws its program's kitty graphics: real, as pixels where it can, or petscii, as glyph art."},
diff --git a/docs/typ/builtins-tty.json b/docs/typ/builtins-tty.json
index 837b2853..fa0a44a1 100644
--- a/docs/typ/builtins-tty.json
+++ b/docs/typ/builtins-tty.json
@@ -101,6 +101,7 @@
{"name": "Supertypes", "leader": "SPC l t", "also": [], "arg": false, "scope": "pane", "setting": false, "choices": null, "doc": "List the supertypes of the type under the cursor."},
{"name": "Symbols", "leader": "SPC l s", "also": [], "arg": false, "scope": "pane", "setting": false, "choices": null, "doc": "List the file's symbols from the language server."},
{"name": "SyntaxBold", "leader": null, "also": [], "arg": true, "scope": "root", "setting": true, "choices": "on, off", "doc": "Keywords and the like are drawn bold, on or off."},
+ {"name": "Tab", "leader": null, "also": [], "arg": true, "scope": "root", "setting": true, "choices": null, "doc": "A tab's width in cells, 1 through 16, in every pane; bare, it says the width."},
{"name": "Tagbottom", "leader": "SPC t b", "also": [], "arg": true, "scope": "root", "setting": true, "choices": "on, off", "doc": "Each pane's tag goes at its foot rather than its head, on or off."},
{"name": "TermImages", "leader": null, "also": [], "arg": true, "scope": "root", "setting": true, "choices": "real, petscii", "doc": "How a new terminal draws its program's kitty graphics: real, as pixels where it can, or petscii, as glyph art."},
{"name": "Theme", "leader": null, "also": [], "arg": true, "scope": "root", "setting": true, "choices": null, "doc": "Switch to the named theme."},
diff --git a/src/File.zig b/src/File.zig
index eb11b0e4..3ef1593d 100644
--- a/src/File.zig
+++ b/src/File.zig
@@ -311,7 +311,7 @@ test "stacked location metadata requires matching adjacent preview ownership" {
}
pub fn graphemeDisplayWidth(grapheme: []const u8) usize {
- if (std.mem.eql(u8, grapheme, "\t")) return config.tab_width;
+ if (std.mem.eql(u8, grapheme, "\t")) return config.tab_now;
if (grapheme.len == 1 and grapheme[0] >= 0x20 and grapheme[0] < 0x7f) return 1;
if (std.unicode.utf8ValidateSlice(grapheme)) return @max(1, @as(usize, vaxis.gwidth.gwidth(grapheme, .unicode)));
// vaxis's gwidth counts back a replacement rune's 3 bytes over the one
@@ -346,7 +346,7 @@ test "an invalid byte in a grapheme is one cell, and never reaches vaxis's width
}
pub fn byteDisplayWidth(byte: u8) usize {
- return if (byte == '\t') config.tab_width else 1;
+ return if (byte == '\t') config.tab_now else 1;
}
pub fn displayWidth(text: []const u8) usize {
@@ -503,7 +503,7 @@ pub fn renderedLineDisplayCol(pane: *const Pane, row: i32, line_text: []const u8
test "the ASCII arm of graphemeDisplayWidth matches the gwidth it skips" {
const ref = struct {
fn width(grapheme: []const u8) usize {
- if (std.mem.eql(u8, grapheme, "\t")) return config.tab_width;
+ if (std.mem.eql(u8, grapheme, "\t")) return config.tab_now;
return @max(1, @as(usize, vaxis.gwidth.gwidth(grapheme, .unicode)));
}
}.width;
diff --git a/src/builtins.zig b/src/builtins.zig
index 780d0bb7..5fdabd87 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -227,6 +227,8 @@ pub const registry = struct {
// are the default
.toggle, .transition, .scene, .dump_dir, .choice, .lift, .shader_animation, .motion, .shell => false,
.theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .shader, .inactive_dim, .grip_width => true,
+ // bare, it says the width (exec.applySettingBuiltin)
+ .tab => false,
};
unreachable;
}
diff --git a/src/config.zig b/src/config.zig
index 375b4c69..89e5f813 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -61,6 +61,7 @@ pub const leader_path = paths: {
.Get = null,
.Putall = null,
.Incl = null,
+ .Tab = null,
.ClipYank = "y",
.ClipYankMain = "Y",
.ClipPaste = "p",
@@ -405,6 +406,11 @@ pub const PREFIX_W: u16 = 5;
pub const default_incl = [_][]const u8{ "/usr/include", "/usr/local/include" };
pub const tab_width: u16 = 4;
+/// The width a tab is drawn and counted at now: `Tab N` (Runtime.tab) sets
+/// it, a new session starts it at tab_width. ponytail: one for the process,
+/// as the session's setting is one; per pane would mean handing a pane to
+/// every width function (File.graphemeDisplayWidth and its ~130 callers).
+pub var tab_now: u16 = tab_width;
comptime {
if (tab_width == 0) @compileError("config.tab_width must be greater than zero");
}
@@ -802,6 +808,9 @@ pub const Runtime = struct {
/// the focused pane's file first, other files only once those run out
/// that way; `all`, every place in the order it was jumped to.
jump_scope: JumpScope = .file,
+ /// acme's Tab: a tab's width in cells, in every pane (config.tab_now
+ /// is what the drawing and the column arithmetic read).
+ tab: u16 = tab_width,
pub const ShaderAnimation = enum { off, on, always };
pub const Placement = enum { acme, pardes };
@@ -999,6 +1008,7 @@ pub const Runtime = struct {
/// A percentage, 0 through 100.
inactive_dim,
grip_width,
+ tab,
transition: animation.Transition,
scene: Scene,
/// A Shadertoy file into the post chain or out of it; `off` empties
@@ -1082,6 +1092,7 @@ pub const Runtime = struct {
.{ .word = "PagerColor", .action = .{ .toggle = .pager_color }, .doc = "A +Pager keeps its program's colours, on or off; for the next text paged." },
.{ .word = "TermImages", .action = .{ .choice = .term_images }, .doc = "How a new terminal draws its program's kitty graphics: real, as pixels where it can, or petscii, as glyph art." },
.{ .word = "JumpScope", .action = .{ .choice = .jump_scope }, .doc = "What Back and Forward keep to: file, the focused pane's file until it runs out that way, or all, every place in order." },
+ .{ .word = "Tab", .action = .tab, .doc = "A tab's width in cells, 1 through 16, in every pane; bare, it says the width." },
};
pub fn find(name: []const u8) ?Setting {
@@ -1282,6 +1293,13 @@ pub const Runtime = struct {
if (percent < 50 or percent > 300) return false;
state.grip_width = percent;
},
+ .tab => {
+ const text = std.mem.trim(u8, argument orelse return false, " \t\r\n");
+ const width = std.fmt.parseInt(u16, text, 10) catch return false;
+ if (width < 1 or width > 16) return false;
+ state.tab = width;
+ tab_now = width;
+ },
.theme, .font => return false,
}
return true;
@@ -1317,6 +1335,7 @@ pub const Runtime = struct {
return choices(action) orelse switch (action) {
.inactive_dim, .window_opacity, .window_blur => "0-100 (a percentage)",
.grip_width => "50-300 (a percentage)",
+ .tab => "1-16 (cells)",
.tagline_size => std.fmt.comptimePrint("{d}-{d} (a percentage)", .{ tagline_percent_min, tagline_percent_max }),
.message_ms => std.fmt.comptimePrint("0-{d} (milliseconds)", .{message_ms_max}),
.shell => "a path of at most 255 bytes; bare, $SHELL, else /bin/sh",
@@ -1479,6 +1498,7 @@ pub const Runtime = struct {
try out.print("# {s} unsupported\n", .{setting.word}),
.motion => try out.print("{s} {s}\n", .{ setting.word, @tagName(state.motion) }),
.inactive_dim => try out.print("{s} {d}\n", .{ setting.word, state.inactive_dim }),
+ .tab => try out.print("{s} {d}\n", .{ setting.word, state.tab }),
.grip_width => if (setting.enabled(context.capabilities))
try out.print("{s} {d}\n", .{ setting.word, state.grip_width })
else
@@ -1775,6 +1795,7 @@ pub const Runtime = struct {
.motion => "playful",
.inactive_dim => "35",
.grip_width => "200",
+ .tab => "8",
else => null,
};
try std.testing.expect(apply(&state, setting, argument));
@@ -1849,6 +1870,7 @@ pub const Runtime = struct {
.motion => "Motion playful\n",
.inactive_dim => "InactiveDim 35\n",
.grip_width => "GripWidth 200\n",
+ .tab => "Tab 8\n",
.theme, .font => unreachable,
};
try std.testing.expect(std.mem.indexOf(u8, report, expected) != null);
diff --git a/src/exec.zig b/src/exec.zig
index 4bdb2584..1ee57520 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -953,7 +953,6 @@ pub const acme_words = [_]struct { acme: []const u8, pardes: ?[]const u8, hint:
.{ .acme = "Load", .pardes = "Restore" },
.{ .acme = "ID", .pardes = null },
.{ .acme = "Send", .pardes = null },
- .{ .acme = "Tab", .pardes = null },
.{ .acme = "Indent", .pardes = null, .hint = ": Enter, o and O keep the indent always" },
.{ .acme = "Local", .pardes = null },
.{ .acme = "Abort", .pardes = null, .hint = ": a debugging word that crashes acme" },
@@ -1120,6 +1119,13 @@ pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[]
}
if (!p.settings.apply(setting, want)) p.reportFailure(p.active, "Shell: takes " ++ comptime config.Runtime.takes(.shell).?);
},
+ // Bare, it says the width, as acme's Tab does.
+ .tab => if (arg == null) {
+ var said: [32]u8 = undefined;
+ return p.setMessage(p.active, std.fmt.bufPrint(&said, "Tab {d}", .{p.settings.tab}) catch "Tab");
+ } else if (!p.settings.apply(setting, arg)) {
+ p.reportFailure(p.active, "Tab: takes " ++ comptime config.Runtime.takes(.tab).?);
+ },
// Said with what it takes: its words, its range.
else => if (!p.settings.apply(setting, arg)) {
var text: [160]u8 = undefined;
@@ -1133,7 +1139,7 @@ pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[]
if (pane.file) |*file| file.syntax_dirty = true;
}
}
- if (p.settings.tree_context_tag_style != previous_context_style) {
+ if (p.settings.tree_context_tag_style != previous_context_style or setting.action == .tab) {
for (p.panes, 0..) |slot, id| {
p.surface.body_layers[id].rows = 0;
const pane = slot orelse continue;
diff --git a/src/mini.zig b/src/mini.zig
index a6aef15a..8af64201 100644
--- a/src/mini.zig
+++ b/src/mini.zig
@@ -239,9 +239,9 @@ test "Mini uses display cells for tabs combining text and wide characters" {
}
const tabs = try generate(gpa, "\tx\n", "");
defer tabs.deinit(gpa);
- const spaces = config.tab_width / 2;
+ const spaces = config.tab_now / 2;
for (tabs.content[0..spaces]) |byte| try std.testing.expectEqual(@as(u8, ' '), byte);
- try std.testing.expectEqualStrings(if (config.tab_width % 2 == 0) "⠁\n" else "⠈\n", tabs.content[spaces..]);
+ try std.testing.expectEqualStrings(if (config.tab_now % 2 == 0) "⠁\n" else "⠈\n", tabs.content[spaces..]);
}
test "Mini chooses highlighted dots over plain ink with stable color ties" {
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index d0b1defb..188795de 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -409,7 +409,7 @@ pub fn paneText(p: *Pardes, pane: *Pane, buf: []u8) []const u8 {
const f = pane_files.fileOf(pane);
const undo = if (f) |file| file.history.undo_len > 0 else false;
const redo = if (f) |file| file.history.redo_len > 0 else false;
- w.print(" {d:>11} {d:>11} {d:>11} {s} ", .{ config.tab_width, @intFromBool(undo), @intFromBool(redo), if (current) "current" else "notcurrent" }) catch {};
+ w.print(" {d:>11} {d:>11} {d:>11} {s} ", .{ config.tab_now, @intFromBool(undo), @intFromBool(redo), if (current) "current" else "notcurrent" }) catch {};
// A terminal bound as a REPL ends with its id, `python-a`.
var idbuf: [48]u8 = undefined;
const id = exec_line.replId(&idbuf, pane);
@@ -632,6 +632,7 @@ pub fn writeSettingsOf(p: *Pardes, s: *const config.Runtime, w: *std.Io.Writer)
.motion => try w.print("{s} {s}\n", .{ word, @tagName(s.motion) }),
.inactive_dim => try w.print("{s} {d}\n", .{ word, s.inactive_dim }),
.grip_width => try w.print("{s} {d}\n", .{ word, s.grip_width }),
+ .tab => try w.print("{s} {d}\n", .{ word, s.tab }),
}
}
}
@@ -1228,7 +1229,7 @@ test "pane ctl read is acme's fields -- index's five, width in cells, font, tab
var want: std.ArrayList(u8) = .empty;
defer want.deinit(gpa);
try want.print(gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {s} {d:>11} {d:>11} {d:>11} current ", .{
- pane.serial, pane_files.tagOf(p, pane).len, @as(usize, 2), 0, 0, pane.cols, "default", config.tab_width, 0, 0,
+ pane.serial, pane_files.tagOf(p, pane).len, @as(usize, 2), 0, 0, pane.cols, "default", config.tab_now, 0, 0,
});
try testing.expectEqualStrings(want.items, a.bytes);
try testing.expectEqual(@as(u64, a.bytes.len), call(p, .{ .tag = 1, .op = .getattr, .node = Node.of(pane.serial, .ctl) }).reply.attr.size);
@@ -3210,7 +3211,7 @@ test "acme's words run as pardes's (Put is Save, Delete a Del that does not ask)
const p = try withFile(testing.allocator, "x\n");
defer p.deinit();
const serial = serialOf(p);
- for ([_][]const u8{ "Snarf\n", "Zerox\n", "Tab\n", "Indent\n", "Local\n", "Abort\n" }) |line| {
+ for ([_][]const u8{ "Snarf\n", "Zerox\n", "Indent\n", "Local\n", "Abort\n" }) |line| {
for ([_]u64{ Node.of(serial, .ctl), Node.of(serial, .exec) }) |node| {
const r = wr(p, node, line);
try testing.expectEqual(E.INVAL, r.errno());
diff --git a/src/ninep/events.zig b/src/ninep/events.zig
index 10e91138..5b2a763e 100644
--- a/src/ninep/events.zig
+++ b/src/ninep/events.zig
@@ -1403,7 +1403,7 @@ test "a click in the body reports the offsets of the word it takes, so the recor
const rect = p.rects[id];
const x0 = rect.x + config.GUTTER + config.PREFIX_W;
const y0 = if (p.settings.tag_bottom) rect.y else rect.y + p.panes[id].?.tag_rows;
- const tab: u16 = config.tab_width;
+ const tab: u16 = config.tab_now;
const Click = struct { x: u16, y: u16, look: []const u8, exec: []const u8, scroll: i32 = 0 };
// Where the wrapped line's `qty` landed, as drawn.
const surface = try p.render(frame.allocator());
diff --git a/src/pardes.zig b/src/pardes.zig
index 36a37293..af2b80a2 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -3149,6 +3149,47 @@ test "Unicode display cells map back to body and tag byte cursors" {
try std.testing.expectEqualStrings(before, tagline.curTail(pane));
}
+test "Tab N draws every pane's tab N cells wide and clicks count past it; bare Tab says the width; the ctl read and a Dump keep it" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12 });
+ defer p.deinit();
+ // The width is the process's: the next test starts from the default.
+ defer config.tab_now = config.tab_width;
+ const pane = try p.setTestFile("\tx = 1\n");
+ try std.testing.expect(p.executeBuiltinLine(0, "Tab 8"));
+ try std.testing.expectEqual(@as(u16, 8), config.tab_now);
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ const surface = try p.render(frame.allocator());
+ const rect = p.rects[0];
+ const text_x = rect.x + config.GUTTER + config.PREFIX_W;
+ const body_y = rect.y + BOX_H;
+ try std.testing.expectEqualStrings("x", surface.at(text_x + 8, body_y).grapheme());
+ // A click on the x is on its byte, past the tab.
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = text_x + 8, .row = body_y } });
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = text_x + 8, .row = body_y } });
+ try std.testing.expectEqual(@as(i32, 1), pane.body.cur_col);
+ // Bare, it says the width.
+ try std.testing.expect(p.executeBuiltinLine(0, "Tab"));
+ try std.testing.expectEqualStrings("Tab 8", pane.msg[0..pane.msg_len]);
+ // Out of range: refused, the width kept.
+ try std.testing.expect(p.executeBuiltinLine(0, "Tab 0"));
+ try std.testing.expectEqual(@as(u16, 8), config.tab_now);
+ // The ctl read's tab column, and a Dump's settings.
+ const ctl_text = pardes_ctlfs_ctlText(p, pane);
+ try std.testing.expect(std.mem.indexOf(u8, ctl_text, " 8 ") != null);
+ try dump.dumpState(p);
+ const restored = try dump.restore(p, p.dump_out.?, "/tmp/pardes.dump.zon");
+ defer restored.deinit();
+ try std.testing.expectEqual(@as(u16, 8), restored.settings.tab);
+ try std.testing.expectEqual(@as(u16, 8), config.tab_now);
+}
+
+fn pardes_ctlfs_ctlText(p: *Pardes, pane: *Pane) []const u8 {
+ const th = @import("ninep/testing.zig");
+ return th.rd(p, ctlfs.Node.of(pane.serial, .ctl), 0, 4096).bytes;
+}
+
test "tabbed file aligns syntax cursor and mouse at the nearest insertion gap" {
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12 });
@@ -4618,6 +4659,8 @@ pub const Pardes = struct {
}
pub fn init(gpa: std.mem.Allocator, opts: Options) !*Pardes {
+ // A new session draws a tab at the default until `Tab` says otherwise.
+ config.tab_now = config.tab_width;
const p = try create(gpa, opts);
errdefer p.deinit();
p.boot = Boot.of(opts);
diff --git a/src/surface.zig b/src/surface.zig
index a08d6831..f7cc6010 100644
--- a/src/surface.zig
+++ b/src/surface.zig
@@ -589,7 +589,7 @@ pub const Surface = struct {
var cp = decoded orelse 0xFFFD;
if (cp == '\r') continue;
if (cp == '\t') {
- const spaces = @min(config.tab_width, end - col);
+ const spaces = @min(config.tab_now, end - col);
s.fill(col, y, spaces, 1, style);
col += spaces;
continue;
@@ -714,7 +714,7 @@ test "the ASCII fast path in surface print paints what the general arm paints" {
var cp = decoded orelse 0xFFFD;
if (cp == '\r') continue;
if (cp == '\t') {
- const spaces = @min(config.tab_width, end - col);
+ const spaces = @min(config.tab_now, end - col);
s.fill(col, y, spaces, 1, style);
col += spaces;
continue;