pull/11490/merge
Jesus Vazquez 2026-06-03 11:07:03 -04:00 committed by GitHub
commit bb2fa580c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 164 additions and 10 deletions

View File

@ -233,8 +233,9 @@ language: ?[:0]const u8 = null,
///
/// The syntax is fairly loose, but invalid settings will be silently ignored.
///
/// The font feature will apply to all fonts rendered by Ghostty. A future
/// enhancement will allow targeting specific faces.
/// Font features are only applied to the primary font family. Fallback
/// fonts (fonts automatically selected when the primary font doesn't
/// contain a needed glyph) use only the default font features.
///
/// To disable programming ligatures, use `-calt` since this is the typical
/// feature name for programming ligatures. To look into what font features

View File

@ -47,6 +47,9 @@ pub const Shaper = struct {
features: *macos.foundation.Dictionary,
/// A version of the features dictionary with the default features excluded.
features_no_default: *macos.foundation.Dictionary,
/// A version of the features dictionary with only the default features
/// (no user-configured features). Used for fallback fonts.
features_default_only: *macos.foundation.Dictionary,
/// The shared memory used for shaping results.
cell_buf: CellBuf,
@ -168,6 +171,8 @@ pub const Shaper = struct {
errdefer features.release();
const features_no_default = try makeFeaturesDict(feats);
errdefer features_no_default.release();
const features_default_only = try makeFeaturesDict(&default_features);
errdefer features_default_only.release();
var run_state = RunState.init();
errdefer run_state.deinit(alloc);
@ -221,6 +226,7 @@ pub const Shaper = struct {
.run_state = run_state,
.features = features,
.features_no_default = features_no_default,
.features_default_only = features_default_only,
.typesetter_attr_dict = typesetter_attr_dict,
.cached_fonts = .{},
.cached_font_grid = 0,
@ -235,6 +241,7 @@ pub const Shaper = struct {
self.run_state.deinit(self.alloc);
self.features.release();
self.features_no_default.release();
self.features_default_only.release();
self.typesetter_attr_dict.release();
{
@ -610,9 +617,12 @@ pub const Shaper = struct {
defer grid.lock.unlockShared();
const face = try grid.resolver.collection.getFace(index);
const entry = try grid.resolver.collection.getEntry(index);
const original = face.font;
const attrs = if (face.quirks_disable_default_font_features)
const attrs = if (entry.fallback)
self.features_default_only
else if (face.quirks_disable_default_font_features)
self.features_no_default
else
self.features;
@ -2515,6 +2525,93 @@ test "shape high plane sprite font codepoint" {
try testing.expectEqual(null, try it.next(alloc));
}
test "shape fallback font ignores user features" {
// Regression test for https://github.com/ghostty-org/ghostty/issues/11464
//
// font-feature should only apply to the primary font, not fallback fonts.
// We test this by enabling `dlig` (discretionary ligatures) and shaping
// ">=" which both Inconsolata and JetBrainsMono can ligate into a single
// glyph. When Inconsolata is the primary font, dlig should apply and
// produce a ligature (1 glyph from 2 chars). When Inconsolata is a
// fallback font, dlig should NOT apply (2 glyphs from 2 chars).
const testing = std.testing;
const alloc = testing.allocator;
// Glyph indices for Inconsolata-Regular.ttf (embedded in src/font/res/).
const inconsolata_greater_than = 626; // ">"
const inconsolata_equals = 624; // "="
const inconsolata_greater_equal_lig = 875; // ">=" dlig ligature
// First: shape ">=" with Inconsolata as PRIMARY font (dlig enabled).
// This should produce a ligature: 2 input cells 1 output glyph.
{
var testdata = try testShaper(alloc);
defer testdata.deinit();
var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 });
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
s.nextSlice(">=");
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.cells = state.row_data.get(0).cells.slice(),
});
const run = (try it.next(alloc)).?;
try testing.expectEqual(@as(usize, 2), run.cells);
const cells = try shaper.shape(run);
// dlig applied ligature 1 glyph (Inconsolata's ">=" ligature)
try testing.expectEqual(@as(usize, 1), cells.len);
try testing.expectEqual(inconsolata_greater_equal_lig, cells[0].glyph_index);
}
// Second: shape ">=" where NotoEmoji is primary and Inconsolata is fallback.
// NotoEmoji lacks ">=" glyphs, so the resolver falls through to Inconsolata.
// dlig is enabled in config but should NOT be applied to fallback fonts,
// so this should NOT produce a ligature: 2 input cells 2 output glyphs.
{
var testdata = try testShaperPrimaryAndFallback(alloc, .{
.primary = font.embedded.emoji_text,
.fallback = font.embedded.inconsolata,
.features = &.{"dlig"},
});
defer testdata.deinit();
var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 });
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
s.nextSlice(">=");
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.cells = state.row_data.get(0).cells.slice(),
});
const run = (try it.next(alloc)).?;
try testing.expectEqual(@as(usize, 2), run.cells);
const cells = try shaper.shape(run);
// dlig NOT applied to fallback no ligature individual ">" and "=" glyphs
try testing.expectEqual(@as(usize, 2), cells.len);
try testing.expectEqual(inconsolata_greater_than, cells[0].glyph_index);
try testing.expectEqual(inconsolata_equals, cells[1].glyph_index);
}
}
const TestShaper = struct {
alloc: Allocator,
shaper: Shaper,
@ -2676,3 +2773,57 @@ fn testShaperWithDiscoveredFont(alloc: Allocator, font_req: [:0]const u8) !TestS
.lib = lib,
};
}
/// Sets up a collection with a primary font and a fallback font,
/// with configurable font features.
fn testShaperPrimaryAndFallback(
alloc: Allocator,
opts: struct {
primary: [:0]const u8,
fallback: [:0]const u8,
features: []const []const u8 = &.{},
},
) !TestShaper {
var lib = try Library.init(alloc);
errdefer lib.deinit();
var c = Collection.init();
c.load_options = .{ .library = lib };
_ = try c.add(alloc, try .init(
lib,
opts.primary,
.{ .size = .{ .points = 12 } },
), .{
.style = .regular,
.fallback = false,
.size_adjustment = .none,
});
_ = try c.add(alloc, try .init(
lib,
opts.fallback,
.{ .size = .{ .points = 12 } },
), .{
.style = .regular,
.fallback = true,
.size_adjustment = .none,
});
const grid_ptr = try alloc.create(SharedGrid);
errdefer alloc.destroy(grid_ptr);
grid_ptr.* = try .init(alloc, .{ .collection = c });
errdefer grid_ptr.*.deinit(alloc);
var shaper = try Shaper.init(alloc, .{
.features = opts.features,
});
errdefer shaper.deinit();
return TestShaper{
.alloc = alloc,
.shaper = shaper,
.grid = grid_ptr,
.lib = lib,
};
}

View File

@ -138,14 +138,16 @@ pub const Shaper = struct {
defer run.grid.lock.unlock();
const face = try run.grid.resolver.collection.getFace(run.font_index);
const i = if (!face.quirks_disable_default_font_features) 0 else i: {
// If we are disabling default font features we just offset
// our features by the hardcoded items because always
// add those at the beginning.
break :i default_features.len;
};
const entry = try run.grid.resolver.collection.getEntry(run.font_index);
harfbuzz.shape(face.hb_font, self.hb_buf, self.hb_feats[i..]);
// If we are disabling default font features we just offset
// our features by the hardcoded items because we always
// add those at the beginning.
const i = if (!face.quirks_disable_default_font_features) 0 else default_features.len;
// Fallback fonts only get default features, not user-configured ones.
const end = if (entry.fallback) default_features.len else self.hb_feats.len;
harfbuzz.shape(face.hb_font, self.hb_buf, self.hb_feats[i..end]);
}
// If our buffer is empty, we short-circuit the rest of the work