diff options
| author | Gabriel Schneider <[email protected]> | 2026-06-30 15:32:56 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-06-30 17:43:21 -0300 |
| commit | 1f65d7368c9a31427b145f683eb891da16181b73 (patch) | |
| tree | 88a3754e7d01906098af82335b84edb8754f31ca | |
| parent | a68564e9fb9fb8be6d997db5e6ad9148fb506d18 (diff) | |
| download | pardes-1f65d7368c9a31427b145f683eb891da16181b73.tar.gz pardes-1f65d7368c9a31427b145f683eb891da16181b73.zip | |
merging the colors into
| -rw-r--r-- | build.zig | 34 | ||||
| -rw-r--r-- | build.zig.zon | 18 | ||||
| -rw-r--r-- | main.zig | 359 | ||||
| -rw-r--r-- | term.zig | 4 |
4 files changed, 372 insertions, 43 deletions
@@ -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 = .{""}, @@ -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); @@ -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 { |
