diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 5e33da6d..7e33252f 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -137,6 +137,7 @@ struct VerticalTabsSidebar: View { struct TabItemView: View { @EnvironmentObject var tabManager: TabManager + @EnvironmentObject var notificationStore: TerminalNotificationStore @ObservedObject var tab: Tab @Binding var selection: SidebarSelection @State private var isHovering = false @@ -147,9 +148,17 @@ struct TabItemView: View { var body: some View { HStack(spacing: 8) { - Image(systemName: "terminal") - .font(.system(size: 12)) - .foregroundColor(isSelected ? .white : .secondary) + let unreadCount = notificationStore.unreadCount(forTabId: tab.id) + if unreadCount > 0 { + ZStack { + Circle() + .fill(isSelected ? Color.white.opacity(0.25) : Color.accentColor) + Text("\(unreadCount)") + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(.white) + } + .frame(width: 16, height: 16) + } Text(tab.title) .font(.system(size: 12)) @@ -159,15 +168,15 @@ struct TabItemView: View { Spacer() - if isHovering || isSelected { - Button(action: { tabManager.closeTab(tab) }) { - Image(systemName: "xmark") - .font(.system(size: 9, weight: .medium)) - .foregroundColor(isSelected ? .white.opacity(0.7) : .secondary) - } - .buttonStyle(.plain) - .opacity(tabManager.tabs.count > 1 ? 1 : 0) + Button(action: { tabManager.closeTab(tab) }) { + Image(systemName: "xmark") + .font(.system(size: 9, weight: .medium)) + .foregroundColor(isSelected ? .white.opacity(0.7) : .secondary) } + .buttonStyle(.plain) + .frame(width: 16, height: 16) + .opacity((isHovering || isSelected) && tabManager.tabs.count > 1 ? 1 : 0) + .allowsHitTesting((isHovering || isSelected) && tabManager.tabs.count > 1) } .padding(.horizontal, 10) .padding(.vertical, 8) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 386a0db3..2965f005 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -704,12 +704,54 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS + // Translate mods to respect Ghostty config (e.g., macos-option-as-alt) + let translationModsGhostty = ghostty_surface_key_translation_mods(surface, modsFromEvent(event)) + var translationMods = event.modifierFlags + for flag in [NSEvent.ModifierFlags.shift, .control, .option, .command] { + let hasFlag: Bool + switch flag { + case .shift: + hasFlag = (translationModsGhostty.rawValue & GHOSTTY_MODS_SHIFT.rawValue) != 0 + case .control: + hasFlag = (translationModsGhostty.rawValue & GHOSTTY_MODS_CTRL.rawValue) != 0 + case .option: + hasFlag = (translationModsGhostty.rawValue & GHOSTTY_MODS_ALT.rawValue) != 0 + case .command: + hasFlag = (translationModsGhostty.rawValue & GHOSTTY_MODS_SUPER.rawValue) != 0 + default: + hasFlag = translationMods.contains(flag) + } + if hasFlag { + translationMods.insert(flag) + } else { + translationMods.remove(flag) + } + } + + let translationEvent: NSEvent + if translationMods == event.modifierFlags { + translationEvent = event + } else { + translationEvent = NSEvent.keyEvent( + with: event.type, + location: event.locationInWindow, + modifierFlags: translationMods, + timestamp: event.timestamp, + windowNumber: event.windowNumber, + context: nil, + characters: event.characters(byApplyingModifiers: translationMods) ?? "", + charactersIgnoringModifiers: event.charactersIgnoringModifiers ?? "", + isARepeat: event.isARepeat, + keyCode: event.keyCode + ) ?? event + } + // Set up text accumulator for interpretKeyEvents keyTextAccumulator = [] defer { keyTextAccumulator = nil } // Let the input system handle the event (for IME, dead keys, etc.) - interpretKeyEvents([event]) + interpretKeyEvents([translationEvent]) // Build the key event var keyEvent = ghostty_input_key_s() @@ -717,7 +759,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { keyEvent.keycode = UInt32(event.keyCode) keyEvent.mods = modsFromEvent(event) // Control and Command never contribute to text translation - keyEvent.consumed_mods = consumedModsFromEvent(event) + keyEvent.consumed_mods = consumedModsFromFlags(translationMods) keyEvent.composing = markedText.length > 0 keyEvent.unshifted_codepoint = unshiftedCodepointFromEvent(event) @@ -733,7 +775,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // Get the appropriate text for this key event // For control characters, this returns the unmodified character // so Ghostty's KeyEncoder can handle ctrl encoding - if let text = textForKeyEvent(event) { + if let text = textForKeyEvent(translationEvent) { text.withCString { ptr in keyEvent.text = ptr _ = ghostty_surface_key(surface, keyEvent) @@ -789,12 +831,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { /// Consumed mods are modifiers that were used for text translation. /// Control and Command never contribute to text translation, so they /// should be excluded from consumed_mods. - private func consumedModsFromEvent(_ event: NSEvent) -> ghostty_input_mods_e { + private func consumedModsFromFlags(_ flags: NSEvent.ModifierFlags) -> ghostty_input_mods_e { var mods = GHOSTTY_MODS_NONE.rawValue // Only include Shift and Option as potentially consumed // Control and Command are never consumed for text translation - if event.modifierFlags.contains(.shift) { mods |= GHOSTTY_MODS_SHIFT.rawValue } - if event.modifierFlags.contains(.option) { mods |= GHOSTTY_MODS_ALT.rawValue } + if flags.contains(.shift) { mods |= GHOSTTY_MODS_SHIFT.rawValue } + if flags.contains(.option) { mods |= GHOSTTY_MODS_ALT.rawValue } return ghostty_input_mods_e(rawValue: mods) } diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 35c291fb..da62118f 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -29,6 +29,10 @@ final class TerminalNotificationStore: ObservableObject { notifications.filter { !$0.isRead }.count } + func unreadCount(forTabId tabId: UUID) -> Int { + notifications.filter { $0.tabId == tabId && !$0.isRead }.count + } + func addNotification(tabId: UUID, surfaceId: UUID?, title: String, body: String) { let isActiveTab = AppDelegate.shared?.tabManager?.selectedTabId == tabId let focusedSurfaceId = AppDelegate.shared?.tabManager?.focusedSurfaceId(for: tabId)