summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-06-30 15:32:56 -0300
committerGabriel Schneider <[email protected]>2026-06-30 17:43:21 -0300
commit1f65d7368c9a31427b145f683eb891da16181b73 (patch)
tree88a3754e7d01906098af82335b84edb8754f31ca
parenta68564e9fb9fb8be6d997db5e6ad9148fb506d18 (diff)
downloadpardes-1f65d7368c9a31427b145f683eb891da16181b73.tar.gz
pardes-1f65d7368c9a31427b145f683eb891da16181b73.zip
merging the colors into
-rw-r--r--build.zig34
-rw-r--r--build.zig.zon18
-rw-r--r--main.zig359
-rw-r--r--term.zig4
4 files changed, 372 insertions, 43 deletions
diff --git a/build.zig b/build.zig
index 88d2123a..d5bba19a 100644
--- a/build.zig
+++ b/build.zig
@@ -4,6 +4,11 @@ pub fn build(b: *std.Build) void {
const optimize = b.standardOptimizeOption(.{});
const target = b.standardTargetOptions(.{});
+ // build-time IO, only used to slurp the grammars' highlights.scm queries.
+ var threaded: std.Io.Threaded = .init(b.allocator, .{});
+ defer threaded.deinit();
+ const io = threaded.io();
+
// -Dtrace: make the e2e runner print the captured screen state (after each
// pump/waitFor and on every assertion) so failures can be inspected live.
// Usage: `zig build test -Dtrace`.
@@ -90,6 +95,35 @@ pub fn build(b: *std.Build) void {
const zstbi_mod = b.dependency("zstbi", .{ .target = target, .optimize = optimize }).module("root");
exe.root_module.addImport("zstbi", zstbi_mod);
+ // tree-sitter syntax highlighting for file panes: the Zig bindings + C runtime
+ // (the `tree-sitter` module), each grammar's parser.c linked as a static lib,
+ // and its highlights.scm read at build time into the `ts_queries` options module.
+ const tree_sitter = b.dependency("tree_sitter", .{ .target = target, .optimize = optimize });
+ exe.root_module.addImport("tree-sitter", tree_sitter.module("tree_sitter"));
+
+ const grammars = .{
+ .{ "c", "ts_c", false },
+ .{ "rust", "ts_rust", true },
+ .{ "zig", "ts_zig", false },
+ };
+ const ts_queries = b.addOptions();
+ inline for (grammars) |g| {
+ const dep = b.dependency(g[1], .{});
+ const query = std.Io.Dir.cwd().readFileAlloc(io, dep.path("queries/highlights.scm").getPath(b), b.allocator, .limited(0x100000)) catch @panic("read " ++ g[0] ++ " highlights");
+ ts_queries.addOption([]const u8, g[0] ++ "_highlights", query);
+
+ const lib = b.addLibrary(.{
+ .name = "tree-sitter-" ++ g[0],
+ .root_module = b.createModule(.{ .target = target, .optimize = optimize, .link_libc = true }),
+ .linkage = .static,
+ });
+ lib.root_module.addCSourceFile(.{ .file = dep.path("src/parser.c") });
+ if (g[2]) lib.root_module.addCSourceFile(.{ .file = dep.path("src/scanner.c") });
+ lib.root_module.addIncludePath(dep.path("src"));
+ exe.root_module.linkLibrary(lib);
+ }
+ exe.root_module.addOptions("ts_queries", ts_queries);
+
// Install the exe so `zig build` (and `zig build install`) produces it in
// zig-out/bin for global install, not just as a hidden dep of `run`/`test`.
b.installArtifact(exe);
diff --git a/build.zig.zon b/build.zig.zon
index 9da98684..3c72c8a4 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -24,6 +24,24 @@
.url = "git+https://github.com/zig-gamedev/zstbi#3813f5f113b26f644cfa01a30155cc76b3526e91",
.hash = "zstbi-0.11.0-dev-L0Ea_3GWBwAh7m54XX055vwLKPTPij5hyqyeq92MXPE2",
},
+ // tree-sitter: Zig bindings + the C runtime, plus three grammars (parser.c
+ // we link, highlights.scm we read at build time into the ts_queries module).
+ .tree_sitter = .{
+ .url = "git+https://github.com/tree-sitter/zig-tree-sitter#b55d4977d61330390fc733944da06106ff972660",
+ .hash = "tree_sitter-0.26.0-8heIf3CaAQB4SSumoGzq3gLSz2rkvXxcKFyskRWqDGBz",
+ },
+ .ts_rust = .{
+ .url = "git+https://github.com/tree-sitter/tree-sitter-rust#77a3747266f4d621d0757825e6b11edcbf991ca5",
+ .hash = "N-V-__8AAHJYbgAipcanYj2A6rnj9wtGzjGzANkrOsqiFAxm",
+ },
+ .ts_c = .{
+ .url = "git+https://github.com/tree-sitter/tree-sitter-c#b780e47fc780ddc8da13afa35a3f4ed5c157823d",
+ .hash = "tree_sitter_c-0.24.2-y5boS-ptQADHoCoVfjGT_nFtFQ5LbomIkW0fxG3_cmdB",
+ },
+ .ts_zig = .{
+ .url = "git+https://github.com/maxxnino/tree-sitter-zig#a80a6e9be81b33b182ce6305ae4ea28e29211bd5",
+ .hash = "N-V-__8AADLAWgBszUZPv5YTGyO5ZNyxNcuuR8MB3mIMJrZf",
+ },
},
.minimum_zig_version = "0.16.0",
.paths = .{""},
diff --git a/main.zig b/main.zig
index 339cddba..17837cc3 100644
--- a/main.zig
+++ b/main.zig
@@ -4,6 +4,14 @@ const ghostty_vt = @import("ghostty-vt");
const Term = @import("term.zig");
const modal = @import("modal.zig");
const image = @import("image.zig");
+const ts = @import("tree-sitter");
+const ts_queries = @import("ts_queries");
+
+// grammars whose parser.c we link (see build.zig). The query strings ride along
+// in the ts_queries module.
+extern fn tree_sitter_c() callconv(.c) *const ts.Language;
+extern fn tree_sitter_rust() callconv(.c) *const ts.Language;
+extern fn tree_sitter_zig() callconv(.c) *const ts.Language;
// A TUI can't let std.log hit stderr — it's the same tty as our alt-screen, so the
// message lands on top of the UI (this is the stray text you saw when opening yazi:
@@ -127,8 +135,6 @@ const gray = struct {
const lit = [3]u8{ 0x6e, 0x6e, 0x6e }; // dashed resize preview
const txt = [3]u8{ 0x96, 0x96, 0x96 }; // muted text
};
-// tagline background: the dim gray with a slight indigo tint (more blue, hint of red).
-const tag_bg = [3]u8{ 0x2c, 0x2a, 0x3e };
// the drag button: a distinctive red (brighter when the window is focused).
const red = [3]u8{ 0xcc, 0x33, 0x33 };
const red_dim = [3]u8{ 0x66, 0x2a, 0x2a };
@@ -146,6 +152,88 @@ const sel_fg = [3]u8{ 0xd2, 0xd2, 0xd2 };
const msel_bg = [3]u8{ 0x4a, 0x3a, 0x1e };
const msel_fg = [3]u8{ 0xd8, 0xc8, 0xa8 };
+// Master switch for the per-cell recolor passes (tty ansi + file syntax). Off =
+// the plain-text render path, exactly as before colors existed; the recolor loops
+// are skipped so there's no per-frame cost. Toggled by the "Colors" tagbar command.
+var colors_on: bool = true;
+
+// Color themes. Chrome (scrollbar/box/selection/edit colors above) stays fixed;
+// only the page background, text, tag bar and syntax colors swap. `bg`/`fg` are
+// null in the dark theme so it keeps the terminal's own default colors (i.e. looks
+// exactly as it did before themes existed); the acme light theme paints an explicit
+// pale-yellow page with black text. Cycle with the "NextColor" tagbar command.
+const Theme = struct {
+ name: []const u8,
+ bg: ?[3]u8, // body background (null = terminal default, no paint)
+ fg: ?[3]u8, // body text (null = terminal default)
+ tag_bg: [3]u8,
+ tag_fg: [3]u8,
+ // syntax (keyword + comment render bold)
+ kw: [3]u8,
+ str: [3]u8,
+ num: [3]u8,
+ comment: [3]u8,
+ // the 16 ANSI colors for terminal-pane output; null = pass palette indices
+ // through to the real terminal (dark keeps the terminal's own colors).
+ palette: ?[16][3]u8,
+};
+const themes = [_]Theme{
+ .{
+ .name = "dark",
+ .bg = null,
+ .fg = null,
+ .tag_bg = .{ 0x2c, 0x2a, 0x3e }, // dim gray, slight indigo tint
+ .tag_fg = .{ 0x96, 0x96, 0x96 },
+ .kw = .{ 0xcc, 0x70, 0xd8 }, // magenta
+ .str = .{ 0x8a, 0xb8, 0x7a }, // green
+ .num = .{ 0xd0, 0xc0, 0x70 }, // yellow
+ .comment = .{ 0xf0, 0xf0, 0xf0 }, // bold white
+ .palette = null, // terminal-native ANSI colors
+ },
+ .{
+ .name = "acme",
+ .bg = .{ 0xff, 0xff, 0xea }, // classic acme pale yellow
+ .fg = .{ 0x00, 0x00, 0x00 },
+ .tag_bg = .{ 0xea, 0xff, 0xff }, // acme pale cyan
+ .tag_fg = .{ 0x00, 0x00, 0x00 },
+ .kw = .{ 0x88, 0x22, 0x99 },
+ .str = .{ 0x00, 0x77, 0x33 },
+ .num = .{ 0xa0, 0x52, 0x00 },
+ .comment = .{ 0x55, 0x55, 0x55 }, // bold, darkened to read on yellow
+ // darkened ANSI palette so shell output (ls colors, prompts) reads on the
+ // pale-yellow page; 7/15 (the "whites") map to gray/black, not invisible.
+ .palette = .{
+ .{ 0x00, 0x00, 0x00 }, .{ 0xaa, 0x22, 0x22 }, .{ 0x22, 0x80, 0x22 }, .{ 0x88, 0x66, 0x00 },
+ .{ 0x22, 0x44, 0xbb }, .{ 0x99, 0x33, 0x99 }, .{ 0x11, 0x88, 0x88 }, .{ 0x55, 0x55, 0x55 },
+ .{ 0x88, 0x88, 0x88 }, .{ 0xcc, 0x33, 0x33 }, .{ 0x33, 0xaa, 0x33 }, .{ 0xaa, 0x77, 0x00 },
+ .{ 0x33, 0x55, 0xdd }, .{ 0xbb, 0x44, 0xbb }, .{ 0x22, 0xaa, 0xaa }, .{ 0x00, 0x00, 0x00 },
+ },
+ },
+};
+var theme_idx: usize = 0;
+fn T() *const Theme {
+ return &themes[theme_idx];
+}
+fn rgbColor(c: [3]u8) vaxis.Color {
+ return .{ .rgb = c };
+}
+fn colorOpt(c: ?[3]u8) vaxis.Color {
+ return if (c) |x| .{ .rgb = x } else .default;
+}
+
+// tree-sitter syntax classes; also the per-byte style id stored in File.highlights
+// (0 = none). synStyle maps each to the active theme's color (null for .none).
+const Syn = enum(u8) { none, keyword, string, number, comment };
+fn synStyle(s: Syn) ?struct { fg: [3]u8, bold: bool } {
+ return switch (s) {
+ .none => null,
+ .keyword => .{ .fg = T().kw, .bold = true },
+ .string => .{ .fg = T().str, .bold = false },
+ .number => .{ .fg = T().num, .bold = false },
+ .comment => .{ .fg = T().comment, .bold = true },
+ };
+}
+
// Builtins. Clicking the name runs the action. Window-specific ones (Del, Delcol)
// live in each pane's tag; window-agnostic ones (Kill, Newcol, Tutor) live in the
// single global tagbar at the top. onSelect matches against the whole list, so a
@@ -162,8 +250,9 @@ const builtins = [_]Builtin{
// per-pane tag commands (window-specific).
const pane_builtins_str = "Del Delcol";
// the global tagbar: window-agnostic commands. Kill closes everything; Debug
-// toggles the stats overlay (handled in the tagbar click, not a SelectAction).
-const topbar_str = "Kill Newcol Tutor Debug";
+// toggles the stats overlay, Colors the ansi/syntax recolor, NextColor cycles the
+// theme (all handled in the tagbar click, not a SelectAction).
+const topbar_str = "Kill Newcol Tutor Debug Colors NextColor";
// Path of the generated bash rc that adds OSC 133 semantic prompts (shell
// integration). bash --rcfile reads it for interactive shells; it sources the
@@ -413,6 +502,89 @@ fn readFileAlloc(gpa: std.mem.Allocator, path: [*:0]const u8) ![]u8 {
return buf.toOwnedSlice(gpa);
}
+// Map a tree-sitter capture name (e.g. "keyword.function", "string.special") to a
+// syntax color, by substring — the grammars' highlights.scm use many sub-captures
+// we don't care to distinguish. Returns .none for captures we don't color.
+fn synFor(name: []const u8) Syn {
+ for ([_]struct { []const u8, Syn }{
+ .{ "comment", .comment },
+ .{ "string", .string },
+ .{ "character", .string },
+ .{ "number", .number },
+ .{ "float", .number },
+ .{ "boolean", .number },
+ .{ "keyword", .keyword },
+ .{ "include", .keyword },
+ .{ "operator", .keyword },
+ .{ "conditional", .keyword },
+ .{ "repeat", .keyword },
+ }) |m| {
+ if (std.mem.indexOf(u8, name, m[0]) != null) return m[1];
+ }
+ return .none;
+}
+
+// Syntax-highlight `content` with tree-sitter: returns a gpa-owned byte per content
+// byte (a `Syn` id; 0 = none), or empty when the extension has no grammar. Caller
+// frees (stored in File.highlights; see term.zig). Best-effort — any parse/query
+// failure just yields fewer styled bytes, never an error.
+fn highlightFile(gpa: std.mem.Allocator, path: []const u8, content: []const u8) ![]u8 {
+ const ext = std.fs.path.extension(path);
+ var lang: *const ts.Language = undefined;
+ var query_src: []const u8 = undefined;
+ if (std.mem.eql(u8, ext, ".c") or std.mem.eql(u8, ext, ".h")) {
+ lang = tree_sitter_c();
+ query_src = ts_queries.c_highlights;
+ } else if (std.mem.eql(u8, ext, ".rs")) {
+ lang = tree_sitter_rust();
+ query_src = ts_queries.rust_highlights;
+ } else if (std.mem.eql(u8, ext, ".zig") or std.mem.eql(u8, ext, ".zon")) {
+ lang = tree_sitter_zig();
+ query_src = ts_queries.zig_highlights;
+ } else return &.{};
+ // note: no lang.destroy() — tree_sitter_*() return static singletons reused on
+ // every open; deleting one would use-after-free the next file's parse.
+
+ const styles = try gpa.alloc(u8, content.len);
+ errdefer gpa.free(styles);
+ @memset(styles, 0);
+
+ const parser = ts.Parser.create();
+ defer parser.destroy();
+ parser.setLanguage(lang) catch return styles;
+ const tree = parser.parseString(content, null) orelse return styles;
+ defer tree.destroy();
+
+ var error_offset: u32 = 0;
+ const query = ts.Query.create(lang, query_src, &error_offset) catch return styles;
+ defer query.destroy();
+ const cursor = ts.QueryCursor.create();
+ defer cursor.destroy();
+ cursor.exec(query, tree.rootNode());
+
+ while (cursor.nextMatch()) |match| {
+ for (match.captures) |cap| {
+ const syn = synFor(query.captureNameForId(cap.index) orelse "");
+ if (syn == .none) continue;
+ var b: usize = cap.node.startByte();
+ const end = @min(@as(usize, cap.node.endByte()), styles.len);
+ while (b < end) : (b += 1) styles[b] = @intFromEnum(syn);
+ }
+ }
+ return styles;
+}
+
+// Replace a file pane's content (freeing the old buffer) and re-derive syntax
+// highlighting to match. Every file edit funnels through here, so highlights stay
+// in sync with content (and can never index past it). The reparse is skipped when
+// colors are off, keeping the edit path exactly as fast as before colors existed.
+fn setFileContent(gpa: std.mem.Allocator, f: *Term.File, new: []u8) void {
+ gpa.free(f.content);
+ f.content = new;
+ if (f.highlights.len > 0) gpa.free(f.highlights);
+ f.highlights = if (colors_on) (highlightFile(gpa, f.path, new) catch &.{}) else &.{};
+}
+
// Open a read/editable FILE pane: a Term with `file` set, sharing all the
// layout/selection/edit machinery. No pty (handle -1), no reader, a stub 1x1
// emulator (only its allocator-owned bits are touched). `line` (1-based, 0=none)
@@ -422,6 +594,8 @@ fn openFileView(gpa: std.mem.Allocator, rp: [*:0]const u8, line: usize, cols: u1
errdefer gpa.free(content);
const path = try gpa.dupe(u8, std.mem.span(rp));
errdefer gpa.free(path);
+ const highlights = try highlightFile(gpa, path, content);
+ errdefer if (highlights.len > 0) gpa.free(highlights);
const t = try gpa.create(Term);
errdefer gpa.destroy(t);
const total = fileLineCount(content);
@@ -432,7 +606,7 @@ fn openFileView(gpa: std.mem.Allocator, rp: [*:0]const u8, line: usize, cols: u1
.term = try ghostty_vt.Terminal.init(gpa, .{ .cols = 1, .rows = 1 }),
.stream = undefined,
.reader = .{ .any_future = null, .result = {} },
- .file = .{ .path = path, .content = content, .scroll = scroll },
+ .file = .{ .path = path, .content = content, .scroll = scroll, .highlights = highlights },
.cols = cols,
.rows = rows,
.cur_pinned = true,
@@ -695,6 +869,60 @@ fn computeGeom(
}
}
+// A ghostty palette index → vaxis color. With a themed palette (light) the 16
+// ANSI colors resolve to the theme's rgb so shell output matches the page; without
+// one (dark) they pass through as palette indices for the real terminal to render.
+fn palColor(idx: u8) vaxis.Color {
+ if (T().palette) |p| if (idx < 16) return rgbColor(p[idx]);
+ return .{ .index = idx };
+}
+
+// Translate a ghostty cell color to vaxis. `default` (no explicit color) resolves
+// to the theme's page fg/bg — so on the acme theme the terminal's own default text
+// becomes black-on-yellow instead of the real terminal's colors. dark keeps null
+// (→ vaxis .default → terminal native, unchanged).
+fn ghostColor(color: ghostty_vt.Style.Color, is_bg: bool) vaxis.Color {
+ return switch (color) {
+ .none => colorOpt(if (is_bg) T().bg else T().fg),
+ .palette => |idx| palColor(idx),
+ .rgb => |rgb| .{ .rgb = .{ rgb.r, rgb.g, rgb.b } },
+ };
+}
+
+// The vaxis style for one emulator cell: fg/bg/attrs translated from ghostty.
+fn cellStyle(ci: ghostty_vt.PageList.Cell) vaxis.Style {
+ const style = ci.style();
+ var vs: vaxis.Style = .{
+ .fg = ghostColor(style.fg_color, false),
+ .bg = ghostColor(style.bg_color, true),
+ .bold = style.flags.bold,
+ .dim = style.flags.faint,
+ .italic = style.flags.italic,
+ .blink = style.flags.blink,
+ .reverse = style.flags.inverse,
+ .invisible = style.flags.invisible,
+ .strikethrough = style.flags.strikethrough,
+ .ul_style = switch (style.flags.underline) {
+ .none => .off,
+ .single => .single,
+ .double => .double,
+ .curly => .curly,
+ .dotted => .dotted,
+ .dashed => .dashed,
+ },
+ };
+ // a cell can carry its bg directly (palette/rgb) rather than via its style.
+ switch (ci.cell.content_tag) {
+ .bg_color_palette => vs.bg = palColor(ci.cell.content.color_palette.data),
+ .bg_color_rgb => {
+ const rgb = ci.cell.content.color_rgb;
+ vs.bg = .{ .rgb = .{ rgb.r, rgb.g, rgb.b } };
+ },
+ else => {},
+ }
+ return vs;
+}
+
// `alloc` must be a per-frame arena, NOT freed before vx.render(): vaxis is a
// retained renderer and stores each cell's grapheme as a *slice* into the text
// we hand it, so plainString has to outlive the render call.
@@ -704,12 +932,14 @@ fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, a
// text area: tag line on row 0 (same row as the box), terminal body below.
const text = root.child(.{ .x_off = r.x + GUTTER, .y_off = r.y, .width = r.w - GUTTER, .height = r.h });
text.clear();
+ // light themes paint the page; dark leaves it on the terminal's own background.
+ if (T().bg) |bg| text.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = rgbColor(bg) } });
// tag: cwd + builtins, drawn as a dim bar; plain selectable text.
const tag = try tagLine(alloc, t);
const tagw = text.child(.{ .height = BOX_H });
- tagw.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = tag_bg } } });
- _ = tagw.printSegment(.{ .text = tag, .style = .{ .fg = .{ .rgb = gray.txt }, .bg = .{ .rgb = tag_bg } } }, .{ .wrap = .none });
+ tagw.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = rgbColor(T().tag_bg) } });
+ _ = tagw.printSegment(.{ .text = tag, .style = .{ .fg = rgbColor(T().tag_fg), .bg = rgbColor(T().tag_bg) } }, .{ .wrap = .none });
// image pane: the picture fills the body; draw the box + a plain (thumbless)
// gutter so it reads like any other pane. kitty placements are re-issued each
@@ -733,10 +963,63 @@ fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, a
// shells emit OSC 133 via the rc we drop at startup (writeFile/bash_rc).
const off: i32 = paneScroll(t);
const body = try bodyText(alloc, t);
+ const body_style: vaxis.Style = .{ .fg = colorOpt(T().fg), .bg = colorOpt(T().bg) };
var it = std.mem.splitAny(u8, body, "\n");
var i: u16 = 0;
while (it.next()) |line| : (i += 1) {
- _ = text.printSegment(.{ .text = line }, .{ .row_offset = BOX_H + i, .wrap = .none });
+ _ = text.printSegment(.{ .text = line, .style = body_style }, .{ .row_offset = BOX_H + i, .wrap = .none });
+ }
+
+ const body_w = r.w - GUTTER;
+ const body_h = if (r.h > BOX_H) r.h - BOX_H else 0;
+ // syntax colors (file panes): recolor each content cell from its tree-sitter
+ // style. Content starts after the PREFIX_W line-number gutter; highlights are
+ // indexed by content byte, and a line slice's byte offset is its ptr - content.
+ const pw: usize = PREFIX_W;
+ const bw: usize = body_w;
+ if (colors_on) {
+ if (t.file) |f| {
+ if (f.highlights.len > 0) {
+ var lines = std.mem.splitScalar(u8, f.content, '\n');
+ var skip: usize = 0;
+ while (skip < f.scroll) : (skip += 1) if (lines.next() == null) break;
+ var vr: u16 = 0;
+ while (vr < body_h) : (vr += 1) {
+ const line = lines.next() orelse break;
+ const base = @intFromPtr(line.ptr) - @intFromPtr(f.content.ptr);
+ var c: usize = 0;
+ while (c < line.len and pw + c < bw) : (c += 1) {
+ const ss = synStyle(@enumFromInt(f.highlights[base + c])) orelse continue;
+ const cx: u16 = @intCast(pw + c);
+ if (text.readCell(cx, BOX_H + vr)) |cell| {
+ var nc = cell;
+ nc.default = false;
+ nc.style.fg = rgbColor(ss.fg);
+ nc.style.bold = ss.bold;
+ text.writeCell(cx, BOX_H + vr, nc);
+ }
+ }
+ }
+ }
+ } else if (t.isTerminal()) {
+ // tty colors: recolor each body cell from the emulator's per-cell style, so
+ // shell output (ls, prompts, TUIs) keeps its ansi colors. row i <-> viewport
+ // row i, col c <-> viewport col c (1:1; edit runs are repainted just below).
+ var vr: u16 = 0;
+ while (vr < body_h and vr < t.rows) : (vr += 1) {
+ var c: u16 = 0;
+ while (c < body_w) : (c += 1) {
+ const ci = t.term.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(c), .y = @intCast(vr) } }) orelse continue;
+ if (ci.cell.wide == .spacer_tail) continue;
+ if (text.readCell(c, BOX_H + vr)) |cell| {
+ var nc = cell;
+ nc.default = false;
+ nc.style = cellStyle(ci);
+ text.writeCell(c, BOX_H + vr, nc);
+ }
+ }
+ }
+ }
}
// recolor the inserted cells so typed text reads distinctly from tty output.
@@ -807,13 +1090,13 @@ fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, a
if (active) {
if (t.mode != .tty) {
// text mode (normal/insert): cursor at the (scroll-adjusted) edit
- // position. until the user pins it (click/type) it tracks the shell cursor.
+ // position. until the user pins it (click/type) it tracks the shell cursor.
const crow = if (t.cur_pinned) t.cur_row else @as(i32, @intCast(t.term.screens.active.cursor.y)) + off;
const ccol = if (t.cur_pinned) t.cur_col else @as(i32, @intCast(t.term.screens.active.cursor.x));
const coff = if (t.cur_pinned) t.cur_off else 0;
const prow = crow - off + @as(i32, BOX_H);
// insert mode uses the edit-run coordinate -> visual mapping; normal
- // mode keeps cur_col already-visual; file panes add the line-number prefix.
+ // mode keeps cur_col already-visual; file panes add the line-number prefix.
const cx = if (t.file != null)
@as(i32, PREFIX_W) + ccol + @as(i32, coff)
else if (t.mode == .insert)
@@ -919,8 +1202,7 @@ fn applyMove(t: *Term, pl: PaneLines, c: modal.Cursor) void {
fn ensureCursorVisible(t: *Term) void {
const off = paneScroll(t);
const last = off + @as(i32, t.rows) - 1;
- if (t.cur_row < off) paneScrollBy(t, t.cur_row - off)
- else if (t.cur_row > last) paneScrollBy(t, t.cur_row - last);
+ if (t.cur_row < off) paneScrollBy(t, t.cur_row - off) else if (t.cur_row > last) paneScrollBy(t, t.cur_row - last);
}
fn alignViewportTop(t: *Term) void {
@@ -981,8 +1263,7 @@ fn enterInsert(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term, wher
.open_below => {
if (t.file) |*f| {
const new = modal.insertAt(gpa, f.content, .{ .row = cur.row, .col = llen }, "\n") catch return;
- gpa.free(f.content);
- f.content = new;
+ setFileContent(gpa, f, new);
t.cur_row = @as(i32, @intCast(cur.row + 1)) + pl.row0;
t.cur_col = 0;
} else {
@@ -996,8 +1277,7 @@ fn enterInsert(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term, wher
.open_above => {
if (t.file) |*f| {
const new = modal.insertAt(gpa, f.content, .{ .row = cur.row, .col = 0 }, "\n") catch return;
- gpa.free(f.content);
- f.content = new;
+ setFileContent(gpa, f, new);
// current line shifts down; the new empty line is at cur.row
t.cur_col = 0;
} else {
@@ -1071,8 +1351,7 @@ fn normalDelete(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term) voi
t.msel.active = false;
return;
};
- gpa.free(f.content);
- f.content = d.content;
+ setFileContent(gpa, f, d.content);
setYank(gpa, d.deleted);
gpa.free(d.deleted);
const n = modal.lineCount(f.content);
@@ -1089,8 +1368,7 @@ fn normalDelete(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term) voi
} else if (t.file) |*f| {
const c = modal.Cursor{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) };
const new = modal.deleteChar(gpa, f.content, c) catch return;
- gpa.free(f.content);
- f.content = new;
+ setFileContent(gpa, f, new);
} // terminal, no selection: can't delete shell output -> no-op
}
@@ -1126,8 +1404,7 @@ fn normalPaste(gpa: std.mem.Allocator, t: *Term) void {
const y = yank_reg orelse return;
if (t.file) |*f| {
const new = modal.pasteLineBelow(gpa, f.content, @intCast(@max(0, t.cur_row)), y) catch return;
- gpa.free(f.content);
- f.content = new;
+ setFileContent(gpa, f, new);
t.cur_row += 1;
t.cur_col = 0;
} else {
@@ -1160,16 +1437,14 @@ fn normalChange(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term) voi
t.msel.active = false;
return;
};
- gpa.free(f.content);
- f.content = d.content;
+ setFileContent(gpa, f, d.content);
gpa.free(d.deleted);
}
const cl = modal.clearLine(gpa, f.content, @intCast(@max(0, r0))) catch {
t.msel.active = false;
return;
};
- gpa.free(f.content);
- f.content = cl;
+ setFileContent(gpa, f, cl);
t.cur_row = r0;
t.cur_col = 0;
} else {
@@ -1187,8 +1462,7 @@ fn normalChange(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term) voi
} else if (t.file) |*f| {
const c = modal.Cursor{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) };
const new = modal.deleteChar(gpa, f.content, c) catch return;
- gpa.free(f.content);
- f.content = new;
+ setFileContent(gpa, f, new);
t.mode = .insert;
t.cur_off = 0;
t.pending = 0;
@@ -1354,8 +1628,7 @@ fn handleInsert(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term, key
const c = modal.Cursor{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) };
if (key.text) |text| {
const new = modal.insertAt(gpa, f.content, c, text) catch return;
- gpa.free(f.content);
- f.content = new;
+ setFileContent(gpa, f, new);
for (text) |ch| {
if (ch == '\n') {
t.cur_row += 1;
@@ -1369,8 +1642,7 @@ fn handleInsert(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term, key
switch (key.codepoint) {
vaxis.Key.enter => {
const new = modal.insertAt(gpa, f.content, c, "\n") catch return;
- gpa.free(f.content);
- f.content = new;
+ setFileContent(gpa, f, new);
t.cur_row += 1;
t.cur_col = 0;
t.cur_pinned = true;
@@ -1379,14 +1651,12 @@ fn handleInsert(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term, key
vaxis.Key.backspace => {
if (t.cur_col > 0) {
const new = modal.deleteChar(gpa, f.content, .{ .row = c.row, .col = c.col - 1 }) catch return;
- gpa.free(f.content);
- f.content = new;
+ setFileContent(gpa, f, new);
t.cur_col -= 1;
} else if (t.cur_row > 0) {
const prevlen = modal.lineSlice(f.content, c.row - 1).len;
const new = modal.deleteChar(gpa, f.content, .{ .row = c.row - 1, .col = prevlen }) catch return;
- gpa.free(f.content);
- f.content = new;
+ setFileContent(gpa, f, new);
t.cur_row -= 1;
t.cur_col = @intCast(prevlen);
}
@@ -1875,6 +2145,10 @@ pub fn main(init: std.process.Init) !void {
const word = wordAtCol(topbar_str, mcol);
if (std.mem.eql(u8, word, "Debug")) {
show_debug = !show_debug;
+ } else if (std.mem.eql(u8, word, "Colors")) {
+ colors_on = !colors_on;
+ } else if (std.mem.eql(u8, word, "NextColor")) {
+ theme_idx = (theme_idx + 1) % themes.len;
} else if (word.len > 0) {
if (actOnSelection(io, gpa, &loop, &terms, &layout, &rects, &active, screen_w, screen_h, vx.caps.kitty_graphics, .middle, active, word)) break;
}
@@ -2075,10 +2349,7 @@ pub fn main(init: std.process.Init) !void {
// Ctrl-w prefix: helix-style pane focus (Ctrl-w h/j/k/l).
if (ctrl_w_pending) {
ctrl_w_pending = false;
- if (key.matches('h', .{}) or key.matches(vaxis.Key.left, .{})) focusDir(&active, &terms, &rects, .left)
- else if (key.matches('l', .{}) or key.matches(vaxis.Key.right, .{})) focusDir(&active, &terms, &rects, .right)
- else if (key.matches('k', .{}) or key.matches(vaxis.Key.up, .{})) focusDir(&active, &terms, &rects, .up)
- else if (key.matches('j', .{}) or key.matches(vaxis.Key.down, .{})) focusDir(&active, &terms, &rects, .down);
+ if (key.matches('h', .{}) or key.matches(vaxis.Key.left, .{})) focusDir(&active, &terms, &rects, .left) else if (key.matches('l', .{}) or key.matches(vaxis.Key.right, .{})) focusDir(&active, &terms, &rects, .right) else if (key.matches('k', .{}) or key.matches(vaxis.Key.up, .{})) focusDir(&active, &terms, &rects, .up) else if (key.matches('j', .{}) or key.matches(vaxis.Key.down, .{})) focusDir(&active, &terms, &rects, .down);
if (terms[active]) |nt| nt.pending = 0;
break :kpress;
}
@@ -2242,8 +2513,8 @@ pub fn main(init: std.process.Init) !void {
// global tagbar (full width, top row): app name + window-agnostic commands.
const bar = win.child(.{ .x_off = 0, .y_off = 0, .width = screen_w, .height = TOPBAR_H });
- bar.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = tag_bg } } });
- _ = bar.printSegment(.{ .text = topbar_str, .style = .{ .fg = .{ .rgb = gray.txt }, .bg = .{ .rgb = tag_bg } } }, .{ .wrap = .none });
+ bar.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = rgbColor(T().tag_bg) } });
+ _ = bar.printSegment(.{ .text = topbar_str, .style = .{ .fg = rgbColor(T().tag_fg), .bg = rgbColor(T().tag_bg) } }, .{ .wrap = .none });
// No static separators: each pane's tagline (top) and scrollbar gutter
// (left) already read as borders. The 1-cell gap between panes stays as
@@ -2471,7 +2742,9 @@ fn bodyText(alloc: std.mem.Allocator, t: *Term) ![]u8 {
const abs: i32 = @as(i32, @intCast(f.scroll)) + @as(i32, @intCast(i));
if (lines.next()) |text| {
var lbuf: [16]u8 = undefined;
- const prefix = std.fmt.bufPrint(&lbuf, "{d: >4} ", .{abs + 1}) catch " ";
+ // unsigned: Zig 0.16's {d} prints a leading '+' for signed ints.
+ const lineno: usize = @intCast(abs + 1);
+ const prefix = std.fmt.bufPrint(&lbuf, "{d:0>4} ", .{lineno}) catch " ";
var base: std.ArrayList(u8) = .empty;
defer base.deinit(alloc);
try base.appendSlice(alloc, prefix);
diff --git a/term.zig b/term.zig
index 6989eb79..c4fe0d93 100644
--- a/term.zig
+++ b/term.zig
@@ -37,6 +37,9 @@ pub const File = struct {
path: []const u8, // gpa-owned absolute path
content: []u8, // gpa-owned file bytes
scroll: usize = 0, // top visible file line
+ /// tree-sitter syntax styles, one byte per `content` byte (0 = none). Empty
+ /// when the file's language isn't highlighted. gpa-owned. See main.highlightFile.
+ highlights: []u8 = &.{},
};
/// A run of inserted ("char mode") text. `col` is the ORIGINAL tty column it is
@@ -121,6 +124,7 @@ pub fn deinit(self: *Term, io: std.Io, gpa: std.mem.Allocator) void {
if (self.file) |f| {
gpa.free(f.path);
gpa.free(f.content);
+ if (f.highlights.len > 0) gpa.free(f.highlights);
} else if (self.image) |iv| {
gpa.free(iv.path);
} else {