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
46 changes: 12 additions & 34 deletions src/grid.zig
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const std = @import("std");
const vt = @import("vt.zig");
const unicode_width = @import("unicode_width.zig");
const Cell = vt.Cell;

// ─── Row ─────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -145,37 +146,10 @@ pub const Grid = struct {

// ── Character width ───────────────────────────────────────────────────────

/// Returns the display width of a Unicode codepoint (1 or 2).
/// CJK characters, fullwidth forms, and some symbols are 2 cells wide.
/// Returns the display width of a Unicode codepoint (0, 1, or 2).
fn charWidth(cp: u21) u16 {
// CJK Unified Ideographs
if (cp >= 0x4E00 and cp <= 0x9FFF) return 2;
// CJK Unified Ideographs Extension A
if (cp >= 0x3400 and cp <= 0x4DBF) return 2;
// CJK Compatibility Ideographs
if (cp >= 0xF900 and cp <= 0xFAFF) return 2;
// Hiragana
if (cp >= 0x3040 and cp <= 0x309F) return 2;
// Katakana
if (cp >= 0x30A0 and cp <= 0x30FF) return 2;
// Fullwidth Forms
if (cp >= 0xFF01 and cp <= 0xFF60) return 2;
if (cp >= 0xFFE0 and cp <= 0xFFE6) return 2;
// Halfwidth Katakana (1-wide)
if (cp >= 0xFF65 and cp <= 0xFF9F) return 1;
// CJK Symbols and Punctuation
if (cp >= 0x3000 and cp <= 0x303F) return 2;
// Hangul Syllables
if (cp >= 0xAC00 and cp <= 0xD7AF) return 2;
// Enclosed CJK Letters
if (cp >= 0x3200 and cp <= 0x32FF) return 2;
// CJK Compatibility
if (cp >= 0x3300 and cp <= 0x33FF) return 2;
// Bopomofo
if (cp >= 0x3100 and cp <= 0x312F) return 2;
// CJK Extension B+
if (cp >= 0x20000 and cp <= 0x2FA1F) return 2;
return 1;
if (cp == 0) return 1; // NUL from VT (rare); wide spacers use a separate code path
return @as(u16, unicode_width.terminalDisplayWidth(cp));
}

