diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index b77e38f7c..01b211730 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -45,6 +45,11 @@ class BaseTerminalController: NSWindowController, didSet { surfaceTreeDidChange(from: oldValue, to: surfaceTree) } } + /// Whether the terminal surface should focus when the mouse is over it. + var focusFollowsMouse: Bool { + self.derivedConfig.focusFollowsMouse + } + /// Non-nil when an alert is active so we don't overlap multiple. private var alert: NSAlert? = nil @@ -106,8 +111,8 @@ class BaseTerminalController: NSWindowController, // Listen for local events that we need to know of outside of // single surface handlers. self.eventMonitor = NSEvent.addLocalMonitorForEvents( - matching: [.flagsChanged], - handler: localEventHandler) + matching: [.flagsChanged] + ) { [weak self] event in self?.localEventHandler(event) } } deinit { @@ -155,7 +160,7 @@ class BaseTerminalController: NSWindowController, } // MARK: Notifications - + @objc private func didChangeScreenParametersNotification(_ notification: Notification) { // If we have a window that is visible and it is outside the bounds of the // screen then we clamp it back to within the screen. @@ -262,7 +267,6 @@ class BaseTerminalController: NSWindowController, // Set the main window title window.title = to - } func pwdDidChange(to: URL?) { @@ -604,15 +608,18 @@ class BaseTerminalController: NSWindowController, private struct DerivedConfig { let macosTitlebarProxyIcon: Ghostty.MacOSTitlebarProxyIcon let windowStepResize: Bool + let focusFollowsMouse: Bool init() { self.macosTitlebarProxyIcon = .visible self.windowStepResize = false + self.focusFollowsMouse = false } init(_ config: Ghostty.Config) { self.macosTitlebarProxyIcon = config.macosTitlebarProxyIcon self.windowStepResize = config.windowStepResize + self.focusFollowsMouse = config.focusFollowsMouse } } } diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 7fd1802dc..c3b332cd4 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -117,9 +117,6 @@ class TerminalController: BaseTerminalController { // Update our derived config self.derivedConfig = DerivedConfig(config) - guard let window = window as? TerminalWindow else { return } - window.focusFollowsMouse = config.focusFollowsMouse - // If we have no surfaces in our window (is that possible?) then we update // our window appearance based on the root config. If we have surfaces, we // don't call this because the TODO @@ -247,7 +244,7 @@ class TerminalController: BaseTerminalController { let backgroundColor: OSColor if let surfaceTree { if let focusedSurface, surfaceTree.doesBorderTop(view: focusedSurface) { - backgroundColor = OSColor(focusedSurface.backgroundColor ?? surfaceConfig.backgroundColor) + backgroundColor = OSColor(focusedSurface.backgroundColor ?? surfaceConfig.backgroundColor).withAlphaComponent(0.0) } else { // We don't have a focused surface or our surface doesn't border the // top. We choose to match the color of the top-left most surface. @@ -422,8 +419,6 @@ class TerminalController: BaseTerminalController { } } - window.focusFollowsMouse = config.focusFollowsMouse - // Apply any additional appearance-related properties to the new window. We // apply this based on the root config but change it later based on surface // config (see focused surface change callback). diff --git a/macos/Sources/Features/Terminal/TerminalWindow.swift b/macos/Sources/Features/Terminal/TerminalWindow.swift index 503e76791..35f629bfd 100644 --- a/macos/Sources/Features/Terminal/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/TerminalWindow.swift @@ -414,8 +414,6 @@ class TerminalWindow: NSWindow { } } - var focusFollowsMouse: Bool = false - // Find the NSTextField responsible for displaying the titlebar's title. private var titlebarTextField: NSTextField? { guard let titlebarView = titlebarContainer?.subviews diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 60de024d3..2cac4a0dd 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -617,11 +617,12 @@ extension Ghostty { let mods = Ghostty.ghosttyMods(event.modifierFlags) ghostty_surface_mouse_pos(surface, pos.x, frame.height - pos.y, mods) - // If focus follows mouse is enabled then move focus to this surface. - if let window = self.window as? TerminalWindow, - window.isKeyWindow && - window.focusFollowsMouse && - !self.focused + // Handle focus-follows-mouse + if let window, + let controller = window.windowController as? BaseTerminalController, + (window.isKeyWindow && + !self.focused && + controller.focusFollowsMouse) { Ghostty.moveFocus(to: self) } diff --git a/src/Surface.zig b/src/Surface.zig index 9a6d2f6db..8c7c2619e 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -853,11 +853,8 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { }, .color_change => |change| { - // On any color change, we have to report for mode 2031 - // if it is enabled. - self.reportColorScheme(false); - - // Notify our apprt + // Notify our apprt, but don't send a mode 2031 DSR report + // because VT sequences were used to change the color. try self.rt_app.performAction( .{ .surface = self }, .color_change, @@ -4293,11 +4290,16 @@ fn writeScreenFile( tmp_dir.deinit(); return; }; + + // Use topLeft and bottomRight to ensure correct coordinate ordering + const tl = sel.topLeft(&self.io.terminal.screen); + const br = sel.bottomRight(&self.io.terminal.screen); + try self.io.terminal.screen.dumpString( buf_writer.writer(), .{ - .tl = sel.start(), - .br = sel.end(), + .tl = tl, + .br = br, .unwrap = true, }, ); diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 033f4788c..c10ba7993 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -1400,7 +1400,15 @@ pub fn getColorScheme(self: *App) apprt.ColorScheme { null, &err, ) orelse { - if (err) |e| log.err("unable to get current color scheme: {s}", .{e.message}); + if (err) |e| { + // If ReadOne is not yet implemented, fall back to deprecated "Read" method + // Error code: GDBus.Error:org.freedesktop.DBus.Error.UnknownMethod: No such method “ReadOne” + if (e.code == 19) { + return self.getColorSchemeDeprecated(); + } + // Otherwise, log the error and return .light + log.err("unable to get current color scheme: {s}", .{e.message}); + } return .light; }; defer c.g_variant_unref(value); @@ -1417,6 +1425,49 @@ pub fn getColorScheme(self: *App) apprt.ColorScheme { return .light; } +/// Call the deprecated D-Bus "Read" method to determine the current color scheme. If +/// there is any error at any point we'll log the error and return "light" +fn getColorSchemeDeprecated(self: *App) apprt.ColorScheme { + const dbus_connection = c.g_application_get_dbus_connection(@ptrCast(self.app)); + var err: ?*c.GError = null; + defer if (err) |e| c.g_error_free(e); + + const value = c.g_dbus_connection_call_sync( + dbus_connection, + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.Settings", + "Read", + c.g_variant_new("(ss)", "org.freedesktop.appearance", "color-scheme"), + c.G_VARIANT_TYPE("(v)"), + c.G_DBUS_CALL_FLAGS_NONE, + -1, + null, + &err, + ) orelse { + if (err) |e| log.err("Read method failed: {s}", .{e.message}); + return .light; + }; + defer c.g_variant_unref(value); + + if (c.g_variant_is_of_type(value, c.G_VARIANT_TYPE("(v)")) == 1) { + var inner: ?*c.GVariant = null; + c.g_variant_get(value, "(v)", &inner); + defer if (inner) |i| c.g_variant_unref(i); + + if (inner) |i| { + const child = c.g_variant_get_child_value(i, 0) orelse { + return .light; + }; + defer c.g_variant_unref(child); + + const val = c.g_variant_get_uint32(child); + return if (val == 1) .dark else .light; + } + } + return .light; +} + /// This will be called by D-Bus when the style changes between light & dark. fn gtkNotifyColorScheme( _: ?*c.GDBusConnection, diff --git a/src/apprt/gtk/ClipboardConfirmationWindow.zig b/src/apprt/gtk/ClipboardConfirmationWindow.zig index 30b38f1d4..11c68da7e 100644 --- a/src/apprt/gtk/ClipboardConfirmationWindow.zig +++ b/src/apprt/gtk/ClipboardConfirmationWindow.zig @@ -238,7 +238,7 @@ fn promptText(req: apprt.ClipboardRequest) [:0]const u8 { \\Pasting this text into the terminal may be dangerous as it looks like some commands may be executed. , .osc_52_read => - \\An appliclication is attempting to read from the clipboard. + \\An application is attempting to read from the clipboard. \\The current clipboard contents are shown below. , .osc_52_write => diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index ea863051c..c9e274ea0 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -156,6 +156,9 @@ pub fn init(self: *Window, app: *App) !void { if (app.config.@"gtk-titlebar") { const header = HeaderBar.init(self); + // If we are not decorated then we hide the titlebar. + header.setVisible(app.config.@"window-decoration"); + { const btn = c.gtk_menu_button_new(); c.gtk_widget_set_tooltip_text(btn, "Main Menu"); @@ -216,6 +219,14 @@ pub fn init(self: *Window, app: *App) !void { } } + // If Adwaita is enabled and is older than 1.4.0 we don't have the tab overview and so we + // need to stick the headerbar into the content box. + if (!adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config)) { + if (self.header) |h| { + c.gtk_box_append(@ptrCast(box), h.asWidget()); + } + } + // In debug we show a warning and apply the 'devel' class to the window. // This is a really common issue where people build from source in debug and performance is really bad. if (comptime std.debug.runtime_safety) { @@ -290,11 +301,6 @@ pub fn init(self: *Window, app: *App) !void { if (self.header) |header| { const header_widget = header.asWidget(); c.adw_toolbar_view_add_top_bar(toolbar_view, header_widget); - - // If we are not decorated then we hide the titlebar. - if (!app.config.@"window-decoration") { - c.gtk_widget_set_visible(header_widget, 0); - } } if (self.app.config.@"gtk-tabs-location" != .hidden) { @@ -363,8 +369,17 @@ pub fn init(self: *Window, app: *App) !void { } // The box is our main child - c.gtk_window_set_child(gtk_window, box); - if (self.header) |h| c.gtk_window_set_titlebar(gtk_window, h.asWidget()); + if (!adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config)) { + c.adw_application_window_set_content( + @ptrCast(gtk_window), + box, + ); + } else { + c.gtk_window_set_child(gtk_window, box); + if (self.header) |h| { + c.gtk_window_set_titlebar(gtk_window, h.asWidget()); + } + } } // Show the window @@ -499,13 +514,19 @@ pub fn toggleWindowDecorations(self: *Window) void { const new_decorated = !old_decorated; c.gtk_window_set_decorated(self.window, @intFromBool(new_decorated)); + // Fix any artifacting that may occur in window corners. + if (new_decorated) { + c.gtk_widget_add_css_class(@ptrCast(self.window), "without-window-decoration-and-with-titlebar"); + } else { + c.gtk_widget_remove_css_class(@ptrCast(self.window), "without-window-decoration-and-with-titlebar"); + } + // If we have a titlebar, then we also show/hide it depending on the // decorated state. GTK tends to consider the titlebar part of the frame // and hides it with decorations, but libadwaita doesn't. This makes it // explicit. - if (self.header) |v| { - const widget = v.asWidget(); - c.gtk_widget_set_visible(widget, @intFromBool(new_decorated)); + if (self.header) |headerbar| { + headerbar.setVisible(new_decorated); } } diff --git a/src/apprt/gtk/headerbar.zig b/src/apprt/gtk/headerbar.zig index b1567ce27..5bb92aca2 100644 --- a/src/apprt/gtk/headerbar.zig +++ b/src/apprt/gtk/headerbar.zig @@ -30,6 +30,10 @@ pub const HeaderBar = union(enum) { return .{ .gtk = @ptrCast(headerbar) }; } + pub fn setVisible(self: HeaderBar, visible: bool) void { + c.gtk_widget_set_visible(self.asWidget(), @intFromBool(visible)); + } + pub fn asWidget(self: HeaderBar) *c.GtkWidget { return switch (self) { .adw => |headerbar| @ptrCast(@alignCast(headerbar)), diff --git a/src/config/Config.zig b/src/config/Config.zig index a2f71c0c0..91c07cc78 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2668,18 +2668,40 @@ pub fn loadFile(self: *Config, alloc: Allocator, path: []const u8) !void { try self.expandPaths(std.fs.path.dirname(path).?); } +pub const OptionalFileAction = enum { loaded, not_found, @"error" }; + /// Load optional configuration file from `path`. All errors are ignored. -pub fn loadOptionalFile(self: *Config, alloc: Allocator, path: []const u8) void { - self.loadFile(alloc, path) catch |err| switch (err) { - error.FileNotFound => std.log.info( - "optional config file not found, not loading path={s}", - .{path}, - ), - else => std.log.warn( - "error reading optional config file, not loading err={} path={s}", - .{ err, path }, - ), - }; +/// +/// Returns the action that was taken. +pub fn loadOptionalFile( + self: *Config, + alloc: Allocator, + path: []const u8, +) OptionalFileAction { + if (self.loadFile(alloc, path)) { + return .loaded; + } else |err| switch (err) { + error.FileNotFound => return .not_found, + else => { + std.log.warn( + "error reading optional config file, not loading err={} path={s}", + .{ err, path }, + ); + + return .@"error"; + }, + } +} + +fn writeConfigTemplate(path: []const u8) !void { + log.info("creating template config file: path={s}", .{path}); + const file = try std.fs.createFileAbsolute(path, .{}); + defer file.close(); + try std.fmt.format( + file.writer(), + @embedFile("./config-template"), + .{ .path = path }, + ); } /// Load configurations from the default configuration files. The default @@ -2688,14 +2710,30 @@ pub fn loadOptionalFile(self: *Config, alloc: Allocator, path: []const u8) void /// On macOS, `$HOME/Library/Application Support/$CFBundleIdentifier/config` /// is also loaded. pub fn loadDefaultFiles(self: *Config, alloc: Allocator) !void { + // Load XDG first const xdg_path = try internal_os.xdg.config(alloc, .{ .subdir = "ghostty/config" }); defer alloc.free(xdg_path); - self.loadOptionalFile(alloc, xdg_path); + const xdg_action = self.loadOptionalFile(alloc, xdg_path); + // On macOS load the app support directory as well if (comptime builtin.os.tag == .macos) { const app_support_path = try internal_os.macos.appSupportDir(alloc, "config"); defer alloc.free(app_support_path); - self.loadOptionalFile(alloc, app_support_path); + const app_support_action = self.loadOptionalFile(alloc, app_support_path); + + // If both files are not found, then we create a template file. + // For macOS, we only create the template file in the app support + if (app_support_action == .not_found and xdg_action == .not_found) { + writeConfigTemplate(app_support_path) catch |err| { + log.warn("error creating template config file err={}", .{err}); + }; + } + } else { + if (xdg_action == .not_found) { + writeConfigTemplate(xdg_path) catch |err| { + log.warn("error creating template config file err={}", .{err}); + }; + } } } @@ -2805,17 +2843,21 @@ pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void { // replace the entire list with the new list. inline for (fields, 0..) |field, i| { const v = &@field(self, field); - const len = v.list.items.len - counter[i]; - if (len > 0) { - // Note: we don't have to worry about freeing the memory - // that we overwrite or cut off here because its all in - // an arena. - v.list.replaceRangeAssumeCapacity( - 0, - len, - v.list.items[counter[i]..], - ); - v.list.items.len = len; + + // The list can be empty if it was reset, i.e. --font-family="" + if (v.list.items.len > 0) { + const len = v.list.items.len - counter[i]; + if (len > 0) { + // Note: we don't have to worry about freeing the memory + // that we overwrite or cut off here because its all in + // an arena. + v.list.replaceRangeAssumeCapacity( + 0, + len, + v.list.items[counter[i]..], + ); + v.list.items.len = len; + } } } } @@ -3797,17 +3839,22 @@ pub const Color = struct { pub fn fromHex(input: []const u8) !Color { // Trim the beginning '#' if it exists const trimmed = if (input.len != 0 and input[0] == '#') input[1..] else input; + if (trimmed.len != 6 and trimmed.len != 3) return error.InvalidValue; - // We expect exactly 6 for RRGGBB - if (trimmed.len != 6) return error.InvalidValue; + // Expand short hex values to full hex values + const rgb: []const u8 = if (trimmed.len == 3) &.{ + trimmed[0], trimmed[0], + trimmed[1], trimmed[1], + trimmed[2], trimmed[2], + } else trimmed; // Parse the colors two at a time. var result: Color = undefined; comptime var i: usize = 0; inline while (i < 6) : (i += 2) { const v: u8 = - ((try std.fmt.charToDigit(trimmed[i], 16)) * 16) + - try std.fmt.charToDigit(trimmed[i + 1], 16); + ((try std.fmt.charToDigit(rgb[i], 16)) * 16) + + try std.fmt.charToDigit(rgb[i + 1], 16); @field(result, switch (i) { 0 => "r", @@ -3827,6 +3874,8 @@ pub const Color = struct { try testing.expectEqual(Color{ .r = 10, .g = 11, .b = 12 }, try Color.fromHex("#0A0B0C")); try testing.expectEqual(Color{ .r = 10, .g = 11, .b = 12 }, try Color.fromHex("0A0B0C")); try testing.expectEqual(Color{ .r = 255, .g = 255, .b = 255 }, try Color.fromHex("FFFFFF")); + try testing.expectEqual(Color{ .r = 255, .g = 255, .b = 255 }, try Color.fromHex("FFF")); + try testing.expectEqual(Color{ .r = 51, .g = 68, .b = 85 }, try Color.fromHex("#345")); } test "parseCLI from name" { @@ -4701,9 +4750,11 @@ pub const Keybinds = struct { try list.parseCLI(alloc, "ctrl+z>2=goto_tab:2"); try list.formatEntry(formatterpkg.entryFormatter("keybind", buf.writer())); + // Note they turn into translated keys because they match + // their ASCII mapping. const want = - \\keybind = ctrl+z>1=goto_tab:1 - \\keybind = ctrl+z>2=goto_tab:2 + \\keybind = ctrl+z>two=goto_tab:2 + \\keybind = ctrl+z>one=goto_tab:1 \\ ; try std.testing.expectEqualStrings(want, buf.items); diff --git a/src/config/config-template b/src/config/config-template new file mode 100644 index 000000000..4645e60aa --- /dev/null +++ b/src/config/config-template @@ -0,0 +1,43 @@ +# This is the configuration file for Ghostty. +# +# This template file has been automatically created at the following +# path since Ghostty couldn't find any existing config files on your system: +# +# {[path]s} +# +# The template does not set any default options, since Ghostty ships +# with sensible defaults for all options. Users should only need to set +# options that they want to change from the default. +# +# Run `ghostty +show-config --default --docs` to view a list of +# all available config options and their default values. +# +# Additionally, each config option is also explained in detail +# on Ghostty's website, at https://ghostty.org/docs/config. + +# Config syntax crash course +# ========================== +# # The config file consists of simple key-value pairs, +# # separated by equals signs. +# font-family = Iosevka +# window-padding-x = 2 +# +# # Spacing around the equals sign does not matter. +# # All of these are identical: +# key=value +# key= value +# key =value +# key = value +# +# # Any line beginning with a # is a comment. It's not possible to put +# # a comment after a config option, since it would be interpreted as a +# # part of the value. For example, this will have a value of "#123abc": +# background = #123abc +# +# # Empty values are used to reset config keys to default. +# key = +# +# # Some config options have unique syntaxes for their value, +# # which is explained in the docs for that config option. +# # Just for example: +# resize-overlay-duration = 4s 200ms diff --git a/src/config/edit.zig b/src/config/edit.zig index 68d9da88c..871a1a755 100644 --- a/src/config/edit.zig +++ b/src/config/edit.zig @@ -48,13 +48,7 @@ pub fn open(alloc_gpa: Allocator) !void { /// /// The allocator must be an arena allocator. No memory is freed by this /// function and the resulting path is not all the memory that is allocated. -/// -/// NOTE: WHY IS THIS INLINE? This is inline because when this is not -/// inline then Zig 0.13 crashes [most of the time] when trying to compile -/// this file. This is a workaround for that issue. This function is only -/// called from one place that is not performance critical so it is fine -/// to be inline. -inline fn configPath(alloc_arena: Allocator) ![]const u8 { +fn configPath(alloc_arena: Allocator) ![]const u8 { const paths: []const []const u8 = try configPathCandidates(alloc_arena); assert(paths.len > 0); diff --git a/src/font/discovery.zig b/src/font/discovery.zig index a42055d5a..e73ea626f 100644 --- a/src/font/discovery.zig +++ b/src/font/discovery.zig @@ -362,16 +362,9 @@ pub const CoreText = struct { const list = set.createMatchingFontDescriptors(); defer list.release(); - // Bring the list of descriptors in to zig land - var zig_list = try copyMatchingDescriptors(alloc, list); - errdefer alloc.free(zig_list); - - // Filter them. We don't use `CTFontCollectionSetExclusionDescriptors` - // to do this because that requires a mutable collection. This way is - // much more straight forward. - zig_list = try alloc.realloc(zig_list, filterDescriptors(zig_list)); - // Sort our descriptors + const zig_list = try copyMatchingDescriptors(alloc, list); + errdefer alloc.free(zig_list); sortMatchingDescriptors(&desc, zig_list); return DiscoverIterator{ @@ -558,47 +551,13 @@ pub const CoreText = struct { for (0..result.len) |i| { result[i] = list.getValueAtIndex(macos.text.FontDescriptor, i); - // We need to retain because once the list - // is freed it will release all its members. + // We need to retain becauseonce the list is freed it will + // release all its members. result[i].retain(); } return result; } - /// Filter any descriptors out of the list that aren't acceptable for - /// some reason or another (e.g. the font isn't in a format we can handle). - /// - /// Invalid descriptors are filled in from the end of - /// the list and the new length for the list is returned. - fn filterDescriptors(list: []*macos.text.FontDescriptor) usize { - var end = list.len; - var i: usize = 0; - while (i < end) { - if (validDescriptor(list[i])) { - i += 1; - } else { - list[i].release(); - end -= 1; - list[i] = list[end]; - } - } - return end; - } - - /// Used by `filterDescriptors` to decide whether a descriptor is valid. - fn validDescriptor(desc: *macos.text.FontDescriptor) bool { - if (desc.copyAttribute(macos.text.FontAttribute.format)) |format| { - defer format.release(); - var value: c_int = undefined; - assert(format.getValue(.int, &value)); - - // Bitmap fonts are not currently supported. - if (value == macos.text.c.kCTFontFormatBitmap) return false; - } - - return true; - } - fn sortMatchingDescriptors( desc: *const Descriptor, list: []*macos.text.FontDescriptor, diff --git a/src/font/embedded.zig b/src/font/embedded.zig index 098aa3eb4..31b07ff31 100644 --- a/src/font/embedded.zig +++ b/src/font/embedded.zig @@ -34,3 +34,6 @@ pub const cozette = @embedFile("res/CozetteVector.ttf"); /// Monaspace has weird ligature behaviors we want to test in our shapers /// so we embed it here. pub const monaspace_neon = @embedFile("res/MonaspaceNeon-Regular.otf"); + +/// Terminus TTF is a scalable font with bitmap glyphs at various sizes. +pub const terminus_ttf = @embedFile("res/TerminusTTF-Regular.ttf"); diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index 92ab4d396..dd4f6432e 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -515,8 +515,17 @@ pub const Face = struct { 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; + // macOS bitmap-only fonts use a 'bhed' tag rather than 'head', but + // the table format is byte-identical to the 'head' table, so if we + // can't find 'head' we try 'bhed' instead before failing. + // + // ref: https://fontforge.org/docs/techref/bitmaponlysfnt.html + const head_tag = macos.text.FontTableTag.init("head"); + const bhed_tag = macos.text.FontTableTag.init("bhed"); + const data = + ct_font.copyTable(head_tag) orelse + ct_font.copyTable(bhed_tag) orelse + return error.CopyTableError; defer data.release(); const ptr = data.getPointer(); const len = data.getLength(); diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index bc503a3af..630eaee25 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -288,7 +288,6 @@ pub const Face = struct { self.face.loadGlyph(glyph_id, .{ .render = true, .color = self.face.hasColor(), - .no_bitmap = !self.face.hasColor(), }) catch return false; // If the glyph is SVG we assume colorized @@ -323,14 +322,6 @@ pub const Face = struct { // glyph properties before render so we don't render here. .render = !self.synthetic.bold, - // Disable bitmap strikes for now since it causes issues with - // our cell metrics and rasterization. In the future, this is - // all fixable so we can enable it. - // - // This must be enabled for color faces though because those are - // often colored bitmaps, which we support. - .no_bitmap = !self.face.hasColor(), - // use options from config .no_hinting = !self.load_flags.hinting, .force_autohint = !self.load_flags.@"force-autohint", @@ -385,7 +376,7 @@ pub const Face = struct { return error.UnsupportedPixelMode; }; - log.warn("converting from pixel_mode={} to atlas_format={}", .{ + log.debug("converting from pixel_mode={} to atlas_format={}", .{ bitmap_ft.pixel_mode, atlas.format, }); @@ -1005,3 +996,59 @@ test "svg font table" { try testing.expectEqual(430, table.len); } + +const terminus_i = + \\........ + \\........ + \\...#.... + \\...#.... + \\........ + \\..##.... + \\...#.... + \\...#.... + \\...#.... + \\...#.... + \\...#.... + \\..###... + \\........ + \\........ + \\........ + \\........ +; +// Including the newline +const terminus_i_pitch = 9; + +test "bitmap glyph" { + const alloc = testing.allocator; + const testFont = font.embedded.terminus_ttf; + + var lib = try Library.init(); + defer lib.deinit(); + + var atlas = try font.Atlas.init(alloc, 512, .grayscale); + defer atlas.deinit(alloc); + + // Any glyph at 12pt @ 96 DPI is a bitmap + var ft_font = try Face.init(lib, testFont, .{ .size = .{ + .points = 12, + .xdpi = 96, + .ydpi = 96, + } }); + defer ft_font.deinit(); + + // glyph 77 = 'i' + const glyph = try ft_font.renderGlyph(alloc, &atlas, 77, .{}); + + // should render crisp + try testing.expectEqual(8, glyph.width); + try testing.expectEqual(16, glyph.height); + for (0..glyph.height) |y| { + for (0..glyph.width) |x| { + const pixel = terminus_i[y * terminus_i_pitch + x]; + try testing.expectEqual( + @as(u8, if (pixel == '#') 255 else 0), + atlas.data[(glyph.atlas_y + y) * atlas.size + (glyph.atlas_x + x)], + ); + } + } +} diff --git a/src/font/face/freetype_convert.zig b/src/font/face/freetype_convert.zig index 298aad8a0..6df350bfa 100644 --- a/src/font/face/freetype_convert.zig +++ b/src/font/face/freetype_convert.zig @@ -43,26 +43,14 @@ pub fn monoToGrayscale(alloc: Allocator, bm: Bitmap) Allocator.Error!Bitmap { var buf = try alloc.alloc(u8, bm.width * bm.rows); errdefer alloc.free(buf); - // width divided by 8 because each byte has 8 pixels. This is therefore - // the number of bytes in each row. - const bytes_per_row = bm.width >> 3; - - var source_i: usize = 0; - var target_i: usize = 0; - var i: usize = bm.rows; - while (i > 0) : (i -= 1) { - var j: usize = bytes_per_row; - while (j > 0) : (j -= 1) { - var bit: u4 = 8; - while (bit > 0) : (bit -= 1) { - const mask = @as(u8, 1) << @as(u3, @intCast(bit - 1)); - const bitval: u8 = if (bm.buffer[source_i + (j - 1)] & mask > 0) 0xFF else 0; - buf[target_i] = bitval; - target_i += 1; - } + for (0..bm.rows) |y| { + const row_offset = y * @as(usize, @intCast(bm.pitch)); + for (0..bm.width) |x| { + const byte_offset = row_offset + @divTrunc(x, 8); + const mask = @as(u8, 1) << @intCast(7 - (x % 8)); + const bit: u8 = @intFromBool((bm.buffer[byte_offset] & mask) != 0); + buf[y * bm.width + x] = bit * 255; } - - source_i += @intCast(bm.pitch); } var copy = bm; diff --git a/src/font/res/README.md b/src/font/res/README.md index 3195a8916..5ad4b274f 100644 --- a/src/font/res/README.md +++ b/src/font/res/README.md @@ -25,6 +25,9 @@ This project uses several fonts which fall under the SIL Open Font License (OFL- - [Copyright 2013 Google LLC](https://github.com/googlefonts/noto-emoji/blob/main/LICENSE) - Cozette (MIT) - [Copyright (c) 2020, Slavfox](https://github.com/slavfox/Cozette/blob/main/LICENSE) +- Terminus TTF (OFL-1.1) + - [Copyright (c) 2010-2020 Dimitar Toshkov Zhekov with Reserved Font Name "Terminus Font"](https://sourceforge.net/projects/terminus-font/) + - [Copyright (c) 2011-2023 Tilman Blumenbach with Reserved Font Name "Terminus (TTF)"](https://files.ax86.net/terminus-ttf/) A full copy of the OFL license can be found at [OFL.txt](./OFL.txt). An accompanying FAQ is also available at . diff --git a/src/font/res/TerminusTTF-Regular.ttf b/src/font/res/TerminusTTF-Regular.ttf new file mode 100644 index 000000000..d125e6347 Binary files /dev/null and b/src/font/res/TerminusTTF-Regular.ttf differ diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 85721339d..b2c03b674 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -1019,6 +1019,14 @@ pub const Trigger = struct { const cp = it.nextCodepoint() orelse break :unicode; if (it.nextCodepoint() != null) break :unicode; + // If this is ASCII and we have a translated key, set that. + if (std.math.cast(u8, cp)) |ascii| { + if (key.Key.fromASCII(ascii)) |k| { + result.key = .{ .translated = k }; + continue :loop; + } + } + result.key = .{ .unicode = cp }; continue :loop; } @@ -1554,6 +1562,19 @@ test "parse: triggers" { try parseSingle("a=ignore"), ); + // unicode keys that map to translated + try testing.expectEqual(Binding{ + .trigger = .{ .key = .{ .translated = .one } }, + .action = .{ .ignore = {} }, + }, try parseSingle("1=ignore")); + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .super = true }, + .key = .{ .translated = .period }, + }, + .action = .{ .ignore = {} }, + }, try parseSingle("cmd+.=ignore")); + // single modifier try testing.expectEqual(Binding{ .trigger = .{ diff --git a/src/input/key.zig b/src/input/key.zig index eb2526593..a875611d0 100644 --- a/src/input/key.zig +++ b/src/input/key.zig @@ -729,7 +729,9 @@ pub const Key = enum(c_int) { .{ '\t', .tab }, // Keypad entries. We just assume keypad with the kp_ prefix - // so that has some special meaning. These must also always be last. + // so that has some special meaning. These must also always be last, + // so that our `fromASCII` function doesn't accidentally map them + // over normal numerics and other keys. .{ '0', .kp_0 }, .{ '1', .kp_1 }, .{ '2', .kp_2 }, diff --git a/src/os/open.zig b/src/os/open.zig index ff7d6049a..f6dc7ca2a 100644 --- a/src/os/open.zig +++ b/src/os/open.zig @@ -18,7 +18,31 @@ pub fn open( typ: Type, url: []const u8, ) !void { - const cmd = try openCommand(alloc, typ, url); + const cmd: OpenCommand = switch (builtin.os.tag) { + .linux => .{ .child = std.process.Child.init( + &.{ "xdg-open", url }, + alloc, + ) }, + + .windows => .{ .child = std.process.Child.init( + &.{ "rundll32", "url.dll,FileProtocolHandler", url }, + alloc, + ) }, + + .macos => .{ + .child = std.process.Child.init( + switch (typ) { + .text => &.{ "open", "-t", url }, + .unknown => &.{ "open", url }, + }, + alloc, + ), + .wait = true, + }, + + .ios => return error.Unimplemented, + else => @compileError("unsupported OS"), + }; var exe = cmd.child; if (cmd.wait) { @@ -53,31 +77,3 @@ const OpenCommand = struct { child: std.process.Child, wait: bool = false, }; - -fn openCommand(alloc: Allocator, typ: Type, url: []const u8) !OpenCommand { - return switch (builtin.os.tag) { - .linux => .{ .child = std.process.Child.init( - &.{ "xdg-open", url }, - alloc, - ) }, - - .windows => .{ .child = std.process.Child.init( - &.{ "rundll32", "url.dll,FileProtocolHandler", url }, - alloc, - ) }, - - .macos => .{ - .child = std.process.Child.init( - switch (typ) { - .text => &.{ "open", "-t", url }, - .unknown => &.{ "open", url }, - }, - alloc, - ), - .wait = true, - }, - - .ios => return error.Unimplemented, - else => @compileError("unsupported OS"), - }; -} diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index ca928fda6..5fb49ea66 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -3413,6 +3413,16 @@ pub const Pin = struct { direction: Direction, limit: ?Pin, ) PageIterator { + if (build_config.slow_runtime_safety) { + if (limit) |l| { + // Check the order according to the iteration direction. + switch (direction) { + .right_down => assert(self.eql(l) or self.before(l)), + .left_up => assert(self.eql(l) or l.before(self)), + } + } + } + return .{ .row = self, .limit = if (limit) |p| .{ .row = p } else .{ .none = {} },