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: | ${{ diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml new file mode 100644 index 000000000..569ef6765 --- /dev/null +++ b/.github/workflows/update-colorschemes.yml @@ -0,0 +1,72 @@ +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: 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 + 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 + 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: + 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 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/build.zig b/build.zig index 093afe481..6904a4815 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(); @@ -1107,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()); diff --git a/build.zig.zon b/build.zig.zon index 35365af8a..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/80543b14552b7c9fef88fad826552e6ac5632abe.tar.gz", - .hash = "1220217ae916146a4c598f8ba5bfff0ff940335d00572e337f20b4accf24fa2ca4fc", + .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/dist/linux/ghostty_dolphin.desktop b/dist/linux/ghostty_dolphin.desktop old mode 100755 new mode 100644 diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 9056e692a..2d9822d6e 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -947,14 +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 } - - // 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() @@ -1089,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 7e861a229..d06ab36eb 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -12,7 +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 = "👻" + @Published private(set) var title: String = "👻" // The current pwd of the surface as defined by the pty. This can be // changed with escape codes. @@ -110,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 } @@ -339,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) { diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index 56912a28a..a16f329f8 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. + // 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() } @@ -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, @@ -222,8 +213,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 @@ -315,42 +304,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() { diff --git a/nix/zigCacheHash.nix b/nix/zigCacheHash.nix index 162f65500..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-c3MQJG7vwQBOaxHQ8cYP0HxdsLqlgsVmAiT1d7gq6js=" +"sha256-q9UDVryP50HfeeafgnrOd+D6K+cEy33/05K2TB5qiqw=" diff --git a/pkg/freetype/face.zig b/pkg/freetype/face.zig index 8bbc75616..eea3c6851 100644 --- a/pkg/freetype/face.zig +++ b/pkg/freetype/face.zig @@ -215,6 +215,9 @@ 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, + .hhea => c.TT_HoriHeader, else => unreachable, // As-needed... }; } 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/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); 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/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. 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 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}); diff --git a/src/build/fish_completions.zig b/src/build/fish_completions.zig index 2ac67bdad..049ff06be 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 ListFontsConfig = @import("../cli/list_fonts.zig").Config; -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. @@ -12,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()); @@ -41,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| { @@ -100,66 +97,35 @@ fn writeFishCompletions(writer: anytype) !void { try writer.writeAll("\"\n"); } - for (@typeInfo(ListFontsConfig).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"); } } 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'); diff --git a/src/build/zsh_completions.zig b/src/build/zsh_completions.zig new file mode 100644 index 000000000..6a4e88a66 --- /dev/null +++ b/src/build/zsh_completions.zig @@ -0,0 +1,204 @@ +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/^(.*) \(.*$/\1/') + \\ 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=('-e' '--help' '--version') + \\ + \\ _arguments -C \ + \\ '1:actions:->actions' \ + \\ '*:: :->rest' \ + \\ + \\ if [[ "$line[1]" == "--help" || "$line[1]" == "--version" || "$line[1]" == "-e" ]]; 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| { + 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"); + 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; diff --git a/src/config/Config.zig b/src/config/Config.zig index cb874e7a2..0da9f6c6e 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 @@ -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 @@ -4227,14 +4248,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); + std.fmt.format(buffer_stream.writer(), "{}", .{k}) catch return error.OutOfMemory; + try v.formatEntries(&buffer_stream, formatter); } } @@ -4268,6 +4284,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/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..d6b1bdd0c 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,146 @@ 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)); + + var 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), + }; + + // Ensure all metrics are within their allowable range. + result.clamp(); + + // std.log.debug("metrics={}", .{result}); + + return result; +} + /// Apply a set of modifiers. pub fn apply(self: *Metrics, mods: ModifierSet) void { var it = mods.iterator(); @@ -80,6 +227,21 @@ pub fn apply(self: *Metrics, mods: ModifierSet) void { }, } } + + // 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 @@ -152,23 +314,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 +380,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 +407,9 @@ fn init() Metrics { .underline_thickness = 0, .strikethrough_position = 0, .strikethrough_thickness = 0, + .overline_position = 0, + .overline_thickness = 0, + .box_thickness = 0, }; } @@ -337,12 +505,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 +520,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..09fdd7ad0 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(); @@ -532,11 +530,177 @@ pub const Face = struct { }; } - fn calcMetrics(ct_font: *macos.text.Font) !font.face.Metrics { + const CalcMetricsError = error{ + CopyTableError, + InvalidHeadTable, + InvalidPostTable, + InvalidOS2Table, + OS2VersionNotSupported, + InvalidHheaTable, + }; + + 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"); + const data = ct_font.copyTable(tag) orelse return error.CopyTableError; + defer data.release(); + const ptr = data.getPointer(); + const len = data.getLength(); + 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. + 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 opentype.Post.init(ptr[0..len]) catch |err| { + return switch (err) { + error.EndOfStream => error.InvalidPostTable, + }; + }; + }; + + // 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 opentype.OS2.init(ptr[0..len]) catch |err| { + return switch (err) { + error.EndOfStream => error.InvalidOS2Table, + error.OS2VersionNotSupported => error.OS2VersionNotSupported, + }; + }; + }; + + // 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: 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, + // usWinDescent is *positive* -> down unlike sTypoDescender + // and hhea.Descender, so we flip its sign to fix this. + -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 + // 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: ?f64 = 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: ?f64 = if (has_broken_strikethrough and os2.yStrikeoutPosition == 0) + null + else + @as(f64, @floatFromInt(os2.yStrikeoutPosition)) * px_per_unit; + + 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: f64 = if (os2.sCapHeight) |sCapHeight| + @as(f64, @floatFromInt(sCapHeight)) * px_per_unit + else + ct_font.getCapHeight(); + + // Ditto for ex height. + const ex_height: f64 = 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,93 +728,29 @@ 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(); - - // 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(); - - // 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), - }; - }; - - // 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); - - 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; + 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(); @@ -678,7 +778,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. @@ -699,8 +801,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, }; }; @@ -907,3 +1017,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); +} diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index 683f80cc8..7d34c70f8 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,15 @@ 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); + } + + 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 @@ -605,138 +615,195 @@ 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; - // 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); + + // Read the 'head' table out of the font data. + 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.CopyTableError; + + // 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 + 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); + const px_per_unit = px_per_em / @as(f64, @floatFromInt(units_per_em)); + + 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, + // usWinDescent is *positive* -> down unlike sTypoDescender + // and hhea.Descender, so we flip its sign to fix this. + -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 + // 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 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 })) { + max = @max( + f26dot6ToF64(face.handle.*.glyph.*.advance.x), + max, + ); + } else |_| {} } } - break :cell_width f26dot6ToFloat(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; }; - // 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: { + // The OS/2 table does not include sCapHeight or sxHeight in version 1. + const has_os2_height_metrics = os2.version >= 2; + + // 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 |_| {} + } + + break :cap_height null; + }; + + // 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 f26dot6ToFloat(face.handle.*.glyph.*.metrics.height); - } else |_| { - // Ignore the error since we just fall back to 65% of the ascent below - } + break :ex_height f26dot6ToF64(face.handle.*.glyph.*.metrics.height); + } else |_| {} } - break :ex_height f26dot6ToFloat(size_metrics.ascender) * 0.65; + break :ex_height null; }; - // 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); + var result = font.face.Metrics.calc(.{ + .cell_width = cell_width, - // The maximum height a glyph can take in the font - const max_glyph_height = f26dot6ToFloat(size_metrics.ascender) - - f26dot6ToFloat(size_metrics.descender); + .ascent = ascent, + .descent = descent, + .line_gap = line_gap, - // The height of the underscore character - const underscore_height = underscore: { - if (face.getCharIndex('_')) |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; - } else |_| { - // Ignore the error since we just fall back below - } - } + .underline_position = underline_position, + .underline_thickness = underline_thickness, - break :underscore 0; - }; + .strikethrough_position = strikethrough_position, + .strikethrough_thickness = strikethrough_thickness, - break :cell_height @max( - face_height, - @max(max_glyph_height, underscore_height), - ); - }; + .cap_height = cap_height, + .ex_height = ex_height, + }); - // 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); - - const underline_thickness = @max(@as(f32, 1), fontUnitsToPxY( - face, - face.handle.*.underline_thickness, - )); - - // 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; - }; - - // 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)); - - 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, - }; - - 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), - }; if (modifiers) |m| result.apply(m.*); // std.log.warn("font metrics={}", .{result}); @@ -744,13 +811,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 +888,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 +916,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/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..b4ee3ffd4 --- /dev/null +++ b/src/font/opentype/head.zig @@ -0,0 +1,180 @@ +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 +/// +/// 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), + + /// 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) error{EndOfStream}!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..300f29c7a --- /dev/null +++ b/src/font/opentype/hhea.zig @@ -0,0 +1,117 @@ +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 +/// +/// 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), + + /// 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..a18538d5f --- /dev/null +++ b/src/font/opentype/os2.zig @@ -0,0 +1,584 @@ +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 +/// +/// 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), + 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 +/// +/// 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. + /// + /// 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) error{ + EndOfStream, + OS2VersionNotSupported, + }!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..ff56a5013 --- /dev/null +++ b/src/font/opentype/post.zig @@ -0,0 +1,83 @@ +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 +/// +/// Field names are in camelCase to match names in spec. +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) error{EndOfStream}!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); +} 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(); diff --git a/src/font/sprite/Box.zig b/src/font/sprite/Box.zig index 382aa4206..cf929eb67 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,14 @@ pub fn renderGlyph( atlas: *font.Atlas, cp: u32, ) !font.Glyph { + const metrics = self.metrics; + // 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, + metrics.cell_height, + ); defer canvas.deinit(alloc); // Perform the actual drawing @@ -231,29 +231,16 @@ 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)); + const offset_y = @as(i32, @intCast(metrics.cell_height)); 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), - }; -} - -/// 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, + .advance_x = @floatFromInt(metrics.cell_width), }; } @@ -1637,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, } } @@ -1652,16 +1633,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 +1652,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 +1733,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 +1775,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 +1784,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 +1793,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 +1802,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 +1811,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 +1820,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 +1829,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 +1838,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 +1847,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 +1856,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 +1865,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 +1874,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 +1919,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 +1951,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 +1965,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 +1998,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 +2018,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 +2042,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 +2077,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 +2115,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 +2144,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 +2156,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 +2176,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 +2221,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 +2244,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 +2266,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 +2292,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 +2301,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 +2320,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 +2336,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 +2400,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 +2460,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 +2492,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 +2530,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 +2569,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 +2684,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 +2763,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; @@ -2823,33 +2804,14 @@ fn draw_dash_vertical( } } -fn draw_cursor_rect(self: Box, canvas: *font.sprite.Canvas) void { - self.rect(canvas, 0, 0, self.width, self.height); -} - -fn draw_cursor_hollow_rect(self: Box, canvas: *font.sprite.Canvas) void { - const thick_px = Thickness.super_light.height(self.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); -} - -fn draw_cursor_bar(self: Box, canvas: *font.sprite.Canvas) void { - const thick_px = Thickness.light.height(self.thickness); - - self.vline(canvas, 0, self.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 +2823,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 +2840,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 +2857,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 +2875,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 +3006,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..ede67d00d 100644 --- a/src/font/sprite/Face.zig +++ b/src/font/sprite/Face.zig @@ -21,25 +21,12 @@ 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); -/// 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 +52,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 +75,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,30 +92,67 @@ 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); }, + + .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; + }, + }; } @@ -179,6 +163,7 @@ const Kind = enum { overline, strikethrough, powerline, + cursor, pub fn init(cp: u32) ?Kind { return switch (cp) { @@ -199,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), + }; +} 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/Binding.zig b/src/input/Binding.zig index a467bfc2b..b451b5ec9 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 + std.fmt.format(buffer_stream.writer(), ">{s}", .{binding.key_ptr.*}) catch return error.OutOfMemory; + 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. + std.fmt.format(buffer_stream.writer(), "={s}", .{leaf.action}) catch return error.OutOfMemory; + try formatter.formatEntry([]const u8, buffer_stream.getWritten()); + }, + } + } }; /// Leaf node of a set is an action to trigger. This is a "leaf" compared 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, + }; + } +}; diff --git a/src/shell-integration/README.md b/src/shell-integration/README.md index 130ef5dfe..d5294046f 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 +``` 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 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..7d7759130 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 @@ -3048,9 +3049,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 +3160,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 +3593,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 +3645,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 +3692,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 +3833,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 +4316,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 +4376,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 +4453,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 +4496,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"); @@ -7298,6 +7319,7 @@ test "Screen: selectWord with character boundary" { " }abc} \n123", " abc> \n123", + " $abc$ \n123", }; for (cases) |case| { @@ -8254,9 +8276,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..65476108d 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) { @@ -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" { @@ -3193,6 +3199,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/terminal/kitty/graphics_exec.zig b/src/terminal/kitty/graphics_exec.zig index 42f12ea07..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, }); @@ -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,39 @@ 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); + } +} + +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); + } +} 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 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. 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 {