Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 40 additions & 14 deletions src/components/text_area.zig
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ const std = @import("std");
const keys = @import("../input/keys.zig");
const style_mod = @import("../style/style.zig");
const Color = @import("../style/color.zig").Color;
const measure = @import("../layout/measure.zig");
const unicode = @import("../unicode.zig");

pub const TextArea = struct {
Expand Down Expand Up @@ -582,16 +581,7 @@ pub const TextArea = struct {

// Show placeholder on first empty line
if (is_empty and r.line_idx == 0 and r.is_first_segment and self.placeholder.len > 0) {
const styled = try self.placeholder_style.render(allocator, self.placeholder);
defer allocator.free(styled);
try writer.writeAll(styled);
const placeholder_width = measure.width(self.placeholder);
const max_width: usize = text_width;
var rendered_width = @min(placeholder_width, max_width);
while (rendered_width < max_width) {
try writer.writeByte(' ');
rendered_width += 1;
}
try self.renderPlaceholder(writer, allocator, text_width);
continue;
}

Expand Down Expand Up @@ -637,20 +627,56 @@ pub const TextArea = struct {

// Show placeholder on first empty line
if (is_empty and line_idx == 0 and self.placeholder.len > 0) {
const styled = try self.placeholder_style.render(allocator, self.placeholder);
defer allocator.free(styled);
try writer.writeAll(styled);
try self.renderPlaceholder(writer, allocator, text_width);
continue;
}

// Render line content with cursor
try self.renderLine(writer, allocator, line.items, line_idx, text_width);
} else {
// Pad empty rows to full width
var i: usize = 0;
while (i < text_width) : (i += 1) {
try writer.writeByte(' ');
}
}
}

return result.toOwnedSlice();
}

fn renderPlaceholder(self: *const TextArea, writer: anytype, allocator: std.mem.Allocator, max_width: u16) !void {
const width_limit: usize = max_width;
if (width_limit == 0) return;

var rendered_width: usize = 0;
var byte_idx: usize = 0;
while (byte_idx < self.placeholder.len and rendered_width < width_limit) {
const byte_len = std.unicode.utf8ByteSequenceLength(self.placeholder[byte_idx]) catch 1;
if (byte_idx + byte_len > self.placeholder.len) break;
const char_slice = self.placeholder[byte_idx..][0..byte_len];

const cp = std.unicode.utf8Decode(char_slice) catch {
byte_idx += 1;
rendered_width += 1;
continue;
};
const cw = wrapDisplayWidth(unicode.charWidth(cp), width_limit);
if (rendered_width + cw > width_limit) break;

const styled = try self.placeholder_style.render(allocator, char_slice);
defer allocator.free(styled);
try writer.writeAll(styled);
byte_idx += byte_len;
rendered_width += cw;
}

while (rendered_width < width_limit) {
try writer.writeByte(' ');
rendered_width += 1;
}
}

