diff --git a/pkg/harfbuzz/font.zig b/pkg/harfbuzz/font.zig index 259274446..0947ea0b2 100644 --- a/pkg/harfbuzz/font.zig +++ b/pkg/harfbuzz/font.zig @@ -17,4 +17,12 @@ pub const Font = struct { pub fn destroy(self: *Font) void { c.hb_font_destroy(self.handle); } + + pub fn setScale(self: *Font, x: u32, y: u32) void { + c.hb_font_set_scale( + self.handle, + @intCast(x), + @intCast(y), + ); + } }; diff --git a/pkg/macos/foundation/attributed_string.zig b/pkg/macos/foundation/attributed_string.zig index 0c7bb13c3..b25cdef76 100644 --- a/pkg/macos/foundation/attributed_string.zig +++ b/pkg/macos/foundation/attributed_string.zig @@ -6,6 +6,17 @@ const text = @import("../text.zig"); const c = @import("c.zig"); pub const AttributedString = opaque { + pub fn create( + str: *foundation.String, + attributes: *foundation.Dictionary, + ) Allocator.Error!*AttributedString { + return @constCast(@ptrCast(c.CFAttributedStringCreate( + null, + @ptrCast(str), + @ptrCast(attributes), + ) orelse return Allocator.Error.OutOfMemory)); + } + pub fn release(self: *AttributedString) void { foundation.CFRelease(self); } diff --git a/pkg/macos/foundation/dictionary.zig b/pkg/macos/foundation/dictionary.zig index 0b6aa48be..ebb0fcc33 100644 --- a/pkg/macos/foundation/dictionary.zig +++ b/pkg/macos/foundation/dictionary.zig @@ -39,6 +39,23 @@ pub const Dictionary = opaque { key, ))); } + + pub fn getKeysAndValues(self: *Dictionary, alloc: Allocator) !struct { + keys: []?*const anyopaque, + values: []?*const anyopaque, + } { + const count = self.getCount(); + const keys = try alloc.alloc(?*const anyopaque, count); + errdefer alloc.free(keys); + const values = try alloc.alloc(?*const anyopaque, count); + errdefer alloc.free(values); + c.CFDictionaryGetKeysAndValues( + @ptrCast(self), + @ptrCast(keys.ptr), + @ptrCast(values.ptr), + ); + return .{ .keys = keys, .values = values }; + } }; pub const MutableDictionary = opaque { diff --git a/pkg/macos/foundation/string.zig b/pkg/macos/foundation/string.zig index 5f794ae66..b642201de 100644 --- a/pkg/macos/foundation/string.zig +++ b/pkg/macos/foundation/string.zig @@ -66,6 +66,31 @@ pub const String = opaque { } }; +pub const MutableString = opaque { + pub fn create(cap: usize) !*MutableString { + return @ptrCast(c.CFStringCreateMutable( + null, + @intCast(cap), + ) orelse return Allocator.Error.OutOfMemory); + } + + pub fn release(self: *MutableString) void { + foundation.CFRelease(self); + } + + pub fn string(self: *MutableString) *String { + return @ptrCast(self); + } + + pub fn appendCharacters(self: *MutableString, chars: []const u16) void { + c.CFStringAppendCharacters( + @ptrCast(self), + chars.ptr, + @intCast(chars.len), + ); + } +}; + pub const StringComparison = packed struct { case_insensitive: bool = false, _unused_2: bool = false, diff --git a/pkg/macos/text.zig b/pkg/macos/text.zig index 58bfaa632..2d5de91db 100644 --- a/pkg/macos/text.zig +++ b/pkg/macos/text.zig @@ -1,3 +1,4 @@ +pub const c = @import("text/c.zig"); pub usingnamespace @import("text/font.zig"); pub usingnamespace @import("text/font_collection.zig"); pub usingnamespace @import("text/font_descriptor.zig"); @@ -5,6 +6,7 @@ pub usingnamespace @import("text/font_manager.zig"); pub usingnamespace @import("text/frame.zig"); pub usingnamespace @import("text/framesetter.zig"); pub usingnamespace @import("text/line.zig"); +pub usingnamespace @import("text/run.zig"); pub usingnamespace @import("text/stylized_strings.zig"); test { diff --git a/pkg/macos/text/font.zig b/pkg/macos/text/font.zig index 845f19d61..a4997503b 100644 --- a/pkg/macos/text/font.zig +++ b/pkg/macos/text/font.zig @@ -47,6 +47,10 @@ pub const Font = opaque { return @ptrCast(@constCast(c.CTFontCopyFontDescriptor(@ptrCast(self)))); } + pub fn copyFeatures(self: *Font) *foundation.Array { + return @ptrCast(@constCast(c.CTFontCopyFeatures(@ptrCast(self)))); + } + pub fn getGlyphCount(self: *Font) usize { return @intCast(c.CTFontGetGlyphCount(@ptrCast(self))); } diff --git a/pkg/macos/text/line.zig b/pkg/macos/text/line.zig index e6f798bed..c13220c8b 100644 --- a/pkg/macos/text/line.zig +++ b/pkg/macos/text/line.zig @@ -49,6 +49,10 @@ pub const Line = opaque { leading, ); } + + pub fn getGlyphRuns(self: *Line) *foundation.Array { + return @constCast(@ptrCast(c.CTLineGetGlyphRuns(@ptrCast(self)))); + } }; pub const LineBoundsOptions = packed struct { diff --git a/pkg/macos/text/run.zig b/pkg/macos/text/run.zig new file mode 100644 index 000000000..a774ad6f3 --- /dev/null +++ b/pkg/macos/text/run.zig @@ -0,0 +1,93 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const foundation = @import("../foundation.zig"); +const graphics = @import("../graphics.zig"); +const text = @import("../text.zig"); +const c = @import("c.zig"); + +pub const Run = opaque { + pub fn release(self: *Run) void { + foundation.CFRelease(self); + } + + pub fn getGlyphCount(self: *Run) usize { + return @intCast(c.CTRunGetGlyphCount(@ptrCast(self))); + } + + pub fn getGlyphsPtr(self: *Run) []const graphics.Glyph { + const len = self.getGlyphCount(); + if (len == 0) return &.{}; + const ptr = c.CTRunGetGlyphsPtr(@ptrCast(self)) orelse &.{}; + return ptr[0..len]; + } + + pub fn getGlyphs(self: *Run, alloc: Allocator) ![]const graphics.Glyph { + const len = self.getGlyphCount(); + const ptr = try alloc.alloc(graphics.Glyph, len); + errdefer alloc.free(ptr); + c.CTRunGetGlyphs( + @ptrCast(self), + .{ .location = 0, .length = 0 }, + @ptrCast(ptr.ptr), + ); + return ptr; + } + + pub fn getPositionsPtr(self: *Run) []const graphics.Point { + const len = self.getGlyphCount(); + if (len == 0) return &.{}; + const ptr = c.CTRunGetPositionsPtr(@ptrCast(self)) orelse &.{}; + return ptr[0..len]; + } + + pub fn getPositions(self: *Run, alloc: Allocator) ![]const graphics.Point { + const len = self.getGlyphCount(); + const ptr = try alloc.alloc(graphics.Point, len); + errdefer alloc.free(ptr); + c.CTRunGetPositions( + @ptrCast(self), + .{ .location = 0, .length = 0 }, + @ptrCast(ptr.ptr), + ); + return ptr; + } + + pub fn getAdvancesPtr(self: *Run) []const graphics.Size { + const len = self.getGlyphCount(); + if (len == 0) return &.{}; + const ptr = c.CTRunGetAdvancesPtr(@ptrCast(self)) orelse &.{}; + return ptr[0..len]; + } + + pub fn getAdvances(self: *Run, alloc: Allocator) ![]const graphics.Size { + const len = self.getGlyphCount(); + const ptr = try alloc.alloc(graphics.Size, len); + errdefer alloc.free(ptr); + c.CTRunGetAdvances( + @ptrCast(self), + .{ .location = 0, .length = 0 }, + @ptrCast(ptr.ptr), + ); + return ptr; + } + + pub fn getStringIndicesPtr(self: *Run) []const usize { + const len = self.getGlyphCount(); + if (len == 0) return &.{}; + const ptr = c.CTRunGetStringIndicesPtr(@ptrCast(self)) orelse &.{}; + return ptr[0..len]; + } + + pub fn getStringIndices(self: *Run, alloc: Allocator) ![]const usize { + const len = self.getGlyphCount(); + const ptr = try alloc.alloc(usize, len); + errdefer alloc.free(ptr); + c.CTRunGetStringIndices( + @ptrCast(self), + .{ .location = 0, .length = 0 }, + @ptrCast(ptr.ptr), + ); + return ptr; + } +}; diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index 188a3bce8..a57216df4 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -77,6 +77,7 @@ pub const Face = struct { pub fn initFont(ct_font: *macos.text.Font, opts: font.face.Options) !Face { var hb_font = try harfbuzz.coretext.createFont(ct_font); errdefer hb_font.destroy(); + hb_font.setScale(opts.size.pixels(), opts.size.pixels()); const traits = ct_font.getSymbolicTraits(); const metrics = metrics: { diff --git a/src/font/shape.zig b/src/font/shape.zig index 2aa3bf442..d86f06bb0 100644 --- a/src/font/shape.zig +++ b/src/font/shape.zig @@ -1,6 +1,7 @@ const builtin = @import("builtin"); const options = @import("main.zig").options; const harfbuzz = @import("shaper/harfbuzz.zig"); +const coretext = @import("shaper/coretext.zig"); pub const web_canvas = @import("shaper/web_canvas.zig"); pub usingnamespace @import("shaper/run.zig"); @@ -12,6 +13,10 @@ pub const Shaper = switch (options.backend) { .coretext, => harfbuzz.Shaper, + // Has missing features, can't be used yet. See the comments in + // the coretext.zig file for more details. + //.coretext => coretext.Shaper, + .web_canvas => web_canvas.Shaper, }; @@ -24,6 +29,10 @@ pub const Cell = struct { /// caller has access to the original screen cell. x: u16, + /// An additional offset to apply to the rendering. + x_offset: i16 = 0, + y_offset: i16 = 0, + /// The glyph index for this cell. The font index to use alongside /// this cell is available in the text run. This glyph index is only /// valid for a given GroupCache and FontIndex that was used to create diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig new file mode 100644 index 000000000..655d67b04 --- /dev/null +++ b/src/font/shaper/coretext.zig @@ -0,0 +1,1026 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const harfbuzz = @import("harfbuzz"); +const macos = @import("macos"); +const trace = @import("tracy").trace; +const font = @import("../main.zig"); +const Face = font.Face; +const DeferredFace = font.DeferredFace; +const Group = font.Group; +const GroupCache = font.GroupCache; +const Library = font.Library; +const Style = font.Style; +const Presentation = font.Presentation; +const terminal = @import("../../terminal/main.zig"); + +const log = std.log.scoped(.font_shaper); + +/// Shaper that uses CoreText. +/// +/// WARNING: This is not ready for production usage. This is why this shaper +/// can't be configured at build-time without modifying the source. There are +/// a couple major missing features (quirks mode, font features) and I haven't +/// very carefully audited all my memory management. +/// +/// The purpose of this shaper is to keep us honest with our other shapers +/// and to help us find bugs in our other shapers. +pub const Shaper = struct { + /// The allocated used for the feature list and cell buf. + alloc: Allocator, + + /// The string used for shaping the current run. + codepoints: CodepointList = .{}, + + /// The font features we want to use. The hardcoded features are always + /// set first. + features: FeatureList = .{}, + + /// The shared memory used for shaping results. + cell_buf: CellBuf, + + const CellBuf = std.ArrayListUnmanaged(font.shape.Cell); + const CodepointList = std.ArrayListUnmanaged(Codepoint); + const Codepoint = struct { + codepoint: u32, + cluster: u32, + }; + + const FeatureList = std.ArrayListUnmanaged(Feature); + const Feature = struct { + key: *macos.foundation.String, + value: *macos.foundation.Number, + + pub fn init(name_raw: []const u8) !Feature { + const name = if (name_raw[0] == '-') name_raw[1..] else name_raw; + const value_num: c_int = if (name_raw[0] == '-') 0 else 1; + + var key = try macos.foundation.String.createWithBytes(name, .utf8, false); + errdefer key.release(); + var value = try macos.foundation.Number.create(.int, &value_num); + defer value.release(); + + return .{ .key = key, .value = value }; + } + + pub fn deinit(self: Feature) void { + self.key.release(); + self.value.release(); + } + }; + + // These features are hardcoded to always be on by default. Users + // can turn them off by setting the features to "-liga" for example. + const hardcoded_features = [_][]const u8{ "dlig", "liga" }; + + /// The cell_buf argument is the buffer to use for storing shaped results. + /// This should be at least the number of columns in the terminal. + pub fn init(alloc: Allocator, opts: font.shape.Options) !Shaper { + var feats: FeatureList = .{}; + errdefer { + for (feats.items) |feature| feature.deinit(); + feats.deinit(alloc); + } + + for (hardcoded_features) |name| { + const feat = try Feature.init(name); + errdefer feat.deinit(); + try feats.append(alloc, feat); + } + + for (opts.features) |name| { + const feat = try Feature.init(name); + errdefer feat.deinit(); + try feats.append(alloc, feat); + } + + return Shaper{ + .alloc = alloc, + .cell_buf = .{}, + .features = feats, + }; + } + + pub fn deinit(self: *Shaper) void { + self.cell_buf.deinit(self.alloc); + self.codepoints.deinit(self.alloc); + for (self.features.items) |feature| feature.deinit(); + self.features.deinit(self.alloc); + } + + pub fn runIterator( + self: *Shaper, + group: *GroupCache, + row: terminal.Screen.Row, + selection: ?terminal.Selection, + cursor_x: ?usize, + ) font.shape.RunIterator { + return .{ + .hooks = .{ .shaper = self }, + .group = group, + .row = row, + .selection = selection, + .cursor_x = cursor_x, + }; + } + + pub fn shape(self: *Shaper, run: font.shape.TextRun) ![]const font.shape.Cell { + // TODO: quirks fonts + // TODO: font features + + // Special fonts aren't shaped and their codepoint == glyph so we + // can just return the codepoints as-is. + if (run.font_index.special() != null) { + self.cell_buf.clearRetainingCapacity(); + try self.cell_buf.ensureTotalCapacity(self.alloc, self.codepoints.items.len); + for (self.codepoints.items) |entry| { + self.cell_buf.appendAssumeCapacity(.{ + .x = @intCast(entry.cluster), + .glyph_index = @intCast(entry.codepoint), + }); + } + + return self.cell_buf.items; + } + + // Create an arena for any Zig-based allocations we do + var arena = std.heap.ArenaAllocator.init(self.alloc); + defer arena.deinit(); + const alloc = arena.allocator(); + + // Build up our string contents + const str = str: { + const str = try macos.foundation.MutableString.create(0); + errdefer str.release(); + + for (self.codepoints.items) |entry| { + var unichars: [2]u16 = undefined; + const pair = macos.foundation.stringGetSurrogatePairForLongCharacter( + entry.codepoint, + &unichars, + ); + const len: usize = if (pair) 2 else 1; + str.appendCharacters(unichars[0..len]); + // log.warn("append codepoint={} unichar_len={}", .{ cp, len }); + } + + break :str str; + }; + defer str.release(); + + // Get our font and use that get the attributes to set for the + // attributed string so the whole string uses the same font. + const attr_dict = dict: { + const face = try run.group.group.faceFromIndex(run.font_index); + var keys = [_]?*const anyopaque{macos.text.StringAttribute.font.key()}; + var values = [_]?*const anyopaque{face.font}; + break :dict try macos.foundation.Dictionary.create(&keys, &values); + }; + defer attr_dict.release(); + + // Create an attributed string from our string + const attr_str = try macos.foundation.AttributedString.create( + str.string(), + attr_dict, + ); + defer attr_str.release(); + + // We should always have one run because we do our own run splitting. + const line = try macos.text.Line.createWithAttributedString(attr_str); + defer line.release(); + const runs = line.getGlyphRuns(); + assert(runs.getCount() == 1); + const ctrun = runs.getValueAtIndex(macos.text.Run, 0); + + // Get our glyphs and positions + const glyphs = try ctrun.getGlyphs(alloc); + const positions = try ctrun.getPositions(alloc); + const advances = try ctrun.getAdvances(alloc); + const indices = try ctrun.getStringIndices(alloc); + assert(glyphs.len == positions.len); + assert(glyphs.len == advances.len); + assert(glyphs.len == indices.len); + + // This keeps track of the current offsets within a single cell. + var cell_offset: struct { + cluster: u32 = 0, + x: f64 = 0, + y: f64 = 0, + } = .{}; + + self.cell_buf.clearRetainingCapacity(); + try self.cell_buf.ensureTotalCapacity(self.alloc, glyphs.len); + for (glyphs, positions, advances, indices) |glyph, pos, advance, index| { + // Our cluster is also our cell X position. If the cluster changes + // then we need to reset our current cell offsets. + const cluster = self.codepoints.items[index].cluster; + if (cell_offset.cluster != cluster) cell_offset = .{ + .cluster = cluster, + }; + + self.cell_buf.appendAssumeCapacity(.{ + .x = @intCast(cluster), + .x_offset = @intFromFloat(@round(cell_offset.x)), + .y_offset = @intFromFloat(@round(cell_offset.y)), + .glyph_index = glyph, + }); + + // Add our advances to keep track of our current cell offsets. + // Advances apply to the NEXT cell. + cell_offset.x += advance.width; + cell_offset.y += advance.height; + + _ = pos; + // const i = self.cell_buf.items.len - 1; + // log.warn( + // "i={} codepoint={} glyph={} pos={} advance={} index={} cluster={}", + // .{ i, self.codepoints.items[index].codepoint, glyph, pos, advance, index, cluster }, + // ); + } + //log.warn("-------------------------------", .{}); + + return self.cell_buf.items; + } + + /// The hooks for RunIterator. + pub const RunIteratorHook = struct { + shaper: *Shaper, + + pub fn prepare(self: *RunIteratorHook) !void { + self.shaper.codepoints.clearRetainingCapacity(); + } + + pub fn addCodepoint(self: RunIteratorHook, cp: u32, cluster: u32) !void { + try self.shaper.codepoints.append(self.shaper.alloc, .{ + .codepoint = cp, + .cluster = cluster, + }); + } + + pub fn finalize(self: RunIteratorHook) !void { + _ = self; + } + }; +}; + +test "run iterator" { + const testing = std.testing; + const alloc = testing.allocator; + + var testdata = try testShaper(alloc); + defer testdata.deinit(); + + { + // Make a screen with some data + var screen = try terminal.Screen.init(alloc, 3, 5, 0); + defer screen.deinit(); + try screen.testWriteString("ABCD"); + + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var count: usize = 0; + while (try it.next(alloc)) |_| count += 1; + try testing.expectEqual(@as(usize, 1), count); + } + + // Spaces should be part of a run + { + var screen = try terminal.Screen.init(alloc, 3, 10, 0); + defer screen.deinit(); + try screen.testWriteString("ABCD EFG"); + + var shaper = &testdata.shaper; + var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var count: usize = 0; + while (try it.next(alloc)) |_| count += 1; + try testing.expectEqual(@as(usize, 1), count); + } + + { + // Make a screen with some data + var screen = try terminal.Screen.init(alloc, 3, 5, 0); + defer screen.deinit(); + try screen.testWriteString("A😃D"); + + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var count: usize = 0; + while (try it.next(alloc)) |_| count += 1; + try testing.expectEqual(@as(usize, 3), count); + } +} + +test "run iterator: empty cells with background set" { + const testing = std.testing; + const alloc = testing.allocator; + + var testdata = try testShaper(alloc); + defer testdata.deinit(); + + { + // Make a screen with some data + var screen = try terminal.Screen.init(alloc, 3, 5, 0); + defer screen.deinit(); + screen.cursor.pen.bg = try terminal.color.Name.cyan.default(); + screen.cursor.pen.attrs.has_bg = true; + try screen.testWriteString("A"); + + // Get our first row + const row = screen.getRow(.{ .active = 0 }); + row.getCellPtr(1).* = screen.cursor.pen; + row.getCellPtr(2).* = screen.cursor.pen; + + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + + // The run should have length 3 because of the two background + // cells. + try testing.expectEqual(@as(usize, 3), shaper.codepoints.items.len); + const cells = try shaper.shape(run); + try testing.expectEqual(@as(usize, 3), cells.len); + } + try testing.expectEqual(@as(usize, 1), count); + } +} + +test "shape" { + const testing = std.testing; + const alloc = testing.allocator; + + var testdata = try testShaper(alloc); + defer testdata.deinit(); + + var buf: [32]u8 = undefined; + var buf_idx: usize = 0; + buf_idx += try std.unicode.utf8Encode(0x1F44D, buf[buf_idx..]); // Thumbs up plain + buf_idx += try std.unicode.utf8Encode(0x1F44D, buf[buf_idx..]); // Thumbs up plain + buf_idx += try std.unicode.utf8Encode(0x1F3FD, buf[buf_idx..]); // Medium skin tone + + // Make a screen with some data + var screen = try terminal.Screen.init(alloc, 3, 10, 0); + defer screen.deinit(); + try screen.testWriteString(buf[0..buf_idx]); + + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 1), count); +} + +// test "shape inconsolata ligs" { +// const testing = std.testing; +// const alloc = testing.allocator; +// +// var testdata = try testShaper(alloc); +// defer testdata.deinit(); +// +// { +// var screen = try terminal.Screen.init(alloc, 3, 5, 0); +// defer screen.deinit(); +// try screen.testWriteString(">="); +// +// var shaper = &testdata.shaper; +// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); +// var count: usize = 0; +// while (try it.next(alloc)) |run| { +// count += 1; +// +// const cells = try shaper.shape(run); +// try testing.expectEqual(@as(usize, 1), cells.len); +// } +// try testing.expectEqual(@as(usize, 1), count); +// } +// +// { +// var screen = try terminal.Screen.init(alloc, 3, 5, 0); +// defer screen.deinit(); +// try screen.testWriteString("==="); +// +// var shaper = &testdata.shaper; +// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); +// var count: usize = 0; +// while (try it.next(alloc)) |run| { +// count += 1; +// +// const cells = try shaper.shape(run); +// try testing.expectEqual(@as(usize, 1), cells.len); +// } +// try testing.expectEqual(@as(usize, 1), count); +// } +// } + +test "shape emoji width" { + const testing = std.testing; + const alloc = testing.allocator; + + var testdata = try testShaper(alloc); + defer testdata.deinit(); + + { + var screen = try terminal.Screen.init(alloc, 3, 5, 0); + defer screen.deinit(); + try screen.testWriteString("👍"); + + var shaper = &testdata.shaper; + var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + + const cells = try shaper.shape(run); + try testing.expectEqual(@as(usize, 1), cells.len); + } + try testing.expectEqual(@as(usize, 1), count); + } +} + +test "shape emoji width long" { + const testing = std.testing; + const alloc = testing.allocator; + + var testdata = try testShaper(alloc); + defer testdata.deinit(); + + var buf: [32]u8 = undefined; + var buf_idx: usize = 0; + buf_idx += try std.unicode.utf8Encode(0x1F9D4, buf[buf_idx..]); // man: beard + buf_idx += try std.unicode.utf8Encode(0x1F3FB, buf[buf_idx..]); // light skin tone (Fitz 1-2) + buf_idx += try std.unicode.utf8Encode(0x200D, buf[buf_idx..]); // ZWJ + buf_idx += try std.unicode.utf8Encode(0x2642, buf[buf_idx..]); // male sign + buf_idx += try std.unicode.utf8Encode(0xFE0F, buf[buf_idx..]); // emoji representation + + // Make a screen with some data + var screen = try terminal.Screen.init(alloc, 3, 30, 0); + defer screen.deinit(); + try screen.testWriteString(buf[0..buf_idx]); + + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + const cells = try shaper.shape(run); + try testing.expectEqual(@as(usize, 1), cells.len); + } + try testing.expectEqual(@as(usize, 1), count); +} + +test "shape variation selector VS15" { + const testing = std.testing; + const alloc = testing.allocator; + + var testdata = try testShaper(alloc); + defer testdata.deinit(); + + var buf: [32]u8 = undefined; + var buf_idx: usize = 0; + buf_idx += try std.unicode.utf8Encode(0x270C, buf[buf_idx..]); // Victory sign (default text) + buf_idx += try std.unicode.utf8Encode(0xFE0E, buf[buf_idx..]); // ZWJ to force text + + // Make a screen with some data + var screen = try terminal.Screen.init(alloc, 3, 10, 0); + defer screen.deinit(); + try screen.testWriteString(buf[0..buf_idx]); + + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + const cells = try shaper.shape(run); + try testing.expectEqual(@as(usize, 1), cells.len); + } + try testing.expectEqual(@as(usize, 1), count); +} + +test "shape variation selector VS16" { + const testing = std.testing; + const alloc = testing.allocator; + + var testdata = try testShaper(alloc); + defer testdata.deinit(); + + var buf: [32]u8 = undefined; + var buf_idx: usize = 0; + buf_idx += try std.unicode.utf8Encode(0x270C, buf[buf_idx..]); // Victory sign (default text) + buf_idx += try std.unicode.utf8Encode(0xFE0F, buf[buf_idx..]); // ZWJ to force color + + // Make a screen with some data + var screen = try terminal.Screen.init(alloc, 3, 10, 0); + defer screen.deinit(); + try screen.testWriteString(buf[0..buf_idx]); + + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + const cells = try shaper.shape(run); + try testing.expectEqual(@as(usize, 1), cells.len); + } + try testing.expectEqual(@as(usize, 1), count); +} + +test "shape with empty cells in between" { + const testing = std.testing; + const alloc = testing.allocator; + + var testdata = try testShaper(alloc); + defer testdata.deinit(); + + // Make a screen with some data + var screen = try terminal.Screen.init(alloc, 3, 30, 0); + defer screen.deinit(); + try screen.testWriteString("A"); + screen.cursor.x += 5; + try screen.testWriteString("B"); + + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + + const cells = try shaper.shape(run); + try testing.expectEqual(@as(usize, 7), cells.len); + } + try testing.expectEqual(@as(usize, 1), count); +} + +test "shape Chinese characters" { + const testing = std.testing; + const alloc = testing.allocator; + + var testdata = try testShaper(alloc); + defer testdata.deinit(); + + var buf: [32]u8 = undefined; + var buf_idx: usize = 0; + buf_idx += try std.unicode.utf8Encode('n', buf[buf_idx..]); // Combining + buf_idx += try std.unicode.utf8Encode(0x0308, buf[buf_idx..]); // Combining + buf_idx += try std.unicode.utf8Encode(0x0308, buf[buf_idx..]); + buf_idx += try std.unicode.utf8Encode('a', buf[buf_idx..]); + + // Make a screen with some data + var screen = try terminal.Screen.init(alloc, 3, 30, 0); + defer screen.deinit(); + try screen.testWriteString(buf[0..buf_idx]); + + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + + const cells = try shaper.shape(run); + try testing.expectEqual(@as(usize, 4), cells.len); + try testing.expectEqual(@as(u16, 0), cells[0].x); + try testing.expectEqual(@as(u16, 0), cells[1].x); + try testing.expectEqual(@as(u16, 0), cells[2].x); + try testing.expectEqual(@as(u16, 1), cells[3].x); + } + try testing.expectEqual(@as(usize, 1), count); +} + +test "shape box glyphs" { + const testing = std.testing; + const alloc = testing.allocator; + + var testdata = try testShaper(alloc); + defer testdata.deinit(); + + // Setup the box font + testdata.cache.group.sprite = font.sprite.Face{ + .width = 18, + .height = 36, + .thickness = 2, + }; + + var buf: [32]u8 = undefined; + var buf_idx: usize = 0; + buf_idx += try std.unicode.utf8Encode(0x2500, buf[buf_idx..]); // horiz line + buf_idx += try std.unicode.utf8Encode(0x2501, buf[buf_idx..]); // + + // Make a screen with some data + var screen = try terminal.Screen.init(alloc, 3, 10, 0); + defer screen.deinit(); + try screen.testWriteString(buf[0..buf_idx]); + + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + //try testing.expectEqual(@as(u32, 2), shaper.hb_buf.getLength()); + const cells = try shaper.shape(run); + try testing.expectEqual(@as(usize, 2), cells.len); + try testing.expectEqual(@as(u32, 0x2500), cells[0].glyph_index); + try testing.expectEqual(@as(u16, 0), cells[0].x); + try testing.expectEqual(@as(u32, 0x2501), cells[1].glyph_index); + try testing.expectEqual(@as(u16, 1), cells[1].x); + } + try testing.expectEqual(@as(usize, 1), count); +} + +test "shape selection boundary" { + const testing = std.testing; + const alloc = testing.allocator; + + var testdata = try testShaper(alloc); + defer testdata.deinit(); + + // Make a screen with some data + var screen = try terminal.Screen.init(alloc, 3, 10, 0); + defer screen.deinit(); + try screen.testWriteString("a1b2c3d4e5"); + + // Full line selection + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), .{ + .start = .{ .x = 0, .y = 0 }, + .end = .{ .x = screen.cols - 1, .y = 0 }, + }, null); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 1), count); + } + + // Offset x, goes to end of line selection + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), .{ + .start = .{ .x = 2, .y = 0 }, + .end = .{ .x = screen.cols - 1, .y = 0 }, + }, null); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 2), count); + } + + // Offset x, starts at beginning of line + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), .{ + .start = .{ .x = 0, .y = 0 }, + .end = .{ .x = 3, .y = 0 }, + }, null); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 2), count); + } + + // Selection only subset of line + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), .{ + .start = .{ .x = 1, .y = 0 }, + .end = .{ .x = 3, .y = 0 }, + }, null); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 3), count); + } + + // Selection only one character + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), .{ + .start = .{ .x = 1, .y = 0 }, + .end = .{ .x = 1, .y = 0 }, + }, null); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 3), count); + } +} + +test "shape cursor boundary" { + const testing = std.testing; + const alloc = testing.allocator; + + var testdata = try testShaper(alloc); + defer testdata.deinit(); + + // Make a screen with some data + var screen = try terminal.Screen.init(alloc, 3, 10, 0); + defer screen.deinit(); + try screen.testWriteString("a1b2c3d4e5"); + + // No cursor is full line + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 1), count); + } + + // Cursor at index 0 is two runs + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, 0); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 2), count); + } + + // Cursor at index 1 is three runs + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, 1); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 3), count); + } + + // Cursor at last col is two runs + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, 9); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 2), count); + } +} + +test "shape cursor boundary and colored emoji" { + const testing = std.testing; + const alloc = testing.allocator; + + var testdata = try testShaper(alloc); + defer testdata.deinit(); + + // Make a screen with some data + var screen = try terminal.Screen.init(alloc, 3, 10, 0); + defer screen.deinit(); + try screen.testWriteString("👍🏼"); + + // No cursor is full line + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 1), count); + } + + // Cursor on emoji does not split it + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, 0); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 1), count); + } + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, 1); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 1), count); + } +} + +test "shape cell attribute change" { + const testing = std.testing; + const alloc = testing.allocator; + + var testdata = try testShaper(alloc); + defer testdata.deinit(); + + // Plain >= should shape into 1 run + { + var screen = try terminal.Screen.init(alloc, 3, 10, 0); + defer screen.deinit(); + try screen.testWriteString(">="); + + var shaper = &testdata.shaper; + var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 1), count); + } + + // Bold vs regular should split + { + var screen = try terminal.Screen.init(alloc, 3, 10, 0); + defer screen.deinit(); + try screen.testWriteString(">"); + screen.cursor.pen.attrs.bold = true; + try screen.testWriteString("="); + + var shaper = &testdata.shaper; + var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 2), count); + } + + // Changing fg color should split + { + var screen = try terminal.Screen.init(alloc, 3, 10, 0); + defer screen.deinit(); + screen.cursor.pen.attrs.has_fg = true; + screen.cursor.pen.fg = .{ .r = 1, .g = 2, .b = 3 }; + try screen.testWriteString(">"); + screen.cursor.pen.fg = .{ .r = 3, .g = 2, .b = 1 }; + try screen.testWriteString("="); + + var shaper = &testdata.shaper; + var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 2), count); + } + + // Changing bg color should split + { + var screen = try terminal.Screen.init(alloc, 3, 10, 0); + defer screen.deinit(); + screen.cursor.pen.attrs.has_bg = true; + screen.cursor.pen.bg = .{ .r = 1, .g = 2, .b = 3 }; + try screen.testWriteString(">"); + screen.cursor.pen.bg = .{ .r = 3, .g = 2, .b = 1 }; + try screen.testWriteString("="); + + var shaper = &testdata.shaper; + var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 2), count); + } + + // Same bg color should not split + { + var screen = try terminal.Screen.init(alloc, 3, 10, 0); + defer screen.deinit(); + screen.cursor.pen.attrs.has_bg = true; + screen.cursor.pen.bg = .{ .r = 1, .g = 2, .b = 3 }; + try screen.testWriteString(">"); + try screen.testWriteString("="); + + var shaper = &testdata.shaper; + var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 1), count); + } +} + +const TestShaper = struct { + alloc: Allocator, + shaper: Shaper, + cache: *GroupCache, + lib: Library, + + pub fn deinit(self: *TestShaper) void { + self.shaper.deinit(); + self.cache.deinit(self.alloc); + self.alloc.destroy(self.cache); + self.lib.deinit(); + } +}; + +/// Helper to return a fully initialized shaper. +fn testShaper(alloc: Allocator) !TestShaper { + const testFont = @import("../test.zig").fontRegular; + const testEmoji = @import("../test.zig").fontEmoji; + const testEmojiText = @import("../test.zig").fontEmojiText; + + var lib = try Library.init(); + errdefer lib.deinit(); + + var cache_ptr = try alloc.create(GroupCache); + errdefer alloc.destroy(cache_ptr); + cache_ptr.* = try GroupCache.init(alloc, try Group.init( + alloc, + lib, + .{ .points = 12 }, + )); + errdefer cache_ptr.*.deinit(alloc); + + // Setup group + _ = try cache_ptr.group.addFace(.regular, .{ .loaded = try Face.init( + lib, + testFont, + .{ .size = .{ .points = 12 } }, + ) }); + + if (font.options.backend != .coretext) { + // Coretext doesn't support Noto's format + _ = try cache_ptr.group.addFace(.regular, .{ .loaded = try Face.init( + lib, + testEmoji, + .{ .size = .{ .points = 12 } }, + ) }); + } else { + // On CoreText we want to load Apple Emoji, we should have it. + var disco = font.Discover.init(); + defer disco.deinit(); + var disco_it = try disco.discover(alloc, .{ + .family = "Apple Color Emoji", + .size = 12, + .monospace = false, + }); + defer disco_it.deinit(); + var face = (try disco_it.next()).?; + errdefer face.deinit(); + _ = try cache_ptr.group.addFace(.regular, .{ .deferred = face }); + } + _ = try cache_ptr.group.addFace(.regular, .{ .loaded = try Face.init( + lib, + testEmojiText, + .{ .size = .{ .points = 12 } }, + ) }); + + var shaper = try Shaper.init(alloc, .{}); + errdefer shaper.deinit(); + + return TestShaper{ + .alloc = alloc, + .shaper = shaper, + .cache = cache_ptr, + .lib = lib, + }; +} diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index 781d5a708..4a3613276 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -133,17 +133,43 @@ pub const Shaper = struct { // If it isn't true, I'd like to catch it and learn more. assert(info.len == pos.len); + // This keeps track of the current offsets within a single cell. + var cell_offset: struct { + cluster: u32 = 0, + x: i32 = 0, + y: i32 = 0, + } = .{}; + // Convert all our info/pos to cells and set it. self.cell_buf.clearRetainingCapacity(); try self.cell_buf.ensureTotalCapacity(self.alloc, info.len); - for (info) |v| { + for (info, pos) |info_v, pos_v| { + if (info_v.cluster != cell_offset.cluster) cell_offset = .{ + .cluster = info_v.cluster, + }; + self.cell_buf.appendAssumeCapacity(.{ - .x = @intCast(v.cluster), - .glyph_index = v.codepoint, + .x = @intCast(info_v.cluster), + .x_offset = @intCast(cell_offset.x), + .y_offset = @intCast(cell_offset.y), + .glyph_index = info_v.codepoint, }); - // log.warn("i={} info={} pos={} cell={}", .{ i, v, pos[i], self.cell_buf[i] }); + if (font.options.backend.hasFreetype()) { + // Freetype returns 26.6 fixed point values, so we need to + // divide by 64 to get the actual value. I can't find any + // HB API to stop this. + cell_offset.x += pos_v.x_advance >> 6; + cell_offset.y += pos_v.y_advance >> 6; + } else { + cell_offset.x += pos_v.x_advance; + cell_offset.y += pos_v.y_advance; + } + + // const i = self.cell_buf.items.len - 1; + // log.warn("i={} info={} pos={} cell={}", .{ i, info_v, pos_v, self.cell_buf.items[i] }); } + //log.warn("----------------", .{}); return self.cell_buf.items; } diff --git a/src/font/shaper/run.zig b/src/font/shaper/run.zig index 7bc56e2e9..093ffaebe 100644 --- a/src/font/shaper/run.zig +++ b/src/font/shaper/run.zig @@ -209,7 +209,10 @@ pub const RunIterator = struct { } // Add all the codepoints for our grapheme - try self.hooks.addCodepoint(cell.char, @intCast(cluster)); + try self.hooks.addCodepoint( + if (cell.char == 0) ' ' else cell.char, + @intCast(cluster), + ); if (cell.attrs.grapheme) { var it = self.row.codepointIterator(j); while (it.next()) |cp| { diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 9f7356d4b..ea151819a 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -1787,15 +1787,6 @@ pub fn updateCell( .emoji => .fg_color, }; - // If this glyph doesn't have an advance, then we assume it is - // connected to the previous glyph (perhaps an unsafe assumption...) - // and offset by the cell width. - // Related: https://github.com/mitchellh/ghostty/issues/1046 - const extra_offset: i32 = if (glyph.advance_x == 0) - @intCast(self.grid_metrics.cell_width) - else - 0; - self.cells.appendAssumeCapacity(.{ .mode = mode, .grid_pos = .{ @as(f32, @floatFromInt(x)), @as(f32, @floatFromInt(y)) }, @@ -1804,7 +1795,10 @@ pub fn updateCell( .bg_color = bg, .glyph_pos = .{ glyph.atlas_x, glyph.atlas_y }, .glyph_size = .{ glyph.width, glyph.height }, - .glyph_offset = .{ glyph.offset_x + extra_offset, glyph.offset_y }, + .glyph_offset = .{ + glyph.offset_x + shaper_cell.x_offset, + glyph.offset_y + shaper_cell.y_offset, + }, }); } diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 9ba9f0921..bbb36b730 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -1490,15 +1490,6 @@ pub fn updateCell( .emoji => .fg_color, }; - // If this glyph doesn't have an advance, then we assume it is - // connected to the previous glyph (perhaps an unsafe assumption...) - // and offset by the cell width. - // Related: https://github.com/mitchellh/ghostty/issues/1046 - const extra_offset: i32 = if (glyph.advance_x == 0) - @intCast(self.grid_metrics.cell_width) - else - 0; - self.cells.appendAssumeCapacity(.{ .mode = mode, .grid_col = @intCast(x), @@ -1508,8 +1499,8 @@ pub fn updateCell( .glyph_y = glyph.atlas_y, .glyph_width = glyph.width, .glyph_height = glyph.height, - .glyph_offset_x = glyph.offset_x + extra_offset, - .glyph_offset_y = glyph.offset_y, + .glyph_offset_x = glyph.offset_x + shaper_cell.x_offset, + .glyph_offset_y = glyph.offset_y + shaper_cell.y_offset, .r = colors.fg.r, .g = colors.fg.g, .b = colors.fg.b,