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
101 changes: 99 additions & 2 deletions src/backend/windows.zig
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,48 @@ const WAIT_TIMEOUT: DWORD = 0x00000102;

const UTF8_CODE_PAGE: UINT = 65001;

/// Combines a UTF-16 surrogate pair into a code point. Returns null when
/// either half is outside its surrogate range.
fn combineSurrogates(hi: u16, lo: u16) ?u21 {
if (hi < 0xD800 or hi > 0xDBFF) return null;
if (lo < 0xDC00 or lo > 0xDFFF) return null;
return @intCast(0x10000 + ((@as(u21, hi) - 0xD800) << 10) + (@as(u21, lo) - 0xDC00));
}

/// Result of feeding one `KEY_EVENT_RECORD.UnicodeChar` code unit into the
/// surrogate state machine.
const SurrogateStep = union(enum) {
/// A leading surrogate was buffered; emit no event.
pending,
/// A complete code point is ready.
cp: u21,
/// Nothing to emit for this unit (trailing surrogate without a leader).
none,
};

/// UTF-16 input arrives as code units: a non-BMP character (emoji) is delivered
/// as two consecutive KEY_EVENTs, a leading surrogate followed by a trailing
/// one. `pending` holds the leading half across calls; `unit` is the code unit
/// just read. `pending` is always left cleared unless a new leader was stored.
fn resolveSurrogate(pending: *u16, unit: u16) SurrogateStep {
if (unit >= 0xD800 and unit <= 0xDBFF) {
pending.* = unit;
return .pending;
}
if (unit >= 0xDC00 and unit <= 0xDFFF) {
const hi = pending.*;
pending.* = 0;
if (hi != 0) {
if (combineSurrogates(hi, unit)) |cp| return .{ .cp = cp };
}
return .none;
}
// Any other unit: a stray leader never combines, so drop it and let the
// caller process this unit normally.
pending.* = 0;
return .none;
}

