config: grapheme-width-method, default to "unicode"

This adds a new configuration `grapheme-width-method` to change the
default behavior that Ghostty uses to calculate grapheme width. The
default value is `unicode` which is identical to setting mode 2027.

**IMPORTANT:** This changes the default Ghostty behavior to be fully
grapheme-aware including ZWJs, VS15, VS16. This may cause issues with
some legacy programs and shells.

I've changed my mind that this should become the default because enough
people use emojis now that I've found in the beta program there are more
issues reported about "incorrect emoji width" than any possibly desync
issues. We'll see.

For legacy programs, this can still be set to `grapheme-width-method =
wcswidth`.
This commit is contained in:
Mitchell Hashimoto
2023-12-25 09:42:11 -08:00
parent 198e7895c9
commit 8b3094465d
3 changed files with 48 additions and 2 deletions

View File

@ -162,6 +162,32 @@ const c = @cImport({
@"adjust-strikethrough-position": ?MetricModifier = null, @"adjust-strikethrough-position": ?MetricModifier = null,
@"adjust-strikethrough-thickness": ?MetricModifier = null, @"adjust-strikethrough-thickness": ?MetricModifier = null,
/// The method to use for calculating the cell width of a grapheme cluster.
/// The default value is "unicode" which uses the Unicode standard to
/// determine grapheme width. This results in correct grapheme width but
/// may result in cursor-desync issues with some programs (such as shells)
/// that may use a legacy method such as "wcswidth".
///
/// Valid values are:
///
/// - "wcswidth" - Use the wcswidth function to determine grapheme width.
/// This maximizes compatibility with legacy programs but may result
/// in incorrect grapheme width for certain graphemes such as skin-tone
/// emoji, non-English characters, etc.
///
/// Note that this "wcswidth" functionality is based on the libc wcswidth,
/// not any other libraries with that name.
///
/// - "unicode" - Use the Unicode standard to determine grapheme width.
///
/// If a running program explicitly enables terminal mode 2027, then
/// "unicode" width will be forced regardless of this configuration. When
/// mode 2027 is reset, this configuration will be used again.
///
/// This configuration can be changed at runtime but will not affect existing
/// printed cells. Only new cells will use the new configuration.
@"grapheme-width-method": GraphemeWidthMethod = .unicode,
/// A named theme to use. The available themes are currently hardcoded to /// A named theme to use. The available themes are currently hardcoded to
/// the themes that ship with Ghostty. On macOS, this list is in the /// the themes that ship with Ghostty. On macOS, this list is in the
/// `Ghostty.app/Contents/Resources/themes` directory. On Linux, this /// `Ghostty.app/Contents/Resources/themes` directory. On Linux, this
@ -2781,3 +2807,9 @@ pub const WindowSaveState = enum {
never, never,
always, always,
}; };
/// See grapheme-width-method
pub const GraphemeWidthMethod = enum {
wcswidth,
unicode,
};

View File

@ -120,6 +120,9 @@ flags: packed struct {
/// then we want to capture the shift key for the mouse protocol /// then we want to capture the shift key for the mouse protocol
/// if the configuration allows it. /// if the configuration allows it.
mouse_shift_capture: enum { null, false, true } = .null, mouse_shift_capture: enum { null, false, true } = .null,
/// If true, we perform grapheme clustering even if mode 2027 is disabled.
default_grapheme_cluster: bool = false,
} = .{}, } = .{},
/// The event types that can be reported for mouse-related activities. /// The event types that can be reported for mouse-related activities.
@ -724,6 +727,8 @@ pub fn print(self: *Terminal, c: u21) !void {
const tracy = trace(@src()); const tracy = trace(@src());
defer tracy.end(); defer tracy.end();
// log.debug("print={x} y={} x={}", .{ c, self.screen.cursor.y, self.screen.cursor.x });
// If we're not on the main display, do nothing for now // If we're not on the main display, do nothing for now
if (self.status_display != .main) return; if (self.status_display != .main) return;
@ -738,7 +743,7 @@ pub fn print(self: *Terminal, c: u21) !void {
// purposely ordered in least-likely to most-likely so we can drop out // purposely ordered in least-likely to most-likely so we can drop out
// as quickly as possible. // as quickly as possible.
if (c > 255 and if (c > 255 and
self.modes.get(.grapheme_cluster) and (self.modes.get(.grapheme_cluster) or self.flags.default_grapheme_cluster) and
self.screen.cursor.x > 0) self.screen.cursor.x > 0)
grapheme: { grapheme: {
const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); const row = self.screen.getRow(.{ .active = self.screen.cursor.y });
@ -775,6 +780,7 @@ pub fn print(self: *Terminal, c: u21) !void {
if (prev.cell.attrs.grapheme) { if (prev.cell.attrs.grapheme) {
var it = row.codepointIterator(prev.x); var it = row.codepointIterator(prev.x);
while (it.next()) |cp2| { while (it.next()) |cp2| {
// log.debug("cp1={x} cp2={x}", .{ cp1, cp2 });
assert(!ziglyph.graphemeBreak( assert(!ziglyph.graphemeBreak(
cp1, cp1,
cp2, cp2,
@ -785,6 +791,7 @@ pub fn print(self: *Terminal, c: u21) !void {
} }
} }
// log.debug("cp1={x} cp2={x} end", .{ cp1, c });
break :brk ziglyph.graphemeBreak(cp1, c, &state); break :brk ziglyph.graphemeBreak(cp1, c, &state);
}; };
@ -868,7 +875,9 @@ pub fn print(self: *Terminal, c: u21) !void {
// If we have grapheme clustering enabled, we don't blindly attach // If we have grapheme clustering enabled, we don't blindly attach
// any zero width character to our cells and we instead just ignore // any zero width character to our cells and we instead just ignore
// it. // it.
if (self.modes.get(.grapheme_cluster)) return; if (self.modes.get(.grapheme_cluster) or
self.flags.default_grapheme_cluster)
return;
// If we're at cell zero, then this is malformed data and we don't // If we're at cell zero, then this is malformed data and we don't
// print anything or even store this. Zero-width characters are ALWAYS // print anything or even store this. Zero-width characters are ALWAYS

View File

@ -105,6 +105,7 @@ pub const DerivedConfig = struct {
background: configpkg.Config.Color, background: configpkg.Config.Color,
osc_color_report_format: configpkg.Config.OSCColorReportFormat, osc_color_report_format: configpkg.Config.OSCColorReportFormat,
term: []const u8, term: []const u8,
grapheme_width_method: configpkg.Config.GraphemeWidthMethod,
pub fn init( pub fn init(
alloc_gpa: Allocator, alloc_gpa: Allocator,
@ -122,6 +123,7 @@ pub const DerivedConfig = struct {
.background = config.background, .background = config.background,
.osc_color_report_format = config.@"osc-color-report-format", .osc_color_report_format = config.@"osc-color-report-format",
.term = config.term, .term = config.term,
.grapheme_width_method = config.@"grapheme-width-method",
}; };
} }
@ -146,6 +148,7 @@ pub fn init(alloc: Allocator, opts: termio.Options) !Exec {
errdefer term.deinit(alloc); errdefer term.deinit(alloc);
term.default_palette = opts.config.palette; term.default_palette = opts.config.palette;
term.color_palette.colors = opts.config.palette; term.color_palette.colors = opts.config.palette;
term.flags.default_grapheme_cluster = opts.config.grapheme_width_method == .unicode;
// Set the image size limits // Set the image size limits
try term.screen.kitty_images.setLimit(alloc, opts.config.image_storage_limit); try term.screen.kitty_images.setLimit(alloc, opts.config.image_storage_limit);
@ -350,6 +353,8 @@ pub fn changeConfig(self: *Exec, config: *DerivedConfig) !void {
// - command, working-directory: we never restart the underlying // - command, working-directory: we never restart the underlying
// process so we don't care or need to know about these. // process so we don't care or need to know about these.
self.terminal.flags.default_grapheme_cluster = config.grapheme_width_method == .unicode;
// Update the default palette. Note this will only apply to new colors drawn // Update the default palette. Note this will only apply to new colors drawn
// since we decode all palette colors to RGB on usage. // since we decode all palette colors to RGB on usage.
self.terminal.default_palette = config.palette; self.terminal.default_palette = config.palette;