diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index de3bd7879..1e9b01889 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,10 +35,10 @@ jobs: with: nix_path: nixpkgs=channel:nixos-unstable - # Run our go tests within the context of the dev shell from the flake. This - # will ensure we have all our dependencies. + # Cross-compile the binary. We always use static building for this + # because its the only way to access the headers. - name: Test Build - run: nix develop -c zig build -Dtarget=${{ matrix.target }} + run: nix develop -c zig build -Dstatic=true -Dtarget=${{ matrix.target }} test: strategy: @@ -57,7 +57,9 @@ jobs: with: nix_path: nixpkgs=channel:nixos-unstable - # Run our go tests within the context of the dev shell from the flake. This - # will ensure we have all our dependencies. - name: test run: nix develop -c zig build test + + - name: Test Dynamic Build + run: nix develop -c zig build -Dstatic=false + diff --git a/.gitmodules b/.gitmodules index fcee35578..b1120c041 100644 --- a/.gitmodules +++ b/.gitmodules @@ -19,3 +19,6 @@ [submodule "vendor/libpng"] path = vendor/libpng url = https://github.com/glennrp/libpng.git +[submodule "vendor/utf8proc"] + path = vendor/utf8proc + url = https://github.com/JuliaStrings/utf8proc.git diff --git a/build.zig b/build.zig index 48cb6d5a5..08f93e2a6 100644 --- a/build.zig +++ b/build.zig @@ -6,11 +6,12 @@ const glfw = @import("vendor/mach/glfw/build.zig"); const freetype = @import("pkg/freetype/build.zig"); const libuv = @import("pkg/libuv/build.zig"); const libpng = @import("pkg/libpng/build.zig"); +const utf8proc = @import("pkg/utf8proc/build.zig"); const zlib = @import("pkg/zlib/build.zig"); const tracylib = @import("pkg/tracy/build.zig"); const system_sdk = @import("vendor/mach/glfw/system_sdk.zig"); -/// A build that is set to true if Tracy integration should be built. +// Build options, see the build options help for more info. var tracy: bool = false; pub fn build(b: *std.build.Builder) !void { @@ -32,6 +33,12 @@ pub fn build(b: *std.build.Builder) !void { "Enable Tracy integration (default true in Debug on Linux)", ) orelse (mode == .Debug and target.isLinux()); + const static = b.option( + bool, + "static", + "Statically build as much as possible for the exe", + ) orelse true; + const conformance = b.option( []const u8, "conformance", @@ -51,7 +58,7 @@ pub fn build(b: *std.build.Builder) !void { exe.install(); // Add the shared dependencies - try addDeps(b, exe); + try addDeps(b, exe, static); } // term.wasm @@ -64,7 +71,11 @@ pub fn build(b: *std.build.Builder) !void { wasm.setTarget(.{ .cpu_arch = .wasm32, .os_tag = .freestanding }); wasm.setBuildMode(mode); wasm.setOutputDir("zig-out"); + + // Wasm-specific deps wasm.addPackage(tracylib.pkg); + wasm.addPackage(utf8proc.pkg); + _ = try utf8proc.link(b, wasm); const step = b.step("term-wasm", "Build the terminal.wasm library"); step.dependOn(&wasm.step); @@ -104,7 +115,7 @@ pub fn build(b: *std.build.Builder) !void { } main_test.setTarget(target); - try addDeps(b, main_test); + try addDeps(b, main_test, true); var before = b.addLog("\x1b[" ++ color_map.get("cyan").? ++ "\x1b[" ++ color_map.get("b").? ++ "[{s} tests]" ++ "\x1b[" ++ color_map.get("d").? ++ " ----" ++ "\x1b[0m", .{"ghostty"}); var after = b.addLog("\x1b[" ++ color_map.get("d").? ++ "–––---\n\n" ++ "\x1b[0m", .{}); @@ -122,7 +133,7 @@ pub fn build(b: *std.build.Builder) !void { var test_ = b.addTestSource(pkg.source); test_.setTarget(target); - try addDeps(b, test_); + try addDeps(b, test_, true); if (pkg.dependencies) |children| { test_.packages = std.ArrayList(std.build.Pkg).init(b.allocator); try test_.packages.appendSlice(children); @@ -141,53 +152,73 @@ pub fn build(b: *std.build.Builder) !void { fn addDeps( b: *std.build.Builder, step: *std.build.LibExeObjStep, + static: bool, ) !void { + // We always need the Zig packages + step.addPackage(freetype.pkg); + step.addPackage(glfw.pkg); + step.addPackage(libuv.pkg); + step.addPackage(utf8proc.pkg); + + // We always statically compile glad step.addIncludeDir("vendor/glad/include/"); step.addCSourceFile("vendor/glad/src/gl.c", &.{}); - // Dependencies of other dependencies - const zlib_step = try zlib.link(b, step); - const libpng_step = try libpng.link(b, step, .{ - .zlib = .{ - .step = zlib_step, - .include = &zlib.include_paths, - }, - }); - - // Freetype - step.addPackage(freetype.pkg); - _ = try freetype.link(b, step, .{ - .libpng = freetype.Options.Libpng{ - .enabled = true, - .step = libpng_step, - .include = &libpng.include_paths, - }, - - .zlib = .{ - .enabled = true, - .step = zlib_step, - .include = &zlib.include_paths, - }, - }); - - // Glfw - step.addPackage(glfw.pkg); - glfw.link(b, step, .{ - .metal = false, - .opengl = false, // Found at runtime - }); - - // Libuv - step.addPackage(libuv.pkg); - var libuv_step = try libuv.link(b, step); - system_sdk.include(b, libuv_step, .{}); - // Tracy step.addPackage(tracylib.pkg); if (tracy) { var tracy_step = try tracylib.link(b, step); system_sdk.include(b, tracy_step, .{}); } + + // utf8proc + _ = try utf8proc.link(b, step); + + // Glfw + glfw.link(b, step, .{ + .metal = false, + .opengl = false, // Found at runtime + }); + + // Dynamic link + if (!static) { + step.addIncludePath(freetype.include_path_self); + step.linkSystemLibrary("bzip2"); + step.linkSystemLibrary("freetype2"); + step.linkSystemLibrary("libpng"); + step.linkSystemLibrary("libuv"); + step.linkSystemLibrary("zlib"); + } + + // Other dependencies, we may dynamically link + if (static) { + const zlib_step = try zlib.link(b, step); + const libpng_step = try libpng.link(b, step, .{ + .zlib = .{ + .step = zlib_step, + .include = &zlib.include_paths, + }, + }); + + // Freetype + _ = try freetype.link(b, step, .{ + .libpng = freetype.Options.Libpng{ + .enabled = true, + .step = libpng_step, + .include = &libpng.include_paths, + }, + + .zlib = .{ + .enabled = true, + .step = zlib_step, + .include = &zlib.include_paths, + }, + }); + + // Libuv + var libuv_step = try libuv.link(b, step); + system_sdk.include(b, libuv_step, .{}); + } } fn conformanceSteps( diff --git a/nix/devshell.nix b/nix/devshell.nix index b587e1915..1577471a9 100644 --- a/nix/devshell.nix +++ b/nix/devshell.nix @@ -19,18 +19,26 @@ , freetype , libpng , libGL +, libuv , libX11 , libXcursor , libXext , libXi , libXinerama , libXrandr +, zlib }: let # See package.nix. Keep in sync. rpathLibs = [ libGL ] ++ lib.optionals stdenv.isLinux [ + bzip2 + freetype + libpng + libuv + zlib + libX11 libXcursor libXi @@ -61,6 +69,12 @@ in mkShell rec { buildInputs = [ # TODO: non-linux ] ++ lib.optionals stdenv.isLinux [ + bzip2 + freetype + libpng + libuv + zlib + libX11 libXcursor libXext diff --git a/pkg/freetype/build.zig b/pkg/freetype/build.zig index b85bf4a7f..4e7816a59 100644 --- a/pkg/freetype/build.zig +++ b/pkg/freetype/build.zig @@ -3,7 +3,7 @@ const std = @import("std"); /// Directories with our includes. const root = thisDir() ++ "../../../vendor/freetype/"; const include_path = root ++ "include"; -const include_path_self = thisDir(); +pub const include_path_self = thisDir(); pub const pkg = std.build.Pkg{ .name = "freetype", diff --git a/pkg/utf8proc/build.zig b/pkg/utf8proc/build.zig new file mode 100644 index 000000000..6fe9dc3ac --- /dev/null +++ b/pkg/utf8proc/build.zig @@ -0,0 +1,51 @@ +const std = @import("std"); + +/// Directories with our includes. +const root = thisDir() ++ "../../../vendor/utf8proc/"; +const include_path = root; + +pub const include_paths = .{include_path}; + +pub const pkg = std.build.Pkg{ + .name = "utf8proc", + .source = .{ .path = thisDir() ++ "/main.zig" }, +}; + +fn thisDir() []const u8 { + return std.fs.path.dirname(@src().file) orelse "."; +} + +pub fn link(b: *std.build.Builder, step: *std.build.LibExeObjStep) !*std.build.LibExeObjStep { + const lib = try buildLib(b, step); + step.linkLibrary(lib); + step.addIncludePath(include_path); + return lib; +} + +pub fn buildLib( + b: *std.build.Builder, + step: *std.build.LibExeObjStep, +) !*std.build.LibExeObjStep { + const lib = b.addStaticLibrary("utf8proc", null); + lib.setTarget(step.target); + lib.setBuildMode(step.build_mode); + + // Include + lib.addIncludePath(include_path); + + // Link + lib.linkLibC(); + + // Compile + var flags = std.ArrayList([]const u8).init(b.allocator); + defer flags.deinit(); + + // C files + lib.addCSourceFiles(srcs, flags.items); + + return lib; +} + +const srcs = &.{ + root ++ "utf8proc.c", +}; diff --git a/pkg/utf8proc/c.zig b/pkg/utf8proc/c.zig new file mode 100644 index 000000000..adeb226b0 --- /dev/null +++ b/pkg/utf8proc/c.zig @@ -0,0 +1,3 @@ +pub usingnamespace @cImport({ + @cInclude("utf8proc.h"); +}); diff --git a/pkg/utf8proc/main.zig b/pkg/utf8proc/main.zig new file mode 100644 index 000000000..31c54b545 --- /dev/null +++ b/pkg/utf8proc/main.zig @@ -0,0 +1,8 @@ +pub const c = @import("c.zig"); + +/// Given a codepoint, return a character width analogous to `wcwidth(codepoint)`, +/// except that a width of 0 is returned for non-printable codepoints +/// instead of -1 as in `wcwidth`. +pub fn charwidth(codepoint: u21) u8 { + return @intCast(u8, c.utf8proc_charwidth(@intCast(i32, codepoint))); +} diff --git a/shaders/cell.f.glsl b/shaders/cell.f.glsl index 9f17883d1..9fdcea9fc 100644 --- a/shaders/cell.f.glsl +++ b/shaders/cell.f.glsl @@ -18,6 +18,7 @@ layout(location = 0) out vec4 out_FragColor; // Font texture uniform sampler2D text; +uniform sampler2D text_color; // Dimensions of the cell uniform vec2 cell_size; @@ -25,22 +26,30 @@ uniform vec2 cell_size; // See vertex shader const uint MODE_BG = 1u; const uint MODE_FG = 2u; +const uint MODE_FG_COLOR = 7u; const uint MODE_CURSOR_RECT = 3u; const uint MODE_CURSOR_RECT_HOLLOW = 4u; const uint MODE_CURSOR_BAR = 5u; const uint MODE_UNDERLINE = 6u; +const uint MODE_WIDE_MASK = 128u; // 0b1000_0000 void main() { + float a; + switch (mode) { case MODE_BG: out_FragColor = color; break; case MODE_FG: - float a = texture(text, glyph_tex_coords).r; + a = texture(text, glyph_tex_coords).r; out_FragColor = vec4(color.rgb, color.a*a); break; + case MODE_FG_COLOR: + out_FragColor = texture(text_color, glyph_tex_coords); + break; + case MODE_CURSOR_RECT: out_FragColor = color; break; diff --git a/shaders/cell.v.glsl b/shaders/cell.v.glsl index 629f1ffb0..36e1d7a8b 100644 --- a/shaders/cell.v.glsl +++ b/shaders/cell.v.glsl @@ -6,10 +6,12 @@ // NOTE: this must be kept in sync with the fragment shader const uint MODE_BG = 1u; const uint MODE_FG = 2u; +const uint MODE_FG_COLOR = 7u; const uint MODE_CURSOR_RECT = 3u; const uint MODE_CURSOR_RECT_HOLLOW = 4u; const uint MODE_CURSOR_BAR = 5u; const uint MODE_UNDERLINE = 6u; +const uint MODE_WIDE_MASK = 128u; // 0b1000_0000 // The grid coordinates (x, y) where x < columns and y < rows layout (location = 0) in vec2 grid_coord; @@ -50,6 +52,7 @@ flat out vec2 screen_cell_pos; flat out uint mode; uniform sampler2D text; +uniform sampler2D text_color; uniform vec2 cell_size; uniform mat4 projection; uniform float glyph_baseline; @@ -75,8 +78,12 @@ uniform float glyph_baseline; */ void main() { - // We always forward our mode - mode = mode_in; + // Remove any masks from our mode + uint mode_unmasked = mode_in & ~MODE_WIDE_MASK; + + // We always forward our mode unmasked because the fragment + // shader doesn't use any of the masks. + mode = mode_unmasked; // Top-left cell coordinates converted to world space // Example: (1,0) with a 30 wide cell is converted to (30,0) @@ -101,32 +108,59 @@ void main() { position.x = (gl_VertexID == 0 || gl_VertexID == 1) ? 1. : 0.; position.y = (gl_VertexID == 0 || gl_VertexID == 3) ? 0. : 1.; - switch (mode_in) { + // Scaled for wide chars + vec2 cell_size_scaled = cell_size; + if ((mode_in & MODE_WIDE_MASK) == MODE_WIDE_MASK) { + cell_size_scaled.x = cell_size_scaled.x * 2; + } + + switch (mode) { case MODE_BG: // Calculate the final position of our cell in world space. // We have to add our cell size since our vertices are offset // one cell up and to the left. (Do the math to verify yourself) - cell_pos = cell_pos + cell_size * position; + cell_pos = cell_pos + cell_size_scaled * position; gl_Position = projection * vec4(cell_pos, cell_z, 1.0); color = bg_color_in / 255.0; break; case MODE_FG: + case MODE_FG_COLOR: + vec2 glyph_offset_calc = glyph_offset; + + // If the glyph is larger than our cell, we need to downsample it + // TODO: for now, we assume this means it is a full width character + // TODO: in the future, use unicode libs to verify this. + vec2 glyph_size_downsampled = glyph_size; + if (glyph_size.x > cell_size.x) { + glyph_size_downsampled.x = cell_size_scaled.x; + glyph_size_downsampled.y = glyph_size.y * (glyph_size_downsampled.x / glyph_size.x); + glyph_offset_calc.y = glyph_offset.y * (glyph_size_downsampled.x / glyph_size.x); + } + // The glyph_offset.y is the y bearing, a y value that when added // to the baseline is the offset (+y is up). Our grid goes down. // So we flip it with `cell_size.y - glyph_offset.y`. The glyph_baseline // uniform sets our line baseline where characters "sit". - vec2 glyph_offset_calc = glyph_offset; - glyph_offset_calc.y = cell_size.y - glyph_offset.y - glyph_baseline; + glyph_offset_calc.y = cell_size_scaled.y - glyph_offset_calc.y - glyph_baseline; // Calculate the final position of the cell. - cell_pos = cell_pos + glyph_size * position + glyph_offset_calc; + cell_pos = cell_pos + glyph_size_downsampled * position + glyph_offset_calc; gl_Position = projection * vec4(cell_pos, cell_z, 1.0); // We need to convert our texture position and size to normalized // device coordinates (0 to 1.0) by dividing by the size of the texture. - ivec2 text_size = textureSize(text, 0); + ivec2 text_size; + switch(mode) { + case MODE_FG: + text_size = textureSize(text, 0); + break; + + case MODE_FG_COLOR: + text_size = textureSize(text_color, 0); + break; + } vec2 glyph_tex_pos = glyph_pos / text_size; vec2 glyph_tex_size = glyph_size / text_size; glyph_tex_coords = glyph_tex_pos + glyph_tex_size * position; @@ -137,7 +171,7 @@ void main() { case MODE_CURSOR_RECT: // Same as background since we're taking up the whole cell. - cell_pos = cell_pos + cell_size * position; + cell_pos = cell_pos + cell_size_scaled * position; gl_Position = projection * vec4(cell_pos, cell_z, 1.0); color = bg_color_in / 255.0; @@ -148,7 +182,7 @@ void main() { screen_cell_pos = cell_pos; // Same as background since we're taking up the whole cell. - cell_pos = cell_pos + cell_size * position; + cell_pos = cell_pos + cell_size_scaled * position; gl_Position = projection * vec4(cell_pos, cell_z, 1.0); color = bg_color_in / 255.0; @@ -168,11 +202,11 @@ void main() { case MODE_UNDERLINE: // Make the underline a smaller version of our cell // TODO: use real font underline thickness - vec2 underline_size = vec2(cell_size.x, cell_size.y*0.05); + vec2 underline_size = vec2(cell_size_scaled.x, cell_size_scaled.y*0.05); // Position our underline so that it is midway between the glyph // baseline and the bottom of the cell. - vec2 underline_offset = vec2(cell_size.x, cell_size.y - (glyph_baseline / 2)); + vec2 underline_offset = vec2(cell_size_scaled.x, cell_size_scaled.y - (glyph_baseline / 2)); // Go to the bottom of the cell, take away the size of the // underline, and that is our position. We also float it slightly diff --git a/src/Grid.zig b/src/Grid.zig index 3dadae3e2..1c89771fb 100644 --- a/src/Grid.zig +++ b/src/Grid.zig @@ -42,9 +42,10 @@ vao: gl.VertexArray, ebo: gl.Buffer, vbo: gl.Buffer, texture: gl.Texture, +texture_color: gl.Texture, /// The font atlas. -font_atlas: font.Family, +font_set: font.FallbackSet, atlas_dirty: bool, /// Whether the cursor is visible or not. This is used to control cursor @@ -108,7 +109,30 @@ const GPUCell = struct { bg_a: u8, /// uint mode - mode: u8, + mode: GPUCellMode, +}; + +const GPUCellMode = enum(u8) { + bg = 1, + fg = 2, + fg_color = 7, + cursor_rect = 3, + cursor_rect_hollow = 4, + cursor_bar = 5, + underline = 6, + + wide_mask = 0b1000_0000, + + // Non-exhaustive because masks change it + _, + + /// Apply a mask to the mode. + pub fn mask(self: GPUCellMode, m: GPUCellMode) GPUCellMode { + return @intToEnum( + GPUCellMode, + @enumToInt(self) | @enumToInt(m), + ); + } }; pub fn init(alloc: Allocator, config: *const Config) !Grid { @@ -116,10 +140,32 @@ pub fn init(alloc: Allocator, config: *const Config) !Grid { // font atlas with all the visible ASCII characters since they are common. var atlas = try Atlas.init(alloc, 512, .greyscale); errdefer atlas.deinit(alloc); - var fam = try font.Family.init(atlas); - errdefer fam.deinit(alloc); - try fam.loadFaceFromMemory(.regular, face_ttf, config.@"font-size"); - try fam.loadFaceFromMemory(.bold, face_bold_ttf, config.@"font-size"); + + // Load our emoji font + var atlas_color = try Atlas.init(alloc, 512, .rgba); + errdefer atlas_color.deinit(alloc); + + // Build our fallback set so we can look up all codepoints + var font_set: font.FallbackSet = .{}; + try font_set.families.ensureTotalCapacity(alloc, 2); + errdefer font_set.deinit(alloc); + + // Regular text + font_set.families.appendAssumeCapacity(fam: { + var fam = try font.Family.init(atlas); + errdefer fam.deinit(alloc); + try fam.loadFaceFromMemory(.regular, face_ttf, config.@"font-size"); + try fam.loadFaceFromMemory(.bold, face_bold_ttf, config.@"font-size"); + break :fam fam; + }); + + // Emoji + font_set.families.appendAssumeCapacity(fam: { + var fam_emoji = try font.Family.init(atlas_color); + errdefer fam_emoji.deinit(alloc); + try fam_emoji.loadFaceFromMemory(.regular, face_emoji_ttf, config.@"font-size"); + break :fam fam_emoji; + }); // Load all visible ASCII characters and build our cell width based on // the widest character that we see. @@ -127,9 +173,9 @@ pub fn init(alloc: Allocator, config: *const Config) !Grid { var cell_width: f32 = 0; var i: u8 = 32; while (i <= 126) : (i += 1) { - const glyph = try fam.addGlyph(alloc, i, .regular); - if (glyph.advance_x > cell_width) { - cell_width = @ceil(glyph.advance_x); + const goa = try font_set.getOrAddGlyph(alloc, i, .regular); + if (goa.glyph.advance_x > cell_width) { + cell_width = @ceil(goa.glyph.advance_x); } } @@ -139,11 +185,13 @@ pub fn init(alloc: Allocator, config: *const Config) !Grid { // The cell height is the vertical height required to render underscore // '_' which should live at the bottom of a cell. const cell_height: f32 = cell_height: { + const fam = &font_set.families.items[0]; + // This is the height reported by the font face const face_height: i32 = fam.regular.?.unitsToPxY(fam.regular.?.ft_face.*.height); // Determine the height of the underscore char - const glyph = fam.getGlyph('_', .regular).?; + const glyph = font_set.families.items[0].getGlyph('_', .regular).?; var res: i32 = fam.regular.?.unitsToPxY(fam.regular.?.ft_face.*.ascender); res -= glyph.offset_y; res += @intCast(i32, glyph.height); @@ -154,10 +202,13 @@ pub fn init(alloc: Allocator, config: *const Config) !Grid { break :cell_height @intToFloat(f32, res); }; - const cell_baseline = cell_height - @intToFloat( - f32, - fam.regular.?.unitsToPxY(fam.regular.?.ft_face.*.ascender), - ); + const cell_baseline = cell_baseline: { + const fam = &font_set.families.items[0]; + break :cell_baseline cell_height - @intToFloat( + f32, + fam.regular.?.unitsToPxY(fam.regular.?.ft_face.*.ascender), + ); + }; log.debug("cell dimensions w={d} h={d} baseline={d}", .{ cell_width, cell_height, cell_baseline }); // Create our shader @@ -172,6 +223,10 @@ pub fn init(alloc: Allocator, config: *const Config) !Grid { try program.setUniform("cell_size", @Vector(2, f32){ cell_width, cell_height }); try program.setUniform("glyph_baseline", cell_baseline); + // Set all of our texture indexes + try program.setUniform("text", 0); + try program.setUniform("text_color", 1); + // Setup our VAO const vao = try gl.VertexArray.create(); errdefer vao.destroy(); @@ -225,21 +280,44 @@ pub fn init(alloc: Allocator, config: *const Config) !Grid { // Build our texture const tex = try gl.Texture.create(); errdefer tex.destroy(); - const texbind = try tex.bind(.@"2D"); - try texbind.parameter(.WrapS, gl.c.GL_CLAMP_TO_EDGE); - try texbind.parameter(.WrapT, gl.c.GL_CLAMP_TO_EDGE); - try texbind.parameter(.MinFilter, gl.c.GL_LINEAR); - try texbind.parameter(.MagFilter, gl.c.GL_LINEAR); - try texbind.image2D( - 0, - .Red, - @intCast(c_int, atlas.size), - @intCast(c_int, atlas.size), - 0, - .Red, - .UnsignedByte, - atlas.data.ptr, - ); + { + const texbind = try tex.bind(.@"2D"); + try texbind.parameter(.WrapS, gl.c.GL_CLAMP_TO_EDGE); + try texbind.parameter(.WrapT, gl.c.GL_CLAMP_TO_EDGE); + try texbind.parameter(.MinFilter, gl.c.GL_LINEAR); + try texbind.parameter(.MagFilter, gl.c.GL_LINEAR); + try texbind.image2D( + 0, + .Red, + @intCast(c_int, atlas.size), + @intCast(c_int, atlas.size), + 0, + .Red, + .UnsignedByte, + atlas.data.ptr, + ); + } + + // Build our color texture + const tex_color = try gl.Texture.create(); + errdefer tex_color.destroy(); + { + const texbind = try tex_color.bind(.@"2D"); + try texbind.parameter(.WrapS, gl.c.GL_CLAMP_TO_EDGE); + try texbind.parameter(.WrapT, gl.c.GL_CLAMP_TO_EDGE); + try texbind.parameter(.MinFilter, gl.c.GL_LINEAR); + try texbind.parameter(.MagFilter, gl.c.GL_LINEAR); + try texbind.image2D( + 0, + .RGBA, + @intCast(c_int, atlas_color.size), + @intCast(c_int, atlas_color.size), + 0, + .BGRA, + .UnsignedByte, + atlas_color.data.ptr, + ); + } return Grid{ .alloc = alloc, @@ -251,7 +329,8 @@ pub fn init(alloc: Allocator, config: *const Config) !Grid { .ebo = ebo, .vbo = vbo, .texture = tex, - .font_atlas = fam, + .texture_color = tex_color, + .font_set = font_set, .atlas_dirty = false, .cursor_visible = true, .cursor_style = .box, @@ -261,9 +340,14 @@ pub fn init(alloc: Allocator, config: *const Config) !Grid { } pub fn deinit(self: *Grid) void { - self.font_atlas.atlas.deinit(self.alloc); - self.font_atlas.deinit(self.alloc); + for (self.font_set.families.items) |*family| { + family.atlas.deinit(self.alloc); + family.deinit(self.alloc); + } + self.font_set.deinit(self.alloc); + self.texture.destroy(); + self.texture_color.destroy(); self.vbo.destroy(); self.ebo.destroy(); self.vao.destroy(); @@ -338,8 +422,19 @@ pub fn finalizeCells(self: *Grid, term: Terminal) !void { fn addCursor(self: *Grid, term: Terminal) void { // Add the cursor if (self.cursor_visible and term.screen.viewportIsBottom()) { + const cell = term.screen.getCell( + term.screen.cursor.y, + term.screen.cursor.x, + ); + + var mode: GPUCellMode = @intToEnum( + GPUCellMode, + @enumToInt(self.cursor_style), + ); + if (cell.attrs.wide == 1) mode = mode.mask(.wide_mask); + self.cells.appendAssumeCapacity(.{ - .mode = @enumToInt(self.cursor_style), + .mode = mode, .grid_col = @intCast(u16, term.screen.cursor.x), .grid_row = @intCast(u16, term.screen.cursor.y), .fg_r = 0, @@ -415,6 +510,9 @@ pub fn updateCell( break :colors res; }; + // If we are a trailing spacer, we never render anything. + if (cell.attrs.wide_spacer_tail == 1) return true; + // Calculate the amount of space we need in the cells list. const needed = needed: { var i: usize = 0; @@ -427,8 +525,11 @@ pub fn updateCell( // If the cell has a background, we always draw it. if (colors.bg) |rgb| { + var mode: GPUCellMode = .bg; + if (cell.attrs.wide == 1) mode = mode.mask(.wide_mask); + self.cells.appendAssumeCapacity(.{ - .mode = 1, + .mode = mode, .grid_col = @intCast(u16, x), .grid_row = @intCast(u16, y), .glyph_x = 0, @@ -456,17 +557,19 @@ pub fn updateCell( else .regular; - // Get our glyph - // TODO: if we add a glyph, I think we need to rerender the texture. - const glyph = if (self.font_atlas.getGlyph(cell.char, style)) |glyph| - glyph - else glyph: { - self.atlas_dirty = true; - break :glyph try self.font_atlas.addGlyph(self.alloc, cell.char, style); - }; + var mode: GPUCellMode = .fg; + + // Get our glyph. Try our normal font atlas first. + const goa = try self.font_set.getOrAddGlyph(self.alloc, cell.char, style); + if (!goa.found_existing) self.atlas_dirty = true; + if (goa.family == 1) mode = .fg_color; + const glyph = goa.glyph; + + // If the cell is wide, we need to note that in the mode + if (cell.attrs.wide == 1) mode = mode.mask(.wide_mask); self.cells.appendAssumeCapacity(.{ - .mode = 2, + .mode = mode, .grid_col = @intCast(u16, x), .grid_row = @intCast(u16, y), .glyph_x = glyph.atlas_x, @@ -487,8 +590,11 @@ pub fn updateCell( } if (cell.attrs.underline == 1) { + var mode: GPUCellMode = .underline; + if (cell.attrs.wide == 1) mode = mode.mask(.wide_mask); + self.cells.appendAssumeCapacity(.{ - .mode = 6, // underline + .mode = mode, .grid_col = @intCast(u16, x), .grid_row = @intCast(u16, y), .glyph_x = 0, @@ -537,18 +643,37 @@ pub fn setScreenSize(self: *Grid, dim: ScreenSize) !void { /// Updates the font texture atlas if it is dirty. fn flushAtlas(self: *Grid) !void { - var texbind = try self.texture.bind(.@"2D"); - defer texbind.unbind(); - try texbind.subImage2D( - 0, - 0, - 0, - @intCast(c_int, self.font_atlas.atlas.size), - @intCast(c_int, self.font_atlas.atlas.size), - .Red, - .UnsignedByte, - self.font_atlas.atlas.data.ptr, - ); + { + const atlas = &self.font_set.families.items[0].atlas; + var texbind = try self.texture.bind(.@"2D"); + defer texbind.unbind(); + try texbind.subImage2D( + 0, + 0, + 0, + @intCast(c_int, atlas.size), + @intCast(c_int, atlas.size), + .Red, + .UnsignedByte, + atlas.data.ptr, + ); + } + + { + const atlas = &self.font_set.families.items[1].atlas; + var texbind = try self.texture_color.bind(.@"2D"); + defer texbind.unbind(); + try texbind.subImage2D( + 0, + 0, + 0, + @intCast(c_int, atlas.size), + @intCast(c_int, atlas.size), + .BGRA, + .UnsignedByte, + atlas.data.ptr, + ); + } } /// Render renders the current cell state. This will not modify any of @@ -603,11 +728,15 @@ pub fn render(self: *Grid) !void { assert(self.gl_cells_written <= self.cells.items.len); } - // Bind our texture + // Bind our textures try gl.Texture.active(gl.c.GL_TEXTURE0); var texbind = try self.texture.bind(.@"2D"); defer texbind.unbind(); + try gl.Texture.active(gl.c.GL_TEXTURE1); + var texbind1 = try self.texture_color.bind(.@"2D"); + defer texbind1.unbind(); + try gl.drawElementsInstanced( gl.c.GL_TRIANGLES, 6, @@ -686,3 +815,4 @@ test "GridSize update rounding" { const face_ttf = @embedFile("font/res/FiraCode-Regular.ttf"); const face_bold_ttf = @embedFile("font/res/FiraCode-Bold.ttf"); +const face_emoji_ttf = @embedFile("font/res/NotoColorEmoji.ttf"); diff --git a/src/font/Face.zig b/src/font/Face.zig index 98305705e..c5ba7002a 100644 --- a/src/font/Face.zig +++ b/src/font/Face.zig @@ -58,8 +58,31 @@ pub fn loadFaceFromMemory(self: *Face, source: [:0]const u8, size: u32) !void { if (ftc.FT_Select_Charmap(self.ft_face, ftc.FT_ENCODING_UNICODE) != ftok) return error.FaceLoadFailed; - if (ftc.FT_Set_Pixel_Sizes(self.ft_face, size, size) != ftok) - return error.FaceLoadFailed; + // If we have fixed sizes, we just have to try to pick the one closest + // to what the user requested. Otherwise, we can choose an arbitrary + // pixel size. + if (!ftc.FT_HAS_FIXED_SIZES(self.ft_face)) { + if (ftc.FT_Set_Pixel_Sizes(self.ft_face, size, size) != ftok) + return error.FaceLoadFailed; + } else try self.selectSizeNearest(size); +} + +/// Selects the fixed size in the loaded face that is closest to the +/// requested pixel size. +fn selectSizeNearest(self: *Face, size: u32) !void { + var i: usize = 0; + var best_i: usize = 0; + var best_diff: i32 = 0; + while (i < self.ft_face.*.num_fixed_sizes) : (i += 1) { + const diff = @intCast(i32, size) - @intCast(i32, self.ft_face.*.available_sizes[i].width); + if (i == 0 or diff < best_diff) { + best_diff = diff; + best_i = i; + } + } + + if (ftc.FT_Select_Size(self.ft_face, @intCast(c_int, best_i)) != ftok) + return error.FaceSelectSizeFailed; } /// Load a glyph for this face. The codepoint can be either a u8 or @@ -69,26 +92,40 @@ pub fn loadGlyph(self: Face, alloc: Allocator, atlas: *Atlas, cp: u32) !Glyph { // We need a UTF32 codepoint for freetype const glyph_index = glyph_index: { - // log.warn("glyph load: {x}", .{cp}); + //log.warn("glyph load: {x}", .{cp}); const idx = ftc.FT_Get_Char_Index(self.ft_face, cp); if (idx > 0) break :glyph_index idx; // Unknown glyph. - log.warn("glyph not found: {x}", .{cp}); - - // TODO: render something more identifiable than a space - break :glyph_index ftc.FT_Get_Char_Index(self.ft_face, ' '); + //log.warn("glyph not found: {x}", .{cp}); + return error.GlyphNotFound; }; + //log.warn("glyph index: {}", .{glyph_index}); + + // If our glyph has color, we want to render the color + var load_flags: c_int = ftc.FT_LOAD_RENDER; + if (ftc.FT_HAS_COLOR(self.ft_face)) load_flags |= @intCast(c_int, ftc.FT_LOAD_COLOR); if (ftc.FT_Load_Glyph( self.ft_face, glyph_index, - ftc.FT_LOAD_RENDER, + load_flags, ) != ftok) return error.LoadGlyphFailed; const glyph = self.ft_face.*.glyph; const bitmap = glyph.*.bitmap; - assert(bitmap.pixel_mode == ftc.FT_PIXEL_MODE_GRAY); + + // Ensure we know how to work with the font format. And assure that + // or color depth is as expected on the texture atlas. + const format: Atlas.Format = switch (bitmap.pixel_mode) { + ftc.FT_PIXEL_MODE_GRAY => .greyscale, + ftc.FT_PIXEL_MODE_BGRA => .rgba, + else => { + log.warn("pixel mode={}", .{bitmap.pixel_mode}); + @panic("unsupported pixel mode"); + }, + }; + assert(atlas.format == format); const src_w = bitmap.width; const src_h = bitmap.rows; @@ -99,24 +136,26 @@ pub fn loadGlyph(self: Face, alloc: Allocator, atlas: *Atlas, cp: u32) !Glyph { // If we have data, copy it into the atlas if (region.width > 0 and region.height > 0) { + const depth = @enumToInt(format); + // We can avoid a buffer copy if our atlas width and bitmap // width match and the bitmap pitch is just the width (meaning // the data is tightly packed). - const needs_copy = !(tgt_w == bitmap.width and bitmap.width == bitmap.pitch); + const needs_copy = !(tgt_w == bitmap.width and (bitmap.width * depth) == bitmap.pitch); // If we need to copy the data, we copy it into a temporary buffer. const buffer = if (needs_copy) buffer: { - var temp = try alloc.alloc(u8, tgt_w * tgt_h); + var temp = try alloc.alloc(u8, tgt_w * tgt_h * depth); var dst_ptr = temp; var src_ptr = bitmap.buffer; var i: usize = 0; while (i < src_h) : (i += 1) { - std.mem.copy(u8, dst_ptr, src_ptr[0..bitmap.width]); - dst_ptr = dst_ptr[tgt_w..]; + std.mem.copy(u8, dst_ptr, src_ptr[0 .. bitmap.width * depth]); + dst_ptr = dst_ptr[tgt_w * depth ..]; src_ptr += @intCast(usize, bitmap.pitch); } break :buffer temp; - } else bitmap.buffer[0..(tgt_w * tgt_h)]; + } else bitmap.buffer[0..(tgt_w * tgt_h * depth)]; defer if (buffer.ptr != bitmap.buffer) alloc.free(buffer); // Write the glyph information into the atlas @@ -149,6 +188,8 @@ fn f26dot6ToFloat(v: ftc.FT_F26Dot6) f32 { } test { + const testFont = @import("test.zig").fontRegular; + var ft_lib: ftc.FT_Library = undefined; if (ftc.FT_Init_FreeType(&ft_lib) != ftok) return error.FreeTypeInitFailed; @@ -170,4 +211,21 @@ test { } } -const testFont = @embedFile("res/Inconsolata-Regular.ttf"); +test "color emoji" { + const testFont = @import("test.zig").fontEmoji; + + var ft_lib: ftc.FT_Library = undefined; + if (ftc.FT_Init_FreeType(&ft_lib) != ftok) + return error.FreeTypeInitFailed; + defer _ = ftc.FT_Done_FreeType(ft_lib); + + const alloc = testing.allocator; + var atlas = try Atlas.init(alloc, 512, .rgba); + defer atlas.deinit(alloc); + + var font = try init(ft_lib); + defer font.deinit(); + + try font.loadFaceFromMemory(testFont, 48); + _ = try font.loadGlyph(alloc, &atlas, '🥸'); +} diff --git a/src/font/FallbackSet.zig b/src/font/FallbackSet.zig new file mode 100644 index 000000000..416f8fef5 --- /dev/null +++ b/src/font/FallbackSet.zig @@ -0,0 +1,157 @@ +//! FallbackSet represents a set of families in priority order to load a glyph. +//! This can be used to merge multiple font families together to find a glyph +//! for a codepoint. +const FallbackSet = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; + +const ftc = @import("freetype").c; +const Atlas = @import("../Atlas.zig"); +const Family = @import("main.zig").Family; +const Glyph = @import("main.zig").Glyph; +const Style = @import("main.zig").Style; +const codepoint = @import("main.zig").codepoint; + +const ftok = ftc.FT_Err_Ok; +const log = std.log.scoped(.font_fallback); + +/// The families to look for in order. This should be managed directly +/// by the caller of the set. Deinit will deallocate this. +families: std.ArrayListUnmanaged(Family) = .{}, + +/// A quick lookup that points directly to the family that loaded a glyph. +glyphs: std.AutoHashMapUnmanaged(GlyphKey, usize) = .{}, + +const GlyphKey = struct { + style: Style, + codepoint: u32, +}; + +pub fn deinit(self: *FallbackSet, alloc: Allocator) void { + self.families.deinit(alloc); + self.glyphs.deinit(alloc); + self.* = undefined; +} + +pub const GetOrAdd = struct { + /// Index of the family where the glyph was loaded from + family: usize, + + /// True if the glyph was found or whether it was newly loaded + found_existing: bool, + + /// The glyph + glyph: *Glyph, +}; + +pub fn getOrAddGlyph( + self: *FallbackSet, + alloc: Allocator, + v: anytype, + style: Style, +) !GetOrAdd { + assert(self.families.items.len > 0); + + // We need a UTF32 codepoint + const utf32 = codepoint(v); + + // If we have this already, load it directly + const glyphKey: GlyphKey = .{ .style = style, .codepoint = utf32 }; + const gop = try self.glyphs.getOrPut(alloc, glyphKey); + if (gop.found_existing) { + const i = gop.value_ptr.*; + assert(i < self.families.items.len); + return GetOrAdd{ + .family = i, + .found_existing = true, + .glyph = self.families.items[i].getGlyph(v, style) orelse unreachable, + }; + } + errdefer _ = self.glyphs.remove(glyphKey); + + // Go through each familiy and look for a matching glyph + var fam_i: ?usize = 0; + const glyph = glyph: { + for (self.families.items) |*family, i| { + fam_i = i; + + // If this family already has it loaded, return it. + if (family.getGlyph(v, style)) |glyph| break :glyph glyph; + + // Try to load it. + if (family.addGlyph(alloc, v, style)) |glyph| + break :glyph glyph + else |err| switch (err) { + // TODO: this probably doesn't belong here and should + // be higher level... but how? + error.AtlasFull => { + try family.atlas.grow(alloc, family.atlas.size * 2); + break :glyph try family.addGlyph(alloc, v, style); + }, + + error.GlyphNotFound => {}, + else => return err, + } + } + + // If we are regular, we use a fallback character + log.warn("glyph not found, using fallback. codepoint={x}", .{utf32}); + fam_i = null; + break :glyph try self.families.items[0].addGlyph(alloc, ' ', style); + }; + + // If we found a real value, then cache it. + // TODO: support caching fallbacks too + if (fam_i) |i| + gop.value_ptr.* = i + else + _ = self.glyphs.remove(glyphKey); + + return GetOrAdd{ + .family = fam_i orelse 0, + .glyph = glyph, + + // Technically possible that we found this in a cache... + .found_existing = false, + }; +} + +test { + const fontRegular = @import("test.zig").fontRegular; + const fontEmoji = @import("test.zig").fontEmoji; + + const testing = std.testing; + const alloc = testing.allocator; + + var set: FallbackSet = .{}; + try set.families.append(alloc, fam: { + var fam = try Family.init(try Atlas.init(alloc, 512, .greyscale)); + try fam.loadFaceFromMemory(.regular, fontRegular, 48); + break :fam fam; + }); + try set.families.append(alloc, fam: { + var fam = try Family.init(try Atlas.init(alloc, 512, .rgba)); + try fam.loadFaceFromMemory(.regular, fontEmoji, 48); + break :fam fam; + }); + + defer { + for (set.families.items) |*family| { + family.atlas.deinit(alloc); + family.deinit(alloc); + } + set.deinit(alloc); + } + + // Generate all visible ASCII + var i: u8 = 32; + while (i < 127) : (i += 1) { + _ = try set.getOrAddGlyph(alloc, i, .regular); + } + + // Emoji should work + _ = try set.getOrAddGlyph(alloc, '🥸', .regular); + _ = try set.getOrAddGlyph(alloc, '🥸', .bold); +} diff --git a/src/font/Family.zig b/src/font/Family.zig index a1c25268f..f6c068078 100644 --- a/src/font/Family.zig +++ b/src/font/Family.zig @@ -11,6 +11,7 @@ const Face = @import("main.zig").Face; const Glyph = @import("main.zig").Glyph; const Style = @import("main.zig").Style; const testFont = @import("test.zig").fontRegular; +const codepoint = @import("main.zig").codepoint; const log = std.log.scoped(.font_family); @@ -137,22 +138,10 @@ pub fn addGlyph(self: *Family, alloc: Allocator, v: anytype, style: Style) !*Gly errdefer _ = self.glyphs.remove(glyphKey); // Get the glyph and add it to the atlas. - // TODO: handle glyph not found gop.value_ptr.* = try face.loadGlyph(alloc, &self.atlas, utf32); return gop.value_ptr; } -/// Returns the UTF-32 codepoint for the given value. -fn codepoint(v: anytype) u32 { - // We need a UTF32 codepoint for freetype - return switch (@TypeOf(v)) { - u32 => v, - comptime_int, u8 => @intCast(u32, v), - []const u8 => @intCast(u32, try std.unicode.utfDecode(v)), - else => @compileError("invalid codepoint type"), - }; -} - test { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/font/main.zig b/src/font/main.zig index 5882ce145..d7919da28 100644 --- a/src/font/main.zig +++ b/src/font/main.zig @@ -1,6 +1,9 @@ +const std = @import("std"); + pub const Face = @import("Face.zig"); pub const Family = @import("Family.zig"); pub const Glyph = @import("Glyph.zig"); +pub const FallbackSet = @import("FallbackSet.zig"); /// Embedded fonts (for now) pub const fontRegular = @import("test.zig").fontRegular; @@ -14,8 +17,20 @@ pub const Style = enum { bold_italic, }; +/// Returns the UTF-32 codepoint for the given value. +pub fn codepoint(v: anytype) u32 { + // We need a UTF32 codepoint for freetype + return switch (@TypeOf(v)) { + u32 => v, + comptime_int, u8 => @intCast(u32, v), + []const u8 => @intCast(u32, try std.unicode.utfDecode(v)), + else => @compileError("invalid codepoint type"), + }; +} + test { _ = Face; _ = Family; _ = Glyph; + _ = FallbackSet; } diff --git a/src/font/res/NotoColorEmoji.ttf b/src/font/res/NotoColorEmoji.ttf new file mode 100755 index 000000000..2c1f10435 Binary files /dev/null and b/src/font/res/NotoColorEmoji.ttf differ diff --git a/src/font/test.zig b/src/font/test.zig index 660d5f5ec..083212dac 100644 --- a/src/font/test.zig +++ b/src/font/test.zig @@ -1,2 +1,3 @@ pub const fontRegular = @embedFile("res/Inconsolata-Regular.ttf"); pub const fontBold = @embedFile("res/Inconsolata-Bold.ttf"); +pub const fontEmoji = @embedFile("res/NotoColorEmoji.ttf"); diff --git a/src/opengl/Texture.zig b/src/opengl/Texture.zig index 810a15c3c..09bbe043f 100644 --- a/src/opengl/Texture.zig +++ b/src/opengl/Texture.zig @@ -47,6 +47,7 @@ pub const Parameter = enum(c_uint) { /// Internal format enum for texture images. pub const InternalFormat = enum(c_int) { Red = c.GL_RED, + RGBA = c.GL_RGBA, // There are so many more that I haven't filled in. _, @@ -55,6 +56,7 @@ pub const InternalFormat = enum(c_int) { /// Format for texture images pub const Format = enum(c_uint) { Red = c.GL_RED, + BGRA = c.GL_BGRA, // There are so many more that I haven't filled in. _, diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 817c7d8fe..fa3890f5f 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -25,8 +25,10 @@ const Screen = @This(); // one day. const std = @import("std"); +const utf8proc = @import("utf8proc"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; + const color = @import("color.zig"); const point = @import("point.zig"); const Selection = @import("Selection.zig"); @@ -71,6 +73,15 @@ pub const Cell = struct { /// should have this set. The first cell of the next row is actually /// part of this row in raw input. wrap: u1 = 0, + + /// True if this is a wide character. This char takes up + /// two cells. The following cell ALWAYS is a space. + wide: u1 = 0, + + /// Notes that this only exists to be blank for a preceeding + /// wide character (tail) or following (head). + wide_spacer_tail: u1 = 0, + wide_spacer_head: u1 = 0, } = .{}, /// True if the cell should be skipped for drawing @@ -932,6 +943,10 @@ pub fn selectionString(self: Screen, alloc: Allocator, sel: Selection) ![:0]cons i += 1; } + // Skip spacers + if (cell.attrs.wide_spacer_head == 1 or + cell.attrs.wide_spacer_tail == 1) continue; + const char = if (cell.char > 0) cell.char else ' '; i += try std.unicode.utf8Encode(@intCast(u21, char), buf[i..]); } @@ -955,6 +970,10 @@ pub fn selectionString(self: Screen, alloc: Allocator, sel: Selection) ![:0]cons } } + // Skip spacers + if (cell.attrs.wide_spacer_head == 1 or + cell.attrs.wide_spacer_tail == 1) continue; + const char = if (cell.char > 0) cell.char else ' '; i += try std.unicode.utf8Encode(@intCast(u21, char), buf[i..]); } @@ -970,7 +989,7 @@ pub fn selectionString(self: Screen, alloc: Allocator, sel: Selection) ![:0]cons /// Returns the slices that make up the selection, in order. There are at most /// two parts to handle the ring buffer. If the selection fits in one contiguous /// slice, then the second slice will have a length of zero. -fn selectionSlices(self: Screen, sel: Selection) struct { +fn selectionSlices(self: Screen, sel_raw: Selection) struct { // Top offset can be used to determine if a newline is required by // seeing if the cell index plus the offset cleanly divides by screen cols. top_offset: usize, @@ -979,10 +998,35 @@ fn selectionSlices(self: Screen, sel: Selection) struct { } { // Note: this function is tested via selectionString - assert(sel.start.y < self.totalRows()); - assert(sel.end.y < self.totalRows()); - assert(sel.start.x < self.cols); - assert(sel.end.x < self.cols); + assert(sel_raw.start.y < self.totalRows()); + assert(sel_raw.end.y < self.totalRows()); + assert(sel_raw.start.x < self.cols); + assert(sel_raw.end.x < self.cols); + + const sel = sel: { + var sel = sel_raw; + + // If the end of our selection is a wide char leader, include the + // first part of the next line. + if (sel.end.x == self.cols - 1) { + const row = self.getRow(.{ .screen = sel.end.y }); + if (row[sel.end.x].attrs.wide_spacer_head == 1) { + sel.end.y += 1; + sel.end.x = 0; + } + } + + // If the start of our selection is a wide char spacer, include the + // wide char. + if (sel.start.x > 0) { + const row = self.getRow(.{ .screen = sel.start.y }); + if (row[sel.start.x].attrs.wide_spacer_tail == 1) { + sel.end.x -= 1; + } + } + + break :sel sel; + }; // Get the true "top" and "bottom" const sel_top = sel.topLeft(); @@ -1044,7 +1088,10 @@ pub fn testString(self: Screen, alloc: Allocator, tag: RowIndexTag) ![]const u8 fn testWriteString(self: *Screen, text: []const u8) void { var y: usize = 0; var x: usize = 0; - for (text) |c| { + + const view = std.unicode.Utf8View.init(text) catch unreachable; + var iter = view.iterator(); + while (iter.nextCodepoint()) |c| { // Explicit newline forces a new row if (c == '\n') { y += 1; @@ -1073,7 +1120,39 @@ fn testWriteString(self: *Screen, text: []const u8) void { row = self.getRow(.{ .active = y }); } - row[x].char = @intCast(u32, c); + // If our character is double-width, handle it. + const width = utf8proc.charwidth(c); + assert(width == 1 or width == 2); + switch (width) { + 1 => row[x].char = @intCast(u32, c), + + 2 => { + if (x == self.cols - 1) { + row[x].char = ' '; + row[x].attrs.wide_spacer_head = 1; + + // wrap + row[x].attrs.wrap = 1; + y += 1; + x = 0; + if (y >= self.rows) { + y -= 1; + self.scroll(.{ .delta = 1 }); + } + row = self.getRow(.{ .active = y }); + } + + row[x].char = @intCast(u32, c); + row[x].attrs.wide = 1; + + x += 1; + row[x].char = ' '; + row[x].attrs.wide_spacer_tail = 1; + }, + + else => unreachable, + } + x += 1; } } @@ -1459,6 +1538,66 @@ test "Screen: selectionString wrap around" { } } +test "Screen: selectionString wide char" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 0); + defer s.deinit(alloc); + const str = "1A⚡"; + s.testWriteString(str); + + { + var contents = try s.selectionString(alloc, .{ + .start = .{ .x = 0, .y = 0 }, + .end = .{ .x = 3, .y = 0 }, + }); + defer alloc.free(contents); + const expected = str; + try testing.expectEqualStrings(expected, contents); + } + + { + var contents = try s.selectionString(alloc, .{ + .start = .{ .x = 0, .y = 0 }, + .end = .{ .x = 2, .y = 0 }, + }); + defer alloc.free(contents); + const expected = str; + try testing.expectEqualStrings(expected, contents); + } + + { + var contents = try s.selectionString(alloc, .{ + .start = .{ .x = 3, .y = 0 }, + .end = .{ .x = 3, .y = 0 }, + }); + defer alloc.free(contents); + const expected = "⚡"; + try testing.expectEqualStrings(expected, contents); + } +} + +test "Screen: selectionString wide char with header" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 0); + defer s.deinit(alloc); + const str = "1ABC⚡"; + s.testWriteString(str); + + { + var contents = try s.selectionString(alloc, .{ + .start = .{ .x = 0, .y = 0 }, + .end = .{ .x = 4, .y = 0 }, + }); + defer alloc.free(contents); + const expected = str; + try testing.expectEqualStrings(expected, contents); + } +} + test "Screen: resize more rows no scrollback" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index d86e5c2da..6e25a9f81 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -6,9 +6,11 @@ const Terminal = @This(); const std = @import("std"); const builtin = @import("builtin"); +const utf8proc = @import("utf8proc"); const testing = std.testing; const assert = std.debug.assert; const Allocator = std.mem.Allocator; + const ansi = @import("ansi.zig"); const csi = @import("csi.zig"); const sgr = @import("sgr.zig"); @@ -341,23 +343,45 @@ pub fn print(self: *Terminal, c: u21) !void { // If we're not on the main display, do nothing for now if (self.status_display != .main) return; + // Determine the width of this character so we can handle + // non-single-width characters properly. + const width = utf8proc.charwidth(c); + assert(width == 1 or width == 2); + // If we're soft-wrapping, then handle that first. - if (self.screen.cursor.pending_wrap and self.modes.autowrap == 1) { - // Mark that the cell is wrapped, which guarantees that there is - // at least one cell after it in the next row. - const cell = self.screen.getCell(self.screen.cursor.y, self.screen.cursor.x); - cell.attrs.wrap = 1; + if (self.screen.cursor.pending_wrap and self.modes.autowrap == 1) + _ = self.printWrap(); - // Move to the next line - self.index(); - self.screen.cursor.x = 0; + switch (width) { + // Single cell is very easy: just write in the cell + 1 => _ = self.printCell(c), + + // Wide character requires a spacer. We print this by + // using two cells: the first is flagged "wide" and has the + // wide char. The second is guaranteed to be a spacer if + // we're not at the end of the line. + 2 => { + // If we don't have space for the wide char, we need + // to insert spacers and wrap. Then we just print the wide + // char as normal. + if (self.screen.cursor.x == self.cols - 1) { + const spacer_head = self.printCell(' '); + spacer_head.attrs.wide_spacer_head = 1; + _ = self.printWrap(); + } + + const wide_cell = self.printCell(c); + wide_cell.attrs.wide = 1; + + // Write our spacer + self.screen.cursor.x += 1; + const spacer = self.printCell(' '); + spacer.attrs.wide_spacer_tail = 1; + }, + + else => unreachable, } - // Build our cell - const cell = self.screen.getCell(self.screen.cursor.y, self.screen.cursor.x); - cell.* = self.screen.cursor.pen; - cell.char = @intCast(u32, c); - // Move the cursor self.screen.cursor.x += 1; @@ -370,6 +394,69 @@ pub fn print(self: *Terminal, c: u21) !void { } } +fn printCell(self: *Terminal, c: u21) *Screen.Cell { + const cell = self.screen.getCell( + self.screen.cursor.y, + self.screen.cursor.x, + ); + + // If this cell is wide char then we need to clear it. + // We ignore wide spacer HEADS because we can just write + // single-width characters into that. + if (cell.attrs.wide == 1) { + const x = self.screen.cursor.x + 1; + assert(x < self.cols); + + const spacer_cell = self.screen.getCell(self.screen.cursor.y, x); + spacer_cell.attrs.wide_spacer_tail = 0; + + if (self.screen.cursor.x <= 1) { + self.clearWideSpacerHead(); + } + } else if (cell.attrs.wide_spacer_tail == 1) { + assert(self.screen.cursor.x > 0); + const x = self.screen.cursor.x - 1; + + const wide_cell = self.screen.getCell(self.screen.cursor.y, x); + wide_cell.attrs.wide = 0; + + if (self.screen.cursor.x <= 1) { + self.clearWideSpacerHead(); + } + } + + // Write + cell.* = self.screen.cursor.pen; + cell.char = @intCast(u32, c); + return cell; +} + +fn printWrap(self: *Terminal) *Screen.Cell { + // Mark that the cell is wrapped, which guarantees that there is + // at least one cell after it in the next row. + const cell = self.screen.getCell( + self.screen.cursor.y, + self.screen.cursor.x, + ); + cell.attrs.wrap = 1; + + // Move to the next line + self.index(); + self.screen.cursor.x = 0; + + return cell; +} + +fn clearWideSpacerHead(self: *Terminal) void { + // TODO: handle deleting wide char on row 0 of active + assert(self.screen.cursor.y >= 1); + const cell = self.screen.getCell( + self.screen.cursor.y - 1, + self.cols - 1, + ); + cell.attrs.wide_spacer_head = 0; +} + /// Resets all margins and fills the whole screen with the character 'E' /// /// Sets the cursor to the top left corner. diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index e6010011d..33b86d314 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -49,11 +49,11 @@ pub fn Stream(comptime Handler: type) type { //log.debug("char: {x}", .{c}); const actions = self.parser.next(c); for (actions) |action_opt| { - if (action_opt) |action| { - if (action != .print) { - log.info("action: {}", .{action}); - } - } + // if (action_opt) |action| { + // if (action != .print) { + // log.info("action: {}", .{action}); + // } + // } switch (action_opt orelse continue) { .print => |p| if (@hasDecl(T, "print")) try self.handler.print(p), .execute => |code| try self.execute(code), diff --git a/vendor/utf8proc b/vendor/utf8proc new file mode 160000 index 000000000..63f31c908 --- /dev/null +++ b/vendor/utf8proc @@ -0,0 +1 @@ +Subproject commit 63f31c908ef7656415f73d6c178f08181239f74c