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 <hello@simonklee.dk>
This commit is contained in:
avrlandSimon Klee authored and GitHub committed 2026-09-16 07:37:08 +00:00
1 parent f532fa7b2b
commit 0924679971
10 files changed
+171 -3

No files matched your search

+1 -1
View File
@@ -1 +1 @@
0.16.0
0.16.0
@@ -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",
@@ -16,6 +16,7 @@ export interface TextBufferOptions extends RenderableOptions<TextBufferRenderabl
selectable?: boolean
attributes?: number
wrapMode?: "none" | "char" | "word"
textAlign?: "left" | "center" | "right"
tabIndicator?: string | number
tabIndicatorColor?: string | RGBA
truncate?: boolean
@@ -30,6 +31,7 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf
protected _selectionBg: RGBA | undefined
protected _selectionFg: RGBA | undefined
protected _wrapMode: "none" | "char" | "word" = "word"
protected _textAlign: "left" | "center" | "right" = "left"
protected lastLocalSelection: LocalSelectionBounds | null = null
protected _tabIndicator?: string | number
protected _tabIndicatorColor?: RGBA
@@ -51,6 +53,7 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf
selectable: true,
attributes: 0,
wrapMode: "word" as "none" | "char" | "word",
textAlign: "left" as "left" | "center" | "right",
tabIndicator: undefined,
tabIndicatorColor: undefined,
truncate: false,
@@ -66,6 +69,7 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf
this._selectionFg = options.selectionFg ? parseColor(options.selectionFg) : this._defaultOptions.selectionFg
this.selectable = options.selectable ?? this._defaultOptions.selectable
this._wrapMode = options.wrapMode ?? this._defaultOptions.wrapMode
this._textAlign = options.textAlign ?? this._defaultOptions.textAlign
this._tabIndicator = options.tabIndicator ?? this._defaultOptions.tabIndicator
this._tabIndicatorColor = options.tabIndicatorColor
? parseColor(options.tabIndicatorColor)
@@ -80,6 +84,7 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf
this.textBuffer.setSyntaxStyle(this._textBufferSyntaxStyle)
this.textBufferView.setWrapMode(this._wrapMode)
this.textBufferView.setTextAlign(this._textAlign)
this.textBufferView.setFirstLineOffset(this._firstLineOffset)
this.setupNativeRenderable()
@@ -310,6 +315,18 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf
}
}
get textAlign(): "left" | "center" | "right" {
return this._textAlign
}
set textAlign(value: "left" | "center" | "right") {
if (this._textAlign !== value) {
this._textAlign = value
this.textBufferView.setTextAlign(this._textAlign)
this.requestRender()
}
}
get tabIndicator(): string | number | undefined {
return this._tabIndicator
}
+5
View File
@@ -131,6 +131,11 @@ export class TextBufferView {
this.lib.textBufferViewSetWrapMode(this.viewPtr, mode)
}
public setTextAlign(alignment: "left" | "center" | "right"): void {
this.guard()
this.lib.textBufferViewSetTextAlign(this.viewPtr, alignment)
}
public setFirstLineOffset(offset: number): void {
this.guard()
this.lib.textBufferViewSetFirstLineOffset(this.viewPtr, offset)
+10
View File
@@ -1211,6 +1211,10 @@ function getOpenTUILib(libPath?: string) {
args: ["u32", "u8"],
returns: "void",
},
textBufferViewSetTextAlign: {
args: ["u32", "u8"],
returns: "void",
},
textBufferViewSetFirstLineOffset: {
args: ["u32", "u32"],
returns: "void",
@@ -2931,6 +2935,7 @@ export interface RenderLib extends AudioEngineLib {
textBufferViewGetSelectionOccupancy: (view: TextBufferViewHandle) => 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)
}
+8 -1
View File
@@ -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;
+4
View File
@@ -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;
}
+11
View File
@@ -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);
@@ -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 });
}
+38 -1
View File
@@ -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;