// ── Apply VT Event ───────────────────────────────────────────────────────
Expand All @@ -185,6 +159,11 @@ pub const Grid = struct {
switch (ev) {
.print => |ch| {
const w = charWidth(ch);
if (w == 0) {
// Combining marks / ZWJ / VS: terminal consumes without advancing column;
// single-codepoint cells cannot represent overlays — skip.
return null;
}

// Auto-wrap: if cursor + char width exceeds right margin
if (self.cursor_col + w > self.cols) {
Expand Down Expand Up @@ -756,8 +735,8 @@ pub const Grid = struct {
if (cursor_canonical_idx == current_canonical and
cursor_in_canonical >= offset and
(cursor_in_canonical <= chunk_end or
// Cursor can be past trimmed content (contentLen trims trailing spaces)
chunk_end == line_cells.len))
// Cursor can be past trimmed content (contentLen trims trailing spaces)
chunk_end == line_cells.len))
{
// Cursor at chunk_end means it's at the right edge;
// if it's exactly at the boundary and there's more content,
Expand All @@ -775,7 +754,6 @@ pub const Grid = struct {
current_canonical += 1;
}


// Phase 5: Adjust to viewport height
while (self.rows.items.len < new_rows) {
try self.rows.append(alloc, try Row.init(alloc, new_cols, true));
Expand Down Expand Up @@ -1057,7 +1035,7 @@ test "reflow preserves content through narrow-wide cycle" {
try std.testing.expectEqual(@as(u21, 'h'), grid.getCell(0, 0).char);
try std.testing.expectEqual(@as(u21, 'l'), grid.getCell(0, 9).char);

// Resize back to wide (20 cols) — should restore "hello world"
// Resize back to wide (20 cols) — should restore "hello world"
try grid.resize(20, 5);
try std.testing.expectEqual(@as(u21, 'h'), grid.getCell(0, 0).char);
try std.testing.expectEqual(@as(u21, 'd'), grid.getCell(0, 10).char);
Expand Down
2 changes: 2 additions & 0 deletions src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
pub const client = @import("client.zig");
pub const server = @import("server.zig");
pub const session = @import("session.zig");
pub const unicode_width = @import("unicode_width.zig");

// ─── CLI parsing ──────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -304,10 +305,10 @@
const envp_buf = try allocator.alloc(?[*:0]const u8, env_slice.len + 1);
defer allocator.free(envp_buf);
for (env_slice, 0..) |ptr, i| {
envp_buf[i] = @ptrCast(ptr);

Check warning on line 308 in src/main.zig

View workflow job for this annotation

GitHub Actions / security-audit

[Tracked] @ptrCast: envp_buf[i] = @ptrCast(ptr);
}
envp_buf[env_slice.len] = null;
const envp: [*:null]const ?[*:0]const u8 = @ptrCast(envp_buf.ptr);

Check warning on line 311 in src/main.zig

View workflow job for this annotation

GitHub Actions / security-audit

[Tracked] @ptrCast: const envp: [*:null]const ?[*:0]const u8 = @ptrCast(envp_buf.ptr);

const pid = try posix.fork();
if (pid == 0) {
Expand Down Expand Up @@ -544,6 +545,7 @@
_ = client;
_ = server;
_ = session;
_ = unicode_width;
}

test "getSocketDir replaces {uid}" {
Expand Down
66 changes: 59 additions & 7 deletions src/render.zig
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const std = @import("std");
const vt = @import("vt.zig");
const pane_mod = @import("pane.zig");
const unicode_width = @import("unicode_width.zig");

// ─── Cell ─────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -145,17 +146,40 @@ pub const Screen = struct {
c += 1;
}

// Title text (truncated to fit, leaving room for trailing space + at least one dash)
const max_title = if (inner_width > 3) inner_width - 3 else 0;
const title_len: u16 = @intCast(@min(title.len, max_title));
var ti: u16 = 0;
while (ti < title_len and c < c_right) : (ti += 1) {
self.cellAt(r_top, c).* = Cell{ .char = title[ti], .fg = title_fg, .attr = attr };
// Title text (UTF-8; display-width aware; room for trailing space + ≥1 dash)
const max_title_cells: u16 = if (inner_width > 3) inner_width - 3 else 0;
var used_cells: u16 = 0;
var ti: usize = 0;
while (ti < title.len and c < c_right and used_cells < max_title_cells) {
const cp_len = std.unicode.utf8ByteSequenceLength(title[ti]) catch break;
if (ti + cp_len > title.len) break;
const cp = std.unicode.utf8Decode(title[ti .. ti + cp_len]) catch {
ti += 1;
continue;
};
const w: u16 = @intCast(unicode_width.terminalDisplayWidth(cp));
if (w == 0) {
ti += cp_len;
continue;
}
if (used_cells + w > max_title_cells) break;
if (w == 1) {
if (c >= c_right) break;
} else {
if (c + 1 >= c_right) break;
}
self.cellAt(r_top, c).* = Cell{ .char = cp, .fg = title_fg, .attr = attr };
c += 1;
if (w == 2) {
self.cellAt(r_top, c).* = Cell{ .char = 0, .fg = title_fg, .attr = attr };
c += 1;
}
used_cells += w;
ti += cp_len;
}

// Trailing space after title (only if title was written)
if (title_len > 0 and c < c_right) {
if (used_cells > 0 and c < c_right) {
self.cellAt(r_top, c).* = Cell{ .char = ' ', .fg = border_fg, .attr = attr };
c += 1;
}
Expand Down Expand Up @@ -257,10 +281,15 @@ pub const Screen = struct {
// ─── serializeCell ────────────────────────────────────────────────────────────

/// Write a single Cell as terminal escape sequences (SGR + UTF-8 codepoint).
/// Wide characters (EA/emoji width 2) are followed by a space so the host
/// terminal advances two columns, matching our grid/spacer model.
pub fn serializeCell(cell: Cell, writer: anytype) !void {
// Skip spacer cells (second cell of wide characters)
if (cell.char == 0) return;

const disp_w = unicode_width.terminalDisplayWidth(cell.char);
if (disp_w == 0) return; // combining / VS — not representable as one cell here

// Build SGR: reset, then apply fg, bg, attr.
try writer.writeAll("\x1b[0");

Expand Down Expand Up @@ -315,6 +344,14 @@ pub fn serializeCell(cell: Cell, writer: anytype) !void {
return;
};
try writer.writeAll(buf[0..len]);
if (disp_w == 2) try writer.writeByte(' ');
}

test "serializeCell wide char emits trailing space" {
var list: std.ArrayList(u8) = .{};
defer list.deinit(std.testing.allocator);
try serializeCell(Cell{ .char = 0x3042 }, list.writer(std.testing.allocator));
try std.testing.expect(std.mem.endsWith(u8, list.items, " "));
}

// ─── Tests ────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -473,3 +510,18 @@ test "drawBorderWithTitle" {
// Interior untouched
try testing.expect(Cell.eql(screen.cellAt(r_top + 1, c_left + 1).*, Cell{}));
}

test "drawBorderWithTitle UTF-8 wide char uses two cells" {
var screen = try Screen.init(testing.allocator, 30, 10);
defer screen.deinit();

const region = pane_mod.Region{ .row = 0, .col = 0, .rows = 5, .cols = 16 };
screen.drawBorderWithTitle(region, "漢", false);

const r_top = region.row;
const c_left = region.col;
// After leading space: one CJK char + spacer
try testing.expectEqual(@as(u21, ' '), screen.cellAt(r_top, c_left + 1).char);
try testing.expectEqual(@as(u21, 0x6F22), screen.cellAt(r_top, c_left + 2).char);
try testing.expectEqual(@as(u21, 0), screen.cellAt(r_top, c_left + 3).char);
}
Loading
Loading