pub const WindowsBackend = struct {
allocator: Allocator,
stdin_handle: HANDLE,
Expand All @@ -218,6 +260,9 @@ pub const WindowsBackend = struct {
in_alternate_screen: bool = false,
mouse_enabled: bool = false,
write_buffer: std.ArrayListUnmanaged(u8) = .empty,
/// Leading UTF-16 surrogate buffered while waiting for its trailing half
/// (see `resolveSurrogate`). 0 = none pending.
pending_high_surrogate: u16 = 0,
original_console_info: CONSOLE_SCREEN_BUFFER_INFO = undefined,
original_codepage: UINT = undefined,

Expand Down Expand Up @@ -514,6 +559,15 @@ pub const WindowsBackend = struct {
.shift = shift_pressed,
};

// Non-BMP input (emoji) is split across two KEY_EVENTs; combine
// the surrogate halves before the code-unit guard below, which
// would otherwise reject both.
switch (resolveSurrogate(&self.pending_high_surrogate, key_event.uChar.UnicodeChar)) {
.pending => return events.Event.none,
.cp => |cp| return events.Event{ .key = .{ .code = .{ .char = cp }, .modifiers = modifiers } },
.none => {},
}

// Map virtual key codes to KeyCode
const key_code: events.KeyCode = switch (key_event.wVirtualKeyCode) {
VK_RETURN => .enter,
Expand All @@ -532,9 +586,10 @@ pub const WindowsBackend = struct {
VK_INSERT => .insert,
VK_F1...VK_F1 + 11 => |vk| .{ .f = @intCast(vk - VK_F1 + 1) },
else => blk: {
// Use the Unicode character if available
// Use the Unicode character if available (surrogates
// never reach this point; see `resolveSurrogate`).
const unicode_char = key_event.uChar.UnicodeChar;
if (unicode_char > 0 and unicode_char < 0xD800) {
if (unicode_char > 0 and (unicode_char < 0xD800 or unicode_char > 0xDFFF)) {
break :blk .{ .char = unicode_char };
}
return events.Event.none;
Expand Down Expand Up @@ -674,3 +729,45 @@ pub const WindowsBackend = struct {
self.mouse_enabled = false;
}
};

test "combineSurrogates: valid pairs and boundaries" {
// 😀 U+1F600 = D83D DE00
try std.testing.expectEqual(@as(?u21, 0x1F600), combineSurrogates(0xD83D, 0xDE00));
// Boundary: U+10000 = D800 DC00, U+10FFFF = DBFF DFFF
try std.testing.expectEqual(@as(?u21, 0x10000), combineSurrogates(0xD800, 0xDC00));
try std.testing.expectEqual(@as(?u21, 0x10FFFF), combineSurrogates(0xDBFF, 0xDFFF));
// Non-surrogate halves are rejected
try std.testing.expectEqual(@as(?u21, null), combineSurrogates(0x0041, 0xDE00));
try std.testing.expectEqual(@as(?u21, null), combineSurrogates(0xD83D, 0x0041));
try std.testing.expectEqual(@as(?u21, null), combineSurrogates(0xDC00, 0xDC00));
}

test "resolveSurrogate: pairs, stray leaders and lone trailers" {
var pending: u16 = 0;

// 😀 U+1F600: leader is buffered, trailer completes it
try std.testing.expectEqual(SurrogateStep.pending, resolveSurrogate(&pending, 0xD83D));
try std.testing.expectEqual(@as(u16, 0xD83D), pending);
try std.testing.expectEqual(SurrogateStep{ .cp = 0x1F600 }, resolveSurrogate(&pending, 0xDE00));
try std.testing.expectEqual(@as(u16, 0), pending);

// Stray leader followed by a normal character: leader is dropped, the
// character is processed normally by the caller
try std.testing.expectEqual(SurrogateStep.pending, resolveSurrogate(&pending, 0xD83D));
try std.testing.expectEqual(SurrogateStep.none, resolveSurrogate(&pending, 'x'));
try std.testing.expectEqual(@as(u16, 0), pending);

// Lone trailer: nothing to emit, no state left behind
try std.testing.expectEqual(SurrogateStep.none, resolveSurrogate(&pending, 0xDE00));
try std.testing.expectEqual(@as(u16, 0), pending);

// Two leaders in a row: the second replaces the first
try std.testing.expectEqual(SurrogateStep.pending, resolveSurrogate(&pending, 0xD83D));
try std.testing.expectEqual(SurrogateStep.pending, resolveSurrogate(&pending, 0xD83C));
try std.testing.expectEqual(@as(u16, 0xD83C), pending);
// 🏏 U+1F3CF = D83C DFCF
try std.testing.expectEqual(SurrogateStep{ .cp = 0x1F3CF }, resolveSurrogate(&pending, 0xDFCF));

// Plain ASCII passes through the machine untouched
try std.testing.expectEqual(SurrogateStep.none, resolveSurrogate(&pending, 'a'));
}
60 changes: 58 additions & 2 deletions src/widgets/paragraph.zig
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const style = @import("../style/mod.zig");
const Rect = render.Rect;
const Buffer = render.Buffer;
const Style = style.Style;
const codepointWidth = render.codepointWidth;

pub const Paragraph = struct {
text: []const u8,
Expand All @@ -27,7 +28,12 @@ pub const Paragraph = struct {
continue;
}

if (x_offset >= area.width) {
const w: u16 = @intCast(codepointWidth(codepoint));
if (w == 0) continue;

// Wide characters occupy two columns; wrap before one that would
// straddle the right edge.
if (x_offset + w > area.width) {
if (self.wrap) {
y_offset += 1;
x_offset = 0;
Expand All @@ -44,8 +50,58 @@ pub const Paragraph = struct {

if (y_offset < area.height) {
buf.setChar(area.x + x_offset, area.y + y_offset, codepoint, self.style);
x_offset += 1;
x_offset += w;
}
}
}
};

test "Paragraph renders wide characters at correct columns" {
var buf = try render.Buffer.init(std.testing.allocator, 12, 1);
defer buf.deinit();

const para = Paragraph{ .text = "你好😊" };
para.render(.{ .x = 0, .y = 0, .width = 12, .height = 1 }, &buf);

// 你 (0-1), 好 (2-3), 😊 (4-5)
try std.testing.expectEqual(@as(u21, 0x4F60), buf.get(0, 0).?.char);
try std.testing.expectEqual(@as(u2, 2), buf.get(0, 0).?.width);
try std.testing.expectEqual(@as(u2, 0), buf.get(1, 0).?.width); // continuation
try std.testing.expectEqual(@as(u21, 0x597D), buf.get(2, 0).?.char);
try std.testing.expectEqual(@as(u2, 2), buf.get(2, 0).?.width);
try std.testing.expectEqual(@as(u21, 0x1F60A), buf.get(4, 0).?.char);
try std.testing.expectEqual(@as(u2, 2), buf.get(4, 0).?.width);
try std.testing.expectEqual(@as(u2, 0), buf.get(5, 0).?.width); // continuation
}

test "Paragraph wraps before a wide character that would straddle the edge" {
var buf = try render.Buffer.init(std.testing.allocator, 5, 2);
defer buf.deinit();

// "a你b" in a 5-column area: 'a' (0), 你 (1-2), 'b' (3).
const para = Paragraph{ .text = "a你b" };
para.render(.{ .x = 0, .y = 0, .width = 5, .height = 2 }, &buf);
try std.testing.expectEqual(@as(u21, 'a'), buf.get(0, 0).?.char);
try std.testing.expectEqual(@as(u21, 0x4F60), buf.get(1, 0).?.char);
try std.testing.expectEqual(@as(u21, 'b'), buf.get(3, 0).?.char);

// A 4-column area: 'a' (0), 你 (1-2) fits, 'b' (3) fits. Edge case:
// "ab你" in width 3 must wrap 你 to the next line instead of splitting it.
var buf2 = try render.Buffer.init(std.testing.allocator, 3, 2);
defer buf2.deinit();
const para2 = Paragraph{ .text = "ab你" };
para2.render(.{ .x = 0, .y = 0, .width = 3, .height = 2 }, &buf2);
try std.testing.expectEqual(@as(u21, 'a'), buf2.get(0, 0).?.char);
try std.testing.expectEqual(@as(u21, 'b'), buf2.get(1, 0).?.char);
try std.testing.expectEqual(@as(u21, 0x4F60), buf2.get(0, 1).?.char); // wrapped
}

test "Paragraph respects newlines and non-wrap mode" {
var buf = try render.Buffer.init(std.testing.allocator, 6, 2);
defer buf.deinit();

const para = Paragraph{ .text = "ab\ncd", .wrap = false };
para.render(.{ .x = 0, .y = 0, .width = 6, .height = 2 }, &buf);
try std.testing.expectEqual(@as(u21, 'a'), buf.get(0, 0).?.char);
try std.testing.expectEqual(@as(u21, 'c'), buf.get(0, 1).?.char);
}
45 changes: 42 additions & 3 deletions src/widgets/tabs.zig
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const style = @import("../style/mod.zig");
const Rect = render.Rect;
const Buffer = render.Buffer;
const Style = style.Style;
const codepointWidth = render.codepointWidth;

pub const Tabs = struct {
titles: []const []const u8,
Expand Down Expand Up @@ -45,13 +46,16 @@ pub const Tabs = struct {
x += 1;
}

// Title characters
// Title characters: advance by display width so a wide glyph is
// not written into the previous character's trailing cell.
var view = std.unicode.Utf8View.initUnchecked(title);
var iter = view.iterator();
while (iter.nextCodepoint()) |cp| {
if (x >= area.x + area.width) break;
const w: u16 = @intCast(codepointWidth(cp));
if (w == 0) continue;
if (x + w > area.x + area.width) break;
buf.setChar(x, y, cp, tab_style);
x += 1;
x += w;
}

// Right padding
Expand Down Expand Up @@ -97,3 +101,38 @@ test "Tabs selectPrevious wraps" {
tabs.selectPrevious();
try std.testing.expectEqual(@as(usize, 2), tabs.selected);
}

test "Tabs renders wide characters without clobbering them" {
var buf = try render.Buffer.init(std.testing.allocator, 24, 1);
defer buf.deinit();

const tabs = Tabs{
.titles = &.{ "中文", "B" },
.selected = 0,
.padding = 0,
};
tabs.render(.{ .x = 0, .y = 0, .width = 24, .height = 1 }, &buf);

// "中文" occupies columns 0-3, then the divider, then "B".
try std.testing.expectEqual(@as(u21, 0x4E2D), buf.get(0, 0).?.char);
try std.testing.expectEqual(@as(u2, 2), buf.get(0, 0).?.width);
try std.testing.expectEqual(@as(u2, 0), buf.get(1, 0).?.width); // continuation
try std.testing.expectEqual(@as(u21, 0x6587), buf.get(2, 0).?.char);
try std.testing.expectEqual(@as(u2, 2), buf.get(2, 0).?.width);
try std.testing.expectEqual(@as(u2, 0), buf.get(3, 0).?.width); // continuation
try std.testing.expectEqual(@as(u21, 0x2502), buf.get(4, 0).?.char); // │
try std.testing.expectEqual(@as(u21, 'B'), buf.get(5, 0).?.char);
}

test "Tabs stops before a wide character that would straddle the edge" {
var buf = try render.Buffer.init(std.testing.allocator, 3, 1);
defer buf.deinit();

const tabs = Tabs{ .titles = &.{"中"}, .padding = 0 };
tabs.render(.{ .x = 0, .y = 0, .width = 3, .height = 1 }, &buf);

// 2-column glyph at column 0 fits; a second one would need columns 2-3
// but the area is only 3 wide, so it must not be written.
try std.testing.expectEqual(@as(u21, 0x4E2D), buf.get(0, 0).?.char);
try std.testing.expectEqual(@as(u21, ' '), buf.get(2, 0).?.char);
}
Loading
Loading