mirror of
https://github.com/aphrody-labs/opentui.git
synced 2026-10-13 21:08:53 +00:00
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:
1 parent
f532fa7b2b
commit
0924679971
10 files changed
+171
-3
No files matched your search
+1
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user