diff --git a/README.md b/README.md index a57b5ad..51848e3 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Architect solves this with a grid view that keeps all your agents visible, with ### Terminal Essentials - Smooth animated transitions for grid expansion, contraction, and reflow (cells and borders move/resize together) -- Keyboard navigation: ⌘+Return to expand, ⌘1–⌘0 to switch grid slots, ⌘N to add, ⌘W to close a terminal (restarts if it's the only terminal), ⌘T for worktrees, ⌘O for recent folders, ⌘D for repo-wide git diff (staged + unstaged), ⌘/ for shortcuts; quit with ⌘Q or the window close button +- Keyboard navigation: ⌘+Return to expand, ⌘1–⌘0 to switch grid slots, ⌘N to add, ⌘W to close a terminal (restarts if it's the only terminal), ⌘T for worktrees, ⌘O for recent folders, ⌘D for repo-wide git diff (staged + unstaged + untracked), ⌘/ for shortcuts; quit with ⌘Q or the window close button - Git diff overlay title shows the repo root folder being diffed - Per-cell cwd bar in grid view with reserved space so terminal content stays visible - Scrollback with trackpad/wheel support and grid indicator when scrolled diff --git a/src/app/runtime.zig b/src/app/runtime.zig index f4a3981..365c033 100644 --- a/src/app/runtime.zig +++ b/src/app/runtime.zig @@ -1826,8 +1826,11 @@ pub fn run() !void { sessions[anim_state.focused_session].cwd_path else null; - diff_overlay_component.toggle(focused_cwd, now); - if (config.ui.show_hotkey_feedback) ui.showHotkey("⌘D", now); + switch (diff_overlay_component.toggle(focused_cwd, now)) { + .not_a_repo => ui.showToast("Not a git repository", now), + .clean => ui.showToast("Working tree clean", now), + .opened => if (config.ui.show_hotkey_feedback) ui.showHotkey("⌘D", now), + } }, }; diff --git a/src/ui/components/diff_overlay.zig b/src/ui/components/diff_overlay.zig index 4c4092d..ecad70a 100644 --- a/src/ui/components/diff_overlay.zig +++ b/src/ui/components/diff_overlay.zig @@ -45,6 +45,7 @@ const DisplayRow = struct { hunk_index: ?usize = null, line_index: ?usize = null, message: ?[]u8 = null, + text_byte_offset: usize = 0, }; const SegmentKind = enum { @@ -106,6 +107,8 @@ pub const DiffOverlayComponent = struct { close_hovered: bool = false, hovered_file: ?usize = null, + wrap_cols: usize = 0, + animation_state: AnimationState = .closed, animation_start_ms: i64 = 0, render_alpha: f32 = 1.0, @@ -127,6 +130,8 @@ pub const DiffOverlayComponent = struct { const chevron_size: c_int = 12; const file_header_pad: c_int = 8; const max_output_bytes: usize = 4 * 1024 * 1024; + const tab_display_width: usize = 4; + const min_printable_char: u8 = 32; // max_chars plus room for tab-to-spaces expansion const max_display_buffer: usize = 520; @@ -145,13 +150,15 @@ pub const DiffOverlayComponent = struct { }; } - pub fn show(self: *DiffOverlayComponent, cwd: ?[]const u8, now_ms: i64) void { + pub const ShowResult = enum { opened, not_a_repo, clean }; + + pub fn show(self: *DiffOverlayComponent, cwd: ?[]const u8, now_ms: i64) ShowResult { self.visible = true; self.scroll_offset = 0; self.animation_state = .opening; self.animation_start_ms = now_ms; self.first_frame.markTransition(); - self.loadDiff(cwd); + return self.loadDiff(cwd); } pub fn hide(self: *DiffOverlayComponent, now_ms: i64) void { @@ -160,24 +167,37 @@ pub const DiffOverlayComponent = struct { self.first_frame.markTransition(); } - pub fn toggle(self: *DiffOverlayComponent, cwd: ?[]const u8, now_ms: i64) void { + pub fn toggle(self: *DiffOverlayComponent, cwd: ?[]const u8, now_ms: i64) ShowResult { switch (self.animation_state) { - .open, .opening => self.hide(now_ms), - .closed => self.show(cwd, now_ms), - .closing => {}, + .open, .opening => { + self.hide(now_ms); + return .opened; + }, + .closed => return self.show(cwd, now_ms), + .closing => return .opened, } } - fn loadDiff(self: *DiffOverlayComponent, cwd: ?[]const u8) void { + fn cancelShow(self: *DiffOverlayComponent) void { + self.visible = false; + self.animation_state = .closed; + } + + fn loadDiff(self: *DiffOverlayComponent, cwd: ?[]const u8) ShowResult { self.clearContent(); const dir = cwd orelse { - self.setSingleLine("No working directory detected."); - return; + self.cancelShow(); + return .not_a_repo; }; self.updateRepoRoot(dir); + if (self.last_repo_root == null) { + self.cancelShow(); + return .not_a_repo; + } + const argv_unstaged = [_][]const u8{ "git", "--no-pager", @@ -199,41 +219,41 @@ pub const DiffOverlayComponent = struct { var combined = std.ArrayList(u8).initCapacity(self.allocator, 1024) catch |err| { log.warn("failed to allocate diff buffer: {}", .{err}); self.setSingleLine("Failed to allocate diff buffer."); - return; + return .opened; }; defer combined.deinit(self.allocator); const unstaged = self.runGitCommand(dir, &argv_unstaged) catch |err| { self.handleGitError(err); - return; + return .opened; }; defer self.freeGitResult(unstaged); if (self.gitExitErrorText(unstaged)) |err_text| { self.setSingleLine(err_text); - return; + return .opened; } if (unstaged.stdout.len > 0) { combined.appendSlice(self.allocator, unstaged.stdout) catch |err| { log.warn("failed to append unstaged diff: {}", .{err}); self.setSingleLine("Failed to build git diff output."); - return; + return .opened; }; } const staged = self.runGitCommand(dir, &argv_staged) catch |err| { if (combined.items.len == 0) { self.handleGitError(err); - return; + return .opened; } log.warn("failed to run staged git diff: {}", .{err}); - return; + return .opened; }; defer self.freeGitResult(staged); if (self.gitExitErrorText(staged)) |err_text| { if (combined.items.len == 0) { self.setSingleLine(err_text); - return; + return .opened; } log.warn("staged git diff failed: {s}", .{err_text}); } else if (staged.stdout.len > 0) { @@ -241,38 +261,187 @@ pub const DiffOverlayComponent = struct { combined.append(self.allocator, '\n') catch |err| { log.warn("failed to append diff separator: {}", .{err}); self.setSingleLine("Failed to build git diff output."); - return; + return .opened; }; } if (combined.items.len > 0) { combined.append(self.allocator, '\n') catch |err| { log.warn("failed to append diff separator: {}", .{err}); self.setSingleLine("Failed to build git diff output."); - return; + return .opened; }; } combined.appendSlice(self.allocator, staged.stdout) catch |err| { log.warn("failed to append staged diff: {}", .{err}); self.setSingleLine("Failed to build git diff output."); - return; + return .opened; }; } + self.appendUntrackedFiles(dir, &combined); + if (combined.items.len == 0) { - self.setSingleLine("Working tree clean."); - return; + self.cancelShow(); + return .clean; } self.raw_output = combined.toOwnedSlice(self.allocator) catch |err| { log.warn("failed to store git diff output: {}", .{err}); self.setSingleLine("Failed to build git diff output."); - return; + return .opened; }; const output = self.raw_output orelse { self.setSingleLine("Failed to build git diff output."); - return; + return .opened; }; self.parseDiffOutput(output); + return .opened; + } + + fn appendUntrackedFiles(self: *DiffOverlayComponent, cwd: []const u8, combined: *std.ArrayList(u8)) void { + const repo_root = self.last_repo_root orelse cwd; + + const argv = [_][]const u8{ + "git", + "ls-files", + "--others", + "--exclude-standard", + }; + + const result = self.runGitCommand(repo_root, &argv) catch |err| { + log.warn("failed to list untracked files: {}", .{err}); + return; + }; + defer self.freeGitResult(result); + + if (self.gitExitErrorText(result) != null) return; + if (result.stdout.len == 0) return; + + var pos: usize = 0; + while (pos < result.stdout.len) { + const line_end = std.mem.indexOfScalarPos(u8, result.stdout, pos, '\n') orelse result.stdout.len; + const rel_path = result.stdout[pos..line_end]; + pos = if (line_end < result.stdout.len) line_end + 1 else result.stdout.len; + + if (rel_path.len == 0) continue; + + self.appendSingleUntrackedFile(repo_root, rel_path, combined); + + if (combined.items.len >= max_output_bytes) break; + } + } + + fn appendSingleUntrackedFile(self: *DiffOverlayComponent, repo_root: []const u8, rel_path: []const u8, combined: *std.ArrayList(u8)) void { + if (rel_path.len > 0 and rel_path[rel_path.len - 1] == '/') return; + + const abs_path = std.fs.path.join(self.allocator, &.{ repo_root, rel_path }) catch |err| { + log.warn("failed to join path for untracked file: {}", .{err}); + return; + }; + defer self.allocator.free(abs_path); + + const file = std.fs.openFileAbsolute(abs_path, .{}) catch |err| { + log.warn("failed to open untracked file {s}: {}", .{ rel_path, err }); + return; + }; + defer file.close(); + + const stat = file.stat() catch |err| { + log.warn("failed to stat untracked file {s}: {}", .{ rel_path, err }); + return; + }; + + // Skip files that are too large or likely binary + const max_file_bytes: usize = 256 * 1024; + if (stat.size > max_file_bytes) { + self.appendUntrackedHeader(rel_path, combined); + combined.appendSlice(self.allocator, "@@ -0,0 +1 @@\n+\n") catch |err| { + log.warn("failed to append untracked placeholder: {}", .{err}); + }; + return; + } + + const content = file.readToEndAlloc(self.allocator, max_file_bytes) catch |err| { + log.warn("failed to read untracked file {s}: {}", .{ rel_path, err }); + return; + }; + defer self.allocator.free(content); + + if (content.len == 0) { + self.appendUntrackedHeader(rel_path, combined); + combined.appendSlice(self.allocator, "@@ -0,0 +0,0 @@\n") catch |err| { + log.warn("failed to append empty file hunk: {}", .{err}); + }; + return; + } + + if (looksLikeBinary(content)) { + self.appendUntrackedHeader(rel_path, combined); + combined.appendSlice(self.allocator, "@@ -0,0 +1 @@\n+\n") catch |err| { + log.warn("failed to append binary placeholder: {}", .{err}); + }; + return; + } + + // Count lines + var line_count: usize = 0; + for (content) |ch| { + if (ch == '\n') line_count += 1; + } + if (content.len > 0 and content[content.len - 1] != '\n') line_count += 1; + + self.appendUntrackedHeader(rel_path, combined); + + // Hunk header: @@ -0,0 +1,N @@ + var hunk_buf: [64]u8 = undefined; + const hunk_header = std.fmt.bufPrint(&hunk_buf, "@@ -0,0 +1,{d} @@\n", .{line_count}) catch return; + combined.appendSlice(self.allocator, hunk_header) catch |err| { + log.warn("failed to append hunk header: {}", .{err}); + return; + }; + + // Each line prefixed with '+' + var line_pos: usize = 0; + while (line_pos < content.len) { + if (combined.items.len >= max_output_bytes) break; + const eol = std.mem.indexOfScalarPos(u8, content, line_pos, '\n') orelse content.len; + combined.append(self.allocator, '+') catch |err| { + log.warn("failed to append line marker: {}", .{err}); + return; + }; + combined.appendSlice(self.allocator, content[line_pos..eol]) catch |err| { + log.warn("failed to append line content: {}", .{err}); + return; + }; + combined.append(self.allocator, '\n') catch |err| { + log.warn("failed to append newline: {}", .{err}); + return; + }; + line_pos = if (eol < content.len) eol + 1 else content.len; + } + } + + fn appendUntrackedHeader(self: *DiffOverlayComponent, rel_path: []const u8, combined: *std.ArrayList(u8)) void { + if (combined.items.len > 0 and combined.items[combined.items.len - 1] != '\n') { + combined.append(self.allocator, '\n') catch return; + } + + // diff --git a/ b/ + combined.appendSlice(self.allocator, "diff --git a/") catch return; + combined.appendSlice(self.allocator, rel_path) catch return; + combined.appendSlice(self.allocator, " b/") catch return; + combined.appendSlice(self.allocator, rel_path) catch return; + combined.appendSlice(self.allocator, "\nnew file\n--- /dev/null\n+++ b/") catch return; + combined.appendSlice(self.allocator, rel_path) catch return; + combined.append(self.allocator, '\n') catch return; + } + + fn looksLikeBinary(content: []const u8) bool { + const check_len = @min(content.len, 8192); + for (content[0..check_len]) |ch| { + if (ch == 0) return true; + } + return false; } fn updateRepoRoot(self: *DiffOverlayComponent, cwd: []const u8) void { @@ -598,20 +767,66 @@ pub const DiffOverlayComponent = struct { var line_idx: usize = 0; const hunk = &file.hunks.items[hunk_idx]; while (line_idx < hunk.lines.items.len) : (line_idx += 1) { - self.display_rows.append(self.allocator, .{ - .kind = .diff_line, - .file_index = file_idx, - .hunk_index = hunk_idx, - .line_index = line_idx, - }) catch |err| { - log.warn("failed to append diff row: {}", .{err}); - return; - }; + const line_text = hunk.lines.items[line_idx].text; + self.appendWrappedDiffRows(file_idx, hunk_idx, line_idx, line_text); } } } } + fn appendWrappedDiffRows(self: *DiffOverlayComponent, file_idx: usize, hunk_idx: usize, line_idx: usize, text: []const u8) void { + if (self.wrap_cols == 0 or textDisplayCols(text) <= self.wrap_cols) { + self.display_rows.append(self.allocator, .{ + .kind = .diff_line, + .file_index = file_idx, + .hunk_index = hunk_idx, + .line_index = line_idx, + }) catch |err| { + log.warn("failed to append diff row: {}", .{err}); + }; + return; + } + + var byte_off: usize = 0; + while (byte_off < text.len) { + self.display_rows.append(self.allocator, .{ + .kind = .diff_line, + .file_index = file_idx, + .hunk_index = hunk_idx, + .line_index = line_idx, + .text_byte_offset = byte_off, + }) catch |err| { + log.warn("failed to append wrapped diff row: {}", .{err}); + return; + }; + byte_off = byteOffsetAtDisplayCol(text, byte_off, self.wrap_cols); + } + } + + fn textDisplayCols(text: []const u8) usize { + var cols: usize = 0; + for (text) |ch| { + if (ch == '\t') cols += tab_display_width else if (ch >= min_printable_char) cols += 1; + } + return cols; + } + + fn byteOffsetAtDisplayCol(text: []const u8, start: usize, max_cols: usize) usize { + var cols: usize = 0; + var i: usize = start; + while (i < text.len) { + const byte_len = std.unicode.utf8ByteSequenceLength(text[i]) catch |err| blk: { + log.warn("invalid UTF-8 lead byte at offset {}: {}", .{ i, err }); + break :blk 1; + }; + const advance: usize = if (text[i] == '\t') tab_display_width else if (text[i] >= min_printable_char) 1 else 0; + if (cols + advance > max_cols and cols > 0) break; + cols += advance; + i += @min(byte_len, text.len - i); + } + return i; + } + // --- Animation helpers --- fn animationProgress(self: *const DiffOverlayComponent, now_ms: i64) f32 { @@ -918,7 +1133,7 @@ pub const DiffOverlayComponent = struct { }); } - fn renderCloseButton(self: *DiffOverlayComponent, host: *const types.UiHost, renderer: *c.SDL_Renderer, assets: *types.UiAssets, overlay_rect: geom.Rect, scaled_font_size: c_int) void { + fn renderCloseButton(self: *DiffOverlayComponent, host: *const types.UiHost, renderer: *c.SDL_Renderer, _: *types.UiAssets, overlay_rect: geom.Rect, _: c_int) void { const scaled_btn_size = dpi.scale(close_btn_size, host.ui_scale); const scaled_btn_margin = dpi.scale(close_btn_margin, host.ui_scale); const btn_rect = geom.Rect{ @@ -927,50 +1142,63 @@ pub const DiffOverlayComponent = struct { .w = scaled_btn_size, .h = scaled_btn_size, }; - const radius = dpi.scale(6, host.ui_scale); - const btn_alpha: u8 = @intFromFloat(200.0 * self.render_alpha); + const fg = host.theme.foreground; + const alpha: u8 = @intFromFloat(if (self.close_hovered) 255.0 * self.render_alpha else 160.0 * self.render_alpha); _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); - if (self.close_hovered) { - const red = host.theme.palette[1]; - _ = c.SDL_SetRenderDrawColor(renderer, red.r, red.g, red.b, btn_alpha); - } else { - const sel = host.theme.selection; - _ = c.SDL_SetRenderDrawColor(renderer, sel.r, sel.g, sel.b, btn_alpha); - } - primitives.fillRoundedRect(renderer, btn_rect, radius); + _ = c.SDL_SetRenderDrawColor(renderer, fg.r, fg.g, fg.b, alpha); - const cache = assets.font_cache orelse return; - const fonts = cache.get(scaled_font_size) catch return; + const cross_size: c_int = @divFloor(btn_rect.w * 6, 10); + const cross_x = btn_rect.x + @divFloor(btn_rect.w - cross_size, 2); + const cross_y = btn_rect.y + @divFloor(btn_rect.h - cross_size, 2); - const x_text = "X"; - const fg = host.theme.foreground; - const fg_color = c.SDL_Color{ .r = fg.r, .g = fg.g, .b = fg.b, .a = 255 }; + const x1: f32 = @floatFromInt(cross_x); + const y1: f32 = @floatFromInt(cross_y); + const x2: f32 = @floatFromInt(cross_x + cross_size); + const y2: f32 = @floatFromInt(cross_y + cross_size); - var buf: [4]u8 = undefined; - @memcpy(buf[0..x_text.len], x_text); - buf[x_text.len] = 0; + _ = c.SDL_RenderLine(renderer, x1, y1, x2, y2); + _ = c.SDL_RenderLine(renderer, x2, y1, x1, y2); - const surface = c.TTF_RenderText_Blended(fonts.regular, @ptrCast(&buf), x_text.len, fg_color) orelse return; - defer c.SDL_DestroySurface(surface); - const texture = c.SDL_CreateTextureFromSurface(renderer, surface) orelse return; - defer c.SDL_DestroyTexture(texture); + if (self.close_hovered) { + const bold_offset: f32 = 1.0; + _ = c.SDL_RenderLine(renderer, x1 + bold_offset, y1, x2 + bold_offset, y2); + _ = c.SDL_RenderLine(renderer, x2 + bold_offset, y1, x1 + bold_offset, y2); + _ = c.SDL_RenderLine(renderer, x1, y1 + bold_offset, x2, y2 + bold_offset); + _ = c.SDL_RenderLine(renderer, x2, y1 + bold_offset, x1, y2 + bold_offset); + } + } - const tex_alpha: u8 = @intFromFloat(255.0 * self.render_alpha); - _ = c.SDL_SetTextureAlphaMod(texture, tex_alpha); + fn updateWrapCols(self: *DiffOverlayComponent, renderer: *c.SDL_Renderer, host: *const types.UiHost, mono_font: *c.TTF_Font) void { + const char_w = measureCharWidth(renderer, mono_font) orelse return; + if (char_w <= 0) return; - var tw: f32 = 0; - var th: f32 = 0; - _ = c.SDL_GetTextureSize(texture, &tw, &th); + const rect = overlayRect(host); + const scaled_gutter_w = dpi.scale(gutter_width, host.ui_scale); + const scaled_marker_w = dpi.scale(marker_width, host.ui_scale); + const scaled_padding = dpi.scale(text_padding, host.ui_scale); + const scrollbar_w = dpi.scale(10, host.ui_scale); + const text_area_w = rect.w - scaled_gutter_w * 2 - scaled_marker_w - scaled_padding - scrollbar_w; + if (text_area_w <= 0) return; + + const new_wrap: usize = @intCast(@divFloor(text_area_w, char_w)); + if (new_wrap != self.wrap_cols and new_wrap > 0) { + self.wrap_cols = new_wrap; + self.rebuildDisplayRows(); + } + } - const text_x = btn_rect.x + @divFloor(btn_rect.w - @as(c_int, @intFromFloat(tw)), 2); - const text_y = btn_rect.y + @divFloor(btn_rect.h - @as(c_int, @intFromFloat(th)), 2); - _ = c.SDL_RenderTexture(renderer, texture, null, &c.SDL_FRect{ - .x = @floatFromInt(text_x), - .y = @floatFromInt(text_y), - .w = tw, - .h = th, - }); + fn measureCharWidth(renderer: *c.SDL_Renderer, font: *c.TTF_Font) ?c_int { + const probe = "0"; + var buf: [2]u8 = .{ probe[0], 0 }; + const surface = c.TTF_RenderText_Blended(font, @ptrCast(&buf), 1, c.SDL_Color{ .r = 255, .g = 255, .b = 255, .a = 255 }) orelse return null; + defer c.SDL_DestroySurface(surface); + const tex = c.SDL_CreateTextureFromSurface(renderer, surface) orelse return null; + defer c.SDL_DestroyTexture(tex); + var w: f32 = 0; + var h: f32 = 0; + _ = c.SDL_GetTextureSize(tex, &w, &h); + return @intFromFloat(w); } fn ensureCache(self: *DiffOverlayComponent, renderer: *c.SDL_Renderer, host: *const types.UiHost, assets: *types.UiAssets) ?*Cache { @@ -993,6 +1221,8 @@ pub const DiffOverlayComponent = struct { const mono_font = line_fonts.regular; const bold_font = line_fonts.bold orelse line_fonts.regular; + self.updateWrapCols(renderer, host, mono_font); + const title_text = self.buildTitleText() catch return null; defer self.allocator.free(title_text); const title_tex = self.makeTextTexture( @@ -1145,61 +1375,71 @@ pub const DiffOverlayComponent = struct { const hunk_idx = row.hunk_index orelse return LineTexture{ .segments = &.{} }; const line_idx = row.line_index orelse return LineTexture{ .segments = &.{} }; const line = &self.files.items[file_idx].hunks.items[hunk_idx].lines.items[line_idx]; - - if (line.old_line) |num| { - var num_buf: [12]u8 = undefined; - const num_str = std.fmt.bufPrint(&num_buf, "{d}", .{num}) catch ""; - if (num_str.len > 0) { - const tex = try self.makeTextTexture(renderer, mono_font, num_str, dim_color); - errdefer c.SDL_DestroyTexture(tex.tex); - const right_pad: f32 = 6.0; - const text_x = @as(f32, @floatFromInt(scaled_gutter_w)) - @as(f32, @floatFromInt(tex.w)) - right_pad; - try segments.append(self.allocator, .{ - .tex = tex.tex, - .kind = .line_number_old, - .x_offset = @intFromFloat(text_x), - .w = tex.w, - .h = tex.h, - }); + const is_continuation = row.text_byte_offset > 0; + + if (!is_continuation) { + if (line.old_line) |num| { + var num_buf: [12]u8 = undefined; + const num_str = std.fmt.bufPrint(&num_buf, "{d}", .{num}) catch ""; + if (num_str.len > 0) { + const tex = try self.makeTextTexture(renderer, mono_font, num_str, dim_color); + errdefer c.SDL_DestroyTexture(tex.tex); + const right_pad: f32 = 6.0; + const text_x = @as(f32, @floatFromInt(scaled_gutter_w)) - @as(f32, @floatFromInt(tex.w)) - right_pad; + try segments.append(self.allocator, .{ + .tex = tex.tex, + .kind = .line_number_old, + .x_offset = @intFromFloat(text_x), + .w = tex.w, + .h = tex.h, + }); + } } - } - if (line.new_line) |num| { - var num_buf: [12]u8 = undefined; - const num_str = std.fmt.bufPrint(&num_buf, "{d}", .{num}) catch ""; - if (num_str.len > 0) { - const tex = try self.makeTextTexture(renderer, mono_font, num_str, dim_color); - errdefer c.SDL_DestroyTexture(tex.tex); - const right_pad: f32 = 6.0; - const gutter_x: c_int = scaled_gutter_w; - const text_x = @as(f32, @floatFromInt(gutter_x + scaled_gutter_w)) - @as(f32, @floatFromInt(tex.w)) - right_pad; - try segments.append(self.allocator, .{ - .tex = tex.tex, - .kind = .line_number_new, - .x_offset = @intFromFloat(text_x), - .w = tex.w, - .h = tex.h, - }); + if (line.new_line) |num| { + var num_buf: [12]u8 = undefined; + const num_str = std.fmt.bufPrint(&num_buf, "{d}", .{num}) catch ""; + if (num_str.len > 0) { + const tex = try self.makeTextTexture(renderer, mono_font, num_str, dim_color); + errdefer c.SDL_DestroyTexture(tex.tex); + const right_pad: f32 = 6.0; + const gutter_x: c_int = scaled_gutter_w; + const text_x = @as(f32, @floatFromInt(gutter_x + scaled_gutter_w)) - @as(f32, @floatFromInt(tex.w)) - right_pad; + try segments.append(self.allocator, .{ + .tex = tex.tex, + .kind = .line_number_new, + .x_offset = @intFromFloat(text_x), + .w = tex.w, + .h = tex.h, + }); + } } - } - const marker_str: []const u8 = switch (line.kind) { - .add => "+", - .remove => "-", - .context => "", - }; - if (marker_str.len > 0) { - const marker_color: c.SDL_Color = switch (line.kind) { - .add => host.theme.palette[2], - .remove => host.theme.palette[1], - .context => fg, + const marker_str: []const u8 = switch (line.kind) { + .add => "+", + .remove => "-", + .context => "", }; - try self.appendSegmentTexture(&segments, renderer, mono_font, marker_str, marker_color, .marker, gutter_total_w); + if (marker_str.len > 0) { + const marker_color: c.SDL_Color = switch (line.kind) { + .add => host.theme.palette[2], + .remove => host.theme.palette[1], + .context => fg, + }; + try self.appendSegmentTexture(&segments, renderer, mono_font, marker_str, marker_color, .marker, gutter_total_w); + } } - if (line.text.len > 0) { + const slice_start = @min(row.text_byte_offset, line.text.len); + const slice_end = if (self.wrap_cols > 0) + @min(byteOffsetAtDisplayCol(line.text, slice_start, self.wrap_cols), line.text.len) + else + line.text.len; + const text_slice = line.text[slice_start..slice_end]; + + if (text_slice.len > 0) { var text_buf: [max_display_buffer]u8 = undefined; - const text = sanitizeText(line.text, &text_buf); + const text = sanitizeText(text_slice, &text_buf); if (text.len > 0) { const text_color: c.SDL_Color = switch (line.kind) { .add => host.theme.palette[2],