From 0efda28ab5edb9ab90f648410c5867140d2868f3 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 16 Jun 2026 11:34:47 -0600 Subject: preserve indentation of sub-lists, reset formatting for the component after closing formatting --- src/markdown.zig | 83 +++++++++++++++++++++++++++++++++++++++++++++----- src/tui_components.zig | 35 ++++++++++++++++++++- 2 files changed, 110 insertions(+), 8 deletions(-) (limited to 'src') diff --git a/src/markdown.zig b/src/markdown.zig index a029756..fad1676 100644 --- a/src/markdown.zig +++ b/src/markdown.zig @@ -136,6 +136,11 @@ pub const Renderer = struct { buf: std.ArrayList(u8) = .empty, in_code_block: bool = false, list_depth: usize = 0, + list_item_depth: usize = 0, + list_item_first_paragraph: bool = false, + list_item_depth_stack: [64]usize = [_]usize{0} ** 64, + list_item_first_stack: [64]bool = [_]bool{false} ** 64, + list_item_stack_len: usize = 0, heading_level: usize = 0, err: ?anyerror = null, @@ -180,11 +185,44 @@ pub const Renderer = struct { fn flushParagraph(self: *Renderer) !void { const text = std.mem.trim(u8, self.buf.items, " \t\n"); if (text.len == 0) { self.buf.clearRetainingCapacity(); return; } - var wrapped: std.ArrayList(u8) = .empty; - defer wrapped.deinit(self.alloc); - try wrapStyled(text, self.width, &wrapped, self.alloc); - var it = std.mem.splitScalar(u8, wrapped.items, '\n'); - while (it.next()) |line| try self.appendLine(line); + + if (self.list_item_depth > 0) { + var first_prefix: std.ArrayList(u8) = .empty; + defer first_prefix.deinit(self.alloc); + var cont_prefix: std.ArrayList(u8) = .empty; + defer cont_prefix.deinit(self.alloc); + + const indent = (self.list_item_depth - 1) * 2; + try first_prefix.appendNTimes(self.alloc, ' ', indent); + try cont_prefix.appendNTimes(self.alloc, ' ', indent + 2); + if (self.list_item_first_paragraph) { + try first_prefix.appendSlice(self.alloc, "• "); + } else { + try first_prefix.appendNTimes(self.alloc, ' ', 2); + } + + var wrapped: std.ArrayList(u8) = .empty; + defer wrapped.deinit(self.alloc); + const wrap_width = if (self.width > visibleWidth(first_prefix.items)) self.width - visibleWidth(first_prefix.items) else 1; + try wrapStyled(text, wrap_width, &wrapped, self.alloc); + var it = std.mem.splitScalar(u8, wrapped.items, '\n'); + var first = true; + while (it.next()) |line| { + var tmp: std.ArrayList(u8) = .empty; + defer tmp.deinit(self.alloc); + try tmp.appendSlice(self.alloc, if (first) first_prefix.items else cont_prefix.items); + try tmp.appendSlice(self.alloc, line); + try self.appendLine(tmp.items); + first = false; + } + self.list_item_first_paragraph = false; + } else { + var wrapped: std.ArrayList(u8) = .empty; + defer wrapped.deinit(self.alloc); + try wrapStyled(text, self.width, &wrapped, self.alloc); + var it = std.mem.splitScalar(u8, wrapped.items, '\n'); + while (it.next()) |line| try self.appendLine(line); + } self.buf.clearRetainingCapacity(); } @@ -222,7 +260,13 @@ pub const Renderer = struct { c.MD_BLOCK_UL, c.MD_BLOCK_OL => self.list_depth += 1, c.MD_BLOCK_LI => { self.flushParagraph() catch |e| return self.fail(e); - self.add("• ") catch |e| return self.fail(e); + if (self.list_item_stack_len < self.list_item_depth_stack.len) { + self.list_item_depth_stack[self.list_item_stack_len] = self.list_item_depth; + self.list_item_first_stack[self.list_item_stack_len] = self.list_item_first_paragraph; + self.list_item_stack_len += 1; + } + self.list_item_depth = self.list_depth; + self.list_item_first_paragraph = true; }, c.MD_BLOCK_HR => self.appendLine("────────") catch |e| return self.fail(e), else => {}, @@ -240,8 +284,19 @@ pub const Renderer = struct { self.appendBlank() catch |e| return self.fail(e); self.heading_level = 0; }, - c.MD_BLOCK_P, c.MD_BLOCK_LI => { + c.MD_BLOCK_P => { + self.flushParagraph() catch |e| return self.fail(e); + }, + c.MD_BLOCK_LI => { self.flushParagraph() catch |e| return self.fail(e); + if (self.list_item_stack_len > 0) { + self.list_item_stack_len -= 1; + self.list_item_depth = self.list_item_depth_stack[self.list_item_stack_len]; + self.list_item_first_paragraph = self.list_item_first_stack[self.list_item_stack_len]; + } else { + self.list_item_depth = 0; + self.list_item_first_paragraph = false; + } }, c.MD_BLOCK_CODE => { self.flushCodeBlock() catch |e| return self.fail(e); @@ -330,3 +385,17 @@ test "Renderer uses MD4C for common markdown" { } try testing.expect(found_code); } + +test "Renderer preserves nested list indentation" { + var out: std.ArrayList([]const u8) = .empty; + defer { + for (out.items) |l| testing.allocator.free(l); + out.deinit(testing.allocator); + } + var r: Renderer = .{ .alloc = testing.allocator, .width = 80, .out_lines = &out }; + try r.render("- top\n - child\n- next\n"); + try testing.expectEqual(@as(usize, 3), out.items.len); + try testing.expectEqualStrings("• top", out.items[0]); + try testing.expectEqualStrings(" • child", out.items[1]); + try testing.expectEqualStrings("• next", out.items[2]); +} diff --git a/src/tui_components.zig b/src/tui_components.zig index 4f74210..f85c294 100644 --- a/src/tui_components.zig +++ b/src/tui_components.zig @@ -156,6 +156,30 @@ pub fn displayWidthStyled(text: []const u8) usize { return cols; } +fn reapplyAfterReset(alloc: std.mem.Allocator, text: []const u8, bg_open: []const u8, fg_open: []const u8) ![]const u8 { + const reset = theme.reset; + var count: usize = 0; + var pos: usize = 0; + while (std.mem.indexOfPos(u8, text, pos, reset)) |idx| { + count += 1; + pos = idx + reset.len; + } + if (count == 0) return alloc.dupe(u8, text); + + const extra = count * (bg_open.len + fg_open.len); + var out = try std.ArrayList(u8).initCapacity(alloc, text.len + extra); + errdefer out.deinit(alloc); + pos = 0; + while (std.mem.indexOfPos(u8, text, pos, reset)) |idx| { + try out.appendSlice(alloc, text[pos .. idx + reset.len]); + try out.appendSlice(alloc, bg_open); + try out.appendSlice(alloc, fg_open); + pos = idx + reset.len; + } + try out.appendSlice(alloc, text[pos..]); + return try out.toOwnedSlice(alloc); +} + /// ANSI-aware version of `truncateToCols`; never cuts inside a CSI sequence and /// does not count escapes toward the column budget. pub fn truncateStyledToCols(text: []const u8, max_cols: usize) []const u8 { @@ -755,10 +779,19 @@ pub const UserText = struct { const pad_str = try a.alloc(u8, right_pad); defer a.free(pad_str); @memset(pad_str, ' '); + + // Markdown inline styles currently close with a full SGR reset. + // Inside the user-message block that reset must be immediately + // followed by the block's bg+fg again, otherwise the faint user + // background disappears for the rest of the visual line (including + // right padding) after `code`, *em*, **strong**, etc. + const inner_user = try reapplyAfterReset(a, inner, bg.open(), fg.open()); + defer a.free(inner_user); + const composed = try std.fmt.allocPrint( a, "{s}{s}{s}{s}{s}{s}", - .{ bg.open(), fg.open(), indent, inner, pad_str, theme.reset }, + .{ bg.open(), fg.open(), indent, inner_user, pad_str, theme.reset }, ); try out.append(a, composed); } -- cgit v1.3