diff --git a/flake.nix b/flake.nix index b3bcd07c9..0536b5247 100644 --- a/flake.nix +++ b/flake.nix @@ -31,6 +31,7 @@ }; outputs = { + self, nixpkgs-unstable, nixpkgs-stable, nixpkgs-zig-0-12, @@ -54,6 +55,7 @@ packages.${system} = rec { ghostty = pkgs-stable.callPackage ./nix/package.nix { inherit (pkgs-zig-0-12) zig_0_12; + revision = self.shortRev or self.dirtyShortRev or "dirty"; }; default = ghostty; }; diff --git a/nix/package.nix b/nix/package.nix index ea471dd0e..62d28b934 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -23,6 +23,7 @@ ncurses, pkg-config, zig_0_12, + revision ? "dirty", }: let # The Zig hook has no way to select the release type without actual # overriding of the default flags. @@ -121,7 +122,7 @@ in dontConfigure = true; - zigBuildFlags = "-Dversion-string=${finalAttrs.version}"; + zigBuildFlags = "-Dversion-string=${finalAttrs.version}-${revision}-nix"; preBuild = '' rm -rf $ZIG_GLOBAL_CACHE_DIR diff --git a/pkg/freetype/errors.zig b/pkg/freetype/errors.zig index e4f98db87..3fe6dccb8 100644 --- a/pkg/freetype/errors.zig +++ b/pkg/freetype/errors.zig @@ -94,6 +94,7 @@ pub const Error = error{ BbxTooBig, CorruptedFontHeader, CorruptedFontGlyphs, + UnknownFreetypeError, }; pub fn intToError(err: c_int) Error!void { @@ -188,7 +189,7 @@ pub fn intToError(err: c_int) Error!void { c.FT_Err_Bbx_Too_Big => Error.BbxTooBig, c.FT_Err_Corrupted_Font_Header => Error.CorruptedFontHeader, c.FT_Err_Corrupted_Font_Glyphs => Error.CorruptedFontGlyphs, - else => unreachable, + else => Error.UnknownFreetypeError, }; } diff --git a/src/Surface.zig b/src/Surface.zig index 0ed3b47d3..c78959288 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -410,6 +410,21 @@ pub fn init( _ = try group.addFace(.bold_italic, .{ .deferred = face }); } else log.warn("font-family-bold-italic not found: {s}", .{family}); } + + // On macOS, always search for and add the Apple Emoji font + // as our preferred emoji font for fallback. We do this in case + // people add other emoji fonts to their system, we always want to + // prefer the official one. Users can override this by explicitly + // specifying a font-family for emoji. + if (comptime builtin.os.tag == .macos) { + var disco_it = try disco.discover(alloc, .{ + .family = "Apple Color Emoji", + }); + defer disco_it.deinit(); + if (try disco_it.next()) |face| { + _ = try group.addFace(.regular, .{ .fallback_deferred = face }); + } + } } // Our built-in font will be used as a backup @@ -2773,6 +2788,17 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }, .clear_screen => { + // This is a duplicate of some of the logic in termio.clearScreen + // but we need to do this here so we can know the answer before + // we send the message. If the currently active screen is on the + // alternate screen then clear screen does nothing so we want to + // return false so the keybind can be unconsumed. + { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + if (self.io.terminal.active_screen == .alternate) return false; + } + _ = self.io_thread.mailbox.push(.{ .clear_screen = .{ .history = true }, }, .{ .forever = {} }); diff --git a/src/config/Config.zig b/src/config/Config.zig index 025d09412..9881580b9 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1674,16 +1674,22 @@ pub fn finalize(self: *Config) !void { // set to /bin/sh. if (self.command) |cmd| log.info("shell src=config value={s}", .{cmd}) - else { - if (!internal_os.isFlatpak()) { - if (std.process.getEnvVarOwned(alloc, "SHELL")) |value| { - log.info("default shell source=env value={s}", .{value}); - self.command = value; + else shell_env: { + // Flatpak always gets its shell from outside the sandbox + if (internal_os.isFlatpak()) break :shell_env; - // If we don't need the working directory, then we can exit now. - if (!wd_home) break :command; - } else |_| {} - } + // If we were launched from the desktop, our SHELL env var + // will represent our SHELL at login time. We want to use the + // latest shell from /etc/passwd or directory services. + if (internal_os.launchedFromDesktop()) break :shell_env; + + if (std.process.getEnvVarOwned(alloc, "SHELL")) |value| { + log.info("default shell source=env value={s}", .{value}); + self.command = value; + + // If we don't need the working directory, then we can exit now. + if (!wd_home) break :command; + } else |_| {} } switch (builtin.os.tag) { diff --git a/src/font/res/MonaspaceNeon-Regular.otf b/src/font/res/MonaspaceNeon-Regular.otf new file mode 100644 index 000000000..75aeb627f Binary files /dev/null and b/src/font/res/MonaspaceNeon-Regular.otf differ diff --git a/src/font/shape.zig b/src/font/shape.zig index d86f06bb0..b717bce23 100644 --- a/src/font/shape.zig +++ b/src/font/shape.zig @@ -37,7 +37,10 @@ pub const Cell = struct { /// 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 /// the runs. - glyph_index: u32, + /// + /// If this is null then this is an empty cell. If there are styles + /// then those should be applied but there is no glyph to render. + glyph_index: ?u32, }; /// Options for shapers. diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index 655d67b04..94b7e9439 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -230,6 +230,10 @@ pub const Shaper = struct { cell_offset.x += advance.width; cell_offset.y += advance.height; + // TODO: harfbuzz shaper has handling for inserting blank + // cells for multi-cell ligatures. Do we need to port that? + // Example: try Monaspace "===" with a background color. + _ = pos; // const i = self.cell_buf.items.len - 1; // log.warn( diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index cc36bcec4..1e6820243 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -142,13 +142,13 @@ pub const Shaper = struct { // 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, pos) |info_v, pos_v| { + for (info, pos, 0..) |info_v, pos_v, i| { + // If our cluster changed then we've moved to a new cell. if (info_v.cluster != cell_offset.cluster) cell_offset = .{ .cluster = info_v.cluster, }; - self.cell_buf.appendAssumeCapacity(.{ + try self.cell_buf.append(self.alloc, .{ .x = @intCast(info_v.cluster), .x_offset = @intCast(cell_offset.x), .y_offset = @intCast(cell_offset.y), @@ -166,6 +166,43 @@ pub const Shaper = struct { cell_offset.y += pos_v.y_advance; } + // Determine the width of the cell. To do this, we have to + // find the next cluster that has been shaped. This tells us how + // many cells this glyph replaced (i.e. for ligatures). For example + // in some fonts "!=" turns into a single glyph from the component + // parts "!" and "=" so this cell width would be "2" despite + // only having a single glyph. + // + // Many fonts replace ligature cells with space so that this always + // is one (e.g. Fira Code, JetBrains Mono, etc). Some do not + // (e.g. Monaspace). + const cell_width = width: { + if (i + 1 < info.len) { + // We may have to go through multiple glyphs because + // multiple can be replaced. e.g. "===" + for (info[i + 1 ..]) |next_info_v| { + if (next_info_v.cluster != info_v.cluster) { + break :width next_info_v.cluster - info_v.cluster; + } + } + } + + // If we reached the end then our width is our max cluster + // minus this one. + const max = run.offset + run.cells; + break :width max - info_v.cluster; + }; + if (cell_width > 1) { + // To make the renderer implementations simpler, we convert + // the extra spaces for width to blank cells. + for (1..cell_width) |j| { + try self.cell_buf.append(self.alloc, .{ + .x = @intCast(info_v.cluster + j), + .glyph_index = null, + }); + } + } + // const i = self.cell_buf.items.len - 1; // log.warn("i={} info={} pos={} cell={}", .{ i, info_v, pos_v, self.cell_buf.items[i] }); } @@ -334,7 +371,9 @@ test "shape inconsolata ligs" { count += 1; const cells = try shaper.shape(run); - try testing.expectEqual(@as(usize, 1), cells.len); + try testing.expectEqual(@as(usize, 2), cells.len); + try testing.expect(cells[0].glyph_index != null); + try testing.expect(cells[1].glyph_index == null); } try testing.expectEqual(@as(usize, 1), count); } @@ -351,7 +390,38 @@ test "shape inconsolata ligs" { count += 1; const cells = try shaper.shape(run); - try testing.expectEqual(@as(usize, 1), cells.len); + try testing.expectEqual(@as(usize, 3), cells.len); + try testing.expect(cells[0].glyph_index != null); + try testing.expect(cells[1].glyph_index == null); + try testing.expect(cells[2].glyph_index == null); + } + try testing.expectEqual(@as(usize, 1), count); + } +} + +test "shape monaspace ligs" { + const testing = std.testing; + const alloc = testing.allocator; + + var testdata = try testShaperWithFont(alloc, .monaspace_neon); + 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, 3), cells.len); + try testing.expect(cells[0].glyph_index != null); + try testing.expect(cells[1].glyph_index == null); + try testing.expect(cells[2].glyph_index == null); } try testing.expectEqual(@as(usize, 1), count); } @@ -376,7 +446,7 @@ test "shape emoji width" { count += 1; const cells = try shaper.shape(run); - try testing.expectEqual(@as(usize, 1), cells.len); + try testing.expectEqual(@as(usize, 2), cells.len); } try testing.expectEqual(@as(usize, 1), count); } @@ -411,7 +481,9 @@ test "shape emoji width long" { try testing.expectEqual(@as(u32, 4), shaper.hb_buf.getLength()); const cells = try shaper.shape(run); - try testing.expectEqual(@as(usize, 1), cells.len); + + // screen.testWriteString isn't grapheme aware, otherwise this is two + try testing.expectEqual(@as(usize, 5), cells.len); } try testing.expectEqual(@as(usize, 1), count); } @@ -574,9 +646,9 @@ test "shape box glyphs" { 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(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(u32, 0x2501), cells[1].glyph_index.?); try testing.expectEqual(@as(u16, 1), cells[1].x); } try testing.expectEqual(@as(usize, 1), count); @@ -902,11 +974,23 @@ const TestShaper = struct { } }; +const TestFont = enum { + inconsolata, + monaspace_neon, +}; + /// Helper to return a fully initialized shaper. fn testShaper(alloc: Allocator) !TestShaper { - const testFont = @import("../test.zig").fontRegular; + return try testShaperWithFont(alloc, .inconsolata); +} + +fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper { const testEmoji = @import("../test.zig").fontEmoji; const testEmojiText = @import("../test.zig").fontEmojiText; + const testFont = switch (font_req) { + .inconsolata => @import("../test.zig").fontRegular, + .monaspace_neon => @import("../test.zig").fontMonaspaceNeon, + }; var lib = try Library.init(); errdefer lib.deinit(); diff --git a/src/font/test.zig b/src/font/test.zig index 06bdc40d2..09909691e 100644 --- a/src/font/test.zig +++ b/src/font/test.zig @@ -15,3 +15,7 @@ pub const fontVariable = @embedFile("res/Lilex-VF.ttf"); /// Cozette is a unique font because it embeds some emoji characters /// but has a text presentation. pub const fontCozette = @embedFile("res/CozetteVector.ttf"); + +/// Monaspace has weird ligature behaviors we want to test in our shapers +/// so we embed it here. +pub const fontMonaspaceNeon = @embedFile("res/MonaspaceNeon-Regular.otf"); diff --git a/src/os/desktop.zig b/src/os/desktop.zig index ed4f977d9..6475c278e 100644 --- a/src/os/desktop.zig +++ b/src/os/desktop.zig @@ -19,7 +19,15 @@ pub fn launchedFromDesktop() bool { return switch (builtin.os.tag) { // macOS apps launched from finder or `open` always have the init // process as their parent. - .macos => c.getppid() == 1, + .macos => macos: { + // This special case is so that if we launch the app via the + // app bundle (i.e. via open) then we still treat it as if it + // was launched from the desktop. + if (build_config.artifact == .lib and + std.os.getenv("GHOSTTY_MAC_APP") != null) break :macos true; + + break :macos c.getppid() == 1; + }, // On Linux, GTK sets GIO_LAUNCHED_DESKTOP_FILE and // GIO_LAUNCHED_DESKTOP_FILE_PID. We only check the latter to see if diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 81518c6e6..9c17702e0 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -1796,12 +1796,12 @@ fn updateCell( }; // If the cell has a character, draw it - if (cell.char > 0) { + if (cell.char > 0) fg: { // Render const glyph = try self.font_group.renderGlyph( self.alloc, shaper_run.font_index, - shaper_cell.glyph_index, + shaper_cell.glyph_index orelse break :fg, .{ .grid_metrics = self.grid_metrics, .thicken = self.config.font_thicken, diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index aea6991df..66fd966ee 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -1504,12 +1504,12 @@ fn updateCell( }; // If the cell has a character, draw it - if (cell.char > 0) { + if (cell.char > 0) fg: { // Render const glyph = try self.font_group.renderGlyph( self.alloc, shaper_run.font_index, - shaper_cell.glyph_index, + shaper_cell.glyph_index orelse break :fg, .{ .grid_metrics = self.grid_metrics, .thicken = self.config.font_thicken,