Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions ora/Core/Platform/Representables/ClickDetector.swift
Original file line number Diff line number Diff line change
@@ -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) }
}
}
}
15 changes: 14 additions & 1 deletion ora/Core/Utilities/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) }
}
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
}
3 changes: 2 additions & 1 deletion ora/Features/Passwords/Views/PasswordsWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
12 changes: 12 additions & 0 deletions ora/Features/Settings/Sections/GeneralSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 13 additions & 0 deletions ora/Features/Sidebar/Views/ContainerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -78,6 +81,7 @@ struct ContainerView: View {
NormalTabsList(
tabs: normalTabs,
draggedItem: $draggedItem,
hoverTab: $hoverTab,
onDrag: dragTab,
onSelect: selectTab,
onPinToggle: togglePin,
Expand All @@ -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] {
Expand Down
8 changes: 8 additions & 0 deletions ora/Features/Sidebar/Views/TabList/NormalTabsList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down
Loading