From 0d0aeccf0fdc01a97fba0832b9af6303995b90c1 Mon Sep 17 00:00:00 2001 From: Dmitry Zhlobo Date: Sat, 7 Dec 2024 19:46:39 +0100 Subject: [PATCH 01/47] fix unwanted resize of non-native fullscreen window Removing autoHideDock and autoHideMenuBar options cause window to resize. Fix #2516 --- macos/Sources/Helpers/Fullscreen.swift | 53 ++------------------------ 1 file changed, 3 insertions(+), 50 deletions(-) diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index bb3859e07..ca93ac533 100644 --- a/macos/Sources/Helpers/Fullscreen.swift +++ b/macos/Sources/Helpers/Fullscreen.swift @@ -167,6 +167,9 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { self.savedState = savedState // We hide the dock if the window is on a screen with the dock. + // This is crazy but at least on macOS 15.0, you must hide the dock + // FIRST then hide the menu. If you do the opposite, it does not + // work. if (savedState.dock) { hideDock() } @@ -176,18 +179,6 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { hideMenu() } - // When this window becomes or resigns main we need to run some logic. - NotificationCenter.default.addObserver( - self, - selector: #selector(windowDidBecomeMain), - name: NSWindow.didBecomeMainNotification, - object: window) - NotificationCenter.default.addObserver( - self, - selector: #selector(windowDidResignMain), - name: NSWindow.didResignMainNotification, - object: window) - // When we change screens we need to redo everything. NotificationCenter.default.addObserver( self, @@ -218,8 +209,6 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { // Remove all our notifications. We remove them one by one because // we don't want to remove the observers that our superclass sets. let center = NotificationCenter.default - center.removeObserver(self, name: NSWindow.didBecomeMainNotification, object: window) - center.removeObserver(self, name: NSWindow.didResignMainNotification, object: window) center.removeObserver(self, name: NSWindow.didChangeScreenNotification, object: window) // Unhide our elements @@ -311,42 +300,6 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { exit() } - @objc func windowDidBecomeMain(_ notification: Notification) { - guard let savedState else { return } - - // This should always be true due to how we register but just be sure - guard let object = notification.object as? NSWindow, - object == window else { return } - - // This is crazy but at least on macOS 15.0, you must hide the dock - // FIRST then hide the menu. If you do the opposite, it does not - // work. - - if savedState.dock { - hideDock() - } - - if (properties.hideMenu) { - hideMenu() - } - } - - @objc func windowDidResignMain(_ notification: Notification) { - guard let savedState else { return } - - // This should always be true due to how we register but just be sure - guard let object = notification.object as? NSWindow, - object == window else { return } - - if (properties.hideMenu) { - unhideMenu() - } - - if savedState.dock { - unhideDock() - } - } - // MARK: Dock private func hideDock() { From 080afce64947d7f37cd5732be82e739add544edf Mon Sep 17 00:00:00 2001 From: Dmitry Zhlobo Date: Sat, 7 Dec 2024 19:44:22 +0100 Subject: [PATCH 02/47] found a better explanation for the reason to hide dock before menu --- macos/Sources/Helpers/Fullscreen.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index ca93ac533..0d7b8b962 100644 --- a/macos/Sources/Helpers/Fullscreen.swift +++ b/macos/Sources/Helpers/Fullscreen.swift @@ -167,9 +167,9 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { self.savedState = savedState // We hide the dock if the window is on a screen with the dock. - // This is crazy but at least on macOS 15.0, you must hide the dock - // FIRST then hide the menu. If you do the opposite, it does not - // work. + // We must hide the dock FIRST then hide the menu: + // If you specify autoHideMenuBar, it must be accompanied by either hideDock or autoHideDock. + // https://developer.apple.com/documentation/appkit/nsapplication/presentationoptions-swift.struct if (savedState.dock) { hideDock() } From 2f31e1b7fa2b324ae10366e0c91f0aa0fbfcce75 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Tue, 10 Dec 2024 14:30:59 -0500 Subject: [PATCH 03/47] fix(kittygfx): don't respond to T commands with no i or I If a transmit and display command does not specify an ID or a number, then it should not be responded to. We currently automatically assign IDs in this situation, which isn't ideal since collisions can happen which shouldn't, but this at least fixes the glaring observable issue where transmit-and-display commands yield unexpected OK responses. --- src/terminal/kitty/graphics_exec.zig | 28 ++++++++++++++++++++++--- src/terminal/kitty/graphics_image.zig | 6 ++++++ src/terminal/kitty/graphics_storage.zig | 3 +++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/terminal/kitty/graphics_exec.zig b/src/terminal/kitty/graphics_exec.zig index 42f12ea07..057f28065 100644 --- a/src/terminal/kitty/graphics_exec.zig +++ b/src/terminal/kitty/graphics_exec.zig @@ -164,9 +164,9 @@ fn transmit( // If there are more chunks expected we do not respond. if (load.more) return .{}; - // If our image has no ID or number, we don't respond at all. Conversely, - // if we have either an ID or number, we always respond. - if (load.image.id == 0 and load.image.number == 0) return .{}; + // If the loaded image was assigned its ID automatically, not based + // on a number or explicitly specified ID, then we don't respond. + if (load.image.implicit_id) return .{}; // After the image is added, set the ID in case it changed. // The resulting image number and placement ID never change. @@ -335,6 +335,10 @@ fn loadAndAddImage( if (loading.image.id == 0) { loading.image.id = storage.next_image_id; storage.next_image_id +%= 1; + + // If the image also has no number then its auto-ID is "implicit". + // See the doc comment on the Image.implicit_id field for more detail. + if (loading.image.number == 0) loading.image.implicit_id = true; } // If this is chunked, this is the beginning of a new chunked transmission. @@ -529,3 +533,21 @@ test "kittygfx test valid i32 (expect invalid image ID)" { try testing.expect(!resp.ok()); try testing.expectEqual(resp.message, "ENOENT: image not found"); } + +test "kittygfx no response with no image ID or number" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,t=d,s=1,v=2,c=10,r=1,i=0,I=0;////////", + ); + defer cmd.deinit(alloc); + const resp = execute(alloc, &t, &cmd); + try testing.expect(resp == null); + } +} diff --git a/src/terminal/kitty/graphics_image.zig b/src/terminal/kitty/graphics_image.zig index 931d068f9..ff498cbb8 100644 --- a/src/terminal/kitty/graphics_image.zig +++ b/src/terminal/kitty/graphics_image.zig @@ -455,6 +455,12 @@ pub const Image = struct { data: []const u8 = "", transmit_time: std.time.Instant = undefined, + /// Set this to true if this image was loaded by a command that + /// doesn't specify an ID or number, since such commands should + /// not be responded to, even though we do currently give them + /// IDs in the public range (which is bad!). + implicit_id: bool = false, + pub const Error = error{ InternalError, InvalidData, diff --git a/src/terminal/kitty/graphics_storage.zig b/src/terminal/kitty/graphics_storage.zig index ee46b2a6c..ffd3aa580 100644 --- a/src/terminal/kitty/graphics_storage.zig +++ b/src/terminal/kitty/graphics_storage.zig @@ -31,6 +31,9 @@ pub const ImageStorage = struct { /// This is the next automatically assigned image ID. We start mid-way /// through the u32 range to avoid collisions with buggy programs. + /// TODO: This isn't good enough, it's perfectly legal for programs + /// to use IDs in the latter half of the range and collisions + /// are not gracefully handled. next_image_id: u32 = 2147483647, /// This is the next automatically assigned placement ID. This is never From 7e5a164be82889a84d14d5fe7b30b5ae5af95e10 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Sat, 28 Sep 2024 22:01:31 -0600 Subject: [PATCH 04/47] font/opentype: add table parsing for head, hhea, post, and OS/2 (and a utility for parsing SFNT font files and fetching table data from them) --- src/font/opentype.zig | 10 + src/font/opentype/head.zig | 179 ++++++++++++ src/font/opentype/hhea.zig | 115 ++++++++ src/font/opentype/os2.zig | 577 +++++++++++++++++++++++++++++++++++++ src/font/opentype/post.zig | 82 ++++++ src/font/opentype/sfnt.zig | 314 ++++++++++++++++++++ 6 files changed, 1277 insertions(+) create mode 100644 src/font/opentype/head.zig create mode 100644 src/font/opentype/hhea.zig create mode 100644 src/font/opentype/os2.zig create mode 100644 src/font/opentype/post.zig create mode 100644 src/font/opentype/sfnt.zig diff --git a/src/font/opentype.zig b/src/font/opentype.zig index 798df5b2c..dd02efeb3 100644 --- a/src/font/opentype.zig +++ b/src/font/opentype.zig @@ -1,6 +1,16 @@ +pub const sfnt = @import("opentype/sfnt.zig"); + const svg = @import("opentype/svg.zig"); +const os2 = @import("opentype/os2.zig"); +const post = @import("opentype/post.zig"); +const hhea = @import("opentype/hhea.zig"); +const head = @import("opentype/head.zig"); pub const SVG = svg.SVG; +pub const OS2 = os2.OS2; +pub const Post = post.Post; +pub const Hhea = hhea.Hhea; +pub const Head = head.Head; test { @import("std").testing.refAllDecls(@This()); diff --git a/src/font/opentype/head.zig b/src/font/opentype/head.zig new file mode 100644 index 000000000..ff5da7eaa --- /dev/null +++ b/src/font/opentype/head.zig @@ -0,0 +1,179 @@ +const std = @import("std"); +const assert = std.debug.assert; +const sfnt = @import("sfnt.zig"); + +/// Font Header Table +/// +/// References: +/// - https://learn.microsoft.com/en-us/typography/opentype/spec/head +pub const Head = extern struct { + /// Major version number of the font header table — set to 1. + majorVersion: sfnt.uint16 align(1), + + /// Minor version number of the font header table — set to 0. + minorVersion: sfnt.uint16 align(1), + + /// Set by font manufacturer. + fontRevision: sfnt.Fixed align(1), + + /// To compute: set it to 0, sum the entire font as uint32, then store + /// 0xB1B0AFBA - sum. If the font is used as a component in a font + /// collection file, the value of this field will be invalidated by + /// changes to the file structure and font table directory, and must + /// be ignored. + checksumAdjustment: sfnt.uint32 align(1), + + /// Set to 0x5F0F3CF5. + magicNumber: sfnt.uint32 align(1), + + /// Bit 0: Baseline for font at y=0. + /// + /// Bit 1: Left sidebearing point at x=0 + /// (relevant only for TrueType rasterizers) + /// + /// Bit 2: Instructions may depend on point size. + /// + /// Bit 3: Force ppem to integer values for all internal scaler math; may + /// use fractional ppem sizes if this bit is clear. It is strongly + /// recommended that this be set in hinted fonts. + /// + /// Bit 4: Instructions may alter advance width + /// (the advance widths might not scale linearly). + /// + /// Bit 5: This bit is not used in OpenType, and should not be set in order + /// to ensure compatible behavior on all platforms. If set, it may + /// result in different behavior for vertical layout in some + /// platforms. + /// + /// (See Apple’s specification for details + /// regarding behavior in Apple platforms.) + /// + /// Bits 6 – 10: These bits are not used in OpenType and should always be + /// cleared. + /// + /// (See Apple’s specification for details + /// regarding legacy use in Apple platforms.) + /// + /// Bit 11: Font data is “lossless” as a result of having been + /// subjected to optimizing transformation and/or compression + /// (such as compression mechanisms defined by ISO/IEC 14496-18, + /// MicroType® Express, WOFF 2.0, or similar) where the original + /// font functionality and features are retained but the binary + /// compatibility between input and output font files is not + /// guaranteed. As a result of the applied transform, the DSIG + /// table may also be invalidated. + /// + /// Bit 12: Font converted (produce compatible metrics). + /// + /// Bit 13: Font optimized for ClearType®. Note, fonts that rely on embedded + /// bitmaps (EBDT) for rendering should not be considered optimized + /// for ClearType, and therefore should keep this bit cleared. + /// + /// Bit 14: Last Resort font. If set, indicates that the glyphs encoded in + /// the 'cmap' subtables are simply generic symbolic representations + /// of code point ranges and do not truly represent support for + /// those code points. If unset, indicates that the glyphs encoded + /// in the 'cmap' subtables represent proper support for those code + /// points. + /// + /// Bit 15: Reserved, set to 0. + flags: sfnt.uint16 align(1), + + /// Set to a value from 16 to 16384. Any value in this range is valid. + /// + /// In fonts that have TrueType outlines, a power of 2 is recommended + /// as this allows performance optimization in some rasterizers. + unitsPerEm: sfnt.uint16 align(1), + + /// Number of seconds since 12:00 midnight that started + /// January 1st, 1904, in GMT/UTC time zone. + created: sfnt.LONGDATETIME align(1), + + /// Number of seconds since 12:00 midnight that started + /// January 1st, 1904, in GMT/UTC time zone. + modified: sfnt.LONGDATETIME align(1), + + /// Minimum x coordinate across all glyph bounding boxes. + xMin: sfnt.int16 align(1), + + /// Minimum y coordinate across all glyph bounding boxes. + yMin: sfnt.int16 align(1), + + /// Maximum x coordinate across all glyph bounding boxes. + xMax: sfnt.int16 align(1), + + /// Maximum y coordinate across all glyph bounding boxes. + yMax: sfnt.int16 align(1), + + /// Bit 0: Bold (if set to 1); + /// Bit 1: Italic (if set to 1) + /// Bit 2: Underline (if set to 1) + /// Bit 3: Outline (if set to 1) + /// Bit 4: Shadow (if set to 1) + /// Bit 5: Condensed (if set to 1) + /// Bit 6: Extended (if set to 1) + /// Bits 7 – 15: Reserved (set to 0). + macStyle: sfnt.uint16 align(1), + + /// Smallest readable size in pixels. + lowestRecPPEM: sfnt.uint16 align(1), + + /// Deprecated (Set to 2). + /// 0: Fully mixed directional glyphs; + /// 1: Only strongly left to right; + /// 2: Like 1 but also contains neutrals; + /// -1: Only strongly right to left; + /// -2: Like -1 but also contains neutrals. + fontDirectionHint: sfnt.int16 align(1), + + /// 0 for short offsets (Offset16), 1 for long (Offset32). + indexToLocFormat: sfnt.int16 align(1), + + /// 0 for current format. + glyphDataFormat: sfnt.int16 align(1), + + /// Parse the table from raw data. + pub fn init(data: []const u8) !Head { + var fbs = std.io.fixedBufferStream(data); + const reader = fbs.reader(); + + return try reader.readStructEndian(Head, .big); + } +}; + +test "head" { + const testing = std.testing; + const alloc = testing.allocator; + const test_font = @import("../embedded.zig").julia_mono; + + const font = try sfnt.SFNT.init(test_font, alloc); + defer font.deinit(alloc); + + const table = font.getTable("head").?; + + const head = try Head.init(table); + + try testing.expectEqualDeep( + Head{ + .majorVersion = 1, + .minorVersion = 0, + .fontRevision = sfnt.Fixed.from(0.05499267578125), + .checksumAdjustment = 1007668681, + .magicNumber = 1594834165, + .flags = 7, + .unitsPerEm = 2000, + .created = 3797757830, + .modified = 3797760444, + .xMin = -1000, + .yMin = -1058, + .xMax = 3089, + .yMax = 2400, + .macStyle = 0, + .lowestRecPPEM = 7, + .fontDirectionHint = 2, + .indexToLocFormat = 1, + .glyphDataFormat = 0, + }, + head, + ); +} diff --git a/src/font/opentype/hhea.zig b/src/font/opentype/hhea.zig new file mode 100644 index 000000000..8f224e1bb --- /dev/null +++ b/src/font/opentype/hhea.zig @@ -0,0 +1,115 @@ +const std = @import("std"); +const assert = std.debug.assert; +const sfnt = @import("sfnt.zig"); + +/// Horizontal Header Table +/// +/// References: +/// - https://learn.microsoft.com/en-us/typography/opentype/spec/hhea +pub const Hhea = extern struct { + /// Major version number of the horizontal header table — set to 1. + majorVersion: sfnt.uint16 align(1), + + /// Minor version number of the horizontal header table — set to 0. + minorVersion: sfnt.uint16 align(1), + + /// Typographic ascent—see remarks below. + ascender: sfnt.FWORD align(1), + + /// Typographic descent—see remarks below. + descender: sfnt.FWORD align(1), + + /// Typographic line gap. + /// + /// Negative lineGap values are treated as zero + /// in some legacy platform implementations. + lineGap: sfnt.FWORD align(1), + + /// Maximum advance width value in 'hmtx' table. + advanceWidthMax: sfnt.UFWORD align(1), + + /// Minimum left sidebearing value in 'hmtx' table for + /// glyphs with contours (empty glyphs should be ignored). + minLeftSideBearing: sfnt.FWORD align(1), + + /// Minimum right sidebearing value; calculated as + /// min(aw - (lsb + xMax - xMin)) for glyphs with + /// contours (empty glyphs should be ignored). + minRightSideBearing: sfnt.FWORD align(1), + + /// Max(lsb + (xMax - xMin)). + xMaxExtent: sfnt.FWORD align(1), + + /// Used to calculate the slope of the cursor (rise/run); 1 for vertical. + caretSlopeRise: sfnt.int16 align(1), + + /// 0 for vertical. + caretSlopeRun: sfnt.int16 align(1), + + /// The amount by which a slanted highlight on a glyph needs to be shifted + /// to produce the best appearance. Set to 0 for non-slanted fonts + caretOffset: sfnt.int16 align(1), + + /// set to 0 + _reserved0: sfnt.int16 align(1), + + /// set to 0 + _reserved1: sfnt.int16 align(1), + + /// set to 0 + _reserved2: sfnt.int16 align(1), + + /// set to 0 + _reserved3: sfnt.int16 align(1), + + /// 0 for current format. + metricDataFormat: sfnt.int16 align(1), + + /// Number of hMetric entries in 'hmtx' table + numberOfHMetrics: sfnt.uint16 align(1), + + /// Parse the table from raw data. + pub fn init(data: []const u8) !Hhea { + var fbs = std.io.fixedBufferStream(data); + const reader = fbs.reader(); + + return try reader.readStructEndian(Hhea, .big); + } +}; + +test "hhea" { + const testing = std.testing; + const alloc = testing.allocator; + const test_font = @import("../embedded.zig").julia_mono; + + const font = try sfnt.SFNT.init(test_font, alloc); + defer font.deinit(alloc); + + const table = font.getTable("hhea").?; + + const hhea = try Hhea.init(table); + + try testing.expectEqualDeep( + Hhea{ + .majorVersion = 1, + .minorVersion = 0, + .ascender = 1900, + .descender = -450, + .lineGap = 0, + .advanceWidthMax = 1200, + .minLeftSideBearing = -1000, + .minRightSideBearing = -1889, + .xMaxExtent = 3089, + .caretSlopeRise = 1, + .caretSlopeRun = 0, + .caretOffset = 0, + ._reserved0 = 0, + ._reserved1 = 0, + ._reserved2 = 0, + ._reserved3 = 0, + .metricDataFormat = 0, + .numberOfHMetrics = 2, + }, + hhea, + ); +} diff --git a/src/font/opentype/os2.zig b/src/font/opentype/os2.zig new file mode 100644 index 000000000..9bd57fc37 --- /dev/null +++ b/src/font/opentype/os2.zig @@ -0,0 +1,577 @@ +const std = @import("std"); +const assert = std.debug.assert; +const sfnt = @import("sfnt.zig"); + +pub const FSSelection = packed struct(sfnt.uint16) { + /// Font contains italic or oblique glyphs, otherwise they are upright. + italic: bool = false, + + /// Glyphs are underscored. + underscore: bool = false, + + /// Glyphs have their foreground and background reversed. + negative: bool = false, + + /// Outline (hollow) glyphs, otherwise they are solid. + outlined: bool = false, + + /// Glyphs are overstruck. + strikeout: bool = false, + + /// Glyphs are emboldened. + bold: bool = false, + + /// Glyphs are in the standard weight/style for the font. + regular: bool = false, + + /// If set, it is strongly recommended that applications use + /// OS/2.sTypoAscender - OS/2.sTypoDescender + OS/2.sTypoLineGap + /// as the default line spacing for this font. + use_typo_metrics: bool = false, + + /// The font has 'name' table strings consistent with a weight/width/slope + /// family without requiring use of name IDs 21 and 22. + wws: bool = false, + + /// Font contains oblique glyphs. + oblique: bool = false, + + _reserved: u6 = 0, +}; + +/// OS/2 and Windows Metrics Table +/// +/// References: +/// - https://learn.microsoft.com/en-us/typography/opentype/spec/os2 +pub const OS2v5 = extern struct { + version: sfnt.uint16 align(1), + xAvgCharWidth: sfnt.FWORD align(1), + usWeightClass: sfnt.uint16 align(1), + usWidthClass: sfnt.uint16 align(1), + fsType: sfnt.uint16 align(1), + ySubscriptXSize: sfnt.FWORD align(1), + ySubscriptYSize: sfnt.FWORD align(1), + ySubscriptXOffset: sfnt.FWORD align(1), + ySubscriptYOffset: sfnt.FWORD align(1), + ySuperscriptXSize: sfnt.FWORD align(1), + ySuperscriptYSize: sfnt.FWORD align(1), + ySuperscriptXOffset: sfnt.FWORD align(1), + ySuperscriptYOffset: sfnt.FWORD align(1), + yStrikeoutSize: sfnt.FWORD align(1), + yStrikeoutPosition: sfnt.FWORD align(1), + sFamilyClass: sfnt.int16 align(1), + panose: [10]sfnt.uint8 align(1), + ulUnicodeRange1: sfnt.uint32 align(1), + ulUnicodeRange2: sfnt.uint32 align(1), + ulUnicodeRange3: sfnt.uint32 align(1), + ulUnicodeRange4: sfnt.uint32 align(1), + achVendID: sfnt.Tag align(1), + fsSelection: FSSelection align(1), + usFirstCharIndex: sfnt.uint16 align(1), + usLastCharIndex: sfnt.uint16 align(1), + sTypoAscender: sfnt.FWORD align(1), + sTypoDescender: sfnt.FWORD align(1), + sTypoLineGap: sfnt.FWORD align(1), + usWinAscent: sfnt.UFWORD align(1), + usWinDescent: sfnt.UFWORD align(1), + ulCodePageRange1: sfnt.uint32 align(1), + ulCodePageRange2: sfnt.uint32 align(1), + sxHeight: sfnt.FWORD align(1), + sCapHeight: sfnt.FWORD align(1), + usDefaultChar: sfnt.uint16 align(1), + usBreakChar: sfnt.uint16 align(1), + usMaxContext: sfnt.uint16 align(1), + usLowerOpticalPointSize: sfnt.uint16 align(1), + usUpperOpticalPointSize: sfnt.uint16 align(1), +}; + +pub const OS2v4_3_2 = extern struct { + version: sfnt.uint16 align(1), + xAvgCharWidth: sfnt.FWORD align(1), + usWeightClass: sfnt.uint16 align(1), + usWidthClass: sfnt.uint16 align(1), + fsType: sfnt.uint16 align(1), + ySubscriptXSize: sfnt.FWORD align(1), + ySubscriptYSize: sfnt.FWORD align(1), + ySubscriptXOffset: sfnt.FWORD align(1), + ySubscriptYOffset: sfnt.FWORD align(1), + ySuperscriptXSize: sfnt.FWORD align(1), + ySuperscriptYSize: sfnt.FWORD align(1), + ySuperscriptXOffset: sfnt.FWORD align(1), + ySuperscriptYOffset: sfnt.FWORD align(1), + yStrikeoutSize: sfnt.FWORD align(1), + yStrikeoutPosition: sfnt.FWORD align(1), + sFamilyClass: sfnt.int16 align(1), + panose: [10]sfnt.uint8 align(1), + ulUnicodeRange1: sfnt.uint32 align(1), + ulUnicodeRange2: sfnt.uint32 align(1), + ulUnicodeRange3: sfnt.uint32 align(1), + ulUnicodeRange4: sfnt.uint32 align(1), + achVendID: sfnt.Tag align(1), + fsSelection: FSSelection align(1), + usFirstCharIndex: sfnt.uint16 align(1), + usLastCharIndex: sfnt.uint16 align(1), + sTypoAscender: sfnt.FWORD align(1), + sTypoDescender: sfnt.FWORD align(1), + sTypoLineGap: sfnt.FWORD align(1), + usWinAscent: sfnt.UFWORD align(1), + usWinDescent: sfnt.UFWORD align(1), + ulCodePageRange1: sfnt.uint32 align(1), + ulCodePageRange2: sfnt.uint32 align(1), + sxHeight: sfnt.FWORD align(1), + sCapHeight: sfnt.FWORD align(1), + usDefaultChar: sfnt.uint16 align(1), + usBreakChar: sfnt.uint16 align(1), + usMaxContext: sfnt.uint16 align(1), +}; + +pub const OS2v1 = extern struct { + version: sfnt.uint16 align(1), + xAvgCharWidth: sfnt.FWORD align(1), + usWeightClass: sfnt.uint16 align(1), + usWidthClass: sfnt.uint16 align(1), + fsType: sfnt.uint16 align(1), + ySubscriptXSize: sfnt.FWORD align(1), + ySubscriptYSize: sfnt.FWORD align(1), + ySubscriptXOffset: sfnt.FWORD align(1), + ySubscriptYOffset: sfnt.FWORD align(1), + ySuperscriptXSize: sfnt.FWORD align(1), + ySuperscriptYSize: sfnt.FWORD align(1), + ySuperscriptXOffset: sfnt.FWORD align(1), + ySuperscriptYOffset: sfnt.FWORD align(1), + yStrikeoutSize: sfnt.FWORD align(1), + yStrikeoutPosition: sfnt.FWORD align(1), + sFamilyClass: sfnt.int16 align(1), + panose: [10]sfnt.uint8 align(1), + ulUnicodeRange1: sfnt.uint32 align(1), + ulUnicodeRange2: sfnt.uint32 align(1), + ulUnicodeRange3: sfnt.uint32 align(1), + ulUnicodeRange4: sfnt.uint32 align(1), + achVendID: sfnt.Tag align(1), + fsSelection: FSSelection align(1), + usFirstCharIndex: sfnt.uint16 align(1), + usLastCharIndex: sfnt.uint16 align(1), + sTypoAscender: sfnt.FWORD align(1), + sTypoDescender: sfnt.FWORD align(1), + sTypoLineGap: sfnt.FWORD align(1), + usWinAscent: sfnt.UFWORD align(1), + usWinDescent: sfnt.UFWORD align(1), + ulCodePageRange1: sfnt.uint32 align(1), + ulCodePageRange2: sfnt.uint32 align(1), +}; + +pub const OS2v0 = extern struct { + version: sfnt.uint16 align(1), + xAvgCharWidth: sfnt.FWORD align(1), + usWeightClass: sfnt.uint16 align(1), + usWidthClass: sfnt.uint16 align(1), + fsType: sfnt.uint16 align(1), + ySubscriptXSize: sfnt.FWORD align(1), + ySubscriptYSize: sfnt.FWORD align(1), + ySubscriptXOffset: sfnt.FWORD align(1), + ySubscriptYOffset: sfnt.FWORD align(1), + ySuperscriptXSize: sfnt.FWORD align(1), + ySuperscriptYSize: sfnt.FWORD align(1), + ySuperscriptXOffset: sfnt.FWORD align(1), + ySuperscriptYOffset: sfnt.FWORD align(1), + yStrikeoutSize: sfnt.FWORD align(1), + yStrikeoutPosition: sfnt.FWORD align(1), + sFamilyClass: sfnt.int16 align(1), + panose: [10]sfnt.uint8 align(1), + ulUnicodeRange1: sfnt.uint32 align(1), + ulUnicodeRange2: sfnt.uint32 align(1), + ulUnicodeRange3: sfnt.uint32 align(1), + ulUnicodeRange4: sfnt.uint32 align(1), + achVendID: sfnt.Tag align(1), + fsSelection: FSSelection align(1), + usFirstCharIndex: sfnt.uint16 align(1), + usLastCharIndex: sfnt.uint16 align(1), + sTypoAscender: sfnt.FWORD align(1), + sTypoDescender: sfnt.FWORD align(1), + sTypoLineGap: sfnt.FWORD align(1), + usWinAscent: sfnt.UFWORD align(1), + usWinDescent: sfnt.UFWORD align(1), +}; + +/// Generic OS/2 table with optional fields +/// for those that don't exist in all versions. +/// +/// References: +/// - https://learn.microsoft.com/en-us/typography/opentype/spec/os2 +pub const OS2 = struct { + /// The version number for the OS/2 table: 0x0000 to 0x0005. + /// + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2#version + version: u16, + /// The Average Character Width field specifies the arithmetic average of the escapement (width) of all non-zero width glyphs in the font. + /// + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#xavgcharwidth + xAvgCharWidth: i16, + /// Indicates the visual weight (degree of blackness or thickness of strokes) of the characters in the font. Values from 1 to 1000 are valid. + /// + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#usweightclass + usWeightClass: u16, + /// Indicates a relative change from the normal aspect ratio (width to height ratio) as specified by a font designer for the glyphs in a font. + /// + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#uswidthclass + usWidthClass: u16, + /// Indicates font embedding licensing rights for the font. + /// + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#fstype + fsType: u16, + /// The recommended horizontal size in font design units for subscripts for this font. + /// + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#ysubscriptxsize + ySubscriptXSize: i16, + /// The recommended vertical size in font design units for subscripts for this font. + /// + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#ysubscriptysize + ySubscriptYSize: i16, + /// The recommended horizontal offset in font design units for subscripts for this font. + /// + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#ysubscriptxoffset + ySubscriptXOffset: i16, + /// The recommended vertical offset in font design units from the baseline for subscripts for this font. + /// + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#ysubscriptyoffset + ySubscriptYOffset: i16, + /// The recommended horizontal size in font design units for superscripts for this font. + /// + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#ysuperscriptxsize + ySuperscriptXSize: i16, + /// The recommended vertical size in font design units for superscripts for this font. + /// + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#ysuperscriptysize + ySuperscriptYSize: i16, + /// The recommended horizontal offset in font design units for superscripts for this font. + /// + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#ysuperscriptxoffset + ySuperscriptXOffset: i16, + /// The recommended vertical offset in font design units from the baseline for superscripts for this font. + /// + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#ysuperscriptyoffset + ySuperscriptYOffset: i16, + /// Thickness of the strikeout stroke in font design units. Should be > 0. + /// + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#ystrikeoutsize + yStrikeoutSize: i16, + /// The position of the top of the strikeout stroke relative to the baseline in font design units. + /// + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#ystrikeoutposition + yStrikeoutPosition: i16, + /// This field provides a classification of font-family design. + /// + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#sfamilyclass + sFamilyClass: i16, + /// This 10-byte array of numbers is used to describe the visual characteristics of a given typeface. + /// + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#panose + panose: [10]u8, + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#ulunicoderange + ulUnicodeRange1: u32, + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#ulunicoderange + ulUnicodeRange2: u32, + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#ulunicoderange + ulUnicodeRange3: u32, + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#ulunicoderange + ulUnicodeRange4: u32, + /// The four character identifier for the vendor of the given type face. + /// + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#achvendid + achVendID: [4]u8, + /// Contains information concerning the nature of the font patterns. + /// + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#fsselection + fsSelection: FSSelection, + /// The minimum Unicode index (character code) in this font, according to the 'cmap' subtable for platform ID 3 and platform-specific encoding ID 0 or 1. + /// + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#usfirstcharindex + usFirstCharIndex: u16, + /// The maximum Unicode index (character code) in this font, according to the 'cmap' subtable for platform ID 3 and encoding ID 0 or 1. + /// + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#uslastcharindex + usLastCharIndex: u16, + /// The typographic ascender for this font. This field should be combined with the sTypoDescender and sTypoLineGap values to determine default line spacing. + /// + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#stypoascender + sTypoAscender: i16, + /// The typographic descender for this font. This field should be combined with the sTypoAscender and sTypoLineGap values to determine default line spacing. + /// + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#stypodescender + sTypoDescender: i16, + /// The typographic line gap for this font. This field should be combined with the sTypoAscender and sTypoDescender values to determine default line spacing. + /// + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#stypolinegap + sTypoLineGap: i16, + /// The “Windows ascender” metric. This should be used to specify the height above the baseline for a clipping region. + /// + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#uswinascent + usWinAscent: u16, + /// The “Windows descender” metric. This should be used to specify the vertical extent below the baseline for a clipping region. + /// + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#uswindescent + usWinDescent: u16, + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#ulcodepagerange + ulCodePageRange1: ?u32 = null, + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#ulcodepagerange + ulCodePageRange2: ?u32 = null, + /// This metric specifies the distance between the baseline and the approximate height of non-ascending lowercase letters measured in font design units. + /// + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#sxheight + sxHeight: ?i16 = null, + /// This metric specifies the distance between the baseline and the approximate height of uppercase letters measured in font design units. + /// + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#scapheight + sCapHeight: ?i16 = null, + /// This is the Unicode code point, in UTF-16 encoding, of a character that can be used for a default glyph if a requested character is not supported in the font. + /// + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#usdefaultchar + usDefaultChar: ?u16 = null, + /// This is the Unicode code point, in UTF-16 encoding, of a character that can be used as a default break character. + /// + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#usbreakchar + usBreakChar: ?u16 = null, + /// The maximum length of a target glyph context for any feature in this font. For example, a font which has only a pair kerning feature should set this field to 2. + /// + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#usmaxcontext + usMaxContext: ?u16 = null, + /// This field is used for fonts with multiple optical styles. + /// + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#usloweropticalpointsize + usLowerOpticalPointSize: ?u16 = null, + /// This field is used for fonts with multiple optical styles. + /// + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2/#usupperopticalpointsize + usUpperOpticalPointSize: ?u16 = null, + + /// Parse the table from raw data. + pub fn init(data: []const u8) !OS2 { + var fbs = std.io.fixedBufferStream(data); + const reader = fbs.reader(); + + const version = try reader.readInt(sfnt.uint16, .big); + + // Return to the start, cause the version is part of the struct. + try fbs.seekTo(0); + + switch (version) { + 5 => { + const table = try reader.readStructEndian(OS2v5, .big); + return .{ + .version = table.version, + .xAvgCharWidth = table.xAvgCharWidth, + .usWeightClass = table.usWeightClass, + .usWidthClass = table.usWidthClass, + .fsType = table.fsType, + .ySubscriptXSize = table.ySubscriptXSize, + .ySubscriptYSize = table.ySubscriptYSize, + .ySubscriptXOffset = table.ySubscriptXOffset, + .ySubscriptYOffset = table.ySubscriptYOffset, + .ySuperscriptXSize = table.ySuperscriptXSize, + .ySuperscriptYSize = table.ySuperscriptYSize, + .ySuperscriptXOffset = table.ySuperscriptXOffset, + .ySuperscriptYOffset = table.ySuperscriptYOffset, + .yStrikeoutSize = table.yStrikeoutSize, + .yStrikeoutPosition = table.yStrikeoutPosition, + .sFamilyClass = table.sFamilyClass, + .panose = table.panose, + .ulUnicodeRange1 = table.ulUnicodeRange1, + .ulUnicodeRange2 = table.ulUnicodeRange2, + .ulUnicodeRange3 = table.ulUnicodeRange3, + .ulUnicodeRange4 = table.ulUnicodeRange4, + .achVendID = table.achVendID, + .fsSelection = table.fsSelection, + .usFirstCharIndex = table.usFirstCharIndex, + .usLastCharIndex = table.usLastCharIndex, + .sTypoAscender = table.sTypoAscender, + .sTypoDescender = table.sTypoDescender, + .sTypoLineGap = table.sTypoLineGap, + .usWinAscent = table.usWinAscent, + .usWinDescent = table.usWinDescent, + .ulCodePageRange1 = table.ulCodePageRange1, + .ulCodePageRange2 = table.ulCodePageRange2, + .sxHeight = table.sxHeight, + .sCapHeight = table.sCapHeight, + .usDefaultChar = table.usDefaultChar, + .usBreakChar = table.usBreakChar, + .usMaxContext = table.usMaxContext, + .usLowerOpticalPointSize = table.usLowerOpticalPointSize, + .usUpperOpticalPointSize = table.usUpperOpticalPointSize, + }; + }, + 4, 3, 2 => { + const table = try reader.readStructEndian(OS2v4_3_2, .big); + return .{ + .version = table.version, + .xAvgCharWidth = table.xAvgCharWidth, + .usWeightClass = table.usWeightClass, + .usWidthClass = table.usWidthClass, + .fsType = table.fsType, + .ySubscriptXSize = table.ySubscriptXSize, + .ySubscriptYSize = table.ySubscriptYSize, + .ySubscriptXOffset = table.ySubscriptXOffset, + .ySubscriptYOffset = table.ySubscriptYOffset, + .ySuperscriptXSize = table.ySuperscriptXSize, + .ySuperscriptYSize = table.ySuperscriptYSize, + .ySuperscriptXOffset = table.ySuperscriptXOffset, + .ySuperscriptYOffset = table.ySuperscriptYOffset, + .yStrikeoutSize = table.yStrikeoutSize, + .yStrikeoutPosition = table.yStrikeoutPosition, + .sFamilyClass = table.sFamilyClass, + .panose = table.panose, + .ulUnicodeRange1 = table.ulUnicodeRange1, + .ulUnicodeRange2 = table.ulUnicodeRange2, + .ulUnicodeRange3 = table.ulUnicodeRange3, + .ulUnicodeRange4 = table.ulUnicodeRange4, + .achVendID = table.achVendID, + .fsSelection = table.fsSelection, + .usFirstCharIndex = table.usFirstCharIndex, + .usLastCharIndex = table.usLastCharIndex, + .sTypoAscender = table.sTypoAscender, + .sTypoDescender = table.sTypoDescender, + .sTypoLineGap = table.sTypoLineGap, + .usWinAscent = table.usWinAscent, + .usWinDescent = table.usWinDescent, + .ulCodePageRange1 = table.ulCodePageRange1, + .ulCodePageRange2 = table.ulCodePageRange2, + .sxHeight = table.sxHeight, + .sCapHeight = table.sCapHeight, + .usDefaultChar = table.usDefaultChar, + .usBreakChar = table.usBreakChar, + .usMaxContext = table.usMaxContext, + }; + }, + 1 => { + const table = try reader.readStructEndian(OS2v1, .big); + return .{ + .version = table.version, + .xAvgCharWidth = table.xAvgCharWidth, + .usWeightClass = table.usWeightClass, + .usWidthClass = table.usWidthClass, + .fsType = table.fsType, + .ySubscriptXSize = table.ySubscriptXSize, + .ySubscriptYSize = table.ySubscriptYSize, + .ySubscriptXOffset = table.ySubscriptXOffset, + .ySubscriptYOffset = table.ySubscriptYOffset, + .ySuperscriptXSize = table.ySuperscriptXSize, + .ySuperscriptYSize = table.ySuperscriptYSize, + .ySuperscriptXOffset = table.ySuperscriptXOffset, + .ySuperscriptYOffset = table.ySuperscriptYOffset, + .yStrikeoutSize = table.yStrikeoutSize, + .yStrikeoutPosition = table.yStrikeoutPosition, + .sFamilyClass = table.sFamilyClass, + .panose = table.panose, + .ulUnicodeRange1 = table.ulUnicodeRange1, + .ulUnicodeRange2 = table.ulUnicodeRange2, + .ulUnicodeRange3 = table.ulUnicodeRange3, + .ulUnicodeRange4 = table.ulUnicodeRange4, + .achVendID = table.achVendID, + .fsSelection = table.fsSelection, + .usFirstCharIndex = table.usFirstCharIndex, + .usLastCharIndex = table.usLastCharIndex, + .sTypoAscender = table.sTypoAscender, + .sTypoDescender = table.sTypoDescender, + .sTypoLineGap = table.sTypoLineGap, + .usWinAscent = table.usWinAscent, + .usWinDescent = table.usWinDescent, + .ulCodePageRange1 = table.ulCodePageRange1, + .ulCodePageRange2 = table.ulCodePageRange2, + }; + }, + 0 => { + const table = try reader.readStructEndian(OS2v0, .big); + return .{ + .version = table.version, + .xAvgCharWidth = table.xAvgCharWidth, + .usWeightClass = table.usWeightClass, + .usWidthClass = table.usWidthClass, + .fsType = table.fsType, + .ySubscriptXSize = table.ySubscriptXSize, + .ySubscriptYSize = table.ySubscriptYSize, + .ySubscriptXOffset = table.ySubscriptXOffset, + .ySubscriptYOffset = table.ySubscriptYOffset, + .ySuperscriptXSize = table.ySuperscriptXSize, + .ySuperscriptYSize = table.ySuperscriptYSize, + .ySuperscriptXOffset = table.ySuperscriptXOffset, + .ySuperscriptYOffset = table.ySuperscriptYOffset, + .yStrikeoutSize = table.yStrikeoutSize, + .yStrikeoutPosition = table.yStrikeoutPosition, + .sFamilyClass = table.sFamilyClass, + .panose = table.panose, + .ulUnicodeRange1 = table.ulUnicodeRange1, + .ulUnicodeRange2 = table.ulUnicodeRange2, + .ulUnicodeRange3 = table.ulUnicodeRange3, + .ulUnicodeRange4 = table.ulUnicodeRange4, + .achVendID = table.achVendID, + .fsSelection = table.fsSelection, + .usFirstCharIndex = table.usFirstCharIndex, + .usLastCharIndex = table.usLastCharIndex, + .sTypoAscender = table.sTypoAscender, + .sTypoDescender = table.sTypoDescender, + .sTypoLineGap = table.sTypoLineGap, + .usWinAscent = table.usWinAscent, + .usWinDescent = table.usWinDescent, + }; + }, + else => return error.OS2VersionNotSupported, + } + } +}; + +test "OS/2" { + const testing = std.testing; + const alloc = testing.allocator; + const test_font = @import("../embedded.zig").julia_mono; + + const font = try sfnt.SFNT.init(test_font, alloc); + defer font.deinit(alloc); + + const table = font.getTable("OS/2").?; + + const os2 = try OS2.init(table); + + try testing.expectEqualDeep(OS2{ + .version = 4, + .xAvgCharWidth = 1200, + .usWeightClass = 400, + .usWidthClass = 5, + .fsType = 0, + .ySubscriptXSize = 1300, + .ySubscriptYSize = 1200, + .ySubscriptXOffset = 0, + .ySubscriptYOffset = 150, + .ySuperscriptXSize = 1300, + .ySuperscriptYSize = 1200, + .ySuperscriptXOffset = 0, + .ySuperscriptYOffset = 700, + .yStrikeoutSize = 100, + .yStrikeoutPosition = 550, + .sFamilyClass = 0, + .panose = .{ 2, 11, 6, 9, 6, 3, 0, 2, 0, 4 }, + .ulUnicodeRange1 = 3843162111, + .ulUnicodeRange2 = 3603300351, + .ulUnicodeRange3 = 117760229, + .ulUnicodeRange4 = 96510060, + .achVendID = "corm".*, + .fsSelection = .{ + .regular = true, + .use_typo_metrics = true, + }, + .usFirstCharIndex = 13, + .usLastCharIndex = 65535, + .sTypoAscender = 1900, + .sTypoDescender = -450, + .sTypoLineGap = 0, + .usWinAscent = 2400, + .usWinDescent = 450, + .ulCodePageRange1 = 1613234687, + .ulCodePageRange2 = 0, + .sxHeight = 1100, + .sCapHeight = 1450, + .usDefaultChar = 0, + .usBreakChar = 32, + .usMaxContext = 126, + .usLowerOpticalPointSize = null, + .usUpperOpticalPointSize = null, + }, os2); +} diff --git a/src/font/opentype/post.zig b/src/font/opentype/post.zig new file mode 100644 index 000000000..e2c98bca1 --- /dev/null +++ b/src/font/opentype/post.zig @@ -0,0 +1,82 @@ +const std = @import("std"); +const assert = std.debug.assert; +const sfnt = @import("sfnt.zig"); + +/// PostScript Table +/// +/// This implementation doesn't parse the +/// extra fields in versions 2.0 and 2.5. +/// +/// References: +/// - https://learn.microsoft.com/en-us/typography/opentype/spec/post +pub const Post = extern struct { + version: sfnt.Version16Dot16 align(1), + + /// Italic angle in counter-clockwise degrees from the vertical. + /// Zero for upright text, negative for text that leans to the + /// right (forward). + italicAngle: sfnt.Fixed align(1), + + /// Suggested y-coordinate of the top of the underline. + underlinePosition: sfnt.FWORD align(1), + + /// Suggested values for the underline thickness. + /// In general, the underline thickness should match the thickness of + /// the underscore character (U+005F LOW LINE), and should also match + /// the strikeout thickness, which is specified in the OS/2 table. + underlineThickness: sfnt.FWORD align(1), + + /// Set to 0 if the font is proportionally spaced, non-zero if + /// the font is not proportionally spaced (i.e. monospaced). + isFixedPitch: sfnt.uint32 align(1), + + /// Minimum memory usage when an OpenType font is downloaded. + minMemType42: sfnt.uint32 align(1), + + /// Maximum memory usage when an OpenType font is downloaded. + maxMemType42: sfnt.uint32 align(1), + + /// Minimum memory usage when an OpenType + /// font is downloaded as a Type 1 font. + minMemType1: sfnt.uint32 align(1), + + /// Maximum memory usage when an OpenType + /// font is downloaded as a Type 1 font. + maxMemType1: sfnt.uint32 align(1), + + /// Parse the table from raw data. + pub fn init(data: []const u8) !Post { + var fbs = std.io.fixedBufferStream(data); + const reader = fbs.reader(); + + return try reader.readStructEndian(Post, .big); + } +}; + +test "post" { + const testing = std.testing; + const alloc = testing.allocator; + const test_font = @import("../embedded.zig").julia_mono; + + const font = try sfnt.SFNT.init(test_font, alloc); + defer font.deinit(alloc); + + const table = font.getTable("post").?; + + const post = try Post.init(table); + + try testing.expectEqualDeep( + Post{ + .version = sfnt.Version16Dot16{ .minor = 0, .major = 2 }, + .italicAngle = sfnt.Fixed.from(0.0), + .underlinePosition = -200, + .underlineThickness = 100, + .isFixedPitch = 1, + .minMemType42 = 0, + .maxMemType42 = 0, + .minMemType1 = 0, + .maxMemType1 = 0, + }, + post, + ); +} diff --git a/src/font/opentype/sfnt.zig b/src/font/opentype/sfnt.zig new file mode 100644 index 000000000..cbce50455 --- /dev/null +++ b/src/font/opentype/sfnt.zig @@ -0,0 +1,314 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; + +/// 8-bit unsigned integer. +pub const uint8 = u8; + +/// 8-bit signed integer. +pub const int8 = i8; + +/// 16-bit unsigned integer. +pub const uint16 = u16; + +/// 16-bit signed integer. +pub const int16 = i16; + +/// 24-bit unsigned integer. +pub const uint24 = u24; + +/// 32-bit unsigned integer. +pub const uint32 = u32; + +/// 32-bit signed integer. +pub const int32 = i32; + +/// 32-bit signed fixed-point number (16.16) +pub const Fixed = FixedPoint(i32, 16, 16); + +/// int16 that describes a quantity in font design units. +pub const FWORD = i16; + +/// uint16 that describes a quantity in font design units. +pub const UFWORD = u16; + +/// 16-bit signed fixed number with the low 14 bits of fraction (2.14). +pub const F2DOT14 = FixedPoint(i16, 2, 14); + +/// Date and time represented in number of seconds since 12:00 midnight, January 1, 1904, UTC. The value is represented as a signed 64-bit integer. +pub const LONGDATETIME = i64; + +/// Array of four uint8s (length = 32 bits) used to identify a table, +/// design-variation axis, script, language system, feature, or baseline. +pub const Tag = [4]u8; + +/// 8-bit offset to a table, same as uint8, NULL offset = 0x00 +pub const Offset8 = u8; + +/// Short offset to a table, same as uint16, NULL offset = 0x0000 +pub const Offset16 = u16; + +/// 24-bit offset to a table, same as uint24, NULL offset = 0x000000 +pub const Offset24 = u24; + +/// Long offset to a table, same as uint32, NULL offset = 0x00000000 +pub const Offset32 = u32; + +/// Packed 32-bit value with major and minor version numbers +pub const Version16Dot16 = packed struct(u32) { + minor: u16, + major: u16, +}; + +/// 32-bit signed 26.6 fixed point numbers. +pub const F26Dot6 = FixedPoint(i32, 26, 6); + +fn FixedPoint(comptime T: type, int_bits: u64, frac_bits: u64) type { + const type_info: std.builtin.Type.Int = @typeInfo(T).Int; + comptime assert(int_bits + frac_bits == type_info.bits); + + return packed struct(T) { + const Self = FixedPoint(T, int_bits, frac_bits); + const frac_factor: comptime_float = @floatFromInt(std.math.pow( + u64, + 2, + frac_bits, + )); + const half = @as(T, 1) << @intCast(frac_bits - 1); + + frac: std.meta.Int(.unsigned, frac_bits), + int: std.meta.Int(type_info.signedness, int_bits), + + pub fn to(self: Self, comptime FloatType: type) FloatType { + const i: FloatType = @floatFromInt(self.int); + const f: FloatType = @floatFromInt(self.frac); + + return i + f / frac_factor; + } + + pub fn from(float: anytype) Self { + const int = @floor(float); + const frac = @abs(float - int); + + return .{ + .int = @intFromFloat(int), + .frac = @intFromFloat(@round(frac * frac_factor)), + }; + } + + /// Round to the nearest integer, .5 rounds away from 0. + pub fn round(self: Self) T { + if (self.frac & half != 0) + return self.int + 1 + else + return self.int; + } + + pub fn format( + self: Self, + comptime fmt: []const u8, + options: std.fmt.FormatOptions, + writer: anytype, + ) !void { + _ = fmt; + _ = options; + + try writer.print("{d}", .{self.to(f64)}); + } + }; +} + +test FixedPoint { + const testing = std.testing; + + const p26d6 = F26Dot6.from(26.6); + try testing.expectEqual(F26Dot6{ + .int = 26, + .frac = 38, + }, p26d6); + try testing.expectEqual(26.59375, p26d6.to(f64)); + try testing.expectEqual(27, p26d6.round()); + + const n26d6 = F26Dot6.from(-26.6); + try testing.expectEqual(F26Dot6{ + .int = -27, + .frac = 26, + }, n26d6); + try testing.expectEqual(-26.59375, n26d6.to(f64)); + try testing.expectEqual(-27, n26d6.round()); +} + +/// Wrapper for parsing a SFNT font and accessing its tables. +/// +/// References: +/// - https://learn.microsoft.com/en-us/typography/opentype/spec/otff +/// - https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6.html +pub const SFNT = struct { + const Directory = struct { + offset: OffsetSubtable, + records: []TableRecord, + + /// The static (fixed-sized) portion of the table directory + /// + /// This struct matches the memory layout of the TrueType/OpenType + /// TableDirectory, but does not include the TableRecord array, since + /// that is dynamically sized, so we parse it separately. + /// + /// In the TrueType reference manual this + /// is referred to as the "offset subtable". + /// + /// https://learn.microsoft.com/en-us/typography/opentype/spec/otff#table-directory + const OffsetSubtable = extern struct { + /// Indicates the type of font file we're reading. + /// - 0x00_01_00_00 ---- TrueType + /// - 0x74_72_75_65 'true' TrueType + /// - 0x4F_54_54_4F 'OTTO' OpenType + /// - 0x74_79_70_31 'typ1' PostScript + sfnt_version: uint32 align(1), + /// Number of tables. + num_tables: uint16 align(1), + /// Maximum power of 2 less than or equal to numTables, times 16 ((2**floor(log2(numTables))) * 16, where “**” is an exponentiation operator). + search_range: uint16 align(1), + /// Log2 of the maximum power of 2 less than or equal to numTables (log2(searchRange/16), which is equal to floor(log2(numTables))). + entry_selector: uint16 align(1), + /// numTables times 16, minus searchRange ((numTables * 16) - searchRange). + range_shift: uint16 align(1), + + pub fn format( + self: OffsetSubtable, + comptime fmt: []const u8, + options: std.fmt.FormatOptions, + writer: anytype, + ) !void { + _ = fmt; + _ = options; + + try writer.print( + "OffsetSubtable('{s}'){{ .num_tables = {} }}", + .{ + if (self.sfnt_version == 0x00_01_00_00) + &@as([10]u8, "0x00010000".*) + else + &@as([4]u8, @bitCast( + std.mem.nativeToBig(u32, self.sfnt_version), + )), + self.num_tables, + }, + ); + } + }; + + const TableRecord = extern struct { + /// Table identifier. + tag: Tag align(1), + /// Checksum for this table. + checksum: uint32 align(1), + /// Offset from beginning of font file. + offset: Offset32 align(1), + /// Length of this table. + length: uint32 align(1), + + pub fn format( + self: TableRecord, + comptime fmt: []const u8, + options: std.fmt.FormatOptions, + writer: anytype, + ) !void { + _ = fmt; + _ = options; + + try writer.print( + "TableRecord(\"{s}\"){{ .checksum = {}, .offset = {}, .length = {} }}", + .{ + self.tag, + self.checksum, + self.offset, + self.length, + }, + ); + } + }; + }; + + directory: Directory, + + data: []const u8, + + /// Parse a font from raw data. The struct will keep a + /// reference to `data` and use it for future operations. + pub fn init(data: []const u8, alloc: Allocator) !SFNT { + var fbs = std.io.fixedBufferStream(data); + const reader = fbs.reader(); + + // SFNT files use big endian, if our native endian is + // not big we'll need to byte swap the values we read. + const byte_swap = native_endian != .big; + + var directory: Directory = undefined; + + try reader.readNoEof(std.mem.asBytes(&directory.offset)); + if (byte_swap) std.mem.byteSwapAllFields( + Directory.OffsetSubtable, + &directory.offset, + ); + + directory.records = try alloc.alloc(Directory.TableRecord, directory.offset.num_tables); + + try reader.readNoEof(std.mem.sliceAsBytes(directory.records)); + if (byte_swap) for (directory.records) |*record| { + std.mem.byteSwapAllFields( + Directory.TableRecord, + record, + ); + }; + + return .{ + .directory = directory, + .data = data, + }; + } + + pub fn deinit(self: SFNT, alloc: Allocator) void { + alloc.free(self.directory.records); + } + + /// Returns the bytes of the table with the provided tag if present. + pub fn getTable(self: SFNT, tag: *const [4]u8) ?[]const u8 { + for (self.directory.records) |record| { + if (std.mem.eql(u8, tag, &record.tag)) { + return self.data[record.offset..][0..record.length]; + } + } + + return null; + } +}; + +const native_endian = @import("builtin").target.cpu.arch.endian(); + +test "parse font" { + const testing = std.testing; + const alloc = testing.allocator; + + const test_font = @import("../embedded.zig").julia_mono; + + const sfnt = try SFNT.init(&test_font.*, alloc); + defer sfnt.deinit(alloc); + + try testing.expectEqual(19, sfnt.directory.offset.num_tables); + try testing.expectEqualStrings("prep", &sfnt.directory.records[18].tag); +} + +test "get table" { + const testing = std.testing; + const alloc = testing.allocator; + + const test_font = @import("../embedded.zig").julia_mono; + + const sfnt = try SFNT.init(&test_font.*, alloc); + defer sfnt.deinit(alloc); + + const svg = sfnt.getTable("SVG ").?; + + try testing.expectEqual(430, svg.len); +} From e2e12efbbf8331eee5aae1c676065269664f4789 Mon Sep 17 00:00:00 2001 From: Abhinav Gupta Date: Tue, 10 Dec 2024 21:05:33 -0800 Subject: [PATCH 05/47] keybind: format leader bindings into multiple entries **Context** Currently, if there are multiple keybindings with a shared prefix, they are grouped into a nested series of Binding.Sets. For example, as reported in #2734, the following bindings: keybind = ctrl+z>1=goto_tab:1 keybind = ctrl+z>2=goto_tab:2 keybind = ctrl+z>3=goto_tab:3 Result in roughly the following structure (in pseudo-code): Keybinds{ Trigger("ctrl+z"): Value.leader{ Trigger("1"): Value.leaf{action: "goto_tab:1"}), Trigger("2"): Value.leaf{action: "goto_tab:2"}), Trigger("3"): Value.leaf{action: "goto_tab:3"}), } } When this is formatted into a string (and therefore in +list-keybinds), it is turned into the following as Value.format just concatenates all the sibling bindings ('1', '2', '3') into consecutive bindings, and this is then fed into a single configuration entry: keybind = ctrl+z>1=goto_tab:1>3=goto_tab:3>2=goto_tab:2 **Fix** To fix this, Value needs to produce a separate configuration entry for each sibling binding in the Value.leader case. So we can't produce the entry (formatter.formatEntry) in Keybinds and need to pass information down the Value tree to the leaf nodes, each of which will produce a separate entry with that function. This is accomplished with the help of a new Value.formatEntries method that recursively builds up the prefix for the keybinding, finally flushing it to the formatter when it reaches a leaf node. This is done without extra allocations by using a FixedBufferStream with the same buffer as before, sharing it between calls to nested siblings of the same prefix. **Caveats** We do not track the order in which the bindings were added so the order is not retained in the formatConfig output. Resolves #2734 --- src/config/Config.zig | 61 +++++++++++++++++++++++++++++++++++++------ src/input/Binding.zig | 35 +++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 8 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index fa531dc7e..47174aa82 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -4213,14 +4213,9 @@ pub const Keybinds = struct { } } - try formatter.formatEntry( - []const u8, - std.fmt.bufPrint( - &buf, - "{}{}", - .{ k, v }, - ) catch return error.OutOfMemory, - ); + var buffer_stream = std.io.fixedBufferStream(&buf); + try std.fmt.format(buffer_stream.writer(), "{}", .{k}); + try v.formatEntries(&buffer_stream, formatter); } } @@ -4254,6 +4249,56 @@ pub const Keybinds = struct { try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); try std.testing.expectEqualSlices(u8, "a = shift+a=csi:hello\n", buf.items); } + + // Regression test for https://github.com/ghostty-org/ghostty/issues/2734 + test "formatConfig multiple items" { + const testing = std.testing; + var buf = std.ArrayList(u8).init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Keybinds = .{}; + try list.parseCLI(alloc, "ctrl+z>1=goto_tab:1"); + try list.parseCLI(alloc, "ctrl+z>2=goto_tab:2"); + try list.formatEntry(formatterpkg.entryFormatter("keybind", buf.writer())); + + const want = + \\keybind = ctrl+z>1=goto_tab:1 + \\keybind = ctrl+z>2=goto_tab:2 + \\ + ; + try std.testing.expectEqualStrings(want, buf.items); + } + + test "formatConfig multiple items nested" { + const testing = std.testing; + var buf = std.ArrayList(u8).init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Keybinds = .{}; + try list.parseCLI(alloc, "ctrl+a>ctrl+b>n=new_window"); + try list.parseCLI(alloc, "ctrl+a>ctrl+b>w=close_window"); + try list.parseCLI(alloc, "ctrl+a>ctrl+c>t=new_tab"); + try list.parseCLI(alloc, "ctrl+b>ctrl+d>a=previous_tab"); + try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); + + // NB: This does not currently retain the order of the keybinds. + const want = + \\a = ctrl+a>ctrl+b>w=close_window + \\a = ctrl+a>ctrl+b>n=new_window + \\a = ctrl+a>ctrl+c>t=new_tab + \\a = ctrl+b>ctrl+d>a=previous_tab + \\ + ; + try std.testing.expectEqualStrings(want, buf.items); + } }; /// See "font-codepoint-map" for documentation. diff --git a/src/input/Binding.zig b/src/input/Binding.zig index a467bfc2b..ebccac196 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -1149,6 +1149,41 @@ pub const Set = struct { }, } } + + /// Writes the configuration entries for the binding + /// that this value is part of. + /// + /// The value may be part of multiple configuration entries + /// if they're all part of the same prefix sequence (e.g. 'a>b', 'a>c'). + /// These will result in multiple separate entries in the configuration. + /// + /// `buffer_stream` is a FixedBufferStream used for temporary storage + /// that is shared between calls to nested levels of the set. + /// For example, 'a>b>c=x' and 'a>b>d=y' will re-use the 'a>b' written + /// to the buffer before flushing it to the formatter with 'c=x' and 'd=y'. + pub fn formatEntries(self: Value, buffer_stream: anytype, formatter: anytype) !void { + switch (self) { + .leader => |set| { + // We'll rewind to this position after each sub-entry, + // sharing the prefix between siblings. + const pos = try buffer_stream.getPos(); + + var iter = set.bindings.iterator(); + while (iter.next()) |binding| { + buffer_stream.seekTo(pos) catch unreachable; // can't fail + try std.fmt.format(buffer_stream.writer(), ">{s}", .{binding.key_ptr.*}); + try binding.value_ptr.*.formatEntries(buffer_stream, formatter); + } + }, + + .leaf => |leaf| { + // When we get to the leaf, the buffer_stream contains + // the full sequence of keys needed to reach this action. + try std.fmt.format(buffer_stream.writer(), "={s}", .{leaf.action}); + try formatter.formatEntry([]const u8, buffer_stream.getWritten()); + }, + } + } }; /// Leaf node of a set is an action to trigger. This is a "leaf" compared From cb5848c7b7b03680df1943c7b612e15af23023bb Mon Sep 17 00:00:00 2001 From: Khang Nguyen Duy Date: Wed, 11 Dec 2024 16:47:20 +0700 Subject: [PATCH 06/47] command: fix hostname test compatibility hostname is not guaranteed on *nix as in the comment. For example, Arch does not have hostname by default. --- src/Command.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Command.zig b/src/Command.zig index daca54f94..2b600979f 100644 --- a/src/Command.zig +++ b/src/Command.zig @@ -440,9 +440,9 @@ fn isExecutable(mode: std.fs.File.Mode) bool { return mode & 0o0111 != 0; } -// `hostname` is present on both *nix and windows +// `uname -n` is the *nix equivalent of `hostname.exe` on Windows test "expandPath: hostname" { - const executable = if (builtin.os.tag == .windows) "hostname.exe" else "hostname"; + const executable = if (builtin.os.tag == .windows) "hostname.exe" else "uname"; const path = (try expandPath(testing.allocator, executable)).?; defer testing.allocator.free(path); try testing.expect(path.len > executable.len); From c7deea6a7f9978869d711311ded88b3ec2f1333d Mon Sep 17 00:00:00 2001 From: Anund Date: Wed, 11 Dec 2024 19:55:39 +1100 Subject: [PATCH 07/47] zsh: add completions generation --- build.zig | 13 +++ src/build/fish_completions.zig | 4 +- src/build/zsh_completions.zig | 201 +++++++++++++++++++++++++++++++++ src/cli/action.zig | 20 ++++ src/cli/list_fonts.zig | 10 +- src/cli/version.zig | 2 + 6 files changed, 243 insertions(+), 7 deletions(-) create mode 100644 src/build/zsh_completions.zig diff --git a/build.zig b/build.zig index d233bff1f..0c5cfaf41 100644 --- a/build.zig +++ b/build.zig @@ -12,6 +12,7 @@ const terminfo = @import("src/terminfo/main.zig"); const config_vim = @import("src/config/vim.zig"); const config_sublime_syntax = @import("src/config/sublime_syntax.zig"); const fish_completions = @import("src/build/fish_completions.zig"); +const zsh_completions = @import("src/build/zsh_completions.zig"); const build_config = @import("src/build_config.zig"); const BuildConfig = build_config.BuildConfig; const WasmTarget = @import("src/os/wasm/target.zig").Target; @@ -504,6 +505,18 @@ pub fn build(b: *std.Build) !void { }); } + // zsh shell completions + { + const wf = b.addWriteFiles(); + _ = wf.add("_ghostty", zsh_completions.zsh_completions); + + b.installDirectory(.{ + .source_dir = wf.getDirectory(), + .install_dir = .prefix, + .install_subdir = "share/zsh/site-functions", + }); + } + // Vim plugin { const wf = b.addWriteFiles(); diff --git a/src/build/fish_completions.zig b/src/build/fish_completions.zig index 2ac67bdad..87a82e7ee 100644 --- a/src/build/fish_completions.zig +++ b/src/build/fish_completions.zig @@ -2,7 +2,7 @@ const std = @import("std"); const Config = @import("../config/Config.zig"); const Action = @import("../cli/action.zig").Action; -const ListFontsConfig = @import("../cli/list_fonts.zig").Config; +const ListFontsOptions = @import("../cli/list_fonts.zig").Options; const ShowConfigOptions = @import("../cli/show_config.zig").Options; const ListKeybindsOptions = @import("../cli/list_keybinds.zig").Options; @@ -100,7 +100,7 @@ fn writeFishCompletions(writer: anytype) !void { try writer.writeAll("\"\n"); } - for (@typeInfo(ListFontsConfig).Struct.fields) |field| { + for (@typeInfo(ListFontsOptions).Struct.fields) |field| { if (field.name[0] == '_') continue; try writer.writeAll("complete -c ghostty -n \"__fish_seen_subcommand_from +list-fonts\" -l "); try writer.writeAll(field.name); diff --git a/src/build/zsh_completions.zig b/src/build/zsh_completions.zig new file mode 100644 index 000000000..78d256ee2 --- /dev/null +++ b/src/build/zsh_completions.zig @@ -0,0 +1,201 @@ +const std = @import("std"); + +const Config = @import("../config/Config.zig"); +const Action = @import("../cli/action.zig").Action; + +/// A zsh completions configuration that contains all the available commands +/// and options. +pub const zsh_completions = comptimeGenerateZshCompletions(); + +fn comptimeGenerateZshCompletions() []const u8 { + comptime { + @setEvalBranchQuota(19000); + var counter = std.io.countingWriter(std.io.null_writer); + try writeZshCompletions(&counter.writer()); + + var buf: [counter.bytes_written]u8 = undefined; + var stream = std.io.fixedBufferStream(&buf); + try writeZshCompletions(stream.writer()); + const final = buf; + return final[0..stream.getWritten().len]; + } +} + +fn writeZshCompletions(writer: anytype) !void { + try writer.writeAll( + \\#compdef ghostty + \\ + \\_fonts () { + \\ local font_list=$(ghostty +list-fonts | grep -Z '^[A-Z]') + \\ local fonts=(${(f)font_list}) + \\ _describe -t fonts 'fonts' fonts + \\} + \\ + \\_themes() { + \\ local theme_list=$(ghostty +list-themes | sed -E 's/^(.*) \(.*\$/\0/') + \\ local themes=(${(f)theme_list}) + \\ _describe -t themes 'themes' themes + \\} + \\ + ); + + try writer.writeAll("_config() {\n"); + try writer.writeAll(" _arguments \\\n"); + try writer.writeAll(" \"--help\" \\\n"); + try writer.writeAll(" \"--version\" \\\n"); + for (@typeInfo(Config).Struct.fields) |field| { + if (field.name[0] == '_') continue; + try writer.writeAll(" \"--"); + try writer.writeAll(field.name); + try writer.writeAll("=-:::"); + + if (std.mem.startsWith(u8, field.name, "font-family")) + try writer.writeAll("_fonts") + else if (std.mem.eql(u8, "theme", field.name)) + try writer.writeAll("_themes") + else if (std.mem.eql(u8, "working-directory", field.name)) + try writer.writeAll("{_files -/}") + else if (field.type == Config.RepeatablePath) + try writer.writeAll("_files") // todo check if this is needed + else { + try writer.writeAll("("); + switch (@typeInfo(field.type)) { + .Bool => try writer.writeAll("true false"), + .Enum => |info| { + for (info.fields, 0..) |f, i| { + if (i > 0) try writer.writeAll(" "); + try writer.writeAll(f.name); + } + }, + .Struct => |info| { + if (!@hasDecl(field.type, "parseCLI") and info.layout == .@"packed") { + for (info.fields, 0..) |f, i| { + if (i > 0) try writer.writeAll(" "); + try writer.writeAll(f.name); + try writer.writeAll(" no-"); + try writer.writeAll(f.name); + } + } else { + //resize-overlay-duration + //keybind + //window-padding-x ...-y + //link + //palette + //background + //foreground + //font-variation* + //font-feature + try writer.writeAll(" "); + } + }, + else => try writer.writeAll(" "), + } + try writer.writeAll(")"); + } + + try writer.writeAll("\" \\\n"); + } + try writer.writeAll("\n}\n\n"); + + try writer.writeAll( + \\_ghostty() { + \\ typeset -A opt_args + \\ local context state line + \\ local opt=('--help' '--version') + \\ + \\ _arguments -C \ + \\ '1:actions:->actions' \ + \\ '*:: :->rest' \ + \\ + \\ if [[ "$line[1]" == "--help" || "$line[1]" == "--version" ]]; then + \\ return + \\ fi + \\ + \\ if [[ "$line[1]" == -* ]]; then + \\ _config + \\ return + \\ fi + \\ + \\ case "$state" in + \\ (actions) + \\ local actions; actions=( + \\ + ); + + { + // how to get 'commands' + var count: usize = 0; + const padding = " "; + for (@typeInfo(Action).Enum.fields) |field| { + try writer.writeAll(padding ++ "'+"); + try writer.writeAll(field.name); + try writer.writeAll("'\n"); + count += 1; + } + } + + try writer.writeAll( + \\ ) + \\ _describe '' opt + \\ _describe -t action 'action' actions + \\ ;; + \\ (rest) + \\ if [[ "$line[2]" == "--help" ]]; then + \\ return + \\ fi + \\ + \\ local help=('--help') + \\ _describe '' help + \\ + \\ case $line[1] in + \\ + ); + { + const padding = " "; + for (@typeInfo(Action).Enum.fields) |field| { + if (std.mem.eql(u8, "help", field.name)) continue; + if (std.mem.eql(u8, "version", field.name)) continue; + + const options = @field(Action, field.name).options(); + // assumes options will never be created with only <_name> members + if (@typeInfo(options).Struct.fields.len == 0) continue; + + try writer.writeAll(padding ++ "(+" ++ field.name ++ ")\n"); + try writer.writeAll(padding ++ " _arguments \\\n"); + for (@typeInfo(options).Struct.fields) |opt| { + if (opt.name[0] == '_') continue; + + try writer.writeAll(padding ++ " '--"); + try writer.writeAll(opt.name); + try writer.writeAll("=-:::"); + switch (@typeInfo(opt.type)) { + .Bool => try writer.writeAll("(true false)"), + .Enum => |info| { + try writer.writeAll("("); + for (info.opts, 0..) |f, i| { + if (i > 0) try writer.writeAll(" "); + try writer.writeAll(f.name); + } + try writer.writeAll(")"); + }, + else => { + if (std.mem.eql(u8, "config-file", opt.name)) { + try writer.writeAll("_files"); + } else try writer.writeAll("( )"); + }, + } + try writer.writeAll("' \\\n"); + } + try writer.writeAll(padding ++ ";;\n"); + } + } + try writer.writeAll( + \\ esac + \\ ;; + \\ esac + \\} + \\ + \\_ghostty "$@" + \\ + ); +} diff --git a/src/cli/action.zig b/src/cli/action.zig index 1da0c0609..2f4b63638 100644 --- a/src/cli/action.zig +++ b/src/cli/action.zig @@ -163,6 +163,26 @@ pub const Action = enum { return "cli/" ++ filename ++ ".zig"; } } + + /// Returns the options of action. Supports generating shell completions + /// without duplicating the mapping from Action to relevant Option + /// @import(..) declaration. + pub fn options(comptime self: Action) type { + comptime { + return switch (self) { + .version => version.Options, + .help => help.Options, + .@"list-fonts" => list_fonts.Options, + .@"list-keybinds" => list_keybinds.Options, + .@"list-themes" => list_themes.Options, + .@"list-colors" => list_colors.Options, + .@"list-actions" => list_actions.Options, + .@"show-config" => show_config.Options, + .@"validate-config" => validate_config.Options, + .@"crash-report" => crash_report.Options, + }; + } + } }; test "parse action none" { diff --git a/src/cli/list_fonts.zig b/src/cli/list_fonts.zig index aba596b64..9d1f34cd1 100644 --- a/src/cli/list_fonts.zig +++ b/src/cli/list_fonts.zig @@ -7,7 +7,7 @@ const font = @import("../font/main.zig"); const log = std.log.scoped(.list_fonts); -pub const Config = struct { +pub const Options = struct { /// This is set by the CLI parser for deinit. _arena: ?ArenaAllocator = null, @@ -23,13 +23,13 @@ pub const Config = struct { bold: bool = false, italic: bool = false, - pub fn deinit(self: *Config) void { + pub fn deinit(self: *Options) void { if (self._arena) |arena| arena.deinit(); self.* = undefined; } /// Enables "-h" and "--help" to work. - pub fn help(self: Config) !void { + pub fn help(self: Options) !void { _ = self; return Action.help_error; } @@ -59,9 +59,9 @@ pub fn run(alloc: Allocator) !u8 { } fn runArgs(alloc_gpa: Allocator, argsIter: anytype) !u8 { - var config: Config = .{}; + var config: Options = .{}; defer config.deinit(); - try args.parse(Config, alloc_gpa, &config, argsIter); + try args.parse(Options, alloc_gpa, &config, argsIter); // Use an arena for all our memory allocs var arena = ArenaAllocator.init(alloc_gpa); diff --git a/src/cli/version.zig b/src/cli/version.zig index 26d5dcc74..259cb7453 100644 --- a/src/cli/version.zig +++ b/src/cli/version.zig @@ -7,6 +7,8 @@ const xev = @import("xev"); const renderer = @import("../renderer.zig"); const gtk = if (build_config.app_runtime == .gtk) @import("../apprt/gtk/c.zig").c else void; +pub const Options = struct {}; + /// The `version` command is used to display information about Ghostty. pub fn run(alloc: Allocator) !u8 { _ = alloc; From 54bd012443d45a89d49f7b18522b7cfe8aa5abb6 Mon Sep 17 00:00:00 2001 From: Anund Date: Wed, 11 Dec 2024 19:55:59 +1100 Subject: [PATCH 08/47] fish: reuse Action options iteration code --- src/build/fish_completions.zig | 88 +++++++++++----------------------- 1 file changed, 27 insertions(+), 61 deletions(-) diff --git a/src/build/fish_completions.zig b/src/build/fish_completions.zig index 87a82e7ee..5212dab61 100644 --- a/src/build/fish_completions.zig +++ b/src/build/fish_completions.zig @@ -2,9 +2,6 @@ const std = @import("std"); const Config = @import("../config/Config.zig"); const Action = @import("../cli/action.zig").Action; -const ListFontsOptions = @import("../cli/list_fonts.zig").Options; -const ShowConfigOptions = @import("../cli/show_config.zig").Options; -const ListKeybindsOptions = @import("../cli/list_keybinds.zig").Options; /// A fish completions configuration that contains all the available commands /// and options. @@ -100,66 +97,35 @@ fn writeFishCompletions(writer: anytype) !void { try writer.writeAll("\"\n"); } - for (@typeInfo(ListFontsOptions).Struct.fields) |field| { - if (field.name[0] == '_') continue; - try writer.writeAll("complete -c ghostty -n \"__fish_seen_subcommand_from +list-fonts\" -l "); - try writer.writeAll(field.name); - try writer.writeAll(if (field.type != bool) " -r" else " "); - try writer.writeAll(" -f"); - switch (@typeInfo(field.type)) { - .Bool => try writer.writeAll(" -a \"true false\""), - .Enum => |info| { - try writer.writeAll(" -a \""); - for (info.fields, 0..) |f, i| { - if (i > 0) try writer.writeAll(" "); - try writer.writeAll(f.name); - } - try writer.writeAll("\""); - }, - else => {}, - } - try writer.writeAll("\n"); - } + for (@typeInfo(Action).Enum.fields) |field| { + if (std.mem.eql(u8, "help", field.name)) continue; + if (std.mem.eql(u8, "version", field.name)) continue; - for (@typeInfo(ShowConfigOptions).Struct.fields) |field| { - if (field.name[0] == '_') continue; - try writer.writeAll("complete -c ghostty -n \"__fish_seen_subcommand_from +show-config\" -l "); - try writer.writeAll(field.name); - try writer.writeAll(if (field.type != bool) " -r" else " "); - try writer.writeAll(" -f"); - switch (@typeInfo(field.type)) { - .Bool => try writer.writeAll(" -a \"true false\""), - .Enum => |info| { - try writer.writeAll(" -a \""); - for (info.fields, 0..) |f, i| { - if (i > 0) try writer.writeAll(" "); - try writer.writeAll(f.name); - } - try writer.writeAll("\""); - }, - else => {}, - } - try writer.writeAll("\n"); - } + const options = @field(Action, field.name).options(); + for (@typeInfo(options).Struct.fields) |opt| { + if (opt.name[0] == '_') continue; + try writer.writeAll("complete -c ghostty -n \"__fish_seen_subcommand_from +" ++ field.name ++ "\" -l "); + try writer.writeAll(opt.name); + try writer.writeAll(if (opt.type != bool) " -r" else ""); - for (@typeInfo(ListKeybindsOptions).Struct.fields) |field| { - if (field.name[0] == '_') continue; - try writer.writeAll("complete -c ghostty -n \"__fish_seen_subcommand_from +list-keybinds\" -l "); - try writer.writeAll(field.name); - try writer.writeAll(if (field.type != bool) " -r" else " "); - try writer.writeAll(" -f"); - switch (@typeInfo(field.type)) { - .Bool => try writer.writeAll(" -a \"true false\""), - .Enum => |info| { - try writer.writeAll(" -a \""); - for (info.fields, 0..) |f, i| { - if (i > 0) try writer.writeAll(" "); - try writer.writeAll(f.name); - } - try writer.writeAll("\""); - }, - else => {}, + // special case +validate_config --config-file + if (std.mem.eql(u8, "config-file", opt.name)) { + try writer.writeAll(" -F"); + } else try writer.writeAll(" -f"); + + switch (@typeInfo(opt.type)) { + .Bool => try writer.writeAll(" -a \"true false\""), + .Enum => |info| { + try writer.writeAll(" -a \""); + for (info.opts, 0..) |f, i| { + if (i > 0) try writer.writeAll(" "); + try writer.writeAll(f.name); + } + try writer.writeAll("\""); + }, + else => {}, + } + try writer.writeAll("\n"); } - try writer.writeAll("\n"); } } From 495e4081e4945042eb918b66942b59a60d0d6f57 Mon Sep 17 00:00:00 2001 From: Abhinav Gupta Date: Wed, 11 Dec 2024 09:21:31 -0800 Subject: [PATCH 09/47] fix: NoSpaceLeft => OutOfMemory NoSpaceLeft is not permitted to be returned in this context, so turn it into OutOfMemory when we fail to write to the buffer. --- src/config/Config.zig | 2 +- src/input/Binding.zig | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 47174aa82..0bcfc743e 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -4214,7 +4214,7 @@ pub const Keybinds = struct { } var buffer_stream = std.io.fixedBufferStream(&buf); - try std.fmt.format(buffer_stream.writer(), "{}", .{k}); + std.fmt.format(buffer_stream.writer(), "{}", .{k}) catch return error.OutOfMemory; try v.formatEntries(&buffer_stream, formatter); } } diff --git a/src/input/Binding.zig b/src/input/Binding.zig index ebccac196..b451b5ec9 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -1171,7 +1171,7 @@ pub const Set = struct { var iter = set.bindings.iterator(); while (iter.next()) |binding| { buffer_stream.seekTo(pos) catch unreachable; // can't fail - try std.fmt.format(buffer_stream.writer(), ">{s}", .{binding.key_ptr.*}); + std.fmt.format(buffer_stream.writer(), ">{s}", .{binding.key_ptr.*}) catch return error.OutOfMemory; try binding.value_ptr.*.formatEntries(buffer_stream, formatter); } }, @@ -1179,7 +1179,7 @@ pub const Set = struct { .leaf => |leaf| { // When we get to the leaf, the buffer_stream contains // the full sequence of keys needed to reach this action. - try std.fmt.format(buffer_stream.writer(), "={s}", .{leaf.action}); + std.fmt.format(buffer_stream.writer(), "={s}", .{leaf.action}) catch return error.OutOfMemory; try formatter.formatEntry([]const u8, buffer_stream.getWritten()); }, } From cb67fbd08db5472dddd12a2f966d1218c79cf49d Mon Sep 17 00:00:00 2001 From: Khang Nguyen Duy Date: Thu, 12 Dec 2024 00:13:25 +0700 Subject: [PATCH 10/47] gtk: pass surface to clipboard window by reference instead of by value The surface might be mutated during the clipboard confirmation (resized in my case), leading to the copied cursor page_pin being invalidated. --- src/apprt/gtk/ClipboardConfirmationWindow.zig | 6 +++--- src/apprt/gtk/Surface.zig | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/apprt/gtk/ClipboardConfirmationWindow.zig b/src/apprt/gtk/ClipboardConfirmationWindow.zig index bcefb9d8a..30b38f1d4 100644 --- a/src/apprt/gtk/ClipboardConfirmationWindow.zig +++ b/src/apprt/gtk/ClipboardConfirmationWindow.zig @@ -17,13 +17,13 @@ window: *c.GtkWindow, view: PrimaryView, data: [:0]u8, -core_surface: CoreSurface, +core_surface: *CoreSurface, pending_req: apprt.ClipboardRequest, pub fn create( app: *App, data: []const u8, - core_surface: CoreSurface, + core_surface: *CoreSurface, request: apprt.ClipboardRequest, ) !void { if (app.clipboard_confirmation_window != null) return error.WindowAlreadyExists; @@ -54,7 +54,7 @@ fn init( self: *ClipboardConfirmation, app: *App, data: []const u8, - core_surface: CoreSurface, + core_surface: *CoreSurface, request: apprt.ClipboardRequest, ) !void { // Create the window diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 9a361c228..3ad695909 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -1051,7 +1051,7 @@ pub fn clipboardRequest( } pub fn setClipboardString( - self: *const Surface, + self: *Surface, val: [:0]const u8, clipboard_type: apprt.Clipboard, confirm: bool, @@ -1065,7 +1065,7 @@ pub fn setClipboardString( ClipboardConfirmationWindow.create( self.app, val, - self.core_surface, + &self.core_surface, .{ .osc_52_write = clipboard_type }, ) catch |window_err| { log.err("failed to create clipboard confirmation window err={}", .{window_err}); @@ -1113,7 +1113,7 @@ fn gtkClipboardRead( ClipboardConfirmationWindow.create( self.app, str, - self.core_surface, + &self.core_surface, req.state, ) catch |window_err| { log.err("failed to create clipboard confirmation window err={}", .{window_err}); From df97c19a375ea23e3d0db9737357c525e6c86ea9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 11 Dec 2024 09:34:54 -0800 Subject: [PATCH 11/47] macOS: "option-as-alt" defaults to "true" for US keyboard layouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A common issue for US-centric users of a terminal is that the "option" key on macOS is not treated as the "alt" key in the terminal. ## Background macOS does not have an "alt" key, but instead has an "option" key. The "option" key is used for a variety of purposes, but the troublesome behavior for some (and expected/desired behavior for others) is that it is used to input special characters. For example, on a US standard layout, `option-b` inputs `∫`. This is not a typically desired character when using a terminal and most users will instead expect that `option-b` maps to `alt-b` for keybinding purposes with whatever shell, TUI, editor, etc. they're using. On non-US layouts, the "option" key is a critical modifier key for inputting certain characters in the same way "shift" is a critical modifier key for inputting certain characters on US layouts. We previously tried to change the default for `macos-option-as-alt` to `left` (so that the left option key behaves as alt) because I had the wrong assumption that international users always used the right option key with terminals or were used to this. But very quickly beta users with different layouts (such as German, I believe) noted that this is not the case and broke their idiomatic input behavior. This behavior was therefore reverted. ## Solution This confusing behavior happened frequently enough that I decided to implement the more complex behavior in this commit. The new behavior is that when a US layout is active, `macos-option-as-alt` defaults to true if it is unset. When a non-US layout is active, `macos-option-as-alt` defaults to false if it is unset. This happens live as users change their keyboard layout. **An important goal of Ghostty is to have zero-config defaults** that satisfy the majority of users. Fiddling with configurations is -- for most -- an annoying task and software that works well enough out of the box is delightful. Based on surveying beta users, I believe this commit will result in less configuration for the majority of users. ## Other Terminals This behavior is unique amongst terminals as far as I know. Terminal.app, Kitty, iTerm2, Alacritty (I stopped checking there) all default to the default macOS behavior (option is option and special characters are inputted). All of the aforementioned terminals have a setting to change this behavior, identical to Ghostty (or, Ghostty identical to them perhaps since they all predate Ghostty). I couldn't find any history where users requested the behavior of defaulting this to something else for US based keyboards. That's interesting since this has come up so frequently during the Ghostty beta! --- src/Surface.zig | 18 ++++++++++-- src/apprt/embedded.zig | 38 ++++++++++++++++++++++--- src/config/Config.zig | 37 ++++++++++++++++++------ src/input.zig | 2 ++ src/input/KeyEncoder.zig | 4 +-- src/input/KeymapDarwin.zig | 26 +++++++++++++++++ src/input/keyboard.zig | 58 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 167 insertions(+), 16 deletions(-) create mode 100644 src/input/keyboard.zig diff --git a/src/Surface.zig b/src/Surface.zig index 3e7300d08..9fc5b1d90 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -245,7 +245,7 @@ const DerivedConfig = struct { mouse_scroll_multiplier: f64, mouse_shift_capture: configpkg.MouseShiftCapture, macos_non_native_fullscreen: configpkg.NonNativeFullscreen, - macos_option_as_alt: configpkg.OptionAsAlt, + macos_option_as_alt: ?configpkg.OptionAsAlt, vt_kam_allowed: bool, window_padding_top: u32, window_padding_bottom: u32, @@ -1990,12 +1990,26 @@ fn encodeKey( // inputs there are many keybindings that result in no encoding // whatsoever. const enc: input.KeyEncoder = enc: { + const option_as_alt: configpkg.OptionAsAlt = self.config.macos_option_as_alt orelse detect: { + // Non-macOS doesn't use this value so ignore. + if (comptime builtin.os.tag != .macos) break :detect .false; + + // If we don't have alt pressed, it doesn't matter what this + // config is so we can just say "false" and break out and avoid + // more expensive checks below. + if (!event.mods.alt) break :detect .false; + + // Alt is pressed, we're on macOS. We break some encapsulation + // here and assume libghostty for ease... + break :detect self.rt_app.keyboardLayout().detectOptionAsAlt(); + }; + self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); const t = &self.io.terminal; break :enc .{ .event = event, - .macos_option_as_alt = self.config.macos_option_as_alt, + .macos_option_as_alt = option_as_alt, .alt_esc_prefix = t.modes.get(.alt_esc_prefix), .cursor_key_application = t.modes.get(.cursor_keys), .keypad_key_application = t.modes.get(.keypad_keys), diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 6a4411a85..451605af7 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -105,11 +105,14 @@ pub const App = struct { var config_clone = try config.clone(alloc); errdefer config_clone.deinit(); + var keymap = try input.Keymap.init(); + errdefer keymap.deinit(); + return .{ .core_app = core_app, .config = config_clone, .opts = opts, - .keymap = try input.Keymap.init(), + .keymap = keymap, .keymap_state = .{}, }; } @@ -161,8 +164,15 @@ pub const App = struct { // then we strip the alt modifier from the mods for translation. const translate_mods = translate_mods: { var translate_mods = mods; - if (comptime builtin.target.isDarwin()) { - const strip = switch (self.config.@"macos-option-as-alt") { + if ((comptime builtin.target.isDarwin()) and translate_mods.alt) { + // Note: the keyboardLayout() function is not super cheap + // so we only want to run it if alt is already pressed hence + // the above condition. + const option_as_alt: configpkg.OptionAsAlt = + self.config.@"macos-option-as-alt" orelse + self.keyboardLayout().detectOptionAsAlt(); + + const strip = switch (option_as_alt) { .false => false, .true => mods.alt, .left => mods.sides.alt == .left, @@ -382,6 +392,25 @@ pub const App = struct { } } + /// Loads the keyboard layout. + /// + /// Kind of expensive so this should be avoided if possible. When I say + /// "kind of expensive" I mean that its not something you probably want + /// to run on every keypress. + pub fn keyboardLayout(self: *const App) input.KeyboardLayout { + // We only support keyboard layout detection on macOS. + if (comptime builtin.os.tag != .macos) return .unknown; + + // Any layout larger than this is not something we can handle. + var buf: [256]u8 = undefined; + const id = self.keymap.sourceId(&buf) catch |err| { + comptime assert(@TypeOf(err) == error{OutOfMemory}); + return .unknown; + }; + + return input.KeyboardLayout.mapAppleId(id) orelse .unknown; + } + pub fn wakeup(self: *const App) void { self.opts.wakeup(self.opts.userdata); } @@ -1551,7 +1580,8 @@ pub const CAPI = struct { @truncate(@as(c_uint, @bitCast(mods_raw))), )); const result = mods.translation( - surface.core_surface.config.macos_option_as_alt, + surface.core_surface.config.macos_option_as_alt orelse + surface.app.keyboardLayout().detectOptionAsAlt(), ); return @intCast(@as(input.Mods.Backing, @bitCast(result))); } diff --git a/src/config/Config.zig b/src/config/Config.zig index fa531dc7e..7771a60ec 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1574,20 +1574,41 @@ keybind: Keybinds = .{}, /// editor, etc. @"macos-titlebar-proxy-icon": MacTitlebarProxyIcon = .visible, -/// If `true`, the *Option* key will be treated as *Alt*. This makes terminal -/// sequences expecting *Alt* to work properly, but will break Unicode input -/// sequences on macOS if you use them via the *Alt* key. You may set this to -/// `false` to restore the macOS *Alt* key unicode sequences but this will break -/// terminal sequences expecting *Alt* to work. +/// macOS doesn't have a distinct "alt" key and instead has the "option" +/// key which behaves slightly differently. On macOS by default, the +/// option key plus a character will sometimes produces a Unicode character. +/// For example, on US standard layouts option-b produces "∫". This may be +/// undesirable if you want to use "option" as an "alt" key for keybindings +/// in terminal programs or shells. /// -/// The values `left` or `right` enable this for the left or right *Option* -/// key, respectively. +/// This configuration lets you change the behavior so that option is treated +/// as alt. +/// +/// The default behavior (unset) will depend on your active keyboard +/// layout. If your keyboard layout is one of the keyboard layouts listed +/// below, then the default value is "true". Otherwise, the default +/// value is "false". Keyboard layouts with a default value of "true" are: +/// +/// - U.S. Standard +/// - U.S. International /// /// Note that if an *Option*-sequence doesn't produce a printable character, it /// will be treated as *Alt* regardless of this setting. (i.e. `alt+ctrl+a`). /// +/// Explicit values that can be set: +/// +/// If `true`, the *Option* key will be treated as *Alt*. This makes terminal +/// sequences expecting *Alt* to work properly, but will break Unicode input +/// sequences on macOS if you use them via the *Alt* key. +/// +/// You may set this to `false` to restore the macOS *Alt* key unicode +/// sequences but this will break terminal sequences expecting *Alt* to work. +/// +/// The values `left` or `right` enable this for the left or right *Option* +/// key, respectively. +/// /// This does not work with GLFW builds. -@"macos-option-as-alt": OptionAsAlt = .false, +@"macos-option-as-alt": ?OptionAsAlt = null, /// Whether to enable the macOS window shadow. The default value is true. /// With some window managers and window transparency settings, you may diff --git a/src/input.zig b/src/input.zig index 9e3997d97..83be38d3d 100644 --- a/src/input.zig +++ b/src/input.zig @@ -3,6 +3,7 @@ const builtin = @import("builtin"); const mouse = @import("input/mouse.zig"); const key = @import("input/key.zig"); +const keyboard = @import("input/keyboard.zig"); pub const function_keys = @import("input/function_keys.zig"); pub const keycodes = @import("input/keycodes.zig"); @@ -13,6 +14,7 @@ pub const Action = key.Action; pub const Binding = @import("input/Binding.zig"); pub const Link = @import("input/Link.zig"); pub const Key = key.Key; +pub const KeyboardLayout = keyboard.Layout; pub const KeyEncoder = @import("input/KeyEncoder.zig"); pub const KeyEvent = key.KeyEvent; pub const InspectorMode = Binding.Action.InspectorMode; diff --git a/src/input/KeyEncoder.zig b/src/input/KeyEncoder.zig index 25d85e78d..4bac7ee6b 100644 --- a/src/input/KeyEncoder.zig +++ b/src/input/KeyEncoder.zig @@ -208,7 +208,7 @@ fn kitty( // Determine if the Alt modifier should be treated as an actual // modifier (in which case it prevents associated text) or as // the macOS Option key, which does not prevent associated text. - const alt_prevents_text = if (comptime builtin.target.isDarwin()) + const alt_prevents_text = if (comptime builtin.os.tag == .macos) switch (self.macos_option_as_alt) { .left => all_mods.sides.alt == .left, .right => all_mods.sides.alt == .right, @@ -422,7 +422,7 @@ fn legacyAltPrefix( // On macOS, we only handle option like alt in certain // circumstances. Otherwise, macOS does a unicode translation // and we allow that to happen. - if (comptime builtin.target.isDarwin()) { + if (comptime builtin.os.tag == .macos) { switch (self.macos_option_as_alt) { .false => return null, .left => if (mods.sides.alt == .right) return null, diff --git a/src/input/KeymapDarwin.zig b/src/input/KeymapDarwin.zig index 5ba7c6440..3d81b0f4b 100644 --- a/src/input/KeymapDarwin.zig +++ b/src/input/KeymapDarwin.zig @@ -14,6 +14,7 @@ const Keymap = @This(); const std = @import("std"); const builtin = @import("builtin"); +const Allocator = std.mem.Allocator; const macos = @import("macos"); const codes = @import("keycodes.zig").entries; const Key = @import("key.zig").Key; @@ -72,6 +73,24 @@ pub fn reload(self: *Keymap) !void { try self.reinit(); } +/// Get the input source ID for the current keyboard layout. The input +/// source ID is a unique identifier for the keyboard layout which is uniquely +/// defined by Apple. +/// +/// This is macOS-only. Other platforms don't have an equivalent of this +/// so this isn't expected to be generally implemented. +pub fn sourceId(self: *const Keymap, buf: []u8) Allocator.Error![]const u8 { + // Get the raw CFStringRef + const id_raw = TISGetInputSourceProperty( + self.source, + kTISPropertyInputSourceID, + ) orelse return error.OutOfMemory; + + // Convert the CFStringRef to a C string into our buffer. + const id: *CFString = @ptrCast(id_raw); + return id.cstring(buf, .utf8) orelse error.OutOfMemory; +} + /// Reinit reinitializes the keymap. It assumes that all the memory associated /// with the keymap is already freed. fn reinit(self: *Keymap) !void { @@ -89,6 +108,12 @@ fn reinit(self: *Keymap) !void { // The CFDataRef contains a UCKeyboardLayout pointer break :layout @ptrCast(data.getPointer()); }; + + if (comptime builtin.mode == .Debug) id: { + var buf: [256]u8 = undefined; + const id = self.sourceId(&buf) catch break :id; + std.log.debug("keyboard layout={s}", .{id}); + } } /// Translate a single key input into a utf8 sequence. @@ -200,6 +225,7 @@ extern "c" fn LMGetKbdType() u8; extern "c" fn UCKeyTranslate(*const UCKeyboardLayout, u16, u16, u32, u32, u32, *u32, c_ulong, *c_ulong, [*]u16) i32; extern const kTISPropertyLocalizedName: *CFString; extern const kTISPropertyUnicodeKeyLayoutData: *CFString; +extern const kTISPropertyInputSourceID: *CFString; const TISInputSource = opaque {}; const UCKeyboardLayout = opaque {}; const kUCKeyActionDown: u16 = 0; diff --git a/src/input/keyboard.zig b/src/input/keyboard.zig new file mode 100644 index 000000000..73674df2c --- /dev/null +++ b/src/input/keyboard.zig @@ -0,0 +1,58 @@ +const std = @import("std"); +const OptionAsAlt = @import("../config.zig").OptionAsAlt; + +/// Keyboard layouts. +/// +/// These aren't heavily used in Ghostty and having a fully comprehensive +/// list is not important. We only need to distinguish between a few +/// different layouts for some nice-to-have features, such as setting a default +/// value for "macos-option-as-alt". +pub const Layout = enum { + // Unknown, unmapped layout. Ghostty should not make any assumptions + // about the layout of the keyboard. + unknown, + + // The remaining should be fairly self-explanatory: + us_standard, + us_international, + + /// Map an Apple keyboard layout ID to a value in this enum. The layout + /// ID can be retrieved using Carbon's TIKeyboardLayoutGetInputSourceProperty + /// function. + /// + /// Even though our layout supports "unknown", we return null if we don't + /// recognize the layout ID so callers can detect this scenario. + pub fn mapAppleId(id: []const u8) ?Layout { + if (std.mem.eql(u8, id, "com.apple.keylayout.US")) { + return .us_standard; + } else if (std.mem.eql(u8, id, "com.apple.keylayout.USInternational")) { + return .us_international; + } + + return null; + } + + /// Returns the default macos-option-as-alt value for this layout. + /// + /// We apply some heuristics to change the default based on the keyboard + /// layout if "macos-option-as-alt" is unset. We do this because on some + /// keyboard layouts such as US standard layouts, users generally expect + /// an input such as option-b to map to alt-b but macOS by default will + /// convert it to the codepoint "∫". + /// + /// This behavior however is desired on international layout where the + /// option key is used for important, regularly used inputs. + pub fn detectOptionAsAlt(self: Layout) OptionAsAlt { + return switch (self) { + // On US standard, the option key is typically used as alt + // and not as a modifier for other codepoints. For example, + // option-B = ∫ but usually the user wants alt-B. + .us_standard, + .us_international, + => .true, + + .unknown, + => .false, + }; + } +}; From ab60fbc096674da013e87c769f2d79a4f30944f6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 11 Dec 2024 11:14:36 -0800 Subject: [PATCH 12/47] apprt/glfw: add noop keyboardLayout func to satisfy tests and builds --- src/apprt/glfw.zig | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index bf4c44ad0..64b0cbe81 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -409,6 +409,13 @@ pub const App = struct { } } + pub fn keyboardLayout(self: *const App) input.KeyboardLayout { + _ = self; + + // Not supported by glfw + return .unknown; + } + /// Mac-specific settings. This is only enabled when the target is /// Mac and the artifact is a standalone exe. We don't target libs because /// the embedded API doesn't do windowing. From d016bf8392c9c6a82d2d8e77a1dea4912744621d Mon Sep 17 00:00:00 2001 From: Gregory Anders Date: Wed, 11 Dec 2024 13:15:24 -0600 Subject: [PATCH 13/47] mdgen: use bold face for option and action names --- src/build/mdgen/mdgen.zig | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/build/mdgen/mdgen.zig b/src/build/mdgen/mdgen.zig index 7e05596d7..c7e8c8638 100644 --- a/src/build/mdgen/mdgen.zig +++ b/src/build/mdgen/mdgen.zig @@ -30,10 +30,10 @@ pub fn genConfig(writer: anytype, cli: bool) !void { inline for (@typeInfo(Config).Struct.fields) |field| { if (field.name[0] == '_') continue; - try writer.writeAll("`"); + try writer.writeAll("**`"); if (cli) try writer.writeAll("--"); try writer.writeAll(field.name); - try writer.writeAll("`\n\n"); + try writer.writeAll("`**\n\n"); if (@hasDecl(help_strings.Config, field.name)) { var iter = std.mem.splitScalar(u8, @field(help_strings.Config, field.name), '\n'); var first = true; @@ -60,12 +60,12 @@ pub fn genActions(writer: anytype) !void { const action = std.meta.stringToEnum(Action, field.name).?; switch (action) { - .help => try writer.writeAll("`--help`\n\n"), - .version => try writer.writeAll("`--version`\n\n"), + .help => try writer.writeAll("**`--help`**\n\n"), + .version => try writer.writeAll("**`--version`**\n\n"), else => { - try writer.writeAll("`+"); + try writer.writeAll("**`+"); try writer.writeAll(field.name); - try writer.writeAll("`\n\n"); + try writer.writeAll("`**\n\n"); }, } @@ -97,9 +97,9 @@ pub fn genKeybindActions(writer: anytype) !void { inline for (info.Union.fields) |field| { if (field.name[0] == '_') continue; - try writer.writeAll("`"); + try writer.writeAll("**`"); try writer.writeAll(field.name); - try writer.writeAll("`\n\n"); + try writer.writeAll("`**\n\n"); if (@hasDecl(help_strings.KeybindAction, field.name)) { var iter = std.mem.splitScalar(u8, @field(help_strings.KeybindAction, field.name), '\n'); From bd1845231011d2b445a78bef07a5b54f0b077479 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Wed, 11 Dec 2024 16:30:40 -0500 Subject: [PATCH 14/47] font: unify metrics calculations & separate sprite metrics Unify grid metrics calculations by relying on shared logic mostly based on values directly from the font tables, this deduplicates a lot of code and gives us more control over how we interpret various metrics. Also separate metrics for underlined, strikethrough, and overline thickness and position, and box drawing thickness, so that they can individually be adjusted as the user desires. --- pkg/freetype/face.zig | 2 + src/font/CodepointResolver.zig | 9 +- src/font/SharedGrid.zig | 8 +- src/font/face/Metrics.zig | 198 +++++++++++++-- src/font/face/coretext.zig | 173 +++++++------ src/font/face/freetype.zig | 271 +++++++++++---------- src/font/sprite/Box.zig | 426 ++++++++++++++++++--------------- src/font/sprite/Face.zig | 112 +++------ 8 files changed, 691 insertions(+), 508 deletions(-) diff --git a/pkg/freetype/face.zig b/pkg/freetype/face.zig index 8bbc75616..e3bcd5292 100644 --- a/pkg/freetype/face.zig +++ b/pkg/freetype/face.zig @@ -215,6 +215,8 @@ pub const SfntTag = enum(c_int) { pub fn DataType(comptime self: SfntTag) type { return switch (self) { .os2 => c.TT_OS2, + .head => c.TT_Header, + .post => c.TT_Postscript, else => unreachable, // As-needed... }; } diff --git a/src/font/CodepointResolver.zig b/src/font/CodepointResolver.zig index f3be843c5..326ca0186 100644 --- a/src/font/CodepointResolver.zig +++ b/src/font/CodepointResolver.zig @@ -520,7 +520,14 @@ test "getIndex box glyph" { var r: CodepointResolver = .{ .collection = c, - .sprite = .{ .width = 18, .height = 36, .thickness = 2 }, + .sprite = .{ + .metrics = font.Metrics.calc(.{ + .cell_width = 18.0, + .ascent = 30.0, + .descent = -6.0, + .line_gap = 0.0, + }), + }, }; defer r.deinit(alloc); diff --git a/src/font/SharedGrid.zig b/src/font/SharedGrid.zig index 8af385b84..f907b59ad 100644 --- a/src/font/SharedGrid.zig +++ b/src/font/SharedGrid.zig @@ -122,13 +122,7 @@ fn reloadMetrics(self: *SharedGrid) !void { self.metrics = face.metrics; // Setup our sprite font. - self.resolver.sprite = .{ - .width = self.metrics.cell_width, - .height = self.metrics.cell_height, - .thickness = self.metrics.underline_thickness, - .underline_position = self.metrics.underline_position, - .strikethrough_position = self.metrics.strikethrough_position, - }; + self.resolver.sprite = .{ .metrics = self.metrics }; } /// Returns the grid cell size. diff --git a/src/font/face/Metrics.zig b/src/font/face/Metrics.zig index a1eb50bdd..17a03d497 100644 --- a/src/font/face/Metrics.zig +++ b/src/font/face/Metrics.zig @@ -6,21 +6,28 @@ const std = @import("std"); cell_width: u32, cell_height: u32, -/// For monospace grids, the recommended y-value from the bottom to set -/// the baseline for font rendering. This is chosen so that things such -/// as the bottom of a "g" or "y" do not drop below the cell. +/// Distance in pixels from the bottom of the cell to the text baseline. cell_baseline: u32, -/// The position of the underline from the top of the cell and the -/// thickness in pixels. +/// Distance in pixels from the top of the cell to the top of the underline. underline_position: u32, +/// Thickness in pixels of the underline. underline_thickness: u32, -/// The position and thickness of a strikethrough. Same units/style -/// as the underline fields. +/// Distance in pixels from the top of the cell to the top of the strikethrough. strikethrough_position: u32, +/// Thickness in pixels of the strikethrough. strikethrough_thickness: u32, +/// Distance in pixels from the top of the cell to the top of the overline. +/// Can be negative to adjust the position above the top of the cell. +overline_position: i32, +/// Thickness in pixels of the overline. +overline_thickness: u32, + +/// Thickness in pixels of box drawing characters. +box_thickness: u32, + /// The thickness in pixels of the cursor sprite. This has a default value /// because it is not determined by fonts but rather by user configuration. cursor_thickness: u32 = 1, @@ -30,6 +37,143 @@ cursor_thickness: u32 = 1, original_cell_width: ?u32 = null, original_cell_height: ?u32 = null, +/// Minimum acceptable values for some fields to prevent modifiers +/// from being able to, for example, cause 0-thickness underlines. +const Minimums = struct { + const cell_width = 1; + const cell_height = 1; + const underline_thickness = 1; + const strikethrough_thickness = 1; + const overline_thickness = 1; + const box_thickness = 1; + const cursor_thickness = 1; +}; + +const CalcOpts = struct { + cell_width: f64, + + /// The typographic ascent metric from the font. + /// This represents the maximum vertical position of the highest ascender. + /// + /// Relative to the baseline, in px, +Y=up + ascent: f64, + + /// The typographic descent metric from the font. + /// This represents the minimum vertical position of the lowest descender. + /// + /// Relative to the baseline, in px, +Y=up + /// + /// Note: + /// As this value is generally below the baseline, it is typically negative. + descent: f64, + + /// The typographic line gap (aka "leading") metric from the font. + /// This represents the additional space to be added between lines in + /// addition to the space defined by the ascent and descent metrics. + /// + /// Positive value in px + line_gap: f64, + + /// The TOP of the underline stroke. + /// + /// Relative to the baseline, in px, +Y=up + underline_position: ?f64 = null, + + /// The thickness of the underline stroke in px. + underline_thickness: ?f64 = null, + + /// The TOP of the strikethrough stroke. + /// + /// Relative to the baseline, in px, +Y=up + strikethrough_position: ?f64 = null, + + /// The thickness of the strikethrough stroke in px. + strikethrough_thickness: ?f64 = null, + + /// The height of capital letters in the font, either derived from + /// a provided cap height metric or measured from the height of the + /// capital H glyph. + cap_height: ?f64 = null, + + /// The height of lowercase letters in the font, either derived from + /// a provided ex height metric or measured from the height of the + /// lowercase x glyph. + ex_height: ?f64 = null, +}; + +/// Calculate our metrics based on values extracted from a font. +/// +/// Try to pass values with as much precision as possible, +/// do not round them before using them for this function. +/// +/// For any nullable options that are not provided, estimates will be used. +pub fn calc(opts: CalcOpts) Metrics { + // We use the ceiling of the provided cell width and height to ensure + // that the cell is large enough for the provided size, since we cast + // it to an integer later. + const cell_width = @ceil(opts.cell_width); + const cell_height = @ceil(opts.ascent - opts.descent + opts.line_gap); + + // We split our line gap in two parts, and put half of it on the top + // of the cell and the other half on the bottom, so that our text never + // bumps up against either edge of the cell vertically. + const half_line_gap = opts.line_gap / 2; + + // Unlike all our other metrics, `cell_baseline` is relative to the + // BOTTOM of the cell. + const cell_baseline = @round(half_line_gap - opts.descent); + + // We calculate a top_to_baseline to make following calculations simpler. + const top_to_baseline = cell_height - cell_baseline; + + // If we don't have a provided cap height, + // we estimate it as 75% of the ascent. + const cap_height = opts.cap_height orelse opts.ascent * 0.75; + + // If we don't have a provided ex height, + // we estimate it as 75% of the cap height. + const ex_height = opts.ex_height orelse cap_height * 0.75; + + // If we don't have a provided underline thickness, + // we estimate it as 15% of the ex height. + const underline_thickness = @max(1, @ceil(opts.underline_thickness orelse 0.15 * ex_height)); + + // If we don't have a provided strikethrough thickness + // then we just use the underline thickness for it. + const strikethrough_thickness = @max(1, @ceil(opts.strikethrough_thickness orelse underline_thickness)); + + // If we don't have a provided underline position then + // we place it 1 underline-thickness below the baseline. + const underline_position = @round(top_to_baseline - + (opts.underline_position orelse + -underline_thickness)); + + // If we don't have a provided strikethrough position + // then we center the strikethrough stroke at half the + // ex height, so that it's perfectly centered on lower + // case text. + const strikethrough_position = @round(top_to_baseline - + (opts.strikethrough_position orelse + ex_height * 0.5 + strikethrough_thickness * 0.5)); + + const result: Metrics = .{ + .cell_width = @intFromFloat(cell_width), + .cell_height = @intFromFloat(cell_height), + .cell_baseline = @intFromFloat(cell_baseline), + .underline_position = @intFromFloat(underline_position), + .underline_thickness = @intFromFloat(underline_thickness), + .strikethrough_position = @intFromFloat(strikethrough_position), + .strikethrough_thickness = @intFromFloat(strikethrough_thickness), + .overline_position = 0, + .overline_thickness = @intFromFloat(underline_thickness), + .box_thickness = @intFromFloat(underline_thickness), + }; + + // std.log.debug("metrics={}", .{result}); + + return result; +} + /// Apply a set of modifiers. pub fn apply(self: *Metrics, mods: ModifierSet) void { var it = mods.iterator(); @@ -76,7 +220,13 @@ pub fn apply(self: *Metrics, mods: ModifierSet) void { }, inline else => |tag| { - @field(self, @tagName(tag)) = entry.value_ptr.apply(@field(self, @tagName(tag))); + var new = entry.value_ptr.apply(@field(self, @tagName(tag))); + // If we have a minimum acceptable value + // for this metric, clamp the new value. + if (@hasDecl(Minimums, @tagName(tag))) { + new = @max(new, @field(Minimums, @tagName(tag))); + } + @field(self, @tagName(tag)) = new; }, } } @@ -152,23 +302,26 @@ pub const Modifier = union(enum) { } /// Apply a modifier to a numeric value. - pub fn apply(self: Modifier, v: u32) u32 { + pub fn apply(self: Modifier, v: anytype) @TypeOf(v) { + const T = @TypeOf(v); + const signed = @typeInfo(T).Int.signedness == .signed; return switch (self) { .percent => |p| percent: { const p_clamped: f64 = @max(0, p); const v_f64: f64 = @floatFromInt(v); const applied_f64: f64 = @round(v_f64 * p_clamped); - const applied_u32: u32 = @intFromFloat(applied_f64); - break :percent applied_u32; + const applied_T: T = @intFromFloat(applied_f64); + break :percent applied_T; }, .absolute => |abs| absolute: { const v_i64: i64 = @intCast(v); const abs_i64: i64 = @intCast(abs); - const applied_i64: i64 = @max(0, v_i64 +| abs_i64); - const applied_u32: u32 = std.math.cast(u32, applied_i64) orelse - std.math.maxInt(u32); - break :absolute applied_u32; + const applied_i64: i64 = v_i64 +| abs_i64; + const clamped_i64: i64 = if (signed) applied_i64 else @max(0, applied_i64); + const applied_T: T = std.math.cast(T, clamped_i64) orelse + std.math.maxInt(T) * @as(T, @intCast(std.math.sign(clamped_i64))); + break :absolute applied_T; }, }; } @@ -215,7 +368,7 @@ pub const Key = key: { var enumFields: [field_infos.len]std.builtin.Type.EnumField = undefined; var count: usize = 0; for (field_infos, 0..) |field, i| { - if (field.type != u32) continue; + if (field.type != u32 and field.type != i32) continue; enumFields[i] = .{ .name = field.name, .value = i }; count += 1; } @@ -242,6 +395,9 @@ fn init() Metrics { .underline_thickness = 0, .strikethrough_position = 0, .strikethrough_thickness = 0, + .overline_position = 0, + .overline_thickness = 0, + .box_thickness = 0, }; } @@ -337,12 +493,12 @@ test "Modifier: percent" { { const m: Modifier = .{ .percent = 0.8 }; - const v: u32 = m.apply(100); + const v: u32 = m.apply(@as(u32, 100)); try testing.expectEqual(@as(u32, 80), v); } { const m: Modifier = .{ .percent = 1.8 }; - const v: u32 = m.apply(100); + const v: u32 = m.apply(@as(u32, 100)); try testing.expectEqual(@as(u32, 180), v); } } @@ -352,17 +508,17 @@ test "Modifier: absolute" { { const m: Modifier = .{ .absolute = -100 }; - const v: u32 = m.apply(100); + const v: u32 = m.apply(@as(u32, 100)); try testing.expectEqual(@as(u32, 0), v); } { const m: Modifier = .{ .absolute = -120 }; - const v: u32 = m.apply(100); + const v: u32 = m.apply(@as(u32, 100)); try testing.expectEqual(@as(u32, 0), v); } { const m: Modifier = .{ .absolute = 100 }; - const v: u32 = m.apply(100); + const v: u32 = m.apply(@as(u32, 100)); try testing.expectEqual(@as(u32, 200), v); } } diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index 363dbacd8..6a77ee159 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -533,10 +533,91 @@ pub const Face = struct { } fn calcMetrics(ct_font: *macos.text.Font) !font.face.Metrics { + // Read the 'head' table out of the font data. + const head: opentype.Head = head: { + const tag = macos.text.FontTableTag.init("head"); + const data = ct_font.copyTable(tag) orelse return error.CopyTableError; + defer data.release(); + const ptr = data.getPointer(); + const len = data.getLength(); + break :head try opentype.Head.init(ptr[0..len]); + }; + + // Read the 'post' table out of the font data. + const post: opentype.Post = post: { + const tag = macos.text.FontTableTag.init("post"); + const data = ct_font.copyTable(tag) orelse return error.CopyTableError; + defer data.release(); + const ptr = data.getPointer(); + const len = data.getLength(); + break :post try opentype.Post.init(ptr[0..len]); + }; + + // Read the 'OS/2' table out of the font data. + const os2: opentype.OS2 = os2: { + const tag = macos.text.FontTableTag.init("OS/2"); + const data = ct_font.copyTable(tag) orelse return error.CopyTableError; + defer data.release(); + const ptr = data.getPointer(); + const len = data.getLength(); + break :os2 try opentype.OS2.init(ptr[0..len]); + }; + + const units_per_em = head.unitsPerEm; + const px_per_em = ct_font.getSize(); + const px_per_unit = px_per_em / @as(f64, @floatFromInt(units_per_em)); + + const ascent = @as(f64, @floatFromInt(os2.sTypoAscender)) * px_per_unit; + const descent = @as(f64, @floatFromInt(os2.sTypoDescender)) * px_per_unit; + const line_gap = @as(f64, @floatFromInt(os2.sTypoLineGap)) * px_per_unit; + + // Some fonts have degenerate 'post' tables where the underline + // thickness (and often position) are 0. We consider them null + // if this is the case and use our own fallbacks when we calculate. + const has_broken_underline = post.underlineThickness == 0; + + // If the underline position isn't 0 then we do use it, + // even if the thickness is't properly specified. + const underline_position = if (has_broken_underline and post.underlinePosition == 0) + null + else + @as(f64, @floatFromInt(post.underlinePosition)) * px_per_unit; + + const underline_thickness = if (has_broken_underline) + null + else + @as(f64, @floatFromInt(post.underlineThickness)) * px_per_unit; + + // Similar logic to the underline above. + const has_broken_strikethrough = os2.yStrikeoutSize == 0; + + const strikethrough_position = if (has_broken_strikethrough and os2.yStrikeoutPosition == 0) + null + else + @as(f64, @floatFromInt(os2.yStrikeoutPosition)) * px_per_unit; + + const strikethrough_thickness = if (has_broken_strikethrough) + null + else + @as(f64, @floatFromInt(os2.yStrikeoutSize)) * px_per_unit; + + // We fall back to whatever CoreText does if + // the OS/2 table doesn't specify a cap height. + const cap_height = if (os2.sCapHeight) |sCapHeight| + @as(f64, @floatFromInt(sCapHeight)) * px_per_unit + else + ct_font.getCapHeight(); + + // Ditto for ex height. + const ex_height = if (os2.sxHeight) |sxHeight| + @as(f64, @floatFromInt(sxHeight)) * px_per_unit + else + ct_font.getXHeight(); + // Cell width is calculated by calculating the widest width of the // visible ASCII characters. Usually 'M' is widest but we just take // whatever is widest. - const cell_width: f32 = cell_width: { + const cell_width: f64 = cell_width: { // Build a comptime array of all the ASCII chars const unichars = comptime unichars: { const len = 127 - 32; @@ -564,89 +645,25 @@ pub const Face = struct { max = @max(advances[i].width, max); } - break :cell_width @floatCast(@ceil(max)); + break :cell_width max; }; - // Calculate the layout metrics for height/ascent by just asking - // the font. I also tried Kitty's approach at one point which is to - // use the CoreText layout engine but this led to some glyphs being - // set incorrectly. - const layout_metrics: struct { - height: f32, - ascent: f32, - leading: f32, - } = metrics: { - const ascent = ct_font.getAscent(); - const descent = ct_font.getDescent(); + return font.face.Metrics.calc(.{ + .cell_width = cell_width, - // Leading is the value between lines at the TOP of a line. - // Because we are rendering a fixed size terminal grid, we - // want the leading to be split equally between the top and bottom. - const leading = ct_font.getLeading(); + .ascent = ascent, + .descent = descent, + .line_gap = line_gap, - // We ceil the metrics below because we don't want to cut off any - // potential used pixels. This tends to only make a one pixel - // difference but at small font sizes this can be noticeable. - break :metrics .{ - .height = @floatCast(@ceil(ascent + descent + leading)), - .ascent = @floatCast(@ceil(ascent + (leading / 2))), - .leading = @floatCast(leading), - }; - }; + .underline_position = underline_position, + .underline_thickness = underline_thickness, - // All of these metrics are based on our layout above. - const cell_height = @ceil(layout_metrics.height); - const cell_baseline = @ceil(layout_metrics.height - layout_metrics.ascent); + .strikethrough_position = strikethrough_position, + .strikethrough_thickness = strikethrough_thickness, - const underline_thickness = @ceil(@as(f32, @floatCast(ct_font.getUnderlineThickness()))); - const strikethrough_thickness = underline_thickness; - - const strikethrough_position = strikethrough_position: { - // This is the height of lower case letters in our font. - const ex_height = ct_font.getXHeight(); - - // We want to position the strikethrough so that it's - // vertically centered on any lower case text. This is - // a fairly standard choice for strikethrough positioning. - // - // Because our `strikethrough_position` is relative to the - // top of the cell we start with the ascent metric, which - // is the distance from the top down to the baseline, then - // we subtract half of the ex height to go back up to the - // correct height that should evenly split lowercase text. - const pos = layout_metrics.ascent - - ex_height * 0.5 - - strikethrough_thickness * 0.5; - - break :strikethrough_position @ceil(pos); - }; - - // Underline position reported is usually something like "-1" to - // represent the amount under the baseline. We add this to our real - // baseline to get the actual value from the bottom (+y is up). - // The final underline position is +y from the TOP (confusing) - // so we have to subtract from the cell height. - const underline_position = @ceil(layout_metrics.ascent - - @as(f32, @floatCast(ct_font.getUnderlinePosition()))); - - // Note: is this useful? - // const units_per_em = ct_font.getUnitsPerEm(); - // const units_per_point = @intToFloat(f64, units_per_em) / ct_font.getSize(); - - const result = font.face.Metrics{ - .cell_width = @intFromFloat(cell_width), - .cell_height = @intFromFloat(cell_height), - .cell_baseline = @intFromFloat(cell_baseline), - .underline_position = @intFromFloat(underline_position), - .underline_thickness = @intFromFloat(underline_thickness), - .strikethrough_position = @intFromFloat(strikethrough_position), - .strikethrough_thickness = @intFromFloat(strikethrough_thickness), - }; - - // std.log.warn("font size size={d}", .{ct_font.getSize()}); - // std.log.warn("font metrics={}", .{result}); - - return result; + .cap_height = cap_height, + .ex_height = ex_height, + }); } /// Copy the font table data for the given tag. diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index 683f80cc8..d7fb2e4a3 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -16,6 +16,7 @@ const font = @import("../main.zig"); const Glyph = font.Glyph; const Library = font.Library; const convert = @import("freetype_convert.zig"); +const opentype = @import("../opentype.zig"); const fastmem = @import("../../fastmem.zig"); const quirks = @import("../../quirks.zig"); const config = @import("../../config.zig"); @@ -85,7 +86,7 @@ pub const Face = struct { .lib = lib.lib, .face = face, .hb_font = hb_font, - .metrics = calcMetrics(face, opts.metric_modifiers), + .metrics = try calcMetrics(face, opts.metric_modifiers), .load_flags = opts.freetype_load_flags, }; result.quirks_disable_default_font_features = quirks.disableDefaultFontFeatures(&result); @@ -185,7 +186,7 @@ pub const Face = struct { /// for clearing any glyph caches, font atlas data, etc. pub fn setSize(self: *Face, opts: font.face.Options) !void { try setSize_(self.face, opts.size); - self.metrics = calcMetrics(self.face, opts.metric_modifiers); + self.metrics = try calcMetrics(self.face, opts.metric_modifiers); } fn setSize_(face: freetype.Face, size: font.face.DesiredSize) !void { @@ -258,7 +259,7 @@ pub const Face = struct { try self.face.setVarDesignCoordinates(coords); // We need to recalculate font metrics which may have changed. - self.metrics = calcMetrics(self.face, opts.metric_modifiers); + self.metrics = try calcMetrics(self.face, opts.metric_modifiers); } /// Returns the glyph index for the given Unicode code point. If this @@ -593,6 +594,10 @@ pub const Face = struct { return @floatFromInt(v >> 6); } + fn f26dot6ToF64(v: freetype.c.FT_F26Dot6) f64 { + return @as(opentype.sfnt.F26Dot6, @bitCast(@as(u32, @intCast(v)))).to(f64); + } + /// Calculate the metrics associated with a face. This is not public because /// the metrics are calculated for every face and cached since they're /// frequently required for renderers and take up next to little memory space @@ -605,138 +610,136 @@ pub const Face = struct { fn calcMetrics( face: freetype.Face, modifiers: ?*const font.face.Metrics.ModifierSet, - ) font.face.Metrics { + ) !font.face.Metrics { const size_metrics = face.handle.*.size.*.metrics; - // Cell width is calculated by preferring to use 'M' as the width of a - // cell since 'M' is generally the widest ASCII character. If loading 'M' - // fails then we use the max advance of the font face size metrics. - const cell_width: f32 = cell_width: { - if (face.getCharIndex('M')) |glyph_index| { - if (face.loadGlyph(glyph_index, .{ .render = true })) { - break :cell_width f26dot6ToFloat(face.handle.*.glyph.*.advance.x); - } else |_| { - // Ignore the error since we just fall back to max_advance below - } - } + // This code relies on this assumption, and it should always be + // true since we don't do any non-uniform scaling on the font ever. + assert(size_metrics.x_ppem == size_metrics.y_ppem); - break :cell_width f26dot6ToFloat(size_metrics.max_advance); - }; + // Read the 'head' table out of the font data. + const head = face.getSfntTable(.head) orelse return error.CannotGetTable; - // Ex height is calculated by measuring the height of the `x` glyph. - // If that fails then we just pretend it's 65% of the ascent height. - const ex_height: f32 = ex_height: { - if (face.getCharIndex('x')) |glyph_index| { - if (face.loadGlyph(glyph_index, .{ .render = true })) { - break :ex_height f26dot6ToFloat(face.handle.*.glyph.*.metrics.height); - } else |_| { - // Ignore the error since we just fall back to 65% of the ascent below - } - } + // Read the 'post' table out of the font data. + const post = face.getSfntTable(.post) orelse return error.CannotGetTable; - break :ex_height f26dot6ToFloat(size_metrics.ascender) * 0.65; - }; + // Read the 'OS/2' table out of the font data. + const os2 = face.getSfntTable(.os2) orelse return error.CannotGetTable; - // Cell height is calculated as the maximum of multiple things in order - // to handle edge cases in fonts: (1) the height as reported in metadata - // by the font designer (2) the maximum glyph height as measured in the - // font and (3) the height from the ascender to an underscore. - const cell_height: f32 = cell_height: { - // The height as reported by the font designer. - const face_height = f26dot6ToFloat(size_metrics.height); + // Some fonts don't actually have an OS/2 table, which + // we need in order to do the metrics calculations, in + // such cases FreeType sets the version to 0xFFFF + if (os2.version == 0xFFFF) return error.MissingTable; - // The maximum height a glyph can take in the font - const max_glyph_height = f26dot6ToFloat(size_metrics.ascender) - - f26dot6ToFloat(size_metrics.descender); + const units_per_em = head.Units_Per_EM; + const px_per_em: f64 = @floatFromInt(size_metrics.y_ppem); + const px_per_unit = px_per_em / @as(f64, @floatFromInt(units_per_em)); - // The height of the underscore character - const underscore_height = underscore: { - if (face.getCharIndex('_')) |glyph_index| { + const ascent = @as(f64, @floatFromInt(os2.sTypoAscender)) * px_per_unit; + const descent = @as(f64, @floatFromInt(os2.sTypoDescender)) * px_per_unit; + const line_gap = @as(f64, @floatFromInt(os2.sTypoLineGap)) * px_per_unit; + + // Some fonts have degenerate 'post' tables where the underline + // thickness (and often position) are 0. We consider them null + // if this is the case and use our own fallbacks when we calculate. + const has_broken_underline = post.underlineThickness == 0; + + // If the underline position isn't 0 then we do use it, + // even if the thickness is't properly specified. + const underline_position = if (has_broken_underline and post.underlinePosition == 0) + null + else + @as(f64, @floatFromInt(post.underlinePosition)) * px_per_unit; + + const underline_thickness = if (has_broken_underline) + null + else + @as(f64, @floatFromInt(post.underlineThickness)) * px_per_unit; + + // Similar logic to the underline above. + const has_broken_strikethrough = os2.yStrikeoutSize == 0; + + const strikethrough_position = if (has_broken_strikethrough and os2.yStrikeoutPosition == 0) + null + else + @as(f64, @floatFromInt(os2.yStrikeoutPosition)) * px_per_unit; + + const strikethrough_thickness = if (has_broken_strikethrough) + null + else + @as(f64, @floatFromInt(os2.yStrikeoutSize)) * px_per_unit; + + // Cell width is calculated by calculating the widest width of the + // visible ASCII characters. Usually 'M' is widest but we just take + // whatever is widest. + // + // If we fail to load any visible ASCII we just use max_advance from + // the metrics provided by FreeType. + const cell_width: f64 = cell_width: { + var c: u8 = ' '; + while (c < 127) : (c += 1) { + if (face.getCharIndex(c)) |glyph_index| { if (face.loadGlyph(glyph_index, .{ .render = true })) { - var res: f32 = f26dot6ToFloat(size_metrics.ascender); - res -= @floatFromInt(face.handle.*.glyph.*.bitmap_top); - res += @floatFromInt(face.handle.*.glyph.*.bitmap.rows); - break :underscore res; + break :cell_width f26dot6ToF64(face.handle.*.glyph.*.advance.x); } else |_| { - // Ignore the error since we just fall back below + // Ignore the error since we just fall back to max_advance below } } + } - break :underscore 0; - }; - - break :cell_height @max( - face_height, - @max(max_glyph_height, underscore_height), - ); + break :cell_width f26dot6ToF64(size_metrics.max_advance); }; - // The baseline is the descender amount for the font. This is the maximum - // that a font may go down. We switch signs because our coordinate system - // is reversed. - const cell_baseline = -1 * f26dot6ToFloat(size_metrics.descender); + // The OS/2 table does not include sCapHeight or sxHeight in version 1. + const has_os2_height_metrics = os2.version >= 2; - const underline_thickness = @max(@as(f32, 1), fontUnitsToPxY( - face, - face.handle.*.underline_thickness, - )); + // We use the cap height specified by the font if it's + // available, otherwise we try to measure the `H` glyph. + const cap_height: ?f64 = cap_height: { + if (has_os2_height_metrics) { + break :cap_height @as(f64, @floatFromInt(os2.sCapHeight)) * px_per_unit; + } + if (face.getCharIndex('H')) |glyph_index| { + if (face.loadGlyph(glyph_index, .{ .render = true })) { + break :cap_height f26dot6ToF64(face.handle.*.glyph.*.metrics.height); + } else |_| {} + } - // The underline position. This is a value from the top where the - // underline should go. - const underline_position: f32 = underline_pos: { - // From the FreeType docs: - // > `underline_position` - // > The position, in font units, of the underline line for - // > this face. It is the center of the underlining stem. - - const declared_px = @as(f32, @floatFromInt(freetype.mulFix( - face.handle.*.underline_position, - @intCast(face.handle.*.size.*.metrics.y_scale), - ))) / 64; - - // We use the declared underline position if its available. - const declared = @ceil(cell_height - cell_baseline - declared_px - underline_thickness * 0.5); - if (declared > 0) - break :underline_pos declared; - - // If we have no declared underline position, we go slightly under the - // cell height (mainly: non-scalable fonts, i.e. emoji) - break :underline_pos cell_height - 1; + break :cap_height null; }; - // The strikethrough position. We use the position provided by the - // font if it exists otherwise we calculate a best guess. - const strikethrough: struct { - pos: f32, - thickness: f32, - } = if (face.getSfntTable(.os2)) |os2| st: { - const thickness = @max(@as(f32, 1), fontUnitsToPxY(face, os2.yStrikeoutSize)); + // We use the ex height specified by the font if it's + // available, otherwise we try to measure the `x` glyph. + const ex_height: ?f64 = ex_height: { + if (has_os2_height_metrics) { + break :ex_height @as(f64, @floatFromInt(os2.sxHeight)) * px_per_unit; + } + if (face.getCharIndex('x')) |glyph_index| { + if (face.loadGlyph(glyph_index, .{ .render = true })) { + break :ex_height f26dot6ToF64(face.handle.*.glyph.*.metrics.height); + } else |_| {} + } - const pos = @as(f32, @floatFromInt(freetype.mulFix( - os2.yStrikeoutPosition, - @as(i32, @intCast(face.handle.*.size.*.metrics.y_scale)), - ))) / 64; - - break :st .{ - .pos = @ceil(cell_height - cell_baseline - pos), - .thickness = thickness, - }; - } else .{ - // Exactly 50% of the ex height so that our strikethrough is - // centered through lowercase text. This is a common choice. - .pos = @ceil(cell_height - cell_baseline - ex_height * 0.5 - underline_thickness * 0.5), - .thickness = underline_thickness, + break :ex_height null; }; - var result = font.face.Metrics{ - .cell_width = @intFromFloat(cell_width), - .cell_height = @intFromFloat(cell_height), - .cell_baseline = @intFromFloat(cell_baseline), - .underline_position = @intFromFloat(underline_position), - .underline_thickness = @intFromFloat(underline_thickness), - .strikethrough_position = @intFromFloat(strikethrough.pos), - .strikethrough_thickness = @intFromFloat(strikethrough.thickness), - }; + var result = font.face.Metrics.calc(.{ + .cell_width = cell_width, + + .ascent = ascent, + .descent = descent, + .line_gap = line_gap, + + .underline_position = underline_position, + .underline_thickness = underline_thickness, + + .strikethrough_position = strikethrough_position, + .strikethrough_thickness = strikethrough_thickness, + + .cap_height = cap_height, + .ex_height = ex_height, + }); + if (modifiers) |m| result.apply(m.*); // std.log.warn("font metrics={}", .{result}); @@ -744,13 +747,6 @@ pub const Face = struct { return result; } - /// Convert freetype "font units" to pixels using the Y scale. - fn fontUnitsToPxY(face: freetype.Face, x: i32) f32 { - const mul = freetype.mulFix(x, @as(i32, @intCast(face.handle.*.size.*.metrics.y_scale))); - const div = @as(f32, @floatFromInt(mul)) / 64; - return @ceil(div); - } - /// Copy the font table data for the given tag. pub fn copyTable(self: Face, alloc: Allocator, tag: *const [4]u8) !?[]u8 { return try self.face.loadSfntTable(alloc, freetype.Tag.init(tag)); @@ -828,6 +824,9 @@ test "color emoji" { .underline_thickness = 0, .strikethrough_position = 0, .strikethrough_thickness = 0, + .overline_position = 0, + .overline_thickness = 0, + .box_thickness = 0, }, }); try testing.expectEqual(@as(u32, 24), glyph.height); @@ -853,24 +852,42 @@ test "metrics" { try testing.expectEqual(font.face.Metrics{ .cell_width = 8, - .cell_height = 1.8e1, - .cell_baseline = 4, - .underline_position = 18, + // The cell height is 17 px because the calculation is + // + // ascender - descender + gap + // + // which, for inconsolata is + // + // 859 - -190 + 0 + // + // font units, at 1000 units per em that works out to 1.049 em, + // and 1em should be the point size * dpi scale, so 12 * (96/72) + // which is 16, and 16 * 1.049 = 16.784, which finally is rounded + // to 17. + .cell_height = 17, + .cell_baseline = 3, + .underline_position = 17, .underline_thickness = 1, .strikethrough_position = 10, .strikethrough_thickness = 1, + .overline_position = 0, + .overline_thickness = 1, + .box_thickness = 1, }, ft_font.metrics); // Resize should change metrics try ft_font.setSize(.{ .size = .{ .points = 24, .xdpi = 96, .ydpi = 96 } }); try testing.expectEqual(font.face.Metrics{ .cell_width = 16, - .cell_height = 35, - .cell_baseline = 7, - .underline_position = 35, + .cell_height = 34, + .cell_baseline = 6, + .underline_position = 34, .underline_thickness = 2, - .strikethrough_position = 20, + .strikethrough_position = 19, .strikethrough_thickness = 2, + .overline_position = 0, + .overline_thickness = 2, + .box_thickness = 2, }, ft_font.metrics); } diff --git a/src/font/sprite/Box.zig b/src/font/sprite/Box.zig index 382aa4206..03fa8fb1e 100644 --- a/src/font/sprite/Box.zig +++ b/src/font/sprite/Box.zig @@ -27,14 +27,8 @@ const Sprite = @import("../sprite.zig").Sprite; const log = std.log.scoped(.box_font); -/// The cell width and height because the boxes are fit perfectly -/// into a cell so that they all properly connect with zero spacing. -width: u32, -height: u32, - -/// Base thickness value for lines of the box. This is in pixels. If you -/// want to do any DPI scaling, it is expected to be done earlier. -thickness: u32, +/// Grid metrics for the rendering. +metrics: font.Metrics, /// The thickness of a line. const Thickness = enum { @@ -218,8 +212,29 @@ pub fn renderGlyph( atlas: *font.Atlas, cp: u32, ) !font.Glyph { + const metrics = self.metrics; + + // Some codepoints (such as a few cursors) should not + // grow when the cell height is adjusted to be larger. + // And we also will need to adjust the vertical position. + const height, const dy = adjust: { + const h = metrics.cell_height; + if (unadjustedCodepoint(cp)) { + if (metrics.original_cell_height) |original| { + if (h > original) { + break :adjust .{ original, (h - original) / 2 }; + } + } + } + break :adjust .{ h, 0 }; + }; + // Create the canvas we'll use to draw - var canvas = try font.sprite.Canvas.init(alloc, self.width, self.height); + var canvas = try font.sprite.Canvas.init( + alloc, + metrics.cell_width, + height, + ); defer canvas.deinit(alloc); // Perform the actual drawing @@ -231,16 +246,20 @@ pub fn renderGlyph( // Our coordinates start at the BOTTOM for our renderers so we have to // specify an offset of the full height because we rendered a full size // cell. - const offset_y = @as(i32, @intCast(self.height)); + // + // If we have an adjustment (see above) to the cell height that we need + // to account for, we subtract half the difference (dy) to keep the glyph + // centered. + const offset_y = @as(i32, @intCast(metrics.cell_height - dy)); return font.Glyph{ - .width = self.width, - .height = self.height, + .width = metrics.cell_width, + .height = metrics.cell_height, .offset_x = 0, .offset_y = offset_y, .atlas_x = region.x, .atlas_y = region.y, - .advance_x = @floatFromInt(self.width), + .advance_x = @floatFromInt(metrics.cell_width), }; } @@ -1652,16 +1671,16 @@ fn draw_lines( canvas: *font.sprite.Canvas, lines: Lines, ) void { - const light_px = Thickness.light.height(self.thickness); - const heavy_px = Thickness.heavy.height(self.thickness); + const light_px = Thickness.light.height(self.metrics.box_thickness); + const heavy_px = Thickness.heavy.height(self.metrics.box_thickness); // Top of light horizontal strokes - const h_light_top = (self.height -| light_px) / 2; + const h_light_top = (self.metrics.cell_height -| light_px) / 2; // Bottom of light horizontal strokes const h_light_bottom = h_light_top +| light_px; // Top of heavy horizontal strokes - const h_heavy_top = (self.height -| heavy_px) / 2; + const h_heavy_top = (self.metrics.cell_height -| heavy_px) / 2; // Bottom of heavy horizontal strokes const h_heavy_bottom = h_heavy_top +| heavy_px; @@ -1671,12 +1690,12 @@ fn draw_lines( const h_double_bottom = h_light_bottom +| light_px; // Left of light vertical strokes - const v_light_left = (self.width -| light_px) / 2; + const v_light_left = (self.metrics.cell_width -| light_px) / 2; // Right of light vertical strokes const v_light_right = v_light_left +| light_px; // Left of heavy vertical strokes - const v_heavy_left = (self.width -| heavy_px) / 2; + const v_heavy_left = (self.metrics.cell_width -| heavy_px) / 2; // Right of heavy vertical strokes const v_heavy_right = v_heavy_left +| heavy_px; @@ -1752,27 +1771,27 @@ fn draw_lines( switch (lines.right) { .none => {}, - .light => self.rect(canvas, right_left, h_light_top, self.width, h_light_bottom), - .heavy => self.rect(canvas, right_left, h_heavy_top, self.width, h_heavy_bottom), + .light => self.rect(canvas, right_left, h_light_top, self.metrics.cell_width, h_light_bottom), + .heavy => self.rect(canvas, right_left, h_heavy_top, self.metrics.cell_width, h_heavy_bottom), .double => { const top_left = if (lines.up == .double) v_light_right else right_left; const bottom_left = if (lines.down == .double) v_light_right else right_left; - self.rect(canvas, top_left, h_double_top, self.width, h_light_top); - self.rect(canvas, bottom_left, h_light_bottom, self.width, h_double_bottom); + self.rect(canvas, top_left, h_double_top, self.metrics.cell_width, h_light_top); + self.rect(canvas, bottom_left, h_light_bottom, self.metrics.cell_width, h_double_bottom); }, } switch (lines.down) { .none => {}, - .light => self.rect(canvas, v_light_left, down_top, v_light_right, self.height), - .heavy => self.rect(canvas, v_heavy_left, down_top, v_heavy_right, self.height), + .light => self.rect(canvas, v_light_left, down_top, v_light_right, self.metrics.cell_height), + .heavy => self.rect(canvas, v_heavy_left, down_top, v_heavy_right, self.metrics.cell_height), .double => { const left_top = if (lines.left == .double) h_light_bottom else down_top; const right_top = if (lines.right == .double) h_light_bottom else down_top; - self.rect(canvas, v_double_left, left_top, v_light_left, self.height); - self.rect(canvas, v_light_right, right_top, v_double_right, self.height); + self.rect(canvas, v_double_left, left_top, v_light_left, self.metrics.cell_height); + self.rect(canvas, v_light_right, right_top, v_double_right, self.metrics.cell_height); }, } @@ -1794,8 +1813,8 @@ fn draw_light_triple_dash_horizontal(self: Box, canvas: *font.sprite.Canvas) voi self.draw_dash_horizontal( canvas, 3, - Thickness.light.height(self.thickness), - @max(4, Thickness.light.height(self.thickness)), + Thickness.light.height(self.metrics.box_thickness), + @max(4, Thickness.light.height(self.metrics.box_thickness)), ); } @@ -1803,8 +1822,8 @@ fn draw_heavy_triple_dash_horizontal(self: Box, canvas: *font.sprite.Canvas) voi self.draw_dash_horizontal( canvas, 3, - Thickness.heavy.height(self.thickness), - @max(4, Thickness.light.height(self.thickness)), + Thickness.heavy.height(self.metrics.box_thickness), + @max(4, Thickness.light.height(self.metrics.box_thickness)), ); } @@ -1812,8 +1831,8 @@ fn draw_light_triple_dash_vertical(self: Box, canvas: *font.sprite.Canvas) void self.draw_dash_vertical( canvas, 3, - Thickness.light.height(self.thickness), - @max(4, Thickness.light.height(self.thickness)), + Thickness.light.height(self.metrics.box_thickness), + @max(4, Thickness.light.height(self.metrics.box_thickness)), ); } @@ -1821,8 +1840,8 @@ fn draw_heavy_triple_dash_vertical(self: Box, canvas: *font.sprite.Canvas) void self.draw_dash_vertical( canvas, 3, - Thickness.heavy.height(self.thickness), - @max(4, Thickness.light.height(self.thickness)), + Thickness.heavy.height(self.metrics.box_thickness), + @max(4, Thickness.light.height(self.metrics.box_thickness)), ); } @@ -1830,8 +1849,8 @@ fn draw_light_quadruple_dash_horizontal(self: Box, canvas: *font.sprite.Canvas) self.draw_dash_horizontal( canvas, 4, - Thickness.light.height(self.thickness), - @max(4, Thickness.light.height(self.thickness)), + Thickness.light.height(self.metrics.box_thickness), + @max(4, Thickness.light.height(self.metrics.box_thickness)), ); } @@ -1839,8 +1858,8 @@ fn draw_heavy_quadruple_dash_horizontal(self: Box, canvas: *font.sprite.Canvas) self.draw_dash_horizontal( canvas, 4, - Thickness.heavy.height(self.thickness), - @max(4, Thickness.light.height(self.thickness)), + Thickness.heavy.height(self.metrics.box_thickness), + @max(4, Thickness.light.height(self.metrics.box_thickness)), ); } @@ -1848,8 +1867,8 @@ fn draw_light_quadruple_dash_vertical(self: Box, canvas: *font.sprite.Canvas) vo self.draw_dash_vertical( canvas, 4, - Thickness.light.height(self.thickness), - @max(4, Thickness.light.height(self.thickness)), + Thickness.light.height(self.metrics.box_thickness), + @max(4, Thickness.light.height(self.metrics.box_thickness)), ); } @@ -1857,8 +1876,8 @@ fn draw_heavy_quadruple_dash_vertical(self: Box, canvas: *font.sprite.Canvas) vo self.draw_dash_vertical( canvas, 4, - Thickness.heavy.height(self.thickness), - @max(4, Thickness.light.height(self.thickness)), + Thickness.heavy.height(self.metrics.box_thickness), + @max(4, Thickness.light.height(self.metrics.box_thickness)), ); } @@ -1866,8 +1885,8 @@ fn draw_light_double_dash_horizontal(self: Box, canvas: *font.sprite.Canvas) voi self.draw_dash_horizontal( canvas, 2, - Thickness.light.height(self.thickness), - Thickness.light.height(self.thickness), + Thickness.light.height(self.metrics.box_thickness), + Thickness.light.height(self.metrics.box_thickness), ); } @@ -1875,8 +1894,8 @@ fn draw_heavy_double_dash_horizontal(self: Box, canvas: *font.sprite.Canvas) voi self.draw_dash_horizontal( canvas, 2, - Thickness.heavy.height(self.thickness), - Thickness.heavy.height(self.thickness), + Thickness.heavy.height(self.metrics.box_thickness), + Thickness.heavy.height(self.metrics.box_thickness), ); } @@ -1884,8 +1903,8 @@ fn draw_light_double_dash_vertical(self: Box, canvas: *font.sprite.Canvas) void self.draw_dash_vertical( canvas, 2, - Thickness.light.height(self.thickness), - Thickness.heavy.height(self.thickness), + Thickness.light.height(self.metrics.box_thickness), + Thickness.heavy.height(self.metrics.box_thickness), ); } @@ -1893,26 +1912,26 @@ fn draw_heavy_double_dash_vertical(self: Box, canvas: *font.sprite.Canvas) void self.draw_dash_vertical( canvas, 2, - Thickness.heavy.height(self.thickness), - Thickness.heavy.height(self.thickness), + Thickness.heavy.height(self.metrics.box_thickness), + Thickness.heavy.height(self.metrics.box_thickness), ); } fn draw_light_diagonal_upper_right_to_lower_left(self: Box, canvas: *font.sprite.Canvas) void { canvas.line(.{ - .p0 = .{ .x = @floatFromInt(self.width), .y = 0 }, - .p1 = .{ .x = 0, .y = @floatFromInt(self.height) }, - }, @floatFromInt(Thickness.light.height(self.thickness)), .on) catch {}; + .p0 = .{ .x = @floatFromInt(self.metrics.cell_width), .y = 0 }, + .p1 = .{ .x = 0, .y = @floatFromInt(self.metrics.cell_height) }, + }, @floatFromInt(Thickness.light.height(self.metrics.box_thickness)), .on) catch {}; } fn draw_light_diagonal_upper_left_to_lower_right(self: Box, canvas: *font.sprite.Canvas) void { canvas.line(.{ .p0 = .{ .x = 0, .y = 0 }, .p1 = .{ - .x = @floatFromInt(self.width), - .y = @floatFromInt(self.height), + .x = @floatFromInt(self.metrics.cell_width), + .y = @floatFromInt(self.metrics.cell_height), }, - }, @floatFromInt(Thickness.light.height(self.thickness)), .on) catch {}; + }, @floatFromInt(Thickness.light.height(self.metrics.box_thickness)), .on) catch {}; } fn draw_light_diagonal_cross(self: Box, canvas: *font.sprite.Canvas) void { @@ -1938,21 +1957,21 @@ fn draw_block_shade( comptime height: f64, comptime shade: Shade, ) void { - const float_width: f64 = @floatFromInt(self.width); - const float_height: f64 = @floatFromInt(self.height); + const float_width: f64 = @floatFromInt(self.metrics.cell_width); + const float_height: f64 = @floatFromInt(self.metrics.cell_height); const w: u32 = @intFromFloat(@round(float_width * width)); const h: u32 = @intFromFloat(@round(float_height * height)); const x = switch (alignment.horizontal) { .left => 0, - .right => self.width - w, - .center => (self.width - w) / 2, + .right => self.metrics.cell_width - w, + .center => (self.metrics.cell_width - w) / 2, }; const y = switch (alignment.vertical) { .top => 0, - .bottom => self.height - h, - .middle => (self.height - h) / 2, + .bottom => self.metrics.cell_height - h, + .middle => (self.metrics.cell_height - h) / 2, }; canvas.rect(.{ @@ -1970,10 +1989,10 @@ fn draw_corner_triangle_shade( comptime shade: Shade, ) void { const x0, const y0, const x1, const y1, const x2, const y2 = switch (corner) { - .tl => .{ 0, 0, 0, self.height, self.width, 0 }, - .tr => .{ 0, 0, self.width, self.height, self.width, 0 }, - .bl => .{ 0, 0, 0, self.height, self.width, self.height }, - .br => .{ 0, self.height, self.width, self.height, self.width, 0 }, + .tl => .{ 0, 0, 0, self.metrics.cell_height, self.metrics.cell_width, 0 }, + .tr => .{ 0, 0, self.metrics.cell_width, self.metrics.cell_height, self.metrics.cell_width, 0 }, + .bl => .{ 0, 0, 0, self.metrics.cell_height, self.metrics.cell_width, self.metrics.cell_height }, + .br => .{ 0, self.metrics.cell_height, self.metrics.cell_width, self.metrics.cell_height, self.metrics.cell_width, 0 }, }; canvas.triangle(.{ @@ -1984,26 +2003,26 @@ fn draw_corner_triangle_shade( } fn draw_full_block(self: Box, canvas: *font.sprite.Canvas) void { - self.rect(canvas, 0, 0, self.width, self.height); + self.rect(canvas, 0, 0, self.metrics.cell_width, self.metrics.cell_height); } fn draw_vertical_one_eighth_block_n(self: Box, canvas: *font.sprite.Canvas, n: u32) void { - const x = @as(u32, @intFromFloat(@round(@as(f64, @floatFromInt(n)) * @as(f64, @floatFromInt(self.width)) / 8))); - const w = @as(u32, @intFromFloat(@round(@as(f64, @floatFromInt(self.width)) / 8))); - self.rect(canvas, x, 0, x + w, self.height); + const x = @as(u32, @intFromFloat(@round(@as(f64, @floatFromInt(n)) * @as(f64, @floatFromInt(self.metrics.cell_width)) / 8))); + const w = @as(u32, @intFromFloat(@round(@as(f64, @floatFromInt(self.metrics.cell_width)) / 8))); + self.rect(canvas, x, 0, x + w, self.metrics.cell_height); } fn draw_checkerboard_fill(self: Box, canvas: *font.sprite.Canvas, parity: u1) void { - const float_width: f64 = @floatFromInt(self.width); - const float_height: f64 = @floatFromInt(self.height); + const float_width: f64 = @floatFromInt(self.metrics.cell_width); + const float_height: f64 = @floatFromInt(self.metrics.cell_height); const x_size: usize = 4; const y_size: usize = @intFromFloat(@round(4 * (float_height / float_width))); for (0..x_size) |x| { - const x0 = (self.width * x) / x_size; - const x1 = (self.width * (x + 1)) / x_size; + const x0 = (self.metrics.cell_width * x) / x_size; + const x1 = (self.metrics.cell_width * (x + 1)) / x_size; for (0..y_size) |y| { - const y0 = (self.height * y) / y_size; - const y1 = (self.height * (y + 1)) / y_size; + const y0 = (self.metrics.cell_height * y) / y_size; + const y1 = (self.metrics.cell_height * (y + 1)) / y_size; if ((x + y) % 2 == parity) { canvas.rect(.{ .x = @intCast(x0), @@ -2017,11 +2036,11 @@ fn draw_checkerboard_fill(self: Box, canvas: *font.sprite.Canvas, parity: u1) vo } fn draw_upper_left_to_lower_right_fill(self: Box, canvas: *font.sprite.Canvas) void { - const thick_px = Thickness.light.height(self.thickness); - const line_count = self.width / (2 * thick_px); + const thick_px = Thickness.light.height(self.metrics.box_thickness); + const line_count = self.metrics.cell_width / (2 * thick_px); - const float_width: f64 = @floatFromInt(self.width); - const float_height: f64 = @floatFromInt(self.height); + const float_width: f64 = @floatFromInt(self.metrics.cell_width); + const float_height: f64 = @floatFromInt(self.metrics.cell_height); const float_thick: f64 = @floatFromInt(thick_px); const stride = @round(float_width / @as(f64, @floatFromInt(line_count))); @@ -2037,11 +2056,11 @@ fn draw_upper_left_to_lower_right_fill(self: Box, canvas: *font.sprite.Canvas) v } fn draw_upper_right_to_lower_left_fill(self: Box, canvas: *font.sprite.Canvas) void { - const thick_px = Thickness.light.height(self.thickness); - const line_count = self.width / (2 * thick_px); + const thick_px = Thickness.light.height(self.metrics.box_thickness); + const line_count = self.metrics.cell_width / (2 * thick_px); - const float_width: f64 = @floatFromInt(self.width); - const float_height: f64 = @floatFromInt(self.height); + const float_width: f64 = @floatFromInt(self.metrics.cell_width); + const float_height: f64 = @floatFromInt(self.metrics.cell_height); const float_thick: f64 = @floatFromInt(thick_px); const stride = @round(float_width / @as(f64, @floatFromInt(line_count))); @@ -2061,13 +2080,13 @@ fn draw_corner_diagonal_lines( canvas: *font.sprite.Canvas, comptime corners: Quads, ) void { - const thick_px = Thickness.light.height(self.thickness); + const thick_px = Thickness.light.height(self.metrics.box_thickness); - const float_width: f64 = @floatFromInt(self.width); - const float_height: f64 = @floatFromInt(self.height); + const float_width: f64 = @floatFromInt(self.metrics.cell_width); + const float_height: f64 = @floatFromInt(self.metrics.cell_height); const float_thick: f64 = @floatFromInt(thick_px); - const center_x: f64 = @floatFromInt(self.width / 2 + self.width % 2); - const center_y: f64 = @floatFromInt(self.height / 2 + self.height % 2); + const center_x: f64 = @floatFromInt(self.metrics.cell_width / 2 + self.metrics.cell_width % 2); + const center_y: f64 = @floatFromInt(self.metrics.cell_height / 2 + self.metrics.cell_height % 2); if (corners.tl) canvas.line(.{ .p0 = .{ .x = center_x, .y = 0 }, @@ -2096,8 +2115,8 @@ fn draw_cell_diagonal( comptime from: Alignment, comptime to: Alignment, ) void { - const float_width: f64 = @floatFromInt(self.width); - const float_height: f64 = @floatFromInt(self.height); + const float_width: f64 = @floatFromInt(self.metrics.cell_width); + const float_height: f64 = @floatFromInt(self.metrics.cell_height); const x0: f64 = switch (from.horizontal) { .left => 0, @@ -2134,16 +2153,16 @@ fn draw_fading_line( comptime to: Edge, comptime thickness: Thickness, ) void { - const thick_px = thickness.height(self.thickness); - const float_width: f64 = @floatFromInt(self.width); - const float_height: f64 = @floatFromInt(self.height); + const thick_px = thickness.height(self.metrics.box_thickness); + const float_width: f64 = @floatFromInt(self.metrics.cell_width); + const float_height: f64 = @floatFromInt(self.metrics.cell_height); // Top of horizontal strokes - const h_top = (self.height -| thick_px) / 2; + const h_top = (self.metrics.cell_height -| thick_px) / 2; // Bottom of horizontal strokes const h_bottom = h_top +| thick_px; // Left of vertical strokes - const v_left = (self.width -| thick_px) / 2; + const v_left = (self.metrics.cell_width -| thick_px) / 2; // Right of vertical strokes const v_right = v_left +| thick_px; @@ -2163,7 +2182,7 @@ fn draw_fading_line( switch (to) { .top, .bottom => { - for (0..self.height) |y| { + for (0..self.metrics.cell_height) |y| { for (v_left..v_right) |x| { canvas.pixel( @intCast(x), @@ -2175,7 +2194,7 @@ fn draw_fading_line( } }, .left, .right => { - for (0..self.width) |x| { + for (0..self.metrics.cell_width) |x| { for (h_top..h_bottom) |y| { canvas.pixel( @intCast(x), @@ -2195,17 +2214,17 @@ fn draw_branch_node( node: BranchNode, comptime thickness: Thickness, ) void { - const thick_px = thickness.height(self.thickness); - const float_width: f64 = @floatFromInt(self.width); - const float_height: f64 = @floatFromInt(self.height); + const thick_px = thickness.height(self.metrics.box_thickness); + const float_width: f64 = @floatFromInt(self.metrics.cell_width); + const float_height: f64 = @floatFromInt(self.metrics.cell_height); const float_thick: f64 = @floatFromInt(thick_px); // Top of horizontal strokes - const h_top = (self.height -| thick_px) / 2; + const h_top = (self.metrics.cell_height -| thick_px) / 2; // Bottom of horizontal strokes const h_bottom = h_top +| thick_px; // Left of vertical strokes - const v_left = (self.width -| thick_px) / 2; + const v_left = (self.metrics.cell_width -| thick_px) / 2; // Right of vertical strokes const v_right = v_left +| thick_px; @@ -2240,9 +2259,9 @@ fn draw_branch_node( if (node.up) self.rect(canvas, v_left, 0, v_right, @intFromFloat(@ceil(cy - r))); if (node.right) - self.rect(canvas, @intFromFloat(@floor(cx + r)), h_top, self.width, h_bottom); + self.rect(canvas, @intFromFloat(@floor(cx + r)), h_top, self.metrics.cell_width, h_bottom); if (node.down) - self.rect(canvas, v_left, @intFromFloat(@floor(cy + r)), v_right, self.height); + self.rect(canvas, v_left, @intFromFloat(@floor(cy + r)), v_right, self.metrics.cell_height); if (node.left) self.rect(canvas, 0, h_top, @intFromFloat(@ceil(cx - r)), h_bottom); @@ -2263,8 +2282,8 @@ fn draw_circle( comptime position: Alignment, comptime filled: bool, ) void { - const float_width: f64 = @floatFromInt(self.width); - const float_height: f64 = @floatFromInt(self.height); + const float_width: f64 = @floatFromInt(self.metrics.cell_width); + const float_height: f64 = @floatFromInt(self.metrics.cell_height); const x: f64 = switch (position.horizontal) { .left => 0, @@ -2285,7 +2304,7 @@ fn draw_circle( .pixel = .{ .alpha8 = .{ .a = @intFromEnum(Shade.on) } }, }, }, - .line_width = @floatFromInt(Thickness.light.height(self.thickness)), + .line_width = @floatFromInt(Thickness.light.height(self.metrics.box_thickness)), }; var path = z2d.Path.init(canvas.alloc); @@ -2311,7 +2330,7 @@ fn draw_line( ) !void { canvas.line( .{ .p0 = p0, .p1 = p1 }, - @floatFromInt(thickness.height(self.thickness)), + @floatFromInt(thickness.height(self.metrics.box_thickness)), .on, ) catch {}; } @@ -2320,8 +2339,8 @@ fn draw_shade(self: Box, canvas: *font.sprite.Canvas, v: u16) void { canvas.rect((font.sprite.Box(u32){ .p0 = .{ .x = 0, .y = 0 }, .p1 = .{ - .x = self.width, - .y = self.height, + .x = self.metrics.cell_width, + .y = self.metrics.cell_height, }, }).rect(), @as(font.sprite.Color, @enumFromInt(v))); } @@ -2339,12 +2358,12 @@ fn draw_dark_shade(self: Box, canvas: *font.sprite.Canvas) void { } fn draw_horizontal_one_eighth_block_n(self: Box, canvas: *font.sprite.Canvas, n: u32) void { - const h = @as(u32, @intFromFloat(@round(@as(f64, @floatFromInt(self.height)) / 8))); + const h = @as(u32, @intFromFloat(@round(@as(f64, @floatFromInt(self.metrics.cell_height)) / 8))); const y = @min( - self.height -| h, - @as(u32, @intFromFloat(@round(@as(f64, @floatFromInt(n)) * @as(f64, @floatFromInt(self.height)) / 8))), + self.metrics.cell_height -| h, + @as(u32, @intFromFloat(@round(@as(f64, @floatFromInt(n)) * @as(f64, @floatFromInt(self.metrics.cell_height)) / 8))), ); - self.rect(canvas, 0, y, self.width, y + h); + self.rect(canvas, 0, y, self.metrics.cell_width, y + h); } fn draw_horizontal_one_eighth_1358_block(self: Box, canvas: *font.sprite.Canvas) void { @@ -2355,24 +2374,24 @@ fn draw_horizontal_one_eighth_1358_block(self: Box, canvas: *font.sprite.Canvas) } fn draw_quadrant(self: Box, canvas: *font.sprite.Canvas, comptime quads: Quads) void { - const center_x = self.width / 2 + self.width % 2; - const center_y = self.height / 2 + self.height % 2; + const center_x = self.metrics.cell_width / 2 + self.metrics.cell_width % 2; + const center_y = self.metrics.cell_height / 2 + self.metrics.cell_height % 2; if (quads.tl) self.rect(canvas, 0, 0, center_x, center_y); - if (quads.tr) self.rect(canvas, center_x, 0, self.width, center_y); - if (quads.bl) self.rect(canvas, 0, center_y, center_x, self.height); - if (quads.br) self.rect(canvas, center_x, center_y, self.width, self.height); + if (quads.tr) self.rect(canvas, center_x, 0, self.metrics.cell_width, center_y); + if (quads.bl) self.rect(canvas, 0, center_y, center_x, self.metrics.cell_height); + if (quads.br) self.rect(canvas, center_x, center_y, self.metrics.cell_width, self.metrics.cell_height); } fn draw_braille(self: Box, canvas: *font.sprite.Canvas, cp: u32) void { - var w: u32 = @min(self.width / 4, self.height / 8); - var x_spacing: u32 = self.width / 4; - var y_spacing: u32 = self.height / 8; + var w: u32 = @min(self.metrics.cell_width / 4, self.metrics.cell_height / 8); + var x_spacing: u32 = self.metrics.cell_width / 4; + var y_spacing: u32 = self.metrics.cell_height / 8; var x_margin: u32 = x_spacing / 2; var y_margin: u32 = y_spacing / 2; - var x_px_left: u32 = self.width - 2 * x_margin - x_spacing - 2 * w; - var y_px_left: u32 = self.height - 2 * y_margin - 3 * y_spacing - 4 * w; + var x_px_left: u32 = self.metrics.cell_width - 2 * x_margin - x_spacing - 2 * w; + var y_px_left: u32 = self.metrics.cell_height - 2 * y_margin - 3 * y_spacing - 4 * w; // First, try hard to ensure the DOT width is non-zero if (x_px_left >= 2 and y_px_left >= 4 and w == 0) { @@ -2419,8 +2438,8 @@ fn draw_braille(self: Box, canvas: *font.sprite.Canvas, cp: u32) void { } assert(x_px_left <= 1 or y_px_left <= 1); - assert(2 * x_margin + 2 * w + x_spacing <= self.width); - assert(2 * y_margin + 4 * w + 3 * y_spacing <= self.height); + assert(2 * x_margin + 2 * w + x_spacing <= self.metrics.cell_width); + assert(2 * y_margin + 4 * w + 3 * y_spacing <= self.metrics.cell_height); const x = [2]u32{ x_margin, x_margin + w + x_spacing }; const y = y: { @@ -2479,25 +2498,25 @@ fn draw_sextant(self: Box, canvas: *font.sprite.Canvas, cp: u32) void { const y_thirds = self.yThirds(); if (sex.tl) self.rect(canvas, 0, 0, x_halfs[0], y_thirds[0]); - if (sex.tr) self.rect(canvas, x_halfs[1], 0, self.width, y_thirds[0]); + if (sex.tr) self.rect(canvas, x_halfs[1], 0, self.metrics.cell_width, y_thirds[0]); if (sex.ml) self.rect(canvas, 0, y_thirds[0], x_halfs[0], y_thirds[1]); - if (sex.mr) self.rect(canvas, x_halfs[1], y_thirds[0], self.width, y_thirds[1]); - if (sex.bl) self.rect(canvas, 0, y_thirds[1], x_halfs[0], self.height); - if (sex.br) self.rect(canvas, x_halfs[1], y_thirds[1], self.width, self.height); + if (sex.mr) self.rect(canvas, x_halfs[1], y_thirds[0], self.metrics.cell_width, y_thirds[1]); + if (sex.bl) self.rect(canvas, 0, y_thirds[1], x_halfs[0], self.metrics.cell_height); + if (sex.br) self.rect(canvas, x_halfs[1], y_thirds[1], self.metrics.cell_width, self.metrics.cell_height); } fn xHalfs(self: Box) [2]u32 { return .{ - @as(u32, @intFromFloat(@round(@as(f64, @floatFromInt(self.width)) / 2))), - @as(u32, @intFromFloat(@as(f64, @floatFromInt(self.width)) / 2)), + @as(u32, @intFromFloat(@round(@as(f64, @floatFromInt(self.metrics.cell_width)) / 2))), + @as(u32, @intFromFloat(@as(f64, @floatFromInt(self.metrics.cell_width)) / 2)), }; } fn yThirds(self: Box) [2]u32 { - return switch (@mod(self.height, 3)) { - 0 => .{ self.height / 3, 2 * self.height / 3 }, - 1 => .{ self.height / 3, 2 * self.height / 3 + 1 }, - 2 => .{ self.height / 3 + 1, 2 * self.height / 3 }, + return switch (@mod(self.metrics.cell_height, 3)) { + 0 => .{ self.metrics.cell_height / 3, 2 * self.metrics.cell_height / 3 }, + 1 => .{ self.metrics.cell_height / 3, 2 * self.metrics.cell_height / 3 + 1 }, + 2 => .{ self.metrics.cell_height / 3 + 1, 2 * self.metrics.cell_height / 3 }, else => unreachable, }; } @@ -2511,10 +2530,10 @@ fn draw_smooth_mosaic( const top: f64 = 0.0; const upper: f64 = @floatFromInt(y_thirds[0]); const lower: f64 = @floatFromInt(y_thirds[1]); - const bottom: f64 = @floatFromInt(self.height); + const bottom: f64 = @floatFromInt(self.metrics.cell_height); const left: f64 = 0.0; - const center: f64 = @round(@as(f64, @floatFromInt(self.width)) / 2); - const right: f64 = @floatFromInt(self.width); + const center: f64 = @round(@as(f64, @floatFromInt(self.metrics.cell_width)) / 2); + const right: f64 = @floatFromInt(self.metrics.cell_width); var path = z2d.Path.init(canvas.alloc); defer path.deinit(); @@ -2549,11 +2568,11 @@ fn draw_edge_triangle( comptime edge: Edge, ) !void { const upper: f64 = 0.0; - const middle: f64 = @round(@as(f64, @floatFromInt(self.height)) / 2); - const lower: f64 = @floatFromInt(self.height); + const middle: f64 = @round(@as(f64, @floatFromInt(self.metrics.cell_height)) / 2); + const lower: f64 = @floatFromInt(self.metrics.cell_height); const left: f64 = 0.0; - const center: f64 = @round(@as(f64, @floatFromInt(self.width)) / 2); - const right: f64 = @floatFromInt(self.width); + const center: f64 = @round(@as(f64, @floatFromInt(self.metrics.cell_width)) / 2); + const right: f64 = @floatFromInt(self.metrics.cell_width); var path = z2d.Path.init(canvas.alloc); defer path.deinit(); @@ -2588,12 +2607,12 @@ fn draw_arc( comptime corner: Corner, comptime thickness: Thickness, ) !void { - const thick_px = thickness.height(self.thickness); - const float_width: f64 = @floatFromInt(self.width); - const float_height: f64 = @floatFromInt(self.height); + const thick_px = thickness.height(self.metrics.box_thickness); + const float_width: f64 = @floatFromInt(self.metrics.cell_width); + const float_height: f64 = @floatFromInt(self.metrics.cell_height); const float_thick: f64 = @floatFromInt(thick_px); - const center_x: f64 = @as(f64, @floatFromInt((self.width -| thick_px) / 2)) + float_thick / 2; - const center_y: f64 = @as(f64, @floatFromInt((self.height -| thick_px) / 2)) + float_thick / 2; + const center_x: f64 = @as(f64, @floatFromInt((self.metrics.cell_width -| thick_px) / 2)) + float_thick / 2; + const center_y: f64 = @as(f64, @floatFromInt((self.metrics.cell_height -| thick_px) / 2)) + float_thick / 2; const r = @min(float_width, float_height) / 2; @@ -2703,23 +2722,23 @@ fn draw_dash_horizontal( // We need at least 1 pixel for each gap and each dash, if we don't // have that then we can't draw our dashed line correctly so we just // draw a solid line and return. - if (self.width < count + gap_count) { + if (self.metrics.cell_width < count + gap_count) { self.hline_middle(canvas, .light); return; } // We never want the gaps to take up more than 50% of the space, // because if they do the dashes are too small and look wrong. - const gap_width = @min(desired_gap, self.width / (2 * count)); + const gap_width = @min(desired_gap, self.metrics.cell_width / (2 * count)); const total_gap_width = gap_count * gap_width; - const total_dash_width = self.width - total_gap_width; + const total_dash_width = self.metrics.cell_width - total_gap_width; const dash_width = total_dash_width / count; const remaining = total_dash_width % count; - assert(dash_width * count + gap_width * gap_count + remaining == self.width); + assert(dash_width * count + gap_width * gap_count + remaining == self.metrics.cell_width); // Our dashes should be centered vertically. - const y: u32 = (self.height -| thick_px) / 2; + const y: u32 = (self.metrics.cell_height -| thick_px) / 2; // We start at half a gap from the left edge, in order to center // our dashes properly. @@ -2782,23 +2801,23 @@ fn draw_dash_vertical( // We need at least 1 pixel for each gap and each dash, if we don't // have that then we can't draw our dashed line correctly so we just // draw a solid line and return. - if (self.height < count + gap_count) { + if (self.metrics.cell_height < count + gap_count) { self.vline_middle(canvas, .light); return; } // We never want the gaps to take up more than 50% of the space, // because if they do the dashes are too small and look wrong. - const gap_height = @min(desired_gap, self.height / (2 * count)); + const gap_height = @min(desired_gap, self.metrics.cell_height / (2 * count)); const total_gap_height = gap_count * gap_height; - const total_dash_height = self.height - total_gap_height; + const total_dash_height = self.metrics.cell_height - total_gap_height; const dash_height = total_dash_height / count; const remaining = total_dash_height % count; - assert(dash_height * count + gap_height * gap_count + remaining == self.height); + assert(dash_height * count + gap_height * gap_count + remaining == self.metrics.cell_height); // Our dashes should be centered horizontally. - const x: u32 = (self.width -| thick_px) / 2; + const x: u32 = (self.metrics.cell_width -| thick_px) / 2; // We start at the top of the cell. var y: u32 = 0; @@ -2824,32 +2843,32 @@ fn draw_dash_vertical( } fn draw_cursor_rect(self: Box, canvas: *font.sprite.Canvas) void { - self.rect(canvas, 0, 0, self.width, self.height); + self.rect(canvas, 0, 0, self.metrics.cell_width, self.metrics.cell_height); } fn draw_cursor_hollow_rect(self: Box, canvas: *font.sprite.Canvas) void { - const thick_px = Thickness.super_light.height(self.thickness); + const thick_px = Thickness.super_light.height(self.metrics.cursor_thickness); - self.vline(canvas, 0, self.height, 0, thick_px); - self.vline(canvas, 0, self.height, self.width -| thick_px, thick_px); - self.hline(canvas, 0, self.width, 0, thick_px); - self.hline(canvas, 0, self.width, self.height -| thick_px, thick_px); + self.vline(canvas, 0, self.metrics.cell_height, 0, thick_px); + self.vline(canvas, 0, self.metrics.cell_height, self.metrics.cell_width -| thick_px, thick_px); + self.hline(canvas, 0, self.metrics.cell_width, 0, thick_px); + self.hline(canvas, 0, self.metrics.cell_width, self.metrics.cell_height -| thick_px, thick_px); } fn draw_cursor_bar(self: Box, canvas: *font.sprite.Canvas) void { - const thick_px = Thickness.light.height(self.thickness); + const thick_px = Thickness.light.height(self.metrics.cursor_thickness); - self.vline(canvas, 0, self.height, 0, thick_px); + self.vline(canvas, 0, self.metrics.cell_height, 0, thick_px); } fn vline_middle(self: Box, canvas: *font.sprite.Canvas, thickness: Thickness) void { - const thick_px = thickness.height(self.thickness); - self.vline(canvas, 0, self.height, (self.width -| thick_px) / 2, thick_px); + const thick_px = thickness.height(self.metrics.box_thickness); + self.vline(canvas, 0, self.metrics.cell_height, (self.metrics.cell_width -| thick_px) / 2, thick_px); } fn hline_middle(self: Box, canvas: *font.sprite.Canvas, thickness: Thickness) void { - const thick_px = thickness.height(self.thickness); - self.hline(canvas, 0, self.width, (self.height -| thick_px) / 2, thick_px); + const thick_px = thickness.height(self.metrics.box_thickness); + self.hline(canvas, 0, self.metrics.cell_width, (self.metrics.cell_height -| thick_px) / 2, thick_px); } fn vline( @@ -2861,11 +2880,11 @@ fn vline( thickness_px: u32, ) void { canvas.rect((font.sprite.Box(u32){ .p0 = .{ - .x = @min(@max(x, 0), self.width), - .y = @min(@max(y1, 0), self.height), + .x = @min(@max(x, 0), self.metrics.cell_width), + .y = @min(@max(y1, 0), self.metrics.cell_height), }, .p1 = .{ - .x = @min(@max(x + thickness_px, 0), self.width), - .y = @min(@max(y2, 0), self.height), + .x = @min(@max(x + thickness_px, 0), self.metrics.cell_width), + .y = @min(@max(y2, 0), self.metrics.cell_height), } }).rect(), .on); } @@ -2878,11 +2897,11 @@ fn hline( thickness_px: u32, ) void { canvas.rect((font.sprite.Box(u32){ .p0 = .{ - .x = @min(@max(x1, 0), self.width), - .y = @min(@max(y, 0), self.height), + .x = @min(@max(x1, 0), self.metrics.cell_width), + .y = @min(@max(y, 0), self.metrics.cell_height), }, .p1 = .{ - .x = @min(@max(x2, 0), self.width), - .y = @min(@max(y + thickness_px, 0), self.height), + .x = @min(@max(x2, 0), self.metrics.cell_width), + .y = @min(@max(y + thickness_px, 0), self.metrics.cell_height), } }).rect(), .on); } @@ -2895,11 +2914,11 @@ fn rect( y2: u32, ) void { canvas.rect((font.sprite.Box(u32){ .p0 = .{ - .x = @min(@max(x1, 0), self.width), - .y = @min(@max(y1, 0), self.height), + .x = @min(@max(x1, 0), self.metrics.cell_width), + .y = @min(@max(y1, 0), self.metrics.cell_height), }, .p1 = .{ - .x = @min(@max(x2, 0), self.width), - .y = @min(@max(y2, 0), self.height), + .x = @min(@max(x2, 0), self.metrics.cell_width), + .y = @min(@max(y2, 0), self.metrics.cell_height), } }).rect(), .on); } @@ -2913,14 +2932,21 @@ test "all" { var atlas_grayscale = try font.Atlas.init(alloc, 512, .grayscale); defer atlas_grayscale.deinit(alloc); - const face: Box = .{ .width = 18, .height = 36, .thickness = 2 }; + const face: Box = .{ + .metrics = font.Metrics.calc(.{ + .cell_width = 18.0, + .ascent = 30.0, + .descent = -6.0, + .line_gap = 0.0, + }), + }; const glyph = try face.renderGlyph( alloc, &atlas_grayscale, cp, ); - try testing.expectEqual(@as(u32, face.width), glyph.width); - try testing.expectEqual(@as(u32, face.height), glyph.height); + try testing.expectEqual(@as(u32, face.metrics.cell_width), glyph.width); + try testing.expectEqual(@as(u32, face.metrics.cell_height), glyph.height); } } @@ -3037,18 +3063,28 @@ test "render all sprites" { var atlas_grayscale = try font.Atlas.init(alloc, 1024, .grayscale); defer atlas_grayscale.deinit(alloc); - // Even cell size and thickness + // Even cell size and thickness (18 x 36) try (Box{ - .width = 18, - .height = 36, - .thickness = 2, + .metrics = font.Metrics.calc(.{ + .cell_width = 18.0, + .ascent = 30.0, + .descent = -6.0, + .line_gap = 0.0, + .underline_thickness = 2.0, + .strikethrough_thickness = 2.0, + }), }).testRenderAll(alloc, &atlas_grayscale); - // Odd cell size and thickness + // Odd cell size and thickness (9 x 15) try (Box{ - .width = 9, - .height = 15, - .thickness = 1, + .metrics = font.Metrics.calc(.{ + .cell_width = 9.0, + .ascent = 12.0, + .descent = -3.0, + .line_gap = 0.0, + .underline_thickness = 1.0, + .strikethrough_thickness = 1.0, + }), }).testRenderAll(alloc, &atlas_grayscale); const ground_truth = @embedFile("./testdata/Box.ppm"); diff --git a/src/font/sprite/Face.zig b/src/font/sprite/Face.zig index e1cd12f00..b8c89c74e 100644 --- a/src/font/sprite/Face.zig +++ b/src/font/sprite/Face.zig @@ -24,22 +24,8 @@ const underline = @import("underline.zig"); const log = std.log.scoped(.font_sprite); -/// The cell width and height. -width: u32, -height: u32, - -/// Base thickness value for lines of sprites. This is in pixels. If you -/// want to do any DPI scaling, it is expected to be done earlier. -thickness: u32 = 1, - -/// The position of the underline. -underline_position: u32 = 0, - -/// The position of the strikethrough. -// NOTE(mitchellh): We don't use a dedicated strikethrough thickness -// setting yet but fonts can in theory set this. If this becomes an -// issue in practice we can add it here. -strikethrough_position: u32 = 0, +/// Grid metrics for rendering sprites. +metrics: font.Metrics, /// Returns true if the codepoint exists in our sprite font. pub fn hasCodepoint(self: Face, cp: u32, p: ?font.Presentation) bool { @@ -65,10 +51,12 @@ pub fn renderGlyph( } } + const metrics = opts.grid_metrics orelse self.metrics; + // We adjust our sprite width based on the cell width. const width = switch (opts.cell_width orelse 1) { - 0, 1 => self.width, - else => |width| self.width * width, + 0, 1 => metrics.cell_width, + else => |width| metrics.cell_width * width, }; // It should be impossible for this to be null and we assert that @@ -86,58 +74,16 @@ pub fn renderGlyph( // Safe to ".?" because of the above assertion. return switch (kind) { - .box => box: { - const thickness = switch (cp) { - @intFromEnum(Sprite.cursor_rect), - @intFromEnum(Sprite.cursor_hollow_rect), - @intFromEnum(Sprite.cursor_bar), - => if (opts.grid_metrics) |m| m.cursor_thickness else self.thickness, - else => self.thickness, - }; - - const f: Box, const y_offset: u32 = face: { - // Expected, usual values. - var f: Box = .{ - .width = width, - .height = self.height, - .thickness = thickness, - }; - - // If the codepoint is unadjusted then we want to adjust - // (heh) the width/height to the proper size and also record - // an offset to apply to our final glyph so it renders in the - // correct place because renderGlyph assumes full size. - var y_offset: u32 = 0; - if (Box.unadjustedCodepoint(cp)) unadjust: { - const metrics = opts.grid_metrics orelse break :unadjust; - const height = metrics.original_cell_height orelse break :unadjust; - - // If our height shrunk, then we use the original adjusted - // height because we don't want to overflow the cell. - if (height >= self.height) break :unadjust; - - // The offset is divided by two because it is vertically - // centered. - y_offset = (self.height - height) / 2; - f.height = height; - } - - break :face .{ f, y_offset }; - }; - - var g = try f.renderGlyph(alloc, atlas, cp); - g.offset_y += @intCast(y_offset); - break :box g; - }, + .box => (Box{ .metrics = metrics }).renderGlyph(alloc, atlas, cp), .underline => try underline.renderGlyph( alloc, atlas, @enumFromInt(cp), width, - self.height, - self.underline_position, - self.thickness, + metrics.cell_height, + metrics.underline_position, + metrics.underline_thickness, ), .strikethrough => try underline.renderGlyph( @@ -145,26 +91,34 @@ pub fn renderGlyph( atlas, @enumFromInt(cp), width, - self.height, - self.strikethrough_position, - self.thickness, + metrics.cell_height, + metrics.strikethrough_position, + metrics.strikethrough_thickness, ), - .overline => try underline.renderGlyph( - alloc, - atlas, - @enumFromInt(cp), - width, - self.height, - 0, - self.thickness, - ), + .overline => overline: { + var g = try underline.renderGlyph( + alloc, + atlas, + @enumFromInt(cp), + width, + metrics.cell_height, + 0, + metrics.overline_thickness, + ); + + // We have to manually subtract the overline position + // on the rendered glyph since it can be negative. + g.offset_y -= metrics.overline_position; + + break :overline g; + }, .powerline => powerline: { const f: Powerline = .{ - .width = width, - .height = self.height, - .thickness = self.thickness, + .width = metrics.cell_width, + .height = metrics.cell_height, + .thickness = metrics.box_thickness, }; break :powerline try f.renderGlyph(alloc, atlas, cp); From 5ef422b69a8ec06d7248b70aa250e49fcbbdeb19 Mon Sep 17 00:00:00 2001 From: Toby Jaffey Date: Wed, 11 Dec 2024 23:38:34 +0000 Subject: [PATCH 15/47] Add '$' to select boundaries, for same behaviour as iTerm --- src/terminal/Screen.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index ac9483742..77e3bbe50 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -2428,6 +2428,7 @@ pub fn selectWord(self: *Screen, pin: Pin) ?Selection { '}', '<', '>', + '$', }; // If our cell is empty we can't select a word, because we can't select From fb50143cec9bfec1f86d1fbfda87799519a87829 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Wed, 11 Dec 2024 21:14:21 -0500 Subject: [PATCH 16/47] font(coretext): add metrics test case for CT, fix variable font init Variable font init used to just select the first available predefined instance, if there were any, which is often not desirable- using createFontDescriptorFromData instead of createFontDescritorsFromData ensures that the default variation config is selected. In the future we should probably allow selection of predefined instances, but for now this is the correct behavior. I found this bug when adding the metrics calculation test case for CoreText, hence why fixing it is part of the same commit. --- pkg/macos/text.zig | 1 + pkg/macos/text/font_manager.zig | 7 ++++ src/font/face/coretext.zig | 61 ++++++++++++++++++++++++++++++--- 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/pkg/macos/text.zig b/pkg/macos/text.zig index 149cef66b..0589f8692 100644 --- a/pkg/macos/text.zig +++ b/pkg/macos/text.zig @@ -20,6 +20,7 @@ pub const FontVariationAxisKey = font_descriptor.FontVariationAxisKey; pub const FontSymbolicTraits = font_descriptor.FontSymbolicTraits; pub const createFontDescriptorsFromURL = font_manager.createFontDescriptorsFromURL; pub const createFontDescriptorsFromData = font_manager.createFontDescriptorsFromData; +pub const createFontDescriptorFromData = font_manager.createFontDescriptorFromData; pub const Frame = frame.Frame; pub const Framesetter = framesetter.Framesetter; pub const Line = line.Line; diff --git a/pkg/macos/text/font_manager.zig b/pkg/macos/text/font_manager.zig index f918167a0..988da1220 100644 --- a/pkg/macos/text/font_manager.zig +++ b/pkg/macos/text/font_manager.zig @@ -1,6 +1,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const foundation = @import("../foundation.zig"); +const FontDescriptor = @import("./font_descriptor.zig").FontDescriptor; const c = @import("c.zig").c; pub fn createFontDescriptorsFromURL(url: *foundation.URL) ?*foundation.Array { @@ -14,3 +15,9 @@ pub fn createFontDescriptorsFromData(data: *foundation.Data) ?*foundation.Array @ptrCast(data), ))); } + +pub fn createFontDescriptorFromData(data: *foundation.Data) ?*FontDescriptor { + return @ptrFromInt(@intFromPtr(c.CTFontManagerCreateFontDescriptorFromData( + @ptrCast(data), + ))); +} diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index 6a77ee159..263a5f915 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -55,12 +55,10 @@ pub const Face = struct { const data = try macos.foundation.Data.createWithBytesNoCopy(source); defer data.release(); - const arr = macos.text.createFontDescriptorsFromData(data) orelse + const desc = macos.text.createFontDescriptorFromData(data) orelse return error.FontInitFailure; - defer arr.release(); - if (arr.getCount() == 0) return error.FontInitFailure; + defer desc.release(); - const desc = arr.getValueAtIndex(macos.text.FontDescriptor, 0); const ct_font = try macos.text.Font.createWithFontDescriptor(desc, 12); defer ct_font.release(); @@ -924,3 +922,58 @@ test "glyphIndex colored vs text" { try testing.expect(face.isColorGlyph(glyph)); } } + +test "coretext: metrics" { + const testFont = font.embedded.inconsolata; + const alloc = std.testing.allocator; + + var atlas = try font.Atlas.init(alloc, 512, .grayscale); + defer atlas.deinit(alloc); + + var ct_font = try Face.init( + undefined, + testFont, + .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, + ); + defer ct_font.deinit(); + + try std.testing.expectEqual(font.face.Metrics{ + .cell_width = 8, + // The cell height is 17 px because the calculation is + // + // ascender - descender + gap + // + // which, for inconsolata is + // + // 859 - -190 + 0 + // + // font units, at 1000 units per em that works out to 1.049 em, + // and 1em should be the point size * dpi scale, so 12 * (96/72) + // which is 16, and 16 * 1.049 = 16.784, which finally is rounded + // to 17. + .cell_height = 17, + .cell_baseline = 3, + .underline_position = 17, + .underline_thickness = 1, + .strikethrough_position = 10, + .strikethrough_thickness = 1, + .overline_position = 0, + .overline_thickness = 1, + .box_thickness = 1, + }, ct_font.metrics); + + // Resize should change metrics + try ct_font.setSize(.{ .size = .{ .points = 24, .xdpi = 96, .ydpi = 96 } }); + try std.testing.expectEqual(font.face.Metrics{ + .cell_width = 16, + .cell_height = 34, + .cell_baseline = 6, + .underline_position = 34, + .underline_thickness = 2, + .strikethrough_position = 19, + .strikethrough_thickness = 2, + .overline_position = 0, + .overline_thickness = 2, + .box_thickness = 2, + }, ct_font.metrics); +} From 5180cc6c0ebd497dd8f05e01445ca227faed4b73 Mon Sep 17 00:00:00 2001 From: Anthony Date: Thu, 12 Dec 2024 15:26:26 +1100 Subject: [PATCH 17/47] Remove executable permission from readonly config file --- dist/linux/ghostty_dolphin.desktop | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 dist/linux/ghostty_dolphin.desktop diff --git a/dist/linux/ghostty_dolphin.desktop b/dist/linux/ghostty_dolphin.desktop old mode 100755 new mode 100644 From b19d0d36380af732c61607c7a1669fcb1813290e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 11 Dec 2024 20:25:19 -0800 Subject: [PATCH 18/47] Back out "apprt/gtk: force X11 backend on GTK 4.14" This backs out commit bb185cf6b695420ce8b43b5c1cadd16ef71c481a. This was breaking IME input for some users and overall I couldn't find other users where this really fixed anything other than me so I'm going to back this out and fix this using my own system. --- src/apprt/gtk/App.zig | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 6329644be..8c42ddf37 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -123,13 +123,6 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { // and initializing a Vulkan context was causing a longer delay // on some systems. _ = internal_os.setenv("GDK_DEBUG", "opengl,gl-disable-gles,vulkan-disable"); - - // Wayland-EGL on GTK 4.14 causes "Failed to create EGL context" errors. - // This can be fixed by forcing the backend to prefer X11. This issue - // appears to be fixed in GTK 4.16 but I wasn't able to bisect why. - // The "*" at the end says that if X11 fails, try all remaining - // backends. - _ = internal_os.setenv("GDK_BACKEND", "x11,*"); } else { // Versions prior to 4.14 are a bit of an unknown for Ghostty. It // is an environment that isn't tested well and we don't have a From fb0f5519c164b853159e2e4e358a564e59ce53c2 Mon Sep 17 00:00:00 2001 From: Anthony Date: Thu, 12 Dec 2024 15:35:29 +1100 Subject: [PATCH 19/47] Revert "Change oniguruma link target from `oniguruma` to `onig`" This reverts commit daa0fe00b16989cf4696c686aab33b80763834a3. It is correct to use the pkg-config name instead of the literal dylib name --- build.zig | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/build.zig b/build.zig index f1ff1b1c1..6904a4815 100644 --- a/build.zig +++ b/build.zig @@ -1120,8 +1120,7 @@ fn addDeps( }); step.root_module.addImport("oniguruma", oniguruma_dep.module("oniguruma")); if (b.systemIntegrationOption("oniguruma", .{})) { - // Oniguruma is compiled and distributed as libonig.so - step.linkSystemLibrary2("onig", dynamic_link_opts); + step.linkSystemLibrary2("oniguruma", dynamic_link_opts); } else { step.linkLibrary(oniguruma_dep.artifact("oniguruma")); try static_libs.append(oniguruma_dep.artifact("oniguruma").getEmittedBin()); From db1019b1c04bec6b200efc843a71ccf31119d7b5 Mon Sep 17 00:00:00 2001 From: Josh Mills Date: Thu, 12 Dec 2024 06:42:27 +0000 Subject: [PATCH 20/47] Fix typo in config documentation --- src/config/Config.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 86d045c6a..7f9a5f9e8 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -314,7 +314,7 @@ const c = @cImport({ /// A theme to use. This can be a built-in theme name, a custom theme /// name, or an absolute path to a custom theme file. Ghostty also supports -/// specifying a different them to use for light and dark mode. Each +/// specifying a different theme to use for light and dark mode. Each /// option is documented below. /// /// If the theme is an absolute pathname, Ghostty will attempt to load that From 146b1f2a1bf7edf82afd53d5e852076e92e0d443 Mon Sep 17 00:00:00 2001 From: Pranav Mangal Date: Wed, 11 Dec 2024 23:10:03 +0530 Subject: [PATCH 21/47] Add delay before a title change to avoid flicker on macOS --- .../Features/Terminal/BaseTerminalController.swift | 11 +++++++++-- .../Features/Terminal/TerminalController.swift | 10 +++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 68c243004..5f4f357b6 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -53,6 +53,10 @@ class BaseTerminalController: NSWindowController, /// Fullscreen state management. private(set) var fullscreenStyle: FullscreenStyle? + + var titleChangeDelay: TimeInterval = 0.075 + + private var titleChangeTimer: Timer? /// Event monitor (see individual events for why) private var eventMonitor: Any? = nil @@ -260,9 +264,12 @@ class BaseTerminalController: NSWindowController, func titleDidChange(to: String) { guard let window else { return } - // Set the main window title - window.title = to + titleChangeTimer?.invalidate() + // Set the main window title after a small delay to prevent flicker + titleChangeTimer = Timer.scheduledTimer(withTimeInterval: titleChangeDelay, repeats: false) { _ in + window.title = to + } } func pwdDidChange(to: URL?) { diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 67e7259f3..edb797890 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -26,6 +26,8 @@ class TerminalController: BaseTerminalController { /// The notification cancellable for focused surface property changes. private var surfaceAppearanceCancellables: Set = [] + + private var toolbarTitleChangeTimer: Timer? init(_ ghostty: Ghostty.App, withBaseConfig base: Ghostty.SurfaceConfiguration? = nil, @@ -546,7 +548,13 @@ class TerminalController: BaseTerminalController { // a custom view instead, we need to re-hide it. window.titleVisibility = .hidden } - toolbar.titleText = to + + toolbarTitleChangeTimer?.invalidate() + + // Set the toolbar title after a small delay to prevent flicker + toolbarTitleChangeTimer = Timer.scheduledTimer(withTimeInterval: titleChangeDelay, repeats: false) { _ in + toolbar.titleText = to + } } } From e35bd431f4245a42c35935d8e8c74c59dfd40b28 Mon Sep 17 00:00:00 2001 From: Pranav Mangal Date: Thu, 12 Dec 2024 14:58:42 +0530 Subject: [PATCH 22/47] Move title change timer to SurfaceView and call it from Ghostty.App instead of terminal controllers --- .../Terminal/BaseTerminalController.swift | 11 ++-------- .../Terminal/TerminalController.swift | 10 +--------- macos/Sources/Ghostty/Ghostty.App.swift | 20 ++++++++++++------- .../Sources/Ghostty/SurfaceView_AppKit.swift | 4 ++++ 4 files changed, 20 insertions(+), 25 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 5f4f357b6..68c243004 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -53,10 +53,6 @@ class BaseTerminalController: NSWindowController, /// Fullscreen state management. private(set) var fullscreenStyle: FullscreenStyle? - - var titleChangeDelay: TimeInterval = 0.075 - - private var titleChangeTimer: Timer? /// Event monitor (see individual events for why) private var eventMonitor: Any? = nil @@ -264,12 +260,9 @@ class BaseTerminalController: NSWindowController, func titleDidChange(to: String) { guard let window else { return } - titleChangeTimer?.invalidate() + // Set the main window title + window.title = to - // Set the main window title after a small delay to prevent flicker - titleChangeTimer = Timer.scheduledTimer(withTimeInterval: titleChangeDelay, repeats: false) { _ in - window.title = to - } } func pwdDidChange(to: URL?) { diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index edb797890..67e7259f3 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -26,8 +26,6 @@ class TerminalController: BaseTerminalController { /// The notification cancellable for focused surface property changes. private var surfaceAppearanceCancellables: Set = [] - - private var toolbarTitleChangeTimer: Timer? init(_ ghostty: Ghostty.App, withBaseConfig base: Ghostty.SurfaceConfiguration? = nil, @@ -548,13 +546,7 @@ class TerminalController: BaseTerminalController { // a custom view instead, we need to re-hide it. window.titleVisibility = .hidden } - - toolbarTitleChangeTimer?.invalidate() - - // Set the toolbar title after a small delay to prevent flicker - toolbarTitleChangeTimer = Timer.scheduledTimer(withTimeInterval: titleChangeDelay, repeats: false) { _ in - toolbar.titleText = to - } + toolbar.titleText = to } } diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 9056e692a..c8e3cc476 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -947,15 +947,21 @@ extension Ghostty { guard let surface = target.target.surface else { return } guard let surfaceView = self.surfaceView(from: surface) else { return } guard let title = String(cString: v.title!, encoding: .utf8) else { return } - - // We must set this in a dispatchqueue to avoid a deadlock on startup on some - // versions of macOS. I unfortunately didn't document the exact versions so - // I don't know when its safe to remove this. - DispatchQueue.main.async { - surfaceView.title = title + + surfaceView.titleChangeTimer?.invalidate() + + surfaceView.titleChangeTimer = Timer.scheduledTimer( + withTimeInterval: surfaceView.titleChangeDelay, + repeats: false + ) { _ in + // We must set this in a dispatchqueue to avoid a deadlock on startup on some + // versions of macOS. I unfortunately didn't document the exact versions so + // I don't know when its safe to remove this. + DispatchQueue.main.async { + surfaceView.title = title + } } - default: assertionFailure() } diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 7e861a229..c426e9e07 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -13,6 +13,10 @@ extension Ghostty { // changed with escape codes. This is public because the callbacks go // to the app level and it is set from there. @Published var title: String = "👻" + + // A small delay that is introduced before a title change to avoid flickers + var titleChangeDelay: TimeInterval = 0.075 + var titleChangeTimer: Timer? // The current pwd of the surface as defined by the pty. This can be // changed with escape codes. From 1403f21d1ca7b87c2fa994a29681b527e53eed82 Mon Sep 17 00:00:00 2001 From: Anmol Wadhwani <4815989+anmolw@users.noreply.github.com> Date: Thu, 12 Dec 2024 14:44:14 +0530 Subject: [PATCH 23/47] Add weekly iterm2-colorschemes update workflow --- .github/workflows/update-colorschemes.yml | 53 +++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 .github/workflows/update-colorschemes.yml diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml new file mode 100644 index 000000000..897ae406f --- /dev/null +++ b/.github/workflows/update-colorschemes.yml @@ -0,0 +1,53 @@ +name: Update iTerm2 colorschemes +on: + schedule: + # Once a week + - cron: "0 0 * * 0" + workflow_dispatch: +jobs: + update-iterm2-schemes: + if: github.repository == 'ghostty-org/ghostty' + runs-on: ubuntu-latest + permissions: + # Needed for create-pull-request action + contents: write + pull-requests: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Nix + uses: cachix/install-nix-action@v30 + with: + nix_path: nixpkgs=channel:nixos-unstable + + - name: Run zig fetch + id: zig_fetch + run: | + UPSTREAM_REV="$(curl "https://api.github.com/repos/mbadolato/iTerm2-Color-Schemes/commits/master" | jq -r '.sha')" + nix develop -c zig fetch --save="iterm2_themes" "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/$UPSTREAM_REV.tar.gz" + echo "upstream_rev=$UPSTREAM_REV" >> "$GITHUB_OUTPUT" + + - name: Update zig cache hash + run: | + # Only proceed if build.zig.zon has changed + if [ "$(git status build.zig.zon --porcelain)" ]; then + nix develop -c ./nix/build-support/check-zig-cache-hash.sh --update + fi + + - name: Create pull request + uses: peter-evans/create-pull-request@v7 + with: + title: Update iTerm2 colorschemes + base: main + branch: iterm2_colors_action + commit-message: "deps: Update iTerm2 color schemes" + add-paths: | + build.zig.zon + nix/zigCacheHash.nix + body: | + Upstream revision: https://github.com/mbadolato/iTerm2-Color-Schemes/tree/${{ steps.zig_fetch.outputs.upstream_rev }} + labels: dependencies + From 47273de4c3ca33bbbb7e1ee819505c01bc20ace5 Mon Sep 17 00:00:00 2001 From: Toby Jaffey Date: Thu, 12 Dec 2024 16:47:04 +0000 Subject: [PATCH 24/47] Added "selectWord with character boundary" test for dollar sign. --- src/terminal/Screen.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 77e3bbe50..c7c3fa978 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -7299,6 +7299,7 @@ test "Screen: selectWord with character boundary" { " }abc} \n123", " abc> \n123", + " $abc$ \n123", }; for (cases) |case| { From 2c3e0df6e98dd0c6b49062e5a136d0cfe0ce4436 Mon Sep 17 00:00:00 2001 From: Anmol Wadhwani <4815989+anmolw@users.noreply.github.com> Date: Thu, 12 Dec 2024 23:59:58 +0530 Subject: [PATCH 25/47] Use git diff --exit-code in conditional --- .github/workflows/update-colorschemes.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index 897ae406f..2bb2d44fc 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -33,7 +33,7 @@ jobs: - name: Update zig cache hash run: | # Only proceed if build.zig.zon has changed - if [ "$(git status build.zig.zon --porcelain)" ]; then + if ! git diff --exit-code build.zig.zon; then nix develop -c ./nix/build-support/check-zig-cache-hash.sh --update fi From 0557bf83013003479f92740fd38b4f327c7ea1da Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Thu, 12 Dec 2024 13:47:13 -0500 Subject: [PATCH 26/47] font(metrics): always apply minimum values after calculating --- src/font/face/Metrics.zig | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/font/face/Metrics.zig b/src/font/face/Metrics.zig index 17a03d497..d6b1bdd0c 100644 --- a/src/font/face/Metrics.zig +++ b/src/font/face/Metrics.zig @@ -156,7 +156,7 @@ pub fn calc(opts: CalcOpts) Metrics { (opts.strikethrough_position orelse ex_height * 0.5 + strikethrough_thickness * 0.5)); - const result: Metrics = .{ + var result: Metrics = .{ .cell_width = @intFromFloat(cell_width), .cell_height = @intFromFloat(cell_height), .cell_baseline = @intFromFloat(cell_baseline), @@ -169,6 +169,9 @@ pub fn calc(opts: CalcOpts) Metrics { .box_thickness = @intFromFloat(underline_thickness), }; + // Ensure all metrics are within their allowable range. + result.clamp(); + // std.log.debug("metrics={}", .{result}); return result; @@ -220,16 +223,25 @@ pub fn apply(self: *Metrics, mods: ModifierSet) void { }, inline else => |tag| { - var new = entry.value_ptr.apply(@field(self, @tagName(tag))); - // If we have a minimum acceptable value - // for this metric, clamp the new value. - if (@hasDecl(Minimums, @tagName(tag))) { - new = @max(new, @field(Minimums, @tagName(tag))); - } - @field(self, @tagName(tag)) = new; + @field(self, @tagName(tag)) = entry.value_ptr.apply(@field(self, @tagName(tag))); }, } } + + // Prevent modifiers from pushing metrics out of their allowable range. + self.clamp(); +} + +/// Clamp all metrics to their allowable range. +fn clamp(self: *Metrics) void { + inline for (std.meta.fields(Metrics)) |field| { + if (@hasDecl(Minimums, field.name)) { + @field(self, field.name) = @max( + @field(self, field.name), + @field(Minimums, field.name), + ); + } + } } /// A set of modifiers to apply to metrics. We use a hash map because From 69e253743871bf7ad4bb36630d101c496e56a012 Mon Sep 17 00:00:00 2001 From: Borys Lykah Date: Thu, 12 Dec 2024 12:21:45 -0700 Subject: [PATCH 27/47] Preserve ZSH options in the shell integration --- README.md | 13 +-- src/shell-integration/README.md | 13 +++ src/shell-integration/zsh/ghostty-integration | 88 ++++++++++--------- 3 files changed, 67 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 4cafc6ac1..5052ac214 100644 --- a/README.md +++ b/README.md @@ -286,13 +286,16 @@ if [ -n "${GHOSTTY_RESOURCES_DIR}" ]; then fi ``` +For details see shell-integration/README.md. + Each shell integration's installation instructions are documented inline: -| Shell | Integration | -| ------ | ---------------------------------------------------------------------------------------------- | -| `bash` | `${GHOSTTY_RESOURCES_DIR}/shell-integration/bash/ghostty.bash` | -| `fish` | `${GHOSTTY_RESOURCES_DIR}/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish` | -| `zsh` | `${GHOSTTY_RESOURCES_DIR}/shell-integration/zsh/ghostty-integration` | +| Shell | Integration | +| -------- | ---------------------------------------------------------------------------------------------- | +| `bash` | `${GHOSTTY_RESOURCES_DIR}/shell-integration/bash/ghostty.bash` | +| `fish` | `${GHOSTTY_RESOURCES_DIR}/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish` | +| `zsh` | `${GHOSTTY_RESOURCES_DIR}/shell-integration/zsh/ghostty-integration` | +| `elvish` | `${GHOSTTY_RESOURCES_DIR}/shell-integration/elvish/lib/ghostty-integration.elv` | ### Terminfo diff --git a/src/shell-integration/README.md b/src/shell-integration/README.md index 130ef5dfe..b788f9613 100644 --- a/src/shell-integration/README.md +++ b/src/shell-integration/README.md @@ -24,6 +24,13 @@ must be explicitly enabled (`shell-integration = bash`). Bash shell integration can also be sourced manually from `bash/ghostty.bash`. This also works for older versions of Bash. +```bash +# Ghostty shell integration for Bash. This must be at the top of your bashrc! +if [ -n "${GHOSTTY_RESOURCES_DIR}" ]; then + builtin source "${GHOSTTY_RESOURCES_DIR}/shell-integration/bash/ghostty.bash" +fi +``` + ### Elvish For [Elvish](https://elv.sh), `$GHOSTTY_RESOURCES_DIR/src/shell-integration` @@ -59,3 +66,9 @@ For `zsh`, Ghostty sets `ZDOTDIR` so that it loads our configuration from the `zsh` directory. The existing `ZDOTDIR` is retained so that after loading the Ghostty shell integration the normal Zsh loading sequence occurs. + +```bash +if [[ -n $GHOSTTY_RESOURCES_DIR ]]; then + "$GHOSTTY_RESOURCES_DIR"/shell-integration/zsh/ghostty-integration +fi +``` \ No newline at end of file diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index b65766e6a..fb54cba75 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -25,9 +25,7 @@ # Ghostty in all shells should add the following lines to their .zshrc: # # if [[ -n $GHOSTTY_RESOURCES_DIR ]]; then -# autoload -Uz -- "$GHOSTTY_RESOURCES_DIR"/shell-integration/zsh/ghostty-integration -# ghostty-integration -# unfunction ghostty-integration +# "$GHOSTTY_RESOURCES_DIR"/shell-integration/zsh/ghostty-integration # fi # # Implementation note: We can assume that alias expansion is disabled in this @@ -35,49 +33,53 @@ # builtins with `builtin` to avoid accidentally invoking user-defined functions. # We avoid `function` reserved word as an additional defensive measure. -builtin emulate -L zsh -o no_warn_create_global -o no_aliases +# Note that updating options with `builtin emulate -L zsh` affects the global options +# if it's called outside of a function. So nearly all code has to be in functions. +_entrypoint() { + builtin emulate -L zsh -o no_warn_create_global -o no_aliases -[[ -o interactive ]] || builtin return 0 # non-interactive shell -(( ! $+_ghostty_state )) || builtin return 0 # already initialized + [[ -o interactive ]] || builtin return 0 # non-interactive shell + (( ! $+_ghostty_state )) || builtin return 0 # already initialized -# 0: no OSC 133 [AC] marks have been written yet. -# 1: the last written OSC 133 C has not been closed with D yet. -# 2: none of the above. -builtin typeset -gi _ghostty_state + # 0: no OSC 133 [AC] marks have been written yet. + # 1: the last written OSC 133 C has not been closed with D yet. + # 2: none of the above. + builtin typeset -gi _ghostty_state -# Attempt to create a writable file descriptor to the TTY so that we can print -# to the TTY later even when STDOUT is redirected. This code is fairly subtle. -# -# - It's tempting to do `[[ -t 1 ]] && exec {_ghostty_state}>&1` but we cannot do this -# because it'll create a file descriptor >= 10 without O_CLOEXEC. This file -# descriptor will leak to child processes. -# - If we do `exec {3}>&1`, the file descriptor won't leak to the child processes -# but it'll still leak if the current process is replaced with another. In -# addition, it'll break user code that relies on fd 3 being available. -# - Zsh doesn't expose dup3, which would have allowed us to copy STDOUT with -# O_CLOEXEC. The only way to create a file descriptor with O_CLOEXEC is via -# sysopen. -# - `zmodload zsh/system` and `sysopen -o cloexec -wu _ghostty_fd -- /dev/tty` can -# fail with an error message to STDERR (the latter can happen even if /dev/tty -# is writable), hence the redirection of STDERR. We do it for the whole block -# for performance reasons (redirections are slow). -# - We must open the file descriptor right here rather than in _ghostty_deferred_init -# because there are broken zsh plugins out there that run `exec {fd}< <(cmd)` -# and then close the file descriptor more than once while suppressing errors. -# This could end up closing our file descriptor if we opened it in -# _ghostty_deferred_init. -typeset -gi _ghostty_fd -{ - builtin zmodload zsh/system && (( $+builtins[sysopen] )) && { - { [[ -w $TTY ]] && builtin sysopen -o cloexec -wu _ghostty_fd -- $TTY } || - { [[ -w /dev/tty ]] && builtin sysopen -o cloexec -wu _ghostty_fd -- /dev/tty } - } -} 2>/dev/null || (( _ghostty_fd = 1 )) + # Attempt to create a writable file descriptor to the TTY so that we can print + # to the TTY later even when STDOUT is redirected. This code is fairly subtle. + # + # - It's tempting to do `[[ -t 1 ]] && exec {_ghostty_state}>&1` but we cannot do this + # because it'll create a file descriptor >= 10 without O_CLOEXEC. This file + # descriptor will leak to child processes. + # - If we do `exec {3}>&1`, the file descriptor won't leak to the child processes + # but it'll still leak if the current process is replaced with another. In + # addition, it'll break user code that relies on fd 3 being available. + # - Zsh doesn't expose dup3, which would have allowed us to copy STDOUT with + # O_CLOEXEC. The only way to create a file descriptor with O_CLOEXEC is via + # sysopen. + # - `zmodload zsh/system` and `sysopen -o cloexec -wu _ghostty_fd -- /dev/tty` can + # fail with an error message to STDERR (the latter can happen even if /dev/tty + # is writable), hence the redirection of STDERR. We do it for the whole block + # for performance reasons (redirections are slow). + # - We must open the file descriptor right here rather than in _ghostty_deferred_init + # because there are broken zsh plugins out there that run `exec {fd}< <(cmd)` + # and then close the file descriptor more than once while suppressing errors. + # This could end up closing our file descriptor if we opened it in + # _ghostty_deferred_init. + typeset -gi _ghostty_fd + { + builtin zmodload zsh/system && (( $+builtins[sysopen] )) && { + { [[ -w $TTY ]] && builtin sysopen -o cloexec -wu _ghostty_fd -- $TTY } || + { [[ -w /dev/tty ]] && builtin sysopen -o cloexec -wu _ghostty_fd -- /dev/tty } + } + } 2>/dev/null || (( _ghostty_fd = 1 )) -# Defer initialization so that other zsh init files can be configure -# the integration. -builtin typeset -ag precmd_functions -precmd_functions+=(_ghostty_deferred_init) + # Defer initialization so that other zsh init files can be configure + # the integration. + builtin typeset -ag precmd_functions + precmd_functions+=(_ghostty_deferred_init) +} _ghostty_deferred_init() { builtin emulate -L zsh -o no_warn_create_global -o no_aliases @@ -310,3 +312,5 @@ _ghostty_deferred_init() { # to unfunction themselves when invoked. Unfunctioning is done by calling code. builtin unfunction _ghostty_deferred_init } + +_entrypoint From 536ed60db148a837d8bef26b678994b1fcaeeeee Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Thu, 12 Dec 2024 15:30:37 -0500 Subject: [PATCH 28/47] fix(kittygfx): load & display command shouldn't respond to i=0,I=0 Load and display (`T`) was responding even with implicit IDs, because the display was achieved with an early return in the transmit function that bypassed the logic to silence implicit ID responses- by making it not an early return we fix this. --- src/terminal/kitty/graphics_exec.zig | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/terminal/kitty/graphics_exec.zig b/src/terminal/kitty/graphics_exec.zig index 057f28065..cc87d6c9d 100644 --- a/src/terminal/kitty/graphics_exec.zig +++ b/src/terminal/kitty/graphics_exec.zig @@ -155,7 +155,7 @@ fn transmit( assert(!load.more); var d_copy = d; d_copy.image_id = load.image.id; - return display(alloc, terminal, &.{ + result = display(alloc, terminal, &.{ .control = .{ .display = d_copy }, .quiet = cmd.quiet, }); @@ -551,3 +551,21 @@ test "kittygfx no response with no image ID or number" { try testing.expect(resp == null); } } + +test "kittygfx no response with no image ID or number load and display" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + { + const cmd = try command.Parser.parseString( + alloc, + "a=T,f=24,t=d,s=1,v=2,c=10,r=1,i=0,I=0;////////", + ); + defer cmd.deinit(alloc); + const resp = execute(alloc, &t, &cmd); + try testing.expect(resp == null); + } +} From fd1201323e7dd887dcc98f7fa160e49994e080d0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 12 Dec 2024 12:22:12 -0800 Subject: [PATCH 29/47] unicode: emoji modifier requires emoji modifier base preceding to not break MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #2941 This fixes the rendering of the text below. For those that can't see it, it is the following in UTF-32: `0x22 0x1F3FF 0x22`. ``` "🏿" ``` `0x1F3FF` is the Fitzpatrick modifier for dark skin tone. It has the Unicode property `Emoji_Modifier`. Emoji modifiers are defined in UTS #51 and are only valid based on ED-13: ``` emoji_modifier_sequence := emoji_modifier_base emoji_modifier emoji_modifier_base := \p{Emoji_Modifier_Base} emoji_modifier := \p{Emoji_Modifier} ``` Additional quote from UTS #51: > To have an effect on an emoji, an emoji modifier must immediately follow > that base emoji character. Emoji presentation selectors are neither needed > nor recommended for emoji characters when they are followed by emoji > modifiers, and should not be used in newly generated emoji modifier > sequences; the emoji modifier automatically implies the emoji presentation > style. Our precomputed grapheme break table was mistakingly not following this rule. This commit fixes that by adding a check for that every `Emoji_Modifier` character must be preceded by an `Emoji_Modifier_Base`. This only has a cost during compilation (table generation). The runtime cost is identical; the table size didn't increase since we had leftover bits we could use. --- src/terminal/Terminal.zig | 47 ++++++++++++++++++++++++++++++++++++++- src/unicode/grapheme.zig | 31 +++++++++++++++++++++++--- src/unicode/props.zig | 25 ++++++++++++++++++++- 3 files changed, 98 insertions(+), 5 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index a11028304..eced9e222 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -342,7 +342,7 @@ pub fn print(self: *Terminal, c: u21) !void { if (c == 0xFE0F or c == 0xFE0E) { // This only applies to emoji const prev_props = unicode.getProperties(prev.cell.content.codepoint); - const emoji = prev_props.grapheme_boundary_class == .extended_pictographic; + const emoji = prev_props.grapheme_boundary_class.isExtendedPictographic(); if (!emoji) return; switch (c) { @@ -3193,6 +3193,51 @@ test "Terminal: print multicodepoint grapheme, mode 2027" { } } +test "Terminal: Fitzpatrick skin tone next to non-base" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + // This is: "🏿" (which may not render correctly in your editor!) + try t.print(0x22); // " + try t.print(0x1F3FF); // Dark skin tone + try t.print(0x22); // " + + // We should have 4 cells taken up. Importantly, the skin tone + // should not join with the quotes. + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); + + // Row should be dirty + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + + // Assert various properties about our screen to verify + // we have all expected cells. + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x22), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x1F3FF), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 3, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x22), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } +} + test "Terminal: multicodepoint grapheme marks dirty on every codepoint" { var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); diff --git a/src/unicode/grapheme.zig b/src/unicode/grapheme.zig index 09f452114..25061b5ef 100644 --- a/src/unicode/grapheme.zig +++ b/src/unicode/grapheme.zig @@ -51,7 +51,7 @@ const Precompute = struct { const data = precompute: { var result: [std.math.maxInt(u10)]Value = undefined; - @setEvalBranchQuota(2_000); + @setEvalBranchQuota(3_000); const info = @typeInfo(GraphemeBoundaryClass).Enum; for (0..std.math.maxInt(u2) + 1) |state_init| { for (info.fields) |field1| { @@ -80,7 +80,7 @@ fn graphemeBreakClass( state: *BreakState, ) bool { // GB11: Emoji Extend* ZWJ x Emoji - if (!state.extended_pictographic and gbc1 == .extended_pictographic) { + if (!state.extended_pictographic and gbc1.isExtendedPictographic()) { state.extended_pictographic = true; } @@ -131,12 +131,21 @@ fn graphemeBreakClass( // GB11: Emoji Extend* ZWJ x Emoji if (state.extended_pictographic and gbc1 == .zwj and - gbc2 == .extended_pictographic) + gbc2.isExtendedPictographic()) { state.extended_pictographic = false; return false; } + // UTS #51. This isn't covered by UAX #29 as far as I can tell (but + // I'm probably wrong). This is a special case for emoji modifiers + // which only do not break if they're next to a base. + // + // emoji_modifier_sequence := emoji_modifier_base emoji_modifier + if (gbc2 == .emoji_modifier and gbc1 == .extended_pictographic_base) { + return false; + } + return true; } @@ -181,3 +190,19 @@ pub fn main() !void { pub const std_options = struct { pub const log_level: std.log.Level = .info; }; + +test "grapheme break: emoji modifier" { + const testing = std.testing; + + // Emoji and modifier + { + var state: BreakState = .{}; + try testing.expect(!graphemeBreak(0x261D, 0x1F3FF, &state)); + } + + // Non-emoji and emoji modifier + { + var state: BreakState = .{}; + try testing.expect(graphemeBreak(0x22, 0x1F3FF, &state)); + } +} diff --git a/src/unicode/props.zig b/src/unicode/props.zig index d83f0f699..d77bf4c8a 100644 --- a/src/unicode/props.zig +++ b/src/unicode/props.zig @@ -1,5 +1,6 @@ const props = @This(); const std = @import("std"); +const assert = std.debug.assert; const ziglyph = @import("ziglyph"); const lut = @import("lut.zig"); @@ -73,12 +74,21 @@ pub const GraphemeBoundaryClass = enum(u4) { spacing_mark, regional_indicator, extended_pictographic, + extended_pictographic_base, // \p{Extended_Pictographic} & \p{Emoji_Modifier_Base} + emoji_modifier, // \p{Emoji_Modifier} /// Gets the grapheme boundary class for a codepoint. This is VERY /// SLOW. The use case for this is only in generating lookup tables. pub fn init(cp: u21) GraphemeBoundaryClass { + // We special-case modifier bases because we should not break + // if a modifier isn't next to a base. + if (ziglyph.emoji.isEmojiModifierBase(cp)) { + assert(ziglyph.emoji.isExtendedPictographic(cp)); + return .extended_pictographic_base; + } + + if (ziglyph.emoji.isEmojiModifier(cp)) return .emoji_modifier; if (ziglyph.emoji.isExtendedPictographic(cp)) return .extended_pictographic; - if (ziglyph.emoji.isEmojiModifier(cp)) return .extend; if (ziglyph.grapheme_break.isL(cp)) return .L; if (ziglyph.grapheme_break.isV(cp)) return .V; if (ziglyph.grapheme_break.isT(cp)) return .T; @@ -95,6 +105,19 @@ pub const GraphemeBoundaryClass = enum(u4) { // anything that doesn't fit into the above categories. return .invalid; } + + /// Returns true if this is an extended pictographic type. This + /// should be used instead of comparing the enum value directly + /// because we classify multiple. + pub fn isExtendedPictographic(self: GraphemeBoundaryClass) bool { + return switch (self) { + .extended_pictographic, + .extended_pictographic_base, + => true, + + else => false, + }; + } }; pub fn get(cp: u21) Properties { From 9b4e3622aa30ee10691399525ac514d71c189029 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 12 Dec 2024 13:41:43 -0800 Subject: [PATCH 30/47] ci: iTerm2 job should run on Namespace and use cache --- .github/workflows/update-colorschemes.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index 2bb2d44fc..3855f6015 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -7,21 +7,35 @@ on: jobs: update-iterm2-schemes: if: github.repository == 'ghostty-org/ghostty' - runs-on: ubuntu-latest + runs-on: namespace-profile-ghostty-sm permissions: # Needed for create-pull-request action contents: write pull-requests: write + env: + ZIG_LOCAL_CACHE_DIR: /zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code uses: actions/checkout@v4 with: fetch-depth: 0 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@v1.2.0 + with: + path: | + /nix + /zig + - name: Setup Nix uses: cachix/install-nix-action@v30 with: nix_path: nixpkgs=channel:nixos-unstable + - uses: cachix/cachix-action@v15 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - name: Run zig fetch id: zig_fetch From 5a085267ca1da4cb41c10297ac8a3d3252711db5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 12 Dec 2024 13:42:44 -0800 Subject: [PATCH 31/47] prettier --- .github/workflows/update-colorschemes.yml | 87 +++++++++++------------ 1 file changed, 43 insertions(+), 44 deletions(-) diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index 3855f6015..a911fc736 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -16,52 +16,51 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 - - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 - with: - path: | - /nix - /zig + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@v1.2.0 + with: + path: | + /nix + /zig - - name: Setup Nix - uses: cachix/install-nix-action@v30 - with: - nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 - with: - name: ghostty - authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + - name: Setup Nix + uses: cachix/install-nix-action@v30 + with: + nix_path: nixpkgs=channel:nixos-unstable + - uses: cachix/cachix-action@v15 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - - name: Run zig fetch - id: zig_fetch - run: | - UPSTREAM_REV="$(curl "https://api.github.com/repos/mbadolato/iTerm2-Color-Schemes/commits/master" | jq -r '.sha')" - nix develop -c zig fetch --save="iterm2_themes" "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/$UPSTREAM_REV.tar.gz" - echo "upstream_rev=$UPSTREAM_REV" >> "$GITHUB_OUTPUT" + - name: Run zig fetch + id: zig_fetch + run: | + UPSTREAM_REV="$(curl "https://api.github.com/repos/mbadolato/iTerm2-Color-Schemes/commits/master" | jq -r '.sha')" + nix develop -c zig fetch --save="iterm2_themes" "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/$UPSTREAM_REV.tar.gz" + echo "upstream_rev=$UPSTREAM_REV" >> "$GITHUB_OUTPUT" - - name: Update zig cache hash - run: | - # Only proceed if build.zig.zon has changed - if ! git diff --exit-code build.zig.zon; then - nix develop -c ./nix/build-support/check-zig-cache-hash.sh --update - fi - - - name: Create pull request - uses: peter-evans/create-pull-request@v7 - with: - title: Update iTerm2 colorschemes - base: main - branch: iterm2_colors_action - commit-message: "deps: Update iTerm2 color schemes" - add-paths: | - build.zig.zon - nix/zigCacheHash.nix - body: | - Upstream revision: https://github.com/mbadolato/iTerm2-Color-Schemes/tree/${{ steps.zig_fetch.outputs.upstream_rev }} - labels: dependencies + - name: Update zig cache hash + run: | + # Only proceed if build.zig.zon has changed + if ! git diff --exit-code build.zig.zon; then + nix develop -c ./nix/build-support/check-zig-cache-hash.sh --update + fi + - name: Create pull request + uses: peter-evans/create-pull-request@v7 + with: + title: Update iTerm2 colorschemes + base: main + branch: iterm2_colors_action + commit-message: "deps: Update iTerm2 color schemes" + add-paths: | + build.zig.zon + nix/zigCacheHash.nix + body: | + Upstream revision: https://github.com/mbadolato/iTerm2-Color-Schemes/tree/${{ steps.zig_fetch.outputs.upstream_rev }} + labels: dependencies From c4029015b97168fe520a7fad174804da012646cb Mon Sep 17 00:00:00 2001 From: mitchellh <1299+mitchellh@users.noreply.github.com> Date: Thu, 12 Dec 2024 21:45:11 +0000 Subject: [PATCH 32/47] deps: Update iTerm2 color schemes --- build.zig.zon | 4 ++-- nix/zigCacheHash.nix | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 35365af8a..b53362090 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -49,8 +49,8 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/80543b14552b7c9fef88fad826552e6ac5632abe.tar.gz", - .hash = "1220217ae916146a4c598f8ba5bfff0ff940335d00572e337f20b4accf24fa2ca4fc", + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/d1484b69fcb9ef7f0845e0d0ab67cfe225fad820.tar.gz", + .hash = "122015dac15d6c4d142091ea83619cbce37029c8bc94af4739e2f94ee6d2c625ac5c", }, .vaxis = .{ .url = "git+https://github.com/rockorager/libvaxis/?ref=main#6d729a2dc3b934818dffe06d2ba3ce02841ed74b", diff --git a/nix/zigCacheHash.nix b/nix/zigCacheHash.nix index 162f65500..02d442403 100644 --- a/nix/zigCacheHash.nix +++ b/nix/zigCacheHash.nix @@ -1,3 +1,3 @@ # This file is auto-generated! check build-support/check-zig-cache-hash.sh for # more details. -"sha256-c3MQJG7vwQBOaxHQ8cYP0HxdsLqlgsVmAiT1d7gq6js=" +"sha256-f95EM0DLe5mmU1bg/IIr6ckhxc2KM1Fh1UAmRpM+V2E=" From 10bbb7511b8cf35c17463e2f31d14170ef64ac87 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 12 Dec 2024 13:47:28 -0800 Subject: [PATCH 33/47] ci: colorscheme update should verify nix hash and build --- .github/workflows/update-colorschemes.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index a911fc736..569ef6765 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -49,8 +49,14 @@ jobs: # Only proceed if build.zig.zon has changed if ! git diff --exit-code build.zig.zon; then nix develop -c ./nix/build-support/check-zig-cache-hash.sh --update + nix develop -c ./nix/build-support/check-zig-cache-hash.sh fi + # Verify the build still works. We choose an arbitrary build type + # as a canary instead of testing all build types. + - name: Test Build + run: nix develop -c zig build -Dapp-runtime=gtk -Dgtk-adwaita=true + - name: Create pull request uses: peter-evans/create-pull-request@v7 with: From 10abeba414c077b6e91cdeb410a8a390be0dd1da Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Thu, 12 Dec 2024 16:58:48 -0500 Subject: [PATCH 34/47] test: big perf win by pausing integ checks while growing pages In multiple tests we create 1 or more pages by growing them 1 row at a time, which results in an integrity check of the page for each row grown which is just... horrible. By simply pausing integrity checks while growing these pages (since growing them is not the point of the test) we MASSIVELY speed up all of these tests. Also reduced grapheme bytes during testing and made the Terminal "glitch text" test actually assert what it intends to achieve, rather than just blindly assuming 100 copies of the text will be the right amount -- this lets us stop a lot earlier, making it take practically no time. --- src/terminal/PageList.zig | 91 +++++++++++++++++++++++++++++++++++---- src/terminal/Screen.zig | 24 ++++++++++- src/terminal/Terminal.zig | 8 +++- src/terminal/page.zig | 2 +- 4 files changed, 114 insertions(+), 11 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index f8afc801a..c976cf720 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -3803,10 +3803,18 @@ test "PageList pointFromPin active from prior page" { var s = try init(alloc, 80, 24, null); defer s.deinit(); + // Grow so we take up at least 5 pages. const page = &s.pages.last.?.data; + var cur_page = s.pages.last.?; + cur_page.data.pauseIntegrityChecks(true); for (0..page.capacity.rows * 5) |_| { - _ = try s.grow(); + if (try s.grow()) |new_page| { + cur_page.data.pauseIntegrityChecks(false); + cur_page = new_page; + cur_page.data.pauseIntegrityChecks(true); + } } + cur_page.data.pauseIntegrityChecks(false); { try testing.expectEqual(point.Point{ @@ -3837,10 +3845,19 @@ test "PageList pointFromPin traverse pages" { var s = try init(alloc, 80, 24, null); defer s.deinit(); + + // Grow so we take up at least 2 pages. const page = &s.pages.last.?.data; + var cur_page = s.pages.last.?; + cur_page.data.pauseIntegrityChecks(true); for (0..page.capacity.rows * 2) |_| { - _ = try s.grow(); + if (try s.grow()) |new_page| { + cur_page.data.pauseIntegrityChecks(false); + cur_page = new_page; + cur_page.data.pauseIntegrityChecks(true); + } } + cur_page.data.pauseIntegrityChecks(false); { const pages = s.totalPages(); @@ -4530,9 +4547,11 @@ test "PageList pageIterator two pages" { // Grow to capacity const page1_node = s.pages.last.?; const page1 = page1_node.data; + page1_node.data.pauseIntegrityChecks(true); for (0..page1.capacity.rows - page1.size.rows) |_| { try testing.expect(try s.grow() == null); } + page1_node.data.pauseIntegrityChecks(false); try testing.expect(try s.grow() != null); // Iterate the active area @@ -4564,9 +4583,11 @@ test "PageList pageIterator history two pages" { // Grow to capacity const page1_node = s.pages.last.?; const page1 = page1_node.data; + page1_node.data.pauseIntegrityChecks(true); for (0..page1.capacity.rows - page1.size.rows) |_| { try testing.expect(try s.grow() == null); } + page1_node.data.pauseIntegrityChecks(false); try testing.expect(try s.grow() != null); // Iterate the active area @@ -4615,9 +4636,11 @@ test "PageList pageIterator reverse two pages" { // Grow to capacity const page1_node = s.pages.last.?; const page1 = page1_node.data; + page1_node.data.pauseIntegrityChecks(true); for (0..page1.capacity.rows - page1.size.rows) |_| { try testing.expect(try s.grow() == null); } + page1_node.data.pauseIntegrityChecks(false); try testing.expect(try s.grow() != null); // Iterate the active area @@ -4653,9 +4676,11 @@ test "PageList pageIterator reverse history two pages" { // Grow to capacity const page1_node = s.pages.last.?; const page1 = page1_node.data; + page1_node.data.pauseIntegrityChecks(true); for (0..page1.capacity.rows - page1.size.rows) |_| { try testing.expect(try s.grow() == null); } + page1_node.data.pauseIntegrityChecks(false); try testing.expect(try s.grow() != null); // Iterate the active area @@ -4781,9 +4806,16 @@ test "PageList erase" { // Grow so we take up at least 5 pages. const page = &s.pages.last.?.data; + var cur_page = s.pages.last.?; + cur_page.data.pauseIntegrityChecks(true); for (0..page.capacity.rows * 5) |_| { - _ = try s.grow(); + if (try s.grow()) |new_page| { + cur_page.data.pauseIntegrityChecks(false); + cur_page = new_page; + cur_page.data.pauseIntegrityChecks(true); + } } + cur_page.data.pauseIntegrityChecks(false); try testing.expectEqual(@as(usize, 6), s.totalPages()); // Our total rows should be large @@ -4808,9 +4840,16 @@ test "PageList erase reaccounts page size" { // Grow so we take up at least 5 pages. const page = &s.pages.last.?.data; + var cur_page = s.pages.last.?; + cur_page.data.pauseIntegrityChecks(true); for (0..page.capacity.rows * 5) |_| { - _ = try s.grow(); + if (try s.grow()) |new_page| { + cur_page.data.pauseIntegrityChecks(false); + cur_page = new_page; + cur_page.data.pauseIntegrityChecks(true); + } } + cur_page.data.pauseIntegrityChecks(false); try testing.expect(s.page_size > start_size); // Erase the entire history, we should be back to just our active set. @@ -4827,9 +4866,16 @@ test "PageList erase row with tracked pin resets to top-left" { // Grow so we take up at least 5 pages. const page = &s.pages.last.?.data; + var cur_page = s.pages.last.?; + cur_page.data.pauseIntegrityChecks(true); for (0..page.capacity.rows * 5) |_| { - _ = try s.grow(); + if (try s.grow()) |new_page| { + cur_page.data.pauseIntegrityChecks(false); + cur_page = new_page; + cur_page.data.pauseIntegrityChecks(true); + } } + cur_page.data.pauseIntegrityChecks(false); // Our total rows should be large try testing.expect(s.totalRows() > s.rows); @@ -4899,9 +4945,16 @@ test "PageList erase resets viewport to active if moves within active" { // Grow so we take up at least 5 pages. const page = &s.pages.last.?.data; + var cur_page = s.pages.last.?; + cur_page.data.pauseIntegrityChecks(true); for (0..page.capacity.rows * 5) |_| { - _ = try s.grow(); + if (try s.grow()) |new_page| { + cur_page.data.pauseIntegrityChecks(false); + cur_page = new_page; + cur_page.data.pauseIntegrityChecks(true); + } } + cur_page.data.pauseIntegrityChecks(false); // Move our viewport to the top s.scroll(.{ .delta_row = -@as(isize, @intCast(s.totalRows())) }); @@ -4922,9 +4975,16 @@ test "PageList erase resets viewport if inside erased page but not active" { // Grow so we take up at least 5 pages. const page = &s.pages.last.?.data; + var cur_page = s.pages.last.?; + cur_page.data.pauseIntegrityChecks(true); for (0..page.capacity.rows * 5) |_| { - _ = try s.grow(); + if (try s.grow()) |new_page| { + cur_page.data.pauseIntegrityChecks(false); + cur_page = new_page; + cur_page.data.pauseIntegrityChecks(true); + } } + cur_page.data.pauseIntegrityChecks(false); // Move our viewport to the top s.scroll(.{ .delta_row = -@as(isize, @intCast(s.totalRows())) }); @@ -4946,9 +5006,16 @@ test "PageList erase resets viewport to active if top is inside active" { // Grow so we take up at least 5 pages. const page = &s.pages.last.?.data; + var cur_page = s.pages.last.?; + cur_page.data.pauseIntegrityChecks(true); for (0..page.capacity.rows * 5) |_| { - _ = try s.grow(); + if (try s.grow()) |new_page| { + cur_page.data.pauseIntegrityChecks(false); + cur_page = new_page; + cur_page.data.pauseIntegrityChecks(true); + } } + cur_page.data.pauseIntegrityChecks(false); // Move our viewport to the top s.scroll(.{ .top = {} }); @@ -5106,7 +5173,9 @@ test "PageList eraseRowBounded full rows two pages" { // Grow to two pages so our active area straddles { const page = &s.pages.last.?.data; + page.pauseIntegrityChecks(true); for (0..page.capacity.rows - page.size.rows) |_| _ = try s.grow(); + page.pauseIntegrityChecks(false); try s.growRows(5); try testing.expectEqual(@as(usize, 2), s.totalPages()); try testing.expectEqual(@as(usize, 5), s.pages.last.?.data.size.rows); @@ -6435,9 +6504,11 @@ test "PageList resize reflow more cols wrap across page boundary" { // Grow to the capacity of the first page. { const page = &s.pages.first.?.data; + page.pauseIntegrityChecks(true); for (page.size.rows..page.capacity.rows) |_| { _ = try s.grow(); } + page.pauseIntegrityChecks(false); try testing.expectEqual(@as(usize, 1), s.totalPages()); try s.growRows(1); try testing.expectEqual(@as(usize, 2), s.totalPages()); @@ -6564,9 +6635,11 @@ test "PageList resize reflow more cols wrap across page boundary cursor in secon // Grow to the capacity of the first page. { const page = &s.pages.first.?.data; + page.pauseIntegrityChecks(true); for (page.size.rows..page.capacity.rows) |_| { _ = try s.grow(); } + page.pauseIntegrityChecks(false); try testing.expectEqual(@as(usize, 1), s.totalPages()); try s.growRows(1); try testing.expectEqual(@as(usize, 2), s.totalPages()); @@ -6648,9 +6721,11 @@ test "PageList resize reflow less cols wrap across page boundary cursor in secon // Grow to the capacity of the first page. { const page = &s.pages.first.?.data; + page.pauseIntegrityChecks(true); for (page.size.rows..page.capacity.rows) |_| { _ = try s.grow(); } + page.pauseIntegrityChecks(false); try testing.expectEqual(@as(usize, 1), s.totalPages()); try s.growRows(5); try testing.expectEqual(@as(usize, 2), s.totalPages()); diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index ac9483742..1f67c8a9d 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -3048,9 +3048,11 @@ test "Screen cursorCopy style deref new page" { // Fill the scrollback with blank lines until // there are only 5 rows left on the first page. + s2.pages.pages.first.?.data.pauseIntegrityChecks(true); for (0..first_page_size - 5) |_| { try s2.testWriteString("\n"); } + s2.pages.pages.first.?.data.pauseIntegrityChecks(false); try s2.testWriteString("1\n2\n3\n4\n5\n6\n7\n8\n9\n10"); @@ -3157,9 +3159,11 @@ test "Screen cursorCopy hyperlink deref new page" { // Fill the scrollback with blank lines until // there are only 5 rows left on the first page. + s2.pages.pages.first.?.data.pauseIntegrityChecks(true); for (0..first_page_size - 5) |_| { try s2.testWriteString("\n"); } + s2.pages.pages.first.?.data.pauseIntegrityChecks(false); try s2.testWriteString("1\n2\n3\n4\n5\n6\n7\n8\n9\n10"); @@ -3588,7 +3592,9 @@ test "Screen: cursorDown across pages preserves style" { // Scroll down enough to go to another page const start_page = &s.pages.pages.last.?.data; const rem = start_page.capacity.rows; + start_page.pauseIntegrityChecks(true); for (0..rem) |_| try s.cursorDownOrScroll(); + start_page.pauseIntegrityChecks(false); // We need our page to change for this test o make sense. If this // assertion fails then the bug is in the test: we should be scrolling @@ -3638,7 +3644,9 @@ test "Screen: cursorUp across pages preserves style" { // Scroll down enough to go to another page const start_page = &s.pages.pages.last.?.data; const rem = start_page.capacity.rows; + start_page.pauseIntegrityChecks(true); for (0..rem) |_| try s.cursorDownOrScroll(); + start_page.pauseIntegrityChecks(false); // We need our page to change for this test o make sense. If this // assertion fails then the bug is in the test: we should be scrolling @@ -3683,7 +3691,9 @@ test "Screen: cursorAbsolute across pages preserves style" { // Scroll down enough to go to another page const start_page = &s.pages.pages.last.?.data; const rem = start_page.capacity.rows; + start_page.pauseIntegrityChecks(true); for (0..rem) |_| try s.cursorDownOrScroll(); + start_page.pauseIntegrityChecks(false); // We need our page to change for this test o make sense. If this // assertion fails then the bug is in the test: we should be scrolling @@ -3822,7 +3832,9 @@ test "Screen: scrolling across pages preserves style" { // Scroll down enough to go to another page const rem = start_page.capacity.rows - start_page.size.rows + 1; - for (0..rem) |_| try s.cursorDownScroll(); + start_page.pauseIntegrityChecks(true); + for (0..rem) |_| try s.cursorDownOrScroll(); + start_page.pauseIntegrityChecks(false); // We need our page to change for this test o make sense. If this // assertion fails then the bug is in the test: we should be scrolling @@ -4303,7 +4315,9 @@ test "Screen: scroll above same page but cursor on previous page" { // We need to get the cursor to a new page const first_page_size = s.pages.pages.first.?.data.capacity.rows; + s.pages.pages.first.?.data.pauseIntegrityChecks(true); for (0..first_page_size - 3) |_| try s.testWriteString("\n"); + s.pages.pages.first.?.data.pauseIntegrityChecks(false); try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } }); try s.testWriteString("1A\n2B\n3C\n4D\n5E"); @@ -4361,7 +4375,9 @@ test "Screen: scroll above same page but cursor on previous page last row" { // We need to get the cursor to a new page const first_page_size = s.pages.pages.first.?.data.capacity.rows; + s.pages.pages.first.?.data.pauseIntegrityChecks(true); for (0..first_page_size - 2) |_| try s.testWriteString("\n"); + s.pages.pages.first.?.data.pauseIntegrityChecks(false); try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } }); try s.testWriteString("1A\n2B\n3C\n4D\n5E"); @@ -4436,7 +4452,9 @@ test "Screen: scroll above creates new page" { // We need to get the cursor to a new page const first_page_size = s.pages.pages.first.?.data.capacity.rows; + s.pages.pages.first.?.data.pauseIntegrityChecks(true); for (0..first_page_size - 3) |_| try s.testWriteString("\n"); + s.pages.pages.first.?.data.pauseIntegrityChecks(false); try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } }); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -4477,7 +4495,9 @@ test "Screen: scroll above no scrollback bottom of page" { defer s.deinit(); const first_page_size = s.pages.pages.first.?.data.capacity.rows; + s.pages.pages.first.?.data.pauseIntegrityChecks(true); for (0..first_page_size - 3) |_| try s.testWriteString("\n"); + s.pages.pages.first.?.data.pauseIntegrityChecks(false); try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } }); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -8254,9 +8274,11 @@ test "Screen: selectionString multi-page" { const first_page_size = s.pages.pages.first.?.data.capacity.rows; // Lazy way to seek to the first page boundary. + s.pages.pages.first.?.data.pauseIntegrityChecks(true); for (0..first_page_size - 1) |_| { try s.testWriteString("\n"); } + s.pages.pages.first.?.data.pauseIntegrityChecks(false); try s.testWriteString("y\ny\ny"); diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index a11028304..fd4626861 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -2763,9 +2763,15 @@ test "Terminal: input glitch text" { var t = try init(alloc, .{ .cols = 30, .rows = 30 }); defer t.deinit(alloc); - for (0..100) |_| { + const page = t.screen.pages.pages.first.?; + const grapheme_cap = page.data.capacity.grapheme_bytes; + + while (page.data.capacity.grapheme_bytes == grapheme_cap) { try t.printString(glitch); } + + // We're testing to make sure that grapheme capacity gets increased. + try testing.expect(page.data.capacity.grapheme_bytes > grapheme_cap); } test "Terminal: zero-width character at start" { diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 83164e163..ae14b8c01 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -1795,7 +1795,7 @@ pub const std_capacity: Capacity = .{ .cols = 215, .rows = 215, .styles = 128, - .grapheme_bytes = 8192, + .grapheme_bytes = if (builtin.is_test) 512 else 8192, }; /// The size of this page. From 2d26965f392bf8fe735206ff6696b34cac69b908 Mon Sep 17 00:00:00 2001 From: Borys Lykah Date: Thu, 12 Dec 2024 16:54:01 -0700 Subject: [PATCH 35/47] Fix style warning --- src/shell-integration/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shell-integration/README.md b/src/shell-integration/README.md index b788f9613..d5294046f 100644 --- a/src/shell-integration/README.md +++ b/src/shell-integration/README.md @@ -71,4 +71,4 @@ sequence occurs. if [[ -n $GHOSTTY_RESOURCES_DIR ]]; then "$GHOSTTY_RESOURCES_DIR"/shell-integration/zsh/ghostty-integration fi -``` \ No newline at end of file +``` From 4fdf5eb12b910cb92d34fdbddace1a0f996cb30b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 12 Dec 2024 16:43:10 -0800 Subject: [PATCH 36/47] macos: move title setting into a function to better encapsulate --- macos/Sources/Ghostty/Ghostty.App.swift | 20 ++++------------ .../Sources/Ghostty/SurfaceView_AppKit.swift | 23 +++++++++++++++---- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index c8e3cc476..2d9822d6e 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -947,20 +947,7 @@ extension Ghostty { guard let surface = target.target.surface else { return } guard let surfaceView = self.surfaceView(from: surface) else { return } guard let title = String(cString: v.title!, encoding: .utf8) else { return } - - surfaceView.titleChangeTimer?.invalidate() - - surfaceView.titleChangeTimer = Timer.scheduledTimer( - withTimeInterval: surfaceView.titleChangeDelay, - repeats: false - ) { _ in - // We must set this in a dispatchqueue to avoid a deadlock on startup on some - // versions of macOS. I unfortunately didn't document the exact versions so - // I don't know when its safe to remove this. - DispatchQueue.main.async { - surfaceView.title = title - } - } + surfaceView.setTitle(title) default: assertionFailure() @@ -1095,7 +1082,10 @@ extension Ghostty { guard let surface = target.target.surface else { return } guard let surfaceView = self.surfaceView(from: surface) else { return } let backingSize = NSSize(width: Double(v.width), height: Double(v.height)) - surfaceView.cellSize = surfaceView.convertFromBacking(backingSize) + DispatchQueue.main.async { [weak surfaceView] in + guard let surfaceView else { return } + surfaceView.cellSize = surfaceView.convertFromBacking(backingSize) + } default: assertionFailure() diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index c426e9e07..d06ab36eb 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -12,11 +12,7 @@ extension Ghostty { // The current title of the surface as defined by the pty. This can be // changed with escape codes. This is public because the callbacks go // to the app level and it is set from there. - @Published var title: String = "👻" - - // A small delay that is introduced before a title change to avoid flickers - var titleChangeDelay: TimeInterval = 0.075 - var titleChangeTimer: Timer? + @Published private(set) var title: String = "👻" // The current pwd of the surface as defined by the pty. This can be // changed with escape codes. @@ -114,6 +110,9 @@ extension Ghostty { // This is set to non-null during keyDown to accumulate insertText contents private var keyTextAccumulator: [String]? = nil + // A small delay that is introduced before a title change to avoid flickers + private var titleChangeTimer: Timer? + // We need to support being a first responder so that we can get input events override var acceptsFirstResponder: Bool { return true } @@ -343,6 +342,20 @@ extension Ghostty { NSCursor.setHiddenUntilMouseMoves(!visible) } + func setTitle(_ title: String) { + // This fixes an issue where very quick changes to the title could + // cause an unpleasant flickering. We set a timer so that we can + // coalesce rapid changes. The timer is short enough that it still + // feels "instant". + titleChangeTimer?.invalidate() + titleChangeTimer = Timer.scheduledTimer( + withTimeInterval: 0.075, + repeats: false + ) { [weak self] _ in + self?.title = title + } + } + // MARK: - Notifications @objc private func onUpdateRendererHealth(notification: SwiftUI.Notification) { From 0a29b78a6c729faecde2716836e9a461401733aa Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Thu, 12 Dec 2024 20:38:26 -0500 Subject: [PATCH 37/47] clarify naming convention --- src/font/opentype/head.zig | 2 ++ src/font/opentype/hhea.zig | 2 ++ src/font/opentype/os2.zig | 4 ++++ src/font/opentype/post.zig | 2 ++ 4 files changed, 10 insertions(+) diff --git a/src/font/opentype/head.zig b/src/font/opentype/head.zig index ff5da7eaa..e5be7a352 100644 --- a/src/font/opentype/head.zig +++ b/src/font/opentype/head.zig @@ -6,6 +6,8 @@ const sfnt = @import("sfnt.zig"); /// /// References: /// - https://learn.microsoft.com/en-us/typography/opentype/spec/head +/// +/// Field names are in camelCase to match names in spec. pub const Head = extern struct { /// Major version number of the font header table — set to 1. majorVersion: sfnt.uint16 align(1), diff --git a/src/font/opentype/hhea.zig b/src/font/opentype/hhea.zig index 8f224e1bb..300f29c7a 100644 --- a/src/font/opentype/hhea.zig +++ b/src/font/opentype/hhea.zig @@ -6,6 +6,8 @@ const sfnt = @import("sfnt.zig"); /// /// References: /// - https://learn.microsoft.com/en-us/typography/opentype/spec/hhea +/// +/// Field names are in camelCase to match names in spec. pub const Hhea = extern struct { /// Major version number of the horizontal header table — set to 1. majorVersion: sfnt.uint16 align(1), diff --git a/src/font/opentype/os2.zig b/src/font/opentype/os2.zig index 9bd57fc37..809962d45 100644 --- a/src/font/opentype/os2.zig +++ b/src/font/opentype/os2.zig @@ -43,6 +43,8 @@ pub const FSSelection = packed struct(sfnt.uint16) { /// /// References: /// - https://learn.microsoft.com/en-us/typography/opentype/spec/os2 +/// +/// Field names are in camelCase to match names in spec. pub const OS2v5 = extern struct { version: sfnt.uint16 align(1), xAvgCharWidth: sfnt.FWORD align(1), @@ -198,6 +200,8 @@ pub const OS2v0 = extern struct { /// /// References: /// - https://learn.microsoft.com/en-us/typography/opentype/spec/os2 +/// +/// Field names are in camelCase to match names in spec. pub const OS2 = struct { /// The version number for the OS/2 table: 0x0000 to 0x0005. /// diff --git a/src/font/opentype/post.zig b/src/font/opentype/post.zig index e2c98bca1..ca0891583 100644 --- a/src/font/opentype/post.zig +++ b/src/font/opentype/post.zig @@ -9,6 +9,8 @@ const sfnt = @import("sfnt.zig"); /// /// References: /// - https://learn.microsoft.com/en-us/typography/opentype/spec/post +/// +/// Field names are in camelCase to match names in spec. pub const Post = extern struct { version: sfnt.Version16Dot16 align(1), From f54379aacd7cf8f081c42eb97f7ef97e3bfec552 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Thu, 12 Dec 2024 20:59:30 -0500 Subject: [PATCH 38/47] font(Box/cursors): properly account for un-adjusted height --- src/font/sprite/Box.zig | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/font/sprite/Box.zig b/src/font/sprite/Box.zig index 03fa8fb1e..a6317196f 100644 --- a/src/font/sprite/Box.zig +++ b/src/font/sprite/Box.zig @@ -254,7 +254,7 @@ pub fn renderGlyph( return font.Glyph{ .width = metrics.cell_width, - .height = metrics.cell_height, + .height = height, .offset_x = 0, .offset_y = offset_y, .atlas_x = region.x, @@ -2843,22 +2843,39 @@ fn draw_dash_vertical( } fn draw_cursor_rect(self: Box, canvas: *font.sprite.Canvas) void { - self.rect(canvas, 0, 0, self.metrics.cell_width, self.metrics.cell_height); + // The cursor should fit itself to the canvas it's given, since if + // the cell height is adjusted upwards it will be given a canvas + // with the original un-adjusted height, so we can't use the height + // from the metrics. + const height: u32 = @intCast(canvas.sfc.getHeight()); + self.rect(canvas, 0, 0, self.metrics.cell_width, height); } fn draw_cursor_hollow_rect(self: Box, canvas: *font.sprite.Canvas) void { + // The cursor should fit itself to the canvas it's given, since if + // the cell height is adjusted upwards it will be given a canvas + // with the original un-adjusted height, so we can't use the height + // from the metrics. + const height: u32 = @intCast(canvas.sfc.getHeight()); + const thick_px = Thickness.super_light.height(self.metrics.cursor_thickness); - self.vline(canvas, 0, self.metrics.cell_height, 0, thick_px); - self.vline(canvas, 0, self.metrics.cell_height, self.metrics.cell_width -| thick_px, thick_px); - self.hline(canvas, 0, self.metrics.cell_width, 0, thick_px); - self.hline(canvas, 0, self.metrics.cell_width, self.metrics.cell_height -| thick_px, thick_px); + self.rect(canvas, 0, 0, self.metrics.cell_width, thick_px); + self.rect(canvas, 0, 0, thick_px, height); + self.rect(canvas, self.metrics.cell_width -| thick_px, 0, self.metrics.cell_width, height); + self.rect(canvas, 0, height -| thick_px, self.metrics.cell_width, height); } fn draw_cursor_bar(self: Box, canvas: *font.sprite.Canvas) void { + // The cursor should fit itself to the canvas it's given, since if + // the cell height is adjusted upwards it will be given a canvas + // with the original un-adjusted height, so we can't use the height + // from the metrics. + const height: u32 = @intCast(canvas.sfc.getHeight()); + const thick_px = Thickness.light.height(self.metrics.cursor_thickness); - self.vline(canvas, 0, self.metrics.cell_height, 0, thick_px); + self.rect(canvas, 0, 0, thick_px, height); } fn vline_middle(self: Box, canvas: *font.sprite.Canvas, thickness: Thickness) void { From 586a7e517ed97b0e4ba3dfd840cfcd8454983724 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Thu, 12 Dec 2024 21:30:01 -0500 Subject: [PATCH 39/47] font(freetype): actually take max ascii width instead of first --- src/font/face/freetype.zig | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index d7fb2e4a3..2849e52de 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -676,18 +676,25 @@ pub const Face = struct { // If we fail to load any visible ASCII we just use max_advance from // the metrics provided by FreeType. const cell_width: f64 = cell_width: { + var max: f64 = 0.0; var c: u8 = ' '; while (c < 127) : (c += 1) { if (face.getCharIndex(c)) |glyph_index| { if (face.loadGlyph(glyph_index, .{ .render = true })) { - break :cell_width f26dot6ToF64(face.handle.*.glyph.*.advance.x); - } else |_| { - // Ignore the error since we just fall back to max_advance below - } + max = @max( + f26dot6ToF64(face.handle.*.glyph.*.advance.x), + max, + ); + } else |_| {} } } - break :cell_width f26dot6ToF64(size_metrics.max_advance); + // If we couldn't get any widths, just use FreeType's max_advance. + if (max == 0.0) { + break :cell_width f26dot6ToF64(size_metrics.max_advance); + } + + break :cell_width max; }; // The OS/2 table does not include sCapHeight or sxHeight in version 1. From b7dc7672376bc2ade8ab9235ba895a826ea40c12 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 12 Dec 2024 19:42:35 -0800 Subject: [PATCH 40/47] face: add more RLS types and explicit error sets --- src/font/face/coretext.zig | 70 +++++++++++++++++++++++++++----------- src/font/face/freetype.zig | 15 +++++--- src/font/opentype/head.zig | 3 +- src/font/opentype/os2.zig | 5 ++- src/font/opentype/post.zig | 3 +- src/font/opentype/svg.zig | 5 ++- 6 files changed, 71 insertions(+), 30 deletions(-) diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index 263a5f915..8749f9092 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -530,7 +530,15 @@ pub const Face = struct { }; } - fn calcMetrics(ct_font: *macos.text.Font) !font.face.Metrics { + const CalcMetricsError = error{ + CopyTableError, + InvalidHeadTable, + InvalidPostTable, + InvalidOS2Table, + OS2VersionNotSupported, + }; + + fn calcMetrics(ct_font: *macos.text.Font) CalcMetricsError!font.face.Metrics { // Read the 'head' table out of the font data. const head: opentype.Head = head: { const tag = macos.text.FontTableTag.init("head"); @@ -538,7 +546,12 @@ pub const Face = struct { defer data.release(); const ptr = data.getPointer(); const len = data.getLength(); - break :head try opentype.Head.init(ptr[0..len]); + break :head opentype.Head.init(ptr[0..len]) catch |err| { + return switch (err) { + error.EndOfStream, + => error.InvalidHeadTable, + }; + }; }; // Read the 'post' table out of the font data. @@ -548,7 +561,11 @@ pub const Face = struct { defer data.release(); const ptr = data.getPointer(); const len = data.getLength(); - break :post try opentype.Post.init(ptr[0..len]); + break :post opentype.Post.init(ptr[0..len]) catch |err| { + return switch (err) { + error.EndOfStream => error.InvalidOS2Table, + }; + }; }; // Read the 'OS/2' table out of the font data. @@ -558,12 +575,17 @@ pub const Face = struct { defer data.release(); const ptr = data.getPointer(); const len = data.getLength(); - break :os2 try opentype.OS2.init(ptr[0..len]); + break :os2 opentype.OS2.init(ptr[0..len]) catch |err| { + return switch (err) { + error.EndOfStream => error.InvalidOS2Table, + error.OS2VersionNotSupported => error.OS2VersionNotSupported, + }; + }; }; - const units_per_em = head.unitsPerEm; - const px_per_em = ct_font.getSize(); - const px_per_unit = px_per_em / @as(f64, @floatFromInt(units_per_em)); + const units_per_em: f64 = @floatFromInt(head.unitsPerEm); + const px_per_em: f64 = ct_font.getSize(); + const px_per_unit: f64 = px_per_em / units_per_em; const ascent = @as(f64, @floatFromInt(os2.sTypoAscender)) * px_per_unit; const descent = @as(f64, @floatFromInt(os2.sTypoDescender)) * px_per_unit; @@ -576,7 +598,7 @@ pub const Face = struct { // If the underline position isn't 0 then we do use it, // even if the thickness is't properly specified. - const underline_position = if (has_broken_underline and post.underlinePosition == 0) + const underline_position: ?f64 = if (has_broken_underline and post.underlinePosition == 0) null else @as(f64, @floatFromInt(post.underlinePosition)) * px_per_unit; @@ -589,25 +611,25 @@ pub const Face = struct { // Similar logic to the underline above. const has_broken_strikethrough = os2.yStrikeoutSize == 0; - const strikethrough_position = if (has_broken_strikethrough and os2.yStrikeoutPosition == 0) + const strikethrough_position: ?f64 = if (has_broken_strikethrough and os2.yStrikeoutPosition == 0) null else @as(f64, @floatFromInt(os2.yStrikeoutPosition)) * px_per_unit; - const strikethrough_thickness = if (has_broken_strikethrough) + const strikethrough_thickness: ?f64 = if (has_broken_strikethrough) null else @as(f64, @floatFromInt(os2.yStrikeoutSize)) * px_per_unit; // We fall back to whatever CoreText does if // the OS/2 table doesn't specify a cap height. - const cap_height = if (os2.sCapHeight) |sCapHeight| + const cap_height: f64 = if (os2.sCapHeight) |sCapHeight| @as(f64, @floatFromInt(sCapHeight)) * px_per_unit else ct_font.getCapHeight(); // Ditto for ex height. - const ex_height = if (os2.sxHeight) |sxHeight| + const ex_height: f64 = if (os2.sxHeight) |sxHeight| @as(f64, @floatFromInt(sxHeight)) * px_per_unit else ct_font.getXHeight(); @@ -648,24 +670,24 @@ pub const Face = struct { return font.face.Metrics.calc(.{ .cell_width = cell_width, - .ascent = ascent, .descent = descent, .line_gap = line_gap, - .underline_position = underline_position, .underline_thickness = underline_thickness, - .strikethrough_position = strikethrough_position, .strikethrough_thickness = strikethrough_thickness, - .cap_height = cap_height, .ex_height = ex_height, }); } /// Copy the font table data for the given tag. - pub fn copyTable(self: Face, alloc: Allocator, tag: *const [4]u8) !?[]u8 { + pub fn copyTable( + self: Face, + alloc: Allocator, + tag: *const [4]u8, + ) Allocator.Error!?[]u8 { const data = self.font.copyTable(macos.text.FontTableTag.init(tag)) orelse return null; defer data.release(); @@ -693,7 +715,9 @@ const ColorState = struct { svg: ?opentype.SVG, svg_data: ?*macos.foundation.Data, - pub fn init(f: *macos.text.Font) !ColorState { + pub const Error = error{InvalidSVGTable}; + + pub fn init(f: *macos.text.Font) Error!ColorState { // sbix is true if the table exists in the font data at all. // In the future we probably want to actually parse it and // check for glyphs. @@ -714,8 +738,16 @@ const ColorState = struct { errdefer data.release(); const ptr = data.getPointer(); const len = data.getLength(); + const svg = opentype.SVG.init(ptr[0..len]) catch |err| { + return switch (err) { + error.EndOfStream, + error.SVGVersionNotSupported, + => error.InvalidSVGTable, + }; + }; + break :svg .{ - .svg = try opentype.SVG.init(ptr[0..len]), + .svg = svg, .data = data, }; }; diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index 2849e52de..c3d4a449b 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -598,6 +598,11 @@ pub const Face = struct { return @as(opentype.sfnt.F26Dot6, @bitCast(@as(u32, @intCast(v)))).to(f64); } + const CalcMetricsError = error{ + CopyTableError, + MissingOS2Table, + }; + /// Calculate the metrics associated with a face. This is not public because /// the metrics are calculated for every face and cached since they're /// frequently required for renderers and take up next to little memory space @@ -610,7 +615,7 @@ pub const Face = struct { fn calcMetrics( face: freetype.Face, modifiers: ?*const font.face.Metrics.ModifierSet, - ) !font.face.Metrics { + ) CalcMetricsError!font.face.Metrics { const size_metrics = face.handle.*.size.*.metrics; // This code relies on this assumption, and it should always be @@ -618,18 +623,18 @@ pub const Face = struct { assert(size_metrics.x_ppem == size_metrics.y_ppem); // Read the 'head' table out of the font data. - const head = face.getSfntTable(.head) orelse return error.CannotGetTable; + const head = face.getSfntTable(.head) orelse return error.CopyTableError; // Read the 'post' table out of the font data. - const post = face.getSfntTable(.post) orelse return error.CannotGetTable; + const post = face.getSfntTable(.post) orelse return error.CopyTableError; // Read the 'OS/2' table out of the font data. - const os2 = face.getSfntTable(.os2) orelse return error.CannotGetTable; + const os2 = face.getSfntTable(.os2) orelse return error.CopyTableError; // Some fonts don't actually have an OS/2 table, which // we need in order to do the metrics calculations, in // such cases FreeType sets the version to 0xFFFF - if (os2.version == 0xFFFF) return error.MissingTable; + if (os2.version == 0xFFFF) return error.MissingOS2Table; const units_per_em = head.Units_Per_EM; const px_per_em: f64 = @floatFromInt(size_metrics.y_ppem); diff --git a/src/font/opentype/head.zig b/src/font/opentype/head.zig index e5be7a352..b4ee3ffd4 100644 --- a/src/font/opentype/head.zig +++ b/src/font/opentype/head.zig @@ -135,10 +135,9 @@ pub const Head = extern struct { glyphDataFormat: sfnt.int16 align(1), /// Parse the table from raw data. - pub fn init(data: []const u8) !Head { + pub fn init(data: []const u8) error{EndOfStream}!Head { var fbs = std.io.fixedBufferStream(data); const reader = fbs.reader(); - return try reader.readStructEndian(Head, .big); } }; diff --git a/src/font/opentype/os2.zig b/src/font/opentype/os2.zig index 809962d45..a18538d5f 100644 --- a/src/font/opentype/os2.zig +++ b/src/font/opentype/os2.zig @@ -349,7 +349,10 @@ pub const OS2 = struct { usUpperOpticalPointSize: ?u16 = null, /// Parse the table from raw data. - pub fn init(data: []const u8) !OS2 { + pub fn init(data: []const u8) error{ + EndOfStream, + OS2VersionNotSupported, + }!OS2 { var fbs = std.io.fixedBufferStream(data); const reader = fbs.reader(); diff --git a/src/font/opentype/post.zig b/src/font/opentype/post.zig index ca0891583..ff56a5013 100644 --- a/src/font/opentype/post.zig +++ b/src/font/opentype/post.zig @@ -47,10 +47,9 @@ pub const Post = extern struct { maxMemType1: sfnt.uint32 align(1), /// Parse the table from raw data. - pub fn init(data: []const u8) !Post { + pub fn init(data: []const u8) error{EndOfStream}!Post { var fbs = std.io.fixedBufferStream(data); const reader = fbs.reader(); - return try reader.readStructEndian(Post, .big); } }; diff --git a/src/font/opentype/svg.zig b/src/font/opentype/svg.zig index ff431dee2..15edff5aa 100644 --- a/src/font/opentype/svg.zig +++ b/src/font/opentype/svg.zig @@ -22,7 +22,10 @@ pub const SVG = struct { /// All records in the table. records: []const [12]u8, - pub fn init(data: []const u8) !SVG { + pub fn init(data: []const u8) error{ + EndOfStream, + SVGVersionNotSupported, + }!SVG { var fbs = std.io.fixedBufferStream(data); const reader = fbs.reader(); From 68bf5a9492d9ca0ffcb60464324def8f68a68042 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 12 Dec 2024 21:12:25 -0800 Subject: [PATCH 41/47] ci: on release, only upload appcast after binaries --- .github/workflows/release-tip.yml | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index 911ac8db9..3310898a5 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -297,14 +297,14 @@ jobs: python3 ./dist/macos/update_appcast_tip.py test -f appcast_new.xml - # Update Blob Storage + # Upload our binaries first - name: Prep R2 Storage run: | mkdir blob mkdir -p blob/${GHOSTTY_COMMIT_LONG} cp ghostty-macos-universal.zip blob/${GHOSTTY_COMMIT_LONG}/ghostty-macos-universal.zip cp ghostty-macos-universal-dsym.zip blob/${GHOSTTY_COMMIT_LONG}/ghostty-macos-universal-dsym.zip - cp appcast_new.xml blob/appcast.xml + - name: Upload to R2 uses: ryand56/r2-upload-action@latest with: @@ -315,6 +315,24 @@ jobs: source-dir: blob destination-dir: ./ + # Now upload our appcast. This ensures that the appcast never + # gets out of sync with the binaries. + - name: Prep R2 Storage for Appcast + run: | + rm -r blob + mkdir blob + cp appcast_new.xml blob/appcast.xml + + - name: Upload Appcast to R2 + uses: ryand56/r2-upload-action@latest + with: + r2-account-id: ${{ secrets.CF_R2_TIP_ACCOUNT_ID }} + r2-access-key-id: ${{ secrets.CF_R2_TIP_AWS_KEY }} + r2-secret-access-key: ${{ secrets.CF_R2_TIP_SECRET_KEY }} + r2-bucket: ghostty-tip + source-dir: blob + destination-dir: ./ + build-macos-debug-slow: if: | ${{ From 13dd4bd8971d882ee7ba5b155b06808802217956 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Fri, 13 Dec 2024 12:16:15 -0500 Subject: [PATCH 42/47] font/sprite: separate out cursor rendering from Box (Fixes width handling when hovering wide chars) --- src/font/sprite/Box.zig | 80 ++------------------------------------ src/font/sprite/Face.zig | 33 +++++++++++++++- src/font/sprite/cursor.zig | 61 +++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 78 deletions(-) create mode 100644 src/font/sprite/cursor.zig diff --git a/src/font/sprite/Box.zig b/src/font/sprite/Box.zig index a6317196f..cf929eb67 100644 --- a/src/font/sprite/Box.zig +++ b/src/font/sprite/Box.zig @@ -214,26 +214,11 @@ pub fn renderGlyph( ) !font.Glyph { const metrics = self.metrics; - // Some codepoints (such as a few cursors) should not - // grow when the cell height is adjusted to be larger. - // And we also will need to adjust the vertical position. - const height, const dy = adjust: { - const h = metrics.cell_height; - if (unadjustedCodepoint(cp)) { - if (metrics.original_cell_height) |original| { - if (h > original) { - break :adjust .{ original, (h - original) / 2 }; - } - } - } - break :adjust .{ h, 0 }; - }; - // Create the canvas we'll use to draw var canvas = try font.sprite.Canvas.init( alloc, metrics.cell_width, - height, + metrics.cell_height, ); defer canvas.deinit(alloc); @@ -246,15 +231,11 @@ pub fn renderGlyph( // Our coordinates start at the BOTTOM for our renderers so we have to // specify an offset of the full height because we rendered a full size // cell. - // - // If we have an adjustment (see above) to the cell height that we need - // to account for, we subtract half the difference (dy) to keep the glyph - // centered. - const offset_y = @as(i32, @intCast(metrics.cell_height - dy)); + const offset_y = @as(i32, @intCast(metrics.cell_height)); return font.Glyph{ .width = metrics.cell_width, - .height = height, + .height = metrics.cell_height, .offset_x = 0, .offset_y = offset_y, .atlas_x = region.x, @@ -263,19 +244,6 @@ pub fn renderGlyph( }; } -/// Returns true if this codepoint should be rendered with the -/// width/height set to unadjusted values. -pub fn unadjustedCodepoint(cp: u32) bool { - return switch (cp) { - @intFromEnum(Sprite.cursor_rect), - @intFromEnum(Sprite.cursor_hollow_rect), - @intFromEnum(Sprite.cursor_bar), - => true, - - else => false, - }; -} - fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void { _ = alloc; switch (cp) { @@ -1656,12 +1624,6 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void .right = true, }, .light), - // Not official box characters but special characters we hide - // in the high bits of a unicode codepoint. - @intFromEnum(Sprite.cursor_rect) => self.draw_cursor_rect(canvas), - @intFromEnum(Sprite.cursor_hollow_rect) => self.draw_cursor_hollow_rect(canvas), - @intFromEnum(Sprite.cursor_bar) => self.draw_cursor_bar(canvas), - else => return error.InvalidCodepoint, } } @@ -2842,42 +2804,6 @@ fn draw_dash_vertical( } } -fn draw_cursor_rect(self: Box, canvas: *font.sprite.Canvas) void { - // The cursor should fit itself to the canvas it's given, since if - // the cell height is adjusted upwards it will be given a canvas - // with the original un-adjusted height, so we can't use the height - // from the metrics. - const height: u32 = @intCast(canvas.sfc.getHeight()); - self.rect(canvas, 0, 0, self.metrics.cell_width, height); -} - -fn draw_cursor_hollow_rect(self: Box, canvas: *font.sprite.Canvas) void { - // The cursor should fit itself to the canvas it's given, since if - // the cell height is adjusted upwards it will be given a canvas - // with the original un-adjusted height, so we can't use the height - // from the metrics. - const height: u32 = @intCast(canvas.sfc.getHeight()); - - const thick_px = Thickness.super_light.height(self.metrics.cursor_thickness); - - self.rect(canvas, 0, 0, self.metrics.cell_width, thick_px); - self.rect(canvas, 0, 0, thick_px, height); - self.rect(canvas, self.metrics.cell_width -| thick_px, 0, self.metrics.cell_width, height); - self.rect(canvas, 0, height -| thick_px, self.metrics.cell_width, height); -} - -fn draw_cursor_bar(self: Box, canvas: *font.sprite.Canvas) void { - // The cursor should fit itself to the canvas it's given, since if - // the cell height is adjusted upwards it will be given a canvas - // with the original un-adjusted height, so we can't use the height - // from the metrics. - const height: u32 = @intCast(canvas.sfc.getHeight()); - - const thick_px = Thickness.light.height(self.metrics.cursor_thickness); - - self.rect(canvas, 0, 0, thick_px, height); -} - fn vline_middle(self: Box, canvas: *font.sprite.Canvas, thickness: Thickness) void { const thick_px = thickness.height(self.metrics.box_thickness); self.vline(canvas, 0, self.metrics.cell_height, (self.metrics.cell_width -| thick_px) / 2, thick_px); diff --git a/src/font/sprite/Face.zig b/src/font/sprite/Face.zig index b8c89c74e..ede67d00d 100644 --- a/src/font/sprite/Face.zig +++ b/src/font/sprite/Face.zig @@ -21,6 +21,7 @@ const Sprite = font.sprite.Sprite; const Box = @import("Box.zig"); const Powerline = @import("Powerline.zig"); const underline = @import("underline.zig"); +const cursor = @import("cursor.zig"); const log = std.log.scoped(.font_sprite); @@ -123,6 +124,35 @@ pub fn renderGlyph( break :powerline try f.renderGlyph(alloc, atlas, cp); }, + + .cursor => cursor: { + // Cursors should be drawn with the original cell height if + // it has been adjusted larger, so they don't get stretched. + const height, const dy = adjust: { + const h = metrics.cell_height; + if (metrics.original_cell_height) |original| { + if (h > original) { + break :adjust .{ original, (h - original) / 2 }; + } + } + break :adjust .{ h, 0 }; + }; + + var g = try cursor.renderGlyph( + alloc, + atlas, + @enumFromInt(cp), + width, + height, + metrics.cursor_thickness, + ); + + // Keep the cursor centered in the cell if it's shorter. + g.offset_y += @intCast(dy); + + break :cursor g; + }, + }; } @@ -133,6 +163,7 @@ const Kind = enum { overline, strikethrough, powerline, + cursor, pub fn init(cp: u32) ?Kind { return switch (cp) { @@ -153,7 +184,7 @@ const Kind = enum { .cursor_rect, .cursor_hollow_rect, .cursor_bar, - => .box, + => .cursor, }, // == Box fonts == diff --git a/src/font/sprite/cursor.zig b/src/font/sprite/cursor.zig new file mode 100644 index 000000000..b20b6c531 --- /dev/null +++ b/src/font/sprite/cursor.zig @@ -0,0 +1,61 @@ +//! This file renders cursor sprites. +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const font = @import("../main.zig"); +const Sprite = font.sprite.Sprite; + +/// Draw a cursor. +pub fn renderGlyph( + alloc: Allocator, + atlas: *font.Atlas, + sprite: Sprite, + width: u32, + height: u32, + thickness: u32, +) !font.Glyph { + // Make a canvas of the desired size + var canvas = try font.sprite.Canvas.init(alloc, width, height); + defer canvas.deinit(alloc); + + // Draw the appropriate sprite + switch (sprite) { + Sprite.cursor_rect => canvas.rect(.{ + .x = 0, + .y = 0, + .width = width, + .height = height, + }, .on), + Sprite.cursor_hollow_rect => { + // left + canvas.rect(.{ .x = 0, .y = 0, .width = thickness, .height = height }, .on); + // right + canvas.rect(.{ .x = width -| thickness, .y = 0, .width = thickness, .height = height }, .on); + // top + canvas.rect(.{ .x = 0, .y = 0, .width = width, .height = thickness }, .on); + // bottom + canvas.rect(.{ .x = 0, .y = height -| thickness, .width = width, .height = thickness }, .on); + }, + Sprite.cursor_bar => canvas.rect(.{ + .x = 0, + .y = 0, + .width = thickness, + .height = height, + }, .on), + else => unreachable, + } + + // Write the drawing to the atlas + const region = try canvas.writeAtlas(alloc, atlas); + + return font.Glyph{ + .width = width, + .height = height, + .offset_x = 0, + .offset_y = @intCast(height), + .atlas_x = region.x, + .atlas_y = region.y, + .advance_x = @floatFromInt(width), + }; +} From 8a5d4847297549823c3f7d2e0fc6ea145d3e2061 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Fri, 13 Dec 2024 12:46:36 -0500 Subject: [PATCH 43/47] font: more robust extraction of vertical metrics from tables Previously always assuming the typo metrics were good caused some fonts to have abnormally short cell heights. --- pkg/freetype/face.zig | 1 + src/font/face/coretext.zig | 69 +++++++++++++++++++++++++++++++++++--- src/font/face/freetype.zig | 56 +++++++++++++++++++++++++++++-- 3 files changed, 119 insertions(+), 7 deletions(-) diff --git a/pkg/freetype/face.zig b/pkg/freetype/face.zig index e3bcd5292..eea3c6851 100644 --- a/pkg/freetype/face.zig +++ b/pkg/freetype/face.zig @@ -217,6 +217,7 @@ pub const SfntTag = enum(c_int) { .os2 => c.TT_OS2, .head => c.TT_Header, .post => c.TT_Postscript, + .hhea => c.TT_HoriHeader, else => unreachable, // As-needed... }; } diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index 8749f9092..756d1ae6a 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -536,6 +536,7 @@ pub const Face = struct { InvalidPostTable, InvalidOS2Table, OS2VersionNotSupported, + InvalidHheaTable, }; fn calcMetrics(ct_font: *macos.text.Font) CalcMetricsError!font.face.Metrics { @@ -563,7 +564,7 @@ pub const Face = struct { const len = data.getLength(); break :post opentype.Post.init(ptr[0..len]) catch |err| { return switch (err) { - error.EndOfStream => error.InvalidOS2Table, + error.EndOfStream => error.InvalidPostTable, }; }; }; @@ -583,13 +584,73 @@ pub const Face = struct { }; }; + // Read the 'hhea' table out of the font data. + const hhea: opentype.Hhea = hhea: { + const tag = macos.text.FontTableTag.init("hhea"); + const data = ct_font.copyTable(tag) orelse return error.CopyTableError; + defer data.release(); + const ptr = data.getPointer(); + const len = data.getLength(); + break :hhea opentype.Hhea.init(ptr[0..len]) catch |err| { + return switch (err) { + error.EndOfStream => error.InvalidHheaTable, + }; + }; + }; + const units_per_em: f64 = @floatFromInt(head.unitsPerEm); const px_per_em: f64 = ct_font.getSize(); const px_per_unit: f64 = px_per_em / units_per_em; - const ascent = @as(f64, @floatFromInt(os2.sTypoAscender)) * px_per_unit; - const descent = @as(f64, @floatFromInt(os2.sTypoDescender)) * px_per_unit; - const line_gap = @as(f64, @floatFromInt(os2.sTypoLineGap)) * px_per_unit; + const ascent: f64, const descent: f64, const line_gap: f64 = vertical_metrics: { + const os2_ascent: f64 = @floatFromInt(os2.sTypoAscender); + const os2_descent: f64 = @floatFromInt(os2.sTypoDescender); + const os2_line_gap: f64 = @floatFromInt(os2.sTypoLineGap); + + // If the font says to use typo metrics, trust it. + if (os2.fsSelection.use_typo_metrics) { + break :vertical_metrics .{ + os2_ascent * px_per_unit, + os2_descent * px_per_unit, + os2_line_gap * px_per_unit, + }; + } + + // Otherwise we prefer the height metrics from 'hhea' if they + // are available, or else OS/2 sTypo* metrics, and if all else + // fails then we use OS/2 usWin* metrics. + // + // This is not "standard" behavior, but it's our best bet to + // account for fonts being... just weird. It's pretty much what + // FreeType does to get its generic ascent and descent metrics. + + if (hhea.ascender != 0 or hhea.descender != 0) { + const hhea_ascent: f64 = @floatFromInt(hhea.ascender); + const hhea_descent: f64 = @floatFromInt(hhea.descender); + const hhea_line_gap: f64 = @floatFromInt(hhea.lineGap); + break :vertical_metrics .{ + hhea_ascent * px_per_unit, + hhea_descent * px_per_unit, + hhea_line_gap * px_per_unit, + }; + } + + if (os2_ascent != 0 or os2_descent != 0) { + break :vertical_metrics .{ + os2_ascent * px_per_unit, + os2_descent * px_per_unit, + os2_line_gap * px_per_unit, + }; + } + + const win_ascent: f64 = @floatFromInt(os2.usWinAscent); + const win_descent: f64 = @floatFromInt(os2.usWinDescent); + break :vertical_metrics .{ + win_ascent * px_per_unit, + win_descent * px_per_unit, + 0.0, + }; + }; // Some fonts have degenerate 'post' tables where the underline // thickness (and often position) are 0. We consider them null diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index c3d4a449b..e9f8d3207 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -631,6 +631,9 @@ pub const Face = struct { // Read the 'OS/2' table out of the font data. const os2 = face.getSfntTable(.os2) orelse return error.CopyTableError; + // Read the 'hhea' table out of the font data. + const hhea = face.getSfntTable(.hhea) orelse return error.CopyTableError; + // Some fonts don't actually have an OS/2 table, which // we need in order to do the metrics calculations, in // such cases FreeType sets the version to 0xFFFF @@ -640,9 +643,56 @@ pub const Face = struct { const px_per_em: f64 = @floatFromInt(size_metrics.y_ppem); const px_per_unit = px_per_em / @as(f64, @floatFromInt(units_per_em)); - const ascent = @as(f64, @floatFromInt(os2.sTypoAscender)) * px_per_unit; - const descent = @as(f64, @floatFromInt(os2.sTypoDescender)) * px_per_unit; - const line_gap = @as(f64, @floatFromInt(os2.sTypoLineGap)) * px_per_unit; + const ascent: f64, const descent: f64, const line_gap: f64 = vertical_metrics: { + const os2_ascent: f64 = @floatFromInt(os2.sTypoAscender); + const os2_descent: f64 = @floatFromInt(os2.sTypoDescender); + const os2_line_gap: f64 = @floatFromInt(os2.sTypoLineGap); + + // If the font says to use typo metrics, trust it. + // (The USE_TYPO_METRICS bit is bit 7) + if (os2.fsSelection & (1 << 7) != 0) { + break :vertical_metrics .{ + os2_ascent * px_per_unit, + os2_descent * px_per_unit, + os2_line_gap * px_per_unit, + }; + } + + // Otherwise we prefer the height metrics from 'hhea' if they + // are available, or else OS/2 sTypo* metrics, and if all else + // fails then we use OS/2 usWin* metrics. + // + // This is not "standard" behavior, but it's our best bet to + // account for fonts being... just weird. It's pretty much what + // FreeType does to get its generic ascent and descent metrics. + + if (hhea.Ascender != 0 or hhea.Descender != 0) { + const hhea_ascent: f64 = @floatFromInt(hhea.Ascender); + const hhea_descent: f64 = @floatFromInt(hhea.Descender); + const hhea_line_gap: f64 = @floatFromInt(hhea.Line_Gap); + break :vertical_metrics .{ + hhea_ascent * px_per_unit, + hhea_descent * px_per_unit, + hhea_line_gap * px_per_unit, + }; + } + + if (os2_ascent != 0 or os2_descent != 0) { + break :vertical_metrics .{ + os2_ascent * px_per_unit, + os2_descent * px_per_unit, + os2_line_gap * px_per_unit, + }; + } + + const win_ascent: f64 = @floatFromInt(os2.usWinAscent); + const win_descent: f64 = @floatFromInt(os2.usWinDescent); + break :vertical_metrics .{ + win_ascent * px_per_unit, + win_descent * px_per_unit, + 0.0, + }; + }; // Some fonts have degenerate 'post' tables where the underline // thickness (and often position) are 0. We consider them null From 4573890f22d52ea1f47f2a664f7db4aeb3de7543 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Fri, 13 Dec 2024 13:14:49 -0500 Subject: [PATCH 44/47] font: fix sign of usWinDescent interpretation --- src/font/face/coretext.zig | 4 +++- src/font/face/freetype.zig | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index 756d1ae6a..09fdd7ad0 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -647,7 +647,9 @@ pub const Face = struct { const win_descent: f64 = @floatFromInt(os2.usWinDescent); break :vertical_metrics .{ win_ascent * px_per_unit, - win_descent * px_per_unit, + // usWinDescent is *positive* -> down unlike sTypoDescender + // and hhea.Descender, so we flip its sign to fix this. + -win_descent * px_per_unit, 0.0, }; }; diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index e9f8d3207..7d34c70f8 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -689,7 +689,9 @@ pub const Face = struct { const win_descent: f64 = @floatFromInt(os2.usWinDescent); break :vertical_metrics .{ win_ascent * px_per_unit, - win_descent * px_per_unit, + // usWinDescent is *positive* -> down unlike sTypoDescender + // and hhea.Descender, so we flip its sign to fix this. + -win_descent * px_per_unit, 0.0, }; }; From 71aec52b8c8cd296b165e51b272f341026a6e338 Mon Sep 17 00:00:00 2001 From: Anund Date: Sun, 15 Dec 2024 01:28:52 +1100 Subject: [PATCH 45/47] fish: raise eval quota inline with other completions, add -e --- src/build/fish_completions.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/build/fish_completions.zig b/src/build/fish_completions.zig index 5212dab61..049ff06be 100644 --- a/src/build/fish_completions.zig +++ b/src/build/fish_completions.zig @@ -9,7 +9,7 @@ pub const fish_completions = comptimeGenerateFishCompletions(); fn comptimeGenerateFishCompletions() []const u8 { comptime { - @setEvalBranchQuota(18000); + @setEvalBranchQuota(50000); var counter = std.io.countingWriter(std.io.null_writer); try writeFishCompletions(&counter.writer()); @@ -38,7 +38,7 @@ fn writeFishCompletions(writer: anytype) !void { try writer.writeAll("complete -c ghostty -f\n"); - try writer.writeAll("complete -c ghostty -l help -f\n"); + try writer.writeAll("complete -c ghostty -s e -l help -f\n"); try writer.writeAll("complete -c ghostty -n \"not __fish_seen_subcommand_from $commands\" -l version -f\n"); for (@typeInfo(Config).Struct.fields) |field| { From 5195042f96dc2ebb973fd4054c0656f93016aefb Mon Sep 17 00:00:00 2001 From: Anund Date: Sun, 15 Dec 2024 01:29:34 +1100 Subject: [PATCH 46/47] zsh: add -e completion, fix incorrectly copied sed expression skip +version +help to keep completions the same across bash/zsh/fish --- src/build/zsh_completions.zig | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/build/zsh_completions.zig b/src/build/zsh_completions.zig index 78d256ee2..6a4e88a66 100644 --- a/src/build/zsh_completions.zig +++ b/src/build/zsh_completions.zig @@ -32,7 +32,7 @@ fn writeZshCompletions(writer: anytype) !void { \\} \\ \\_themes() { - \\ local theme_list=$(ghostty +list-themes | sed -E 's/^(.*) \(.*\$/\0/') + \\ local theme_list=$(ghostty +list-themes | sed -E 's/^(.*) \(.*$/\1/') \\ local themes=(${(f)theme_list}) \\ _describe -t themes 'themes' themes \\} @@ -101,13 +101,13 @@ fn writeZshCompletions(writer: anytype) !void { \\_ghostty() { \\ typeset -A opt_args \\ local context state line - \\ local opt=('--help' '--version') + \\ local opt=('-e' '--help' '--version') \\ \\ _arguments -C \ \\ '1:actions:->actions' \ \\ '*:: :->rest' \ \\ - \\ if [[ "$line[1]" == "--help" || "$line[1]" == "--version" ]]; then + \\ if [[ "$line[1]" == "--help" || "$line[1]" == "--version" || "$line[1]" == "-e" ]]; then \\ return \\ fi \\ @@ -127,6 +127,9 @@ fn writeZshCompletions(writer: anytype) !void { var count: usize = 0; const padding = " "; for (@typeInfo(Action).Enum.fields) |field| { + if (std.mem.eql(u8, "help", field.name)) continue; + if (std.mem.eql(u8, "version", field.name)) continue; + try writer.writeAll(padding ++ "'+"); try writer.writeAll(field.name); try writer.writeAll("'\n"); From 854ec586f9e7556764ea80df98083b9390a88a60 Mon Sep 17 00:00:00 2001 From: mitchellh <1299+mitchellh@users.noreply.github.com> Date: Sun, 15 Dec 2024 01:05:52 +0000 Subject: [PATCH 47/47] deps: Update iTerm2 color schemes --- build.zig.zon | 4 ++-- nix/zigCacheHash.nix | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index b53362090..e2bb11da1 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -49,8 +49,8 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/d1484b69fcb9ef7f0845e0d0ab67cfe225fad820.tar.gz", - .hash = "122015dac15d6c4d142091ea83619cbce37029c8bc94af4739e2f94ee6d2c625ac5c", + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/5fd82e34a349e36a5b3422d8225c4e044c8b3b4b.tar.gz", + .hash = "122083713c189f1ceab516efd494123386f3a29132a68a6896b651319a8c57d747e4", }, .vaxis = .{ .url = "git+https://github.com/rockorager/libvaxis/?ref=main#6d729a2dc3b934818dffe06d2ba3ce02841ed74b", diff --git a/nix/zigCacheHash.nix b/nix/zigCacheHash.nix index 02d442403..81ee3c5a1 100644 --- a/nix/zigCacheHash.nix +++ b/nix/zigCacheHash.nix @@ -1,3 +1,3 @@ # This file is auto-generated! check build-support/check-zig-cache-hash.sh for # more details. -"sha256-f95EM0DLe5mmU1bg/IIr6ckhxc2KM1Fh1UAmRpM+V2E=" +"sha256-q9UDVryP50HfeeafgnrOd+D6K+cEy33/05K2TB5qiqw="