diff --git a/ora/Core/Platform/Representables/ClickDetector.swift b/ora/Core/Platform/Representables/ClickDetector.swift new file mode 100644 index 00000000..b4683ed1 --- /dev/null +++ b/ora/Core/Platform/Representables/ClickDetector.swift @@ -0,0 +1,99 @@ +// +// ClickDetector.swift +// ora +// +// Created by Aryan Rogye on 3/8/26. +// + +import AppKit +import SwiftUI + +enum ClickConfig: String, CaseIterable { + case none = "None" + case middleMouse = "Middle Mouse" + case optionClick = "Option Click" +} + +struct ClickDetector: NSViewRepresentable { + var config: ClickConfig + var onClick: () -> Void + + func makeNSView(context: Context) -> NSView { + // Uncomment to visualize the click area bounds when debugging. +// let view = NSView() +// view.wantsLayer = true +// view.layer?.borderColor = NSColor.red.cgColor +// view.layer?.borderWidth = 2 +// return view + return NSView() + } + + func updateNSView(_ nsView: NSView, context: Context) { + context.coordinator.view = nsView + context.coordinator.update(config: config, onClick: onClick, view: nsView) + } + + func makeCoordinator() -> Coordinator { + Coordinator(config: config, onClick: onClick) + } + + class Coordinator: NSObject { + var monitor: Any? + var lastFired: Date = .distantPast + let throttleInterval: TimeInterval = 0.5 + var currentConfig: ClickConfig = .none + var onClick: (() -> Void)? + weak var view: NSView? + + init(config: ClickConfig, onClick: @escaping () -> Void) { + super.init() + update(config: config, onClick: onClick, view: nil) + } + + func update(config: ClickConfig, onClick: @escaping () -> Void, view: NSView?) { + self.view = view + self.onClick = onClick + guard currentConfig != config else { return } + currentConfig = config + if let monitor { NSEvent.removeMonitor(monitor) } + monitor = nil + + switch config { + case .none: + break + case .middleMouse: + monitor = NSEvent.addLocalMonitorForEvents(matching: .otherMouseDown) { [weak self] event in + guard event.buttonNumber == 2 else { return event } + guard self?.isOverView(event) == true else { return event } + self?.fire() + return nil + } + case .optionClick: + monitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in + guard event.modifierFlags.contains(.option) else { return event } + guard self?.isOverView(event) == true else { return event } + self?.fire() + return nil + } + } + } + + private func isOverView(_ event: NSEvent) -> Bool { + guard let view, let window = view.window else { return false } + let locationInWindow = event.locationInWindow + let locationInView = view.convert(locationInWindow, from: nil) + return view.bounds.contains(locationInView) + } + + private func fire() { + let now = Date() + guard now.timeIntervalSince(lastFired) >= throttleInterval else { return } + lastFired = now + onClick?() + } + + deinit { + if let monitor { NSEvent.removeMonitor(monitor) } + } + } +} diff --git a/ora/Core/Utilities/SettingsStore.swift b/ora/Core/Utilities/SettingsStore.swift index 48d27e03..006a1587 100644 --- a/ora/Core/Utilities/SettingsStore.swift +++ b/ora/Core/Utilities/SettingsStore.swift @@ -155,6 +155,7 @@ class SettingsStore: ObservableObject { private let tabRemovalTimeoutKey = "settings.tabRemovalTimeout" private let maxRecentTabsKey = "settings.maxRecentTabs" private let autoPiPEnabledKey = "settings.autoPiPEnabled" + private let clickConfigKey = "settings.clickConfig" private let passwordsEnabledKey = "settings.passwords.enabled" private let passwordManagerProviderKey = "settings.passwords.provider" private let passwordAutofillEnabledKey = "settings.passwords.autofillEnabled" @@ -224,6 +225,10 @@ class SettingsStore: ObservableObject { didSet { defaults.set(maxRecentTabs, forKey: maxRecentTabsKey) } } + @Published var clickConfig: ClickConfig { + didSet { defaults.set(clickConfig.rawValue, forKey: clickConfigKey) } + } + @Published var autoPiPEnabled: Bool { didSet { defaults.set(autoPiPEnabled, forKey: autoPiPEnabledKey) } } @@ -321,7 +326,15 @@ class SettingsStore: ObservableObject { passwordAutofillSubmitEnabled = defaults.object(forKey: passwordAutofillSubmitEnabledKey) as? Bool ?? true passwordSavePromptsEnabled = defaults.object(forKey: passwordSavePromptsEnabledKey) as? Bool ?? true suppressedPasswordSavePromptHosts = Set(defaults - .stringArray(forKey: suppressedPasswordSavePromptHostsKey) ?? []) + .stringArray(forKey: suppressedPasswordSavePromptHostsKey) ?? [] + ) + if let raw = defaults.string(forKey: clickConfigKey), + let config = ClickConfig(rawValue: raw) + { + clickConfig = config + } else { + clickConfig = .none + } } // MARK: - Per-container helpers diff --git a/ora/Features/Passwords/Services/PasswordAutofillCoordinator.swift b/ora/Features/Passwords/Services/PasswordAutofillCoordinator.swift index d66997fa..f65a2a03 100644 --- a/ora/Features/Passwords/Services/PasswordAutofillCoordinator.swift +++ b/ora/Features/Passwords/Services/PasswordAutofillCoordinator.swift @@ -644,7 +644,8 @@ final class PasswordAutofillCoordinator { emailSuggestions: filteredEmailSuggestions, generatedPassword: filteredGeneratedPassword, selectedSuggestionIndex: (savedPasswordEntries.isEmpty && filteredEmailSuggestions - .isEmpty && filteredGeneratedPassword == nil) ? -1 : 0 + .isEmpty && filteredGeneratedPassword == nil + ) ? -1 : 0 ) } } diff --git a/ora/Features/Passwords/Views/PasswordsWindow.swift b/ora/Features/Passwords/Views/PasswordsWindow.swift index 679bf1c6..7d8e34eb 100644 --- a/ora/Features/Passwords/Views/PasswordsWindow.swift +++ b/ora/Features/Passwords/Views/PasswordsWindow.swift @@ -87,7 +87,8 @@ private struct PasswordsWindowView: View { if filteredEntries.isEmpty { emptyState(message: searchText - .isEmpty ? "No saved passwords yet." : "No saved passwords match that search.") + .isEmpty ? "No saved passwords yet." : "No saved passwords match that search." + ) } else { passwordsTable } diff --git a/ora/Features/Settings/Sections/GeneralSettingsView.swift b/ora/Features/Settings/Sections/GeneralSettingsView.swift index 505e6a94..351995a9 100644 --- a/ora/Features/Settings/Sections/GeneralSettingsView.swift +++ b/ora/Features/Settings/Sections/GeneralSettingsView.swift @@ -97,6 +97,18 @@ struct GeneralSettingsView: View { .foregroundColor(.secondary) } + HStack { + Text("Close tab with:") + Spacer() + Picker("", selection: $settings.clickConfig) { + Text("Disabled").tag(ClickConfig.none) + Text("Middle mouse click").tag(ClickConfig.middleMouse) + Text("Option + click").tag(ClickConfig.optionClick) + } + .labelsHidden() + .frame(width: 180) + } + Toggle("Auto Picture-in-Picture on tab switch", isOn: $settings.autoPiPEnabled) } .padding(.vertical, 8) diff --git a/ora/Features/Settings/Sections/PasswordsSettingsView.swift b/ora/Features/Settings/Sections/PasswordsSettingsView.swift index 6a561486..fab65085 100644 --- a/ora/Features/Settings/Sections/PasswordsSettingsView.swift +++ b/ora/Features/Settings/Sections/PasswordsSettingsView.swift @@ -155,7 +155,8 @@ struct PasswordsSettingsView: View { if filteredEntries.isEmpty { emptyState(message: searchText - .isEmpty ? "No saved passwords yet." : "No saved passwords match that search.") + .isEmpty ? "No saved passwords yet." : "No saved passwords match that search." + ) } else { passwordsTable } diff --git a/ora/Features/Sidebar/Views/BottomOption/ContainerForm.swift b/ora/Features/Sidebar/Views/BottomOption/ContainerForm.swift index 113f96d8..cc66646c 100644 --- a/ora/Features/Sidebar/Views/BottomOption/ContainerForm.swift +++ b/ora/Features/Sidebar/Views/BottomOption/ContainerForm.swift @@ -37,7 +37,8 @@ struct ContainerForm: View { value: emoji.isEmpty ) .background(isEmojiPickerHovering ? theme.mutedBackground.opacity(0.8) - : theme.mutedBackground) + : theme.mutedBackground + ) .cornerRadius(ContainerConstants.UI.cornerRadius) if emoji.isEmpty { diff --git a/ora/Features/Sidebar/Views/ContainerView.swift b/ora/Features/Sidebar/Views/ContainerView.swift index 43df9368..f53f8c0f 100644 --- a/ora/Features/Sidebar/Views/ContainerView.swift +++ b/ora/Features/Sidebar/Views/ContainerView.swift @@ -10,8 +10,11 @@ struct ContainerView: View { @EnvironmentObject var tabManager: TabManager @EnvironmentObject var privacyMode: PrivacyMode + @StateObject private var settings = SettingsStore.shared + @State var isDragging = false @State private var draggedItem: UUID? + @State private var hoverTab: UUID? @State private var editingURLString: String = "" var body: some View { @@ -78,6 +81,7 @@ struct ContainerView: View { NormalTabsList( tabs: normalTabs, draggedItem: $draggedItem, + hoverTab: $hoverTab, onDrag: dragTab, onSelect: selectTab, onPinToggle: togglePin, @@ -91,6 +95,15 @@ struct ContainerView: View { } } .modifier(OraWindowDragGesture(isDragging: $isDragging)) + .background( + ClickDetector(config: settings.clickConfig) { + if let hoverTab { + if let tab = normalTabs.first(where: { $0.id == hoverTab }) { + tabManager.closeTab(tab: tab) + } + } + } + ) } private var favoriteTabs: [Tab] { diff --git a/ora/Features/Sidebar/Views/TabList/NormalTabsList.swift b/ora/Features/Sidebar/Views/TabList/NormalTabsList.swift index 00c4794e..2534ec5d 100644 --- a/ora/Features/Sidebar/Views/TabList/NormalTabsList.swift +++ b/ora/Features/Sidebar/Views/TabList/NormalTabsList.swift @@ -4,6 +4,7 @@ import SwiftUI struct NormalTabsList: View { let tabs: [Tab] @Binding var draggedItem: UUID? + @Binding var hoverTab: UUID? let onDrag: (UUID) -> NSItemProvider let onSelect: (Tab) -> Void let onPinToggle: (Tab) -> Void @@ -45,6 +46,13 @@ struct NormalTabsList: View { targetSection: .normal ) ) + .onHover { hovering in + if hovering { + hoverTab = tab.id + } else { + hoverTab = nil + } + } .transition(.asymmetric( insertion: .opacity.combined(with: .move(edge: .bottom)), removal: .opacity.combined(with: .move(edge: .top))