From 09246799712f1b510e670fe4cff3afe07c51a8ed Mon Sep 17 00:00:00 2001 From: avrl <79330197+viralcodex@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:07:08 +0530 Subject: [PATCH] feat: implement per-line text alignment support textAlign (#1507) Fixes: #916 Implemented this textAlign feature for left | center | right positions. Works fine for both wraps (chars and words). It was straightforward to add padding = total cols - line_length. Please let me know if I missed anything. https://github.com/user-attachments/assets/cd45ffff-9ad7-47a7-b3db-bf6e758f36c6 --------- Co-authored-by: Simon Klee --- .zig-version | 2 +- packages/core/src/renderables/Text.test.ts | 20 +++++++ .../src/renderables/TextBufferRenderable.ts | 17 ++++++ packages/core/src/text-buffer-view.ts | 5 ++ packages/core/src/zig.ts | 10 ++++ packages/native/src/buffer.zig | 9 ++- packages/native/src/editor-view.zig | 4 ++ packages/native/src/lib.zig | 11 ++++ .../src/tests/text-buffer-drawing_test.zig | 57 +++++++++++++++++++ packages/native/src/text-buffer-view.zig | 39 ++++++++++++- 10 files changed, 171 insertions(+), 3 deletions(-) diff --git a/.zig-version b/.zig-version index 04a373efe..d183d4ace 100644 --- a/.zig-version +++ b/.zig-version @@ -1 +1 @@ -0.16.0 +0.16.0 \ No newline at end of file diff --git a/packages/core/src/renderables/Text.test.ts b/packages/core/src/renderables/Text.test.ts index cfd7dfccb..512c0b76b 100644 --- a/packages/core/src/renderables/Text.test.ts +++ b/packages/core/src/renderables/Text.test.ts @@ -1501,6 +1501,26 @@ describe("TextRenderable Selection", () => { expect(frame).toMatchSnapshot() }) + it("should align each line and update alignment at runtime", async () => { + const { text } = await createTextRenderable(currentRenderer, { + content: "hi\nworld", + width: 10, + height: 2, + textAlign: "center", + }) + + const rows = () => + captureFrame() + .split("\n", 2) + .map((row) => row.slice(0, 10)) + expect(rows()).toEqual([" hi ", " world "]) + + text.textAlign = "right" + await renderOnce() + + expect(rows()).toEqual([" hi", " world"]) + }) + it("should render text with tab indicator correctly", async () => { await createTextRenderable(currentRenderer, { content: "Line 1\tTabbed\nLine 2\t\tDouble tab", diff --git a/packages/core/src/renderables/TextBufferRenderable.ts b/packages/core/src/renderables/TextBufferRenderable.ts index 2150cdb71..9b875011c 100644 --- a/packages/core/src/renderables/TextBufferRenderable.ts +++ b/packages/core/src/renderables/TextBufferRenderable.ts @@ -16,6 +16,7 @@ export interface TextBufferOptions extends RenderableOptions SelectionOccupancy textBufferViewSetWrapWidth: (view: TextBufferViewHandle, width: number) => void textBufferViewSetWrapMode: (view: TextBufferViewHandle, mode: "none" | "char" | "word") => void + textBufferViewSetTextAlign: (view: TextBufferViewHandle, alignment: "left" | "center" | "right") => void textBufferViewSetFirstLineOffset: (view: TextBufferViewHandle, offset: number) => void textBufferViewSetViewportSize: (view: TextBufferViewHandle, width: number, height: number) => void textBufferViewSetViewport: (view: TextBufferViewHandle, x: number, y: number, width: number, height: number) => void @@ -5298,6 +5303,11 @@ class FFIRenderLib implements RenderLib { this.opentui.symbols.textBufferViewSetWrapMode(view, modeValue) } + public textBufferViewSetTextAlign(view: Pointer, alignment: "left" | "center" | "right"): void { + const alignValue = alignment === "left" ? 0 : alignment === "center" ? 1 : 2 + this.opentui.symbols.textBufferViewSetTextAlign(view, alignValue) + } + public textBufferViewSetFirstLineOffset(view: Pointer, offset: number): void { this.opentui.symbols.textBufferViewSetFirstLineOffset(view, offset) } diff --git a/packages/native/src/buffer.zig b/packages/native/src/buffer.zig index e3de5a3c9..023231f13 100644 --- a/packages/native/src/buffer.zig +++ b/packages/native/src/buffer.zig @@ -1669,6 +1669,7 @@ pub const OptimizedBuffer = struct { const horizontal_offset: u32 = if (viewport) |vp| vp.x else 0; const viewport_width: u32 = if (viewport) |vp| vp.width else std.math.maxInt(u32); + const text_align = view.getTextAlign(); var currentX = x; var currentY = y + @as(i32, @intCast(firstVisibleLine)); @@ -1683,7 +1684,13 @@ pub const OptimizedBuffer = struct { for (virtual_lines[firstVisibleLine..lastPossibleLine], 0..) |vline, slice_idx| { if (currentY >= bufferBottomY) break; - currentX = x; + // Draw-time horizontal alignment: shift each rendered line right so + // it is centered / right-aligned within the viewport width. + const align_pad: i32 = if (viewport != null) + @intCast(tbv.alignmentPadCols(text_align, viewport_width, vline.width_cols)) + else + 0; + currentX = x + align_pad; var rendered_col_in_vline: u32 = 0; document_cell_offset = vline.document_cell_offset; diff --git a/packages/native/src/editor-view.zig b/packages/native/src/editor-view.zig index be8601eca..755d960d1 100644 --- a/packages/native/src/editor-view.zig +++ b/packages/native/src/editor-view.zig @@ -306,6 +306,10 @@ pub const EditorView = struct { return self.text_buffer_view.getVirtualLineSpans(vline_idx); } + pub fn getTextAlign(self: *const EditorView) tbv.TextAlign { + return self.text_buffer_view.getTextAlign(); + } + pub fn getTextBuffer(self: *const EditorView) *UnifiedTextBuffer { return self.text_buffer_view.text_buffer; } diff --git a/packages/native/src/lib.zig b/packages/native/src/lib.zig index d428a5c9e..fa99d8b73 100644 --- a/packages/native/src/lib.zig +++ b/packages/native/src/lib.zig @@ -2423,6 +2423,17 @@ export fn textBufferViewSetWrapMode(view_handle: NativeHandle, mode: u8) void { object_ptr.setWrapMode(wrapMode); } +export fn textBufferViewSetTextAlign(view_handle: NativeHandle, alignment: u8) void { + const object_ptr = acquireTextBufferView(view_handle) orelse return; + const textAlign: text_buffer_view.TextAlign = switch (alignment) { + 0 => .left, + 1 => .center, + 2 => .right, + else => .left, + }; + object_ptr.setTextAlign(textAlign); +} + export fn textBufferViewSetFirstLineOffset(view_handle: NativeHandle, offset: u32) void { const object_ptr = acquireTextBufferView(view_handle) orelse return; object_ptr.setFirstLineOffset(offset); diff --git a/packages/native/src/tests/text-buffer-drawing_test.zig b/packages/native/src/tests/text-buffer-drawing_test.zig index a4328d1cc..ac48a71e6 100644 --- a/packages/native/src/tests/text-buffer-drawing_test.zig +++ b/packages/native/src/tests/text-buffer-drawing_test.zig @@ -3697,3 +3697,60 @@ test "drawTextBuffer - Thai ว่ grapheme in quotes occupies one cell" { try std.testing.expect(std.mem.find(u8, result, "\"ว่\"") != null); } + +test "alignmentPadCols - left/center/right offsets and wide-line clamp" { + try std.testing.expectEqual(@as(u32, 0), text_buffer_view.alignmentPadCols(.left, 20, 4)); + try std.testing.expectEqual(@as(u32, 8), text_buffer_view.alignmentPadCols(.center, 20, 4)); + try std.testing.expectEqual(@as(u32, 7), text_buffer_view.alignmentPadCols(.center, 20, 5)); + try std.testing.expectEqual(@as(u32, 16), text_buffer_view.alignmentPadCols(.right, 20, 4)); + try std.testing.expectEqual(@as(u32, 0), text_buffer_view.alignmentPadCols(.center, 10, 10)); + try std.testing.expectEqual(@as(u32, 0), text_buffer_view.alignmentPadCols(.right, 10, 12)); +} + +fn expectAlignedRows(alignment: text_buffer_view.TextAlign, expected_pads: []const usize) !void { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + const link_pool = link.initGlobalLinkPool(std.testing.allocator); + defer link.deinitGlobalLinkPool(); + + var tb = try TextBuffer.init(std.testing.allocator, pool, link_pool, .wcwidth); + defer tb.deinit(); + var view = try TextBufferView.init(std.testing.allocator, tb); + defer view.deinit(); + try tb.setText("hi\nworld"); + view.setViewport(.{ .x = 0, .y = 0, .width = 10, .height = 2 }); + view.setTextAlign(alignment); + + var opt_buffer = try OptimizedBuffer.init( + std.testing.allocator, + 10, + 2, + .{ .pool = pool, .width_method = .wcwidth }, + ); + defer opt_buffer.deinit(); + opt_buffer.clear(ansi.rgbaFromFloats(0.0, 0.0, 0.0, 1.0), 32); + opt_buffer.drawTextBuffer(view, 0, 0); + + const expected_rows = [_][]const u8{ "hi", "world" }; + for (expected_pads, 0..) |expected_pad, y| { + const row = try resolvedRow(std.testing.allocator, opt_buffer, pool, @intCast(y)); + defer std.testing.allocator.free(row); + try std.testing.expect(std.unicode.utf8ValidateSlice(row)); + + var first_glyph: usize = 0; + while (first_glyph < row.len and row[first_glyph] == ' ') : (first_glyph += 1) {} + try std.testing.expectEqual(expected_pad, first_glyph); + try std.testing.expectEqualStrings(expected_rows[y], std.mem.trim(u8, row, " \x03")); + } + + const selection_x: i32 = @intCast(expected_pads[0]); + _ = view.setLocalSelection(selection_x, 0, selection_x + 1, 0, null, null); + var selected: [2]u8 = undefined; + try std.testing.expectEqualStrings("hi", selected[0..view.getSelectedTextIntoBuffer(&selected)]); +} + +test "drawTextBuffer - textAlign centers and right-aligns each rendered line" { + try expectAlignedRows(.left, &.{ 0, 0 }); + try expectAlignedRows(.center, &.{ 4, 2 }); + try expectAlignedRows(.right, &.{ 8, 5 }); +} diff --git a/packages/native/src/text-buffer-view.zig b/packages/native/src/text-buffer-view.zig index e4ca9730a..2e3d83b11 100644 --- a/packages/native/src/text-buffer-view.zig +++ b/packages/native/src/text-buffer-view.zig @@ -211,6 +211,25 @@ const WordLayoutStorage = struct { } }; +// Horizontal alignment of each rendered (virtual) line within the viewport width. +pub const TextAlign = enum(u8) { + left = 0, + center = 1, + right = 2, +}; + +/// Left padding (in display columns) needed to align a single rendered line of +/// `line_width_cols` within `viewport_width`. +/// Shared by the draw loop & hit-testing to preserve click & selection accuracy. +pub fn alignmentPadCols(alignment: TextAlign, viewport_width: u32, line_width_cols: u32) u32 { + if (line_width_cols >= viewport_width) return 0; + return switch (alignment) { + .left => 0, + .center => (viewport_width - line_width_cols) / 2, + .right => viewport_width - line_width_cols, + }; +} + pub const UnifiedTextBufferView = struct { const Self = UnifiedTextBufferView; @@ -226,6 +245,7 @@ pub const UnifiedTextBufferView = struct { viewport: ?Viewport, wrap_width: ?u32, wrap_mode: WrapMode, + text_align: TextAlign, first_line_offset: u32, virtual_lines: std.ArrayListUnmanaged(VirtualLine), virtual_lines_dirty: bool, @@ -284,6 +304,7 @@ pub const UnifiedTextBufferView = struct { .viewport = null, .wrap_width = null, .wrap_mode = .none, + .text_align = .left, .first_line_offset = 0, .virtual_lines = .empty, .virtual_lines_dirty = true, @@ -382,6 +403,14 @@ pub const UnifiedTextBufferView = struct { } } + pub fn setTextAlign(self: *Self, alignment: TextAlign) void { + self.text_align = alignment; + } + + pub fn getTextAlign(self: *const Self) TextAlign { + return self.text_align; + } + pub fn setFirstLineOffset(self: *Self, offset: u32) void { if (self.first_line_offset != offset) { self.first_line_offset = offset; @@ -1118,7 +1147,15 @@ pub const UnifiedTextBufferView = struct { const lineStart = vline.document_cell_offset; const max_local_x = self.maxLocalXOnVisualLine(self.virtual_lines.items, vline_idx); - var localX = @max(0, @min(abs_x, @as(i32, @intCast(max_local_x)))); + // Undo the draw-time alignment offset so clicks map to the character + // actually painted at this column, there is no alignment padding when there is no viewport. + const align_pad: i32 = if (self.viewport) |vp| + @intCast(alignmentPadCols(self.text_align, vp.width, vline.width_cols)) + else + 0; + const aligned_abs_x = abs_x - align_pad; + + var localX = @max(0, @min(aligned_abs_x, @as(i32, @intCast(max_local_x)))); if (vline.is_truncated) { const ellipsis_width: u32 = 3;