Skip to content

Commit 010e2df

Browse files
committed
feat(ui): refine app rules tab, result card preview, and icon scaling
1 parent 3c9b476 commit 010e2df

17 files changed

Lines changed: 358 additions & 67 deletions

Sources/Core/Rules/AppRule.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
import Foundation
66

77
public struct AppPolicyContext: Sendable {
8+
/// Disables OpenClip entirely for the matching application (no popup, hotkeys ignored).
9+
public let disabled: Bool
10+
/// Suppresses the automatic popup on text selection; OpenClip will only trigger via explicit hotkey.
11+
public let hotkeyOnly: Bool
812
/// Force a text result to be delivered as a copy instead of a paste, even when the app lists a
913
/// Paste command. The explicit escape hatch for apps (e.g. Terminal) that advertise Paste but
1014
/// cannot reliably replace a selection.
@@ -19,11 +23,15 @@ public struct AppPolicyContext: Sendable {
1923
public static let `default` = AppPolicyContext()
2024

2125
public init(
26+
disabled: Bool = false,
27+
hotkeyOnly: Bool = false,
2228
denyPaste: Bool = false,
2329
useMenuCopy: Bool = false,
2430
retrievalMode: SelectionRetrievalMode = .axTextControl,
2531
gate: SelectionGatePolicy = .default
2632
) {
33+
self.disabled = disabled
34+
self.hotkeyOnly = hotkeyOnly
2735
self.denyPaste = denyPaste
2836
self.useMenuCopy = useMenuCopy
2937
self.retrievalMode = retrievalMode
@@ -34,19 +42,25 @@ public struct AppPolicyContext: Sendable {
3442
public struct AppRule: Codable, Sendable, Equatable, Identifiable {
3543
public var id: String { bundleIdentifiers.first ?? UUID().uuidString }
3644
public let bundleIdentifiers: [String]
45+
public let disabled: Bool?
46+
public let hotkeyOnly: Bool?
3747
public let useMenuCopy: Bool?
3848
public let denyPaste: Bool?
3949
public let retrievalMode: SelectionRetrievalMode?
4050
public let gate: SelectionGatePolicy?
4151

4252
public init(
4353
bundleIdentifiers: [String],
54+
disabled: Bool? = nil,
55+
hotkeyOnly: Bool? = nil,
4456
useMenuCopy: Bool? = nil,
4557
denyPaste: Bool? = nil,
4658
retrievalMode: SelectionRetrievalMode? = nil,
4759
gate: SelectionGatePolicy? = nil
4860
) {
4961
self.bundleIdentifiers = bundleIdentifiers
62+
self.disabled = disabled
63+
self.hotkeyOnly = hotkeyOnly
5064
self.useMenuCopy = useMenuCopy
5165
self.denyPaste = denyPaste
5266
self.retrievalMode = retrievalMode
@@ -55,6 +69,8 @@ public struct AppRule: Codable, Sendable, Equatable, Identifiable {
5569

5670
public enum CodingKeys: String, CodingKey {
5771
case bundleIdentifiers = "bundle-identifiers"
72+
case disabled = "disabled"
73+
case hotkeyOnly = "hotkey-only"
5874
case useMenuCopy = "use-menu-copy"
5975
case denyPaste = "deny-paste"
6076
case retrievalMode = "retrieval-mode"

Sources/Core/Rules/RuleEngine.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ public final class RuleEngine: ObservableObject, Sendable {
8383
}
8484
return AppRule(
8585
bundleIdentifiers: expandedIdentifiers,
86+
disabled: rule.disabled,
87+
hotkeyOnly: rule.hotkeyOnly,
8688
useMenuCopy: rule.useMenuCopy,
8789
denyPaste: rule.denyPaste,
8890
retrievalMode: rule.retrievalMode,
@@ -101,6 +103,8 @@ public final class RuleEngine: ObservableObject, Sendable {
101103
explicitRetrievalMode = true
102104
}
103105
context = AppPolicyContext(
106+
disabled: rule.disabled ?? context.disabled,
107+
hotkeyOnly: rule.hotkeyOnly ?? context.hotkeyOnly,
104108
denyPaste: rule.denyPaste ?? context.denyPaste,
105109
useMenuCopy: rule.useMenuCopy ?? context.useMenuCopy,
106110
retrievalMode: rule.retrievalMode ?? context.retrievalMode,
@@ -114,6 +118,8 @@ public final class RuleEngine: ObservableObject, Sendable {
114118
// explicitly supplied `retrieval-mode` (even `.axTextControl`) opts out of the conversion.
115119
if context.useMenuCopy && context.retrievalMode == .axTextControl && !explicitRetrievalMode {
116120
context = AppPolicyContext(
121+
disabled: context.disabled,
122+
hotkeyOnly: context.hotkeyOnly,
117123
denyPaste: context.denyPaste,
118124
useMenuCopy: context.useMenuCopy,
119125
retrievalMode: .menuCopy,

Sources/OpenClip/Platform/Effects/ActionResultHandler.swift

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -147,15 +147,19 @@ public final class DefaultActionResultHandler: ActionResultHandler, Sendable {
147147
pendingRestoreTask = nil
148148
let pasteboard = self.pasteboard
149149
deliverPaste(to: pasteboard) {
150-
pasteboard.setString(text, forType: .string)
150+
let item = NSPasteboardItem()
151+
item.setString(text, forType: .string)
152+
item.setData(Data(), forType: PasteboardSnapshot.transientType)
153+
item.setData(Data(), forType: PasteboardSnapshot.autoGeneratedType)
154+
pasteboard.writeObjects([item])
151155
}
152156

153157
case .pasteContent(let payload):
154158
pendingRestoreTask?.cancel()
155159
pendingRestoreTask = nil
156160
let pasteboard = self.pasteboard
157161
deliverPaste(to: pasteboard) {
158-
writePayload(payload, to: pasteboard)
162+
writePayload(payload, to: pasteboard, transient: true)
159163
}
160164

161165
case .openURL(let url):
@@ -233,16 +237,22 @@ public final class DefaultActionResultHandler: ActionResultHandler, Sendable {
233237
try await center.add(request)
234238
}
235239

236-
private func writePayload(_ payload: RichPasteboardPayload, to pasteboard: NSPasteboard) {
240+
private func writePayload(_ payload: RichPasteboardPayload, to pasteboard: NSPasteboard, transient: Bool = false) {
241+
let item = NSPasteboardItem()
237242
if let rtf = payload.rtf, let rtfData = rtf.data(using: .utf8) {
238-
pasteboard.setData(rtfData, forType: .rtf)
243+
item.setData(rtfData, forType: .rtf)
239244
}
240245
if let html = payload.html {
241-
pasteboard.setString(html, forType: .html)
246+
item.setString(html, forType: .html)
242247
}
243248
if let text = payload.plainText {
244-
pasteboard.setString(text, forType: .string)
249+
item.setString(text, forType: .string)
250+
}
251+
if transient {
252+
item.setData(Data(), forType: PasteboardSnapshot.transientType)
253+
item.setData(Data(), forType: PasteboardSnapshot.autoGeneratedType)
245254
}
255+
pasteboard.writeObjects([item])
246256
}
247257

248258
/// Writes `write` onto the pasteboard then synthesizes ⌘V, honoring the per-click copy

Sources/OpenClip/Platform/HotkeyManager.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,15 @@ public final class HotkeyManager {
1616
public static let shared = HotkeyManager()
1717
private var lastFallbackClipboard: (changeCount: Int, text: String)?
1818

19-
/// Trigger gate for the hotkey path (pure, unit-tested). Mirrors every monitor trigger site:
20-
/// respect the master enable switch, never target OpenClip itself, and never fire inside
21-
/// excluded apps — the default retrieval cascade ends in `.keyboardCopy`, which would inject
22-
/// a synthetic ⌘C into apps that must never be touched.
2319
internal static func triggerAllowed(isAppEnabled: Bool, frontmost: NSRunningApplication?) -> Bool {
2420
guard isAppEnabled,
2521
let frontmost,
2622
let bundleID = frontmost.bundleIdentifier else { return false }
27-
return !AppFilter.isExcluded(bundleID: bundleID)
23+
if AppFilter.isExcluded(bundleID: bundleID) {
24+
return false
25+
}
26+
let policy = RuleEngine.shared.resolvePolicies(for: bundleID)
27+
return !policy.disabled
2828
}
2929

3030
public func setup(popupController: PopupWindowController) {

Sources/OpenClip/Platform/MacSelectionMonitor.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,9 @@ internal final class MacSelectionMonitor: SelectionMonitoring {
216216
defer { if !delivered { self.triggeredByHold = false } }
217217

218218
let policy = self.policyResolver(app.bundleIdentifier)
219+
if policy.disabled || policy.hotkeyOnly {
220+
return
221+
}
219222
let appIdentity = AppIdentity(app)
220223
let probeTask = self.preparePasteProbe?(app, policy)
221224

@@ -314,6 +317,9 @@ internal final class MacSelectionMonitor: SelectionMonitoring {
314317
}
315318

316319
let policy = self.policyResolver(app.bundleIdentifier)
320+
if policy.disabled || policy.hotkeyOnly {
321+
return
322+
}
317323

318324
// Measure drag distance for click filtering
319325
var isDragOrMultiClick = clickCount >= 2
@@ -365,6 +371,9 @@ internal final class MacSelectionMonitor: SelectionMonitoring {
365371
}
366372

367373
let policy = self.policyResolver(app.bundleIdentifier)
374+
if policy.disabled || policy.hotkeyOnly {
375+
return
376+
}
368377
let appIdentity = AppIdentity(app)
369378
let probeTask = self.preparePasteProbe?(app, policy)
370379
let result = await retriever.retrieve(

Sources/OpenClip/UI/Icons/ActionIconView.swift

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,9 @@ public struct ActionIconView: View {
150150
switch icon {
151151
case .symbol(let name):
152152
if name.contains(":") {
153+
// Iconify SVGs usually have internal padding in their viewBox; scale up so optical weight matches SF Symbols.
153154
let isBrand = name.hasPrefix("simple-icons:") || name.hasPrefix("logos:") || name.hasPrefix("cib:") || name.contains("brand")
154-
let opticalFactor: CGFloat = isBrand ? 0.88 : 1.0
155+
let opticalFactor: CGFloat = isBrand ? 1.08 : 1.20
155156
let dim = targetDimension * opticalFactor
156157
IconifySVGView(iconId: name)
157158
.frame(width: dim, height: dim)
@@ -180,7 +181,7 @@ public struct ActionIconView: View {
180181
image
181182
.resizable()
182183
.aspectRatio(contentMode: .fit)
183-
.frame(maxWidth: targetDimension, maxHeight: targetDimension)
184+
.frame(maxWidth: targetDimension * 1.15, maxHeight: targetDimension * 1.15)
184185
} else if phase.error != nil {
185186
Image(systemName: "exclamationmark.triangle")
186187
.font(.system(size: targetDimension * 0.9, weight: .regular))
@@ -197,7 +198,7 @@ public struct ActionIconView: View {
197198
.resizable()
198199
.renderingMode(.template)
199200
.aspectRatio(contentMode: .fit)
200-
.frame(maxWidth: targetDimension, maxHeight: targetDimension)
201+
.frame(maxWidth: targetDimension * 1.18, maxHeight: targetDimension * 1.18)
201202
} else {
202203
Image(systemName: "questionmark.square")
203204
.font(.system(size: targetDimension, weight: .regular))

Sources/OpenClip/UI/Popup/PopupMetrics.swift

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,8 @@ public enum PopupMetrics {
4141
/// Fraction of an extra result row shown beyond `searchMaxRows` so the next action peeks,
4242
/// hinting that the list scrolls.
4343
public static let searchPeekRowFraction: CGFloat = 0.5
44-
/// Shared height cap for the popup panel (search palette field + result rows). The code value
45-
/// 240 wins over any stale comment.
46-
public static let popupMaxHeight: CGFloat = 240
44+
/// Shared height cap for the popup panel (search palette field + result rows and content cards).
45+
public static let popupMaxHeight: CGFloat = 300
4746
/// Native AI result card sizing: width clamped to the shared popup column and a max body
4847
/// height so a long response scrolls instead of growing the panel without bound.
4948
public static let aiCardMinWidth: CGFloat = 220
@@ -53,7 +52,7 @@ public enum PopupMetrics {
5352
/// reports a starved ideal height, so the panel would never grow to fit the response (the body
5453
/// collapsed to nothing while the header/footer rendered). A concrete height gives the card a
5554
/// deterministic preferred size and keeps the whole card under `popupMaxHeight`.
56-
public static let aiCardBodyHeight: CGFloat = 120
55+
public static let aiCardBodyHeight: CGFloat = 160
5756
/// How long an info/error toast stays up before auto-dismissing (1.2 s — long enough to read
5857
/// "Copied"-style feedback). Loading toasts have no timer — they live until the action's
5958
/// result lands.

0 commit comments

Comments
 (0)