diff options
| author | Gabriel Schneider <[email protected]> | 2026-10-01 19:20:19 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 19:57:27 -0300 |
| commit | 2868516c215a96d9e1e933ded7ac5744ec16c368 (patch) | |
| tree | f4283b58044fc0b2155f1a0526a87357b0ca62cc | |
| parent | 5f41a03fdcf0eaa3c1917e0f94243878c481cf8b (diff) | |
| download | pardes-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.json | 1 | ||||
| -rw-r--r-- | docs/typ/builtins-tty.json | 1 | ||||
| -rw-r--r-- | src/File.zig | 6 | ||||
| -rw-r--r-- | src/builtins.zig | 2 | ||||
| -rw-r--r-- | src/config.zig | 22 | ||||
| -rw-r--r-- | src/exec.zig | 10 | ||||
| -rw-r--r-- | src/mini.zig | 4 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 7 | ||||
| -rw-r--r-- | src/ninep/events.zig | 2 | ||||
| -rw-r--r-- | src/pardes.zig | 43 | ||||
| -rw-r--r-- | src/surface.zig | 4 |
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; |