fn renderWrappedLineSegment(
self: *const TextArea,
writer: anytype,
Expand Down
251 changes: 251 additions & 0 deletions tests/input_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -258,3 +258,254 @@ test "TextArea word_wrap keeps cursor visible by wrapped row" {
defer testing.allocator.free(plain);
try testing.expectEqualStrings("fghij\nk ", plain);
}

// ============================================================================
// Tests for TextArea setSize/view fix - placeholder and empty row padding
// ============================================================================

test "TextArea setSize without setValue pads placeholder to full width" {
var area = zz.TextArea.init(testing.allocator);
defer area.deinit();

// Initialize with placeholder
area.placeholder = "Type here";

// Set size WITHOUT calling setValue (the bug scenario)
area.setSize(50, 3);

// Render view
const rendered = try area.view(testing.allocator);
defer testing.allocator.free(rendered);
const plain = try stripAnsi(testing.allocator, rendered);
defer testing.allocator.free(plain);

// Verify full 50x3 viewport shape
var lines = std.mem.splitScalar(u8, plain, '\n');
const line0 = lines.next().?;
const line1 = lines.next().?;
const line2 = lines.next().?;
try testing.expectEqual(@as(usize, 50), line0.len);
try testing.expect(std.mem.startsWith(u8, line0, "Type here"));
try testing.expectEqual(@as(usize, 50), line1.len);
try testing.expectEqual(@as(usize, 50), line2.len);
for (line1) |c| {
try testing.expectEqual(@as(u8, ' '), c);
}
for (line2) |c| {
try testing.expectEqual(@as(u8, ' '), c);
}
try testing.expect(lines.next() == null);
}

test "TextArea placeholder wider than width is clipped and padded" {
var area = zz.TextArea.init(testing.allocator);
defer area.deinit();

area.placeholder = "This is a very long placeholder";
area.setSize(10, 2);

const rendered = try area.view(testing.allocator);
defer testing.allocator.free(rendered);
const plain = try stripAnsi(testing.allocator, rendered);
defer testing.allocator.free(plain);

var lines = std.mem.splitScalar(u8, plain, '\n');
const line0 = lines.next().?;
const line1 = lines.next().?;
try testing.expectEqual(@as(usize, 10), line0.len);
try testing.expectEqual(@as(usize, 10), line1.len);
for (line1) |c| {
try testing.expectEqual(@as(u8, ' '), c);
}
try testing.expect(lines.next() == null);
}

test "TextArea wrapped placeholder wider than width is clipped and padded" {
var area = zz.TextArea.init(testing.allocator);
defer area.deinit();

area.word_wrap = true;
area.placeholder = "This is a very long placeholder";
area.setSize(10, 2);

const rendered = try area.view(testing.allocator);
defer testing.allocator.free(rendered);
const plain = try stripAnsi(testing.allocator, rendered);
defer testing.allocator.free(plain);

var lines = std.mem.splitScalar(u8, plain, '\n');
const line0 = lines.next().?;
const line1 = lines.next().?;
try testing.expectEqual(@as(usize, 10), line0.len);
try testing.expectEqual(@as(usize, 10), line1.len);
for (line1) |c| {
try testing.expectEqual(@as(u8, ' '), c);
}
try testing.expect(lines.next() == null);
}

test "TextArea pads empty rows to full width" {
var area = zz.TextArea.init(testing.allocator);
defer area.deinit();

// Set content to just one line
try area.setValue("Single line");
area.setSize(30, 5);

// Render view
const rendered = try area.view(testing.allocator);
defer testing.allocator.free(rendered);
const plain = try stripAnsi(testing.allocator, rendered);
defer testing.allocator.free(plain);

// Split into lines
var lines = std.mem.splitScalar(u8, plain, '\n');

// First line should have content padded to 30 chars
const line0 = lines.next().?;
try testing.expectEqual(@as(usize, 30), line0.len);
try testing.expect(std.mem.startsWith(u8, line0, "Single line"));

// Remaining lines should be padded to 30 chars of spaces
for (0..4) |_| {
const line = lines.next().?;
try testing.expectEqual(@as(usize, 30), line.len);
for (line) |c| {
try testing.expectEqual(@as(u8, ' '), c);
}
}
try testing.expect(lines.next() == null);
}

test "TextArea renders at correct width after setSize without setValue" {
var area = zz.TextArea.init(testing.allocator);
defer area.deinit();

// Initialize with placeholder (common usage pattern)
area.placeholder = "Enter text...";
area.placeholder_style = blk: {
var s = zz.style.Style{};
s = s.fg(zz.Color.gray(12));
s = s.inline_style(true);
break :blk s;
};

// Simulate resize to 80x24 terminal
area.setSize(80, 24);

// Render without calling setValue
const rendered = try area.view(testing.allocator);
defer testing.allocator.free(rendered);

// Strip ANSI and verify dimensions
const plain = try stripAnsi(testing.allocator, rendered);
defer testing.allocator.free(plain);

var lines = std.mem.splitScalar(u8, plain, '\n');
for (0..24) |_| {
const line = lines.next().?;
try testing.expectEqual(@as(usize, 80), line.len);
}
try testing.expect(lines.next() == null);
}

test "TextArea multiple resize operations maintain correct width" {
var area = zz.TextArea.init(testing.allocator);
defer area.deinit();

area.placeholder = "Test";

// First resize to small
area.setSize(20, 5);
const rendered1 = try area.view(testing.allocator);
defer testing.allocator.free(rendered1);
const plain1 = try stripAnsi(testing.allocator, rendered1);
defer testing.allocator.free(plain1);

var lines1 = std.mem.splitScalar(u8, plain1, '\n');
const first_line1 = lines1.next().?;
try testing.expectEqual(@as(usize, 20), first_line1.len);

// Then resize to large
area.setSize(100, 10);
const rendered2 = try area.view(testing.allocator);
defer testing.allocator.free(rendered2);
const plain2 = try stripAnsi(testing.allocator, rendered2);
defer testing.allocator.free(plain2);

var lines2 = std.mem.splitScalar(u8, plain2, '\n');
const first_line2 = lines2.next().?;
try testing.expectEqual(@as(usize, 100), first_line2.len);

// Then resize back to medium
area.setSize(50, 3);
const rendered3 = try area.view(testing.allocator);
defer testing.allocator.free(rendered3);
const plain3 = try stripAnsi(testing.allocator, rendered3);
defer testing.allocator.free(plain3);

var lines3 = std.mem.splitScalar(u8, plain3, '\n');
const first_line3 = lines3.next().?;
try testing.expectEqual(@as(usize, 50), first_line3.len);
}

test "TextArea empty content renders all rows at correct width" {
var area = zz.TextArea.init(testing.allocator);
defer area.deinit();

// Don't set any content or placeholder
area.setSize(40, 10);

const rendered = try area.view(testing.allocator);
defer testing.allocator.free(rendered);
const plain = try stripAnsi(testing.allocator, rendered);
defer testing.allocator.free(plain);

// All 10 rows should be 40 spaces
var lines = std.mem.splitScalar(u8, plain, '\n');
var line_count: usize = 0;
while (lines.next()) |line| : (line_count += 1) {
if (line_count >= 10) break;
try testing.expectEqual(@as(usize, 40), line.len);
// Verify all spaces
for (line) |c| {
try testing.expectEqual(@as(u8, ' '), c);
}
}
try testing.expectEqual(@as(usize, 10), line_count);
}

test "TextArea partial content fills remaining rows" {
var area = zz.TextArea.init(testing.allocator);
defer area.deinit();

// Set 3 lines of content
try area.setValue("Line 1\nLine 2\nLine 3");
area.setSize(25, 10);

const rendered = try area.view(testing.allocator);
defer testing.allocator.free(rendered);
const plain = try stripAnsi(testing.allocator, rendered);
defer testing.allocator.free(plain);

var lines = std.mem.splitScalar(u8, plain, '\n');

// First 3 lines have content
try testing.expect(std.mem.startsWith(u8, lines.next().?, "Line 1"));
try testing.expect(std.mem.startsWith(u8, lines.next().?, "Line 2"));
try testing.expect(std.mem.startsWith(u8, lines.next().?, "Line 3"));

// Next 7 lines should be empty (spaces)
var empty_count: usize = 0;
while (lines.next()) |line| {
if (empty_count >= 7) break;
try testing.expectEqual(@as(usize, 25), line.len);
var all_spaces = true;
for (line) |c| {
if (c != ' ') all_spaces = false;
}
try testing.expect(all_spaces);
empty_count += 1;
}
try testing.expectEqual(@as(usize, 7), empty_count);
}