Skip to content
Merged
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Privacy manifest for the iOS app.
- Oracle `DBMS_OUTPUT` lines shown with the result of the statement that printed them, and in a new **Output** result view.
- Oracle transactions opened with `SET TRANSACTION`, `SAVEPOINT` or `LOCK TABLE`, held until `COMMIT` or `ROLLBACK`.
- **Highlight When Focused** on the AI chat input's context menu, for turning its colored focus highlight off. (#2995)
- Several label columns beside the key in the foreign key picker, for a parent row only told apart by a combination. (#2996)

### Changed
Expand All @@ -41,6 +42,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Colored highlight on the AI chat input painting over a window that is not key.
- Colored highlight on the AI chat input ignoring Reduce Transparency and Increase Contrast.
- AI chat input focus crossfade playing against Reduce Motion.
- AI chat input announced with no name by VoiceOver.
- Oracle PL/SQL blocks split at their inner semicolons and sent as fragments, failing with PLS-00103. (#2984)
- Oracle procedures, packages and triggers created from the editor stored INVALID while the run reported success.
- SQL*Plus `/` lines, `q'[…]'` literals and backslashes in strings misread in Oracle scripts.
Expand Down
13 changes: 11 additions & 2 deletions TablePro/Models/AI/AIModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,9 @@ struct AISettings: Codable, Equatable, Sendable {
var maxToolRoundtripsEnabled: Bool
var defaultConnectionPolicy: AIConnectionPolicy
var chatMode: AIChatMode
/// Set from the composer's own context menu rather than the Settings window, because the only
/// place the highlight is worth thinking about is the field it wraps.
var composerHighlightEnabled: Bool

static let defaultInlineSuggestionDebounceMs: Int = 500
static let inlineSuggestionDebounceRange: ClosedRange<Int> = 100...3_000
Expand All @@ -255,7 +258,8 @@ struct AISettings: Codable, Equatable, Sendable {
maxToolRoundtrips: AISettings.defaultMaxToolRoundtrips,
maxToolRoundtripsEnabled: true,
defaultConnectionPolicy: .askEachTime,
chatMode: .ask
chatMode: .ask,
composerHighlightEnabled: true
)

init(
Expand All @@ -271,7 +275,8 @@ struct AISettings: Codable, Equatable, Sendable {
maxToolRoundtrips: Int = AISettings.defaultMaxToolRoundtrips,
maxToolRoundtripsEnabled: Bool = true,
defaultConnectionPolicy: AIConnectionPolicy = .askEachTime,
chatMode: AIChatMode = .ask
chatMode: AIChatMode = .ask,
composerHighlightEnabled: Bool = true
) {
self.enabled = enabled
self.providers = providers
Expand All @@ -286,6 +291,7 @@ struct AISettings: Codable, Equatable, Sendable {
self.maxToolRoundtripsEnabled = maxToolRoundtripsEnabled
self.defaultConnectionPolicy = defaultConnectionPolicy
self.chatMode = chatMode
self.composerHighlightEnabled = composerHighlightEnabled
}

init(from decoder: Decoder) throws {
Expand All @@ -311,6 +317,9 @@ struct AISettings: Codable, Equatable, Sendable {
AIConnectionPolicy.self, forKey: .defaultConnectionPolicy
) ?? .askEachTime
chatMode = try container.decodeIfPresent(AIChatMode.self, forKey: .chatMode) ?? .ask
composerHighlightEnabled = try container.decodeIfPresent(
Bool.self, forKey: .composerHighlightEnabled
) ?? true
}

var activeProvider: AIProviderConfig? {
Expand Down
4 changes: 4 additions & 0 deletions TablePro/Views/AIChat/AIChatPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,10 @@ struct AIChatPanelView: View {
},
onImageAttachmentFailed: { message in
viewModel.reportImageAttachmentFailure(message)
},
highlightEnabled: settingsManager.ai.composerHighlightEnabled,
onToggleHighlight: {
settingsManager.ai.composerHighlightEnabled.toggle()
}
)

Expand Down
38 changes: 38 additions & 0 deletions TablePro/Views/AIChat/ChatComposerChrome.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//
// ChatComposerChrome.swift
// TablePro
//

import AppKit
import SwiftUI

internal enum ChatComposerMetrics {
/// One owner for the composer's curvature, read by the SwiftUI background shape and by the
/// scroll view's focus ring mask, so the ring cannot drift from the surface it wraps.
static let cornerRadius: CGFloat = 16
}

/// Whether the composer paints its own focus highlight, which is a user preference first and a
/// system accessibility decision second. The highlight is a wide translucent colour wash, so
/// Reduce Transparency and Increase Contrast both mean "not this", the same answer
/// `SolidSurfacePreference` gives for every other translucent surface in the app.
///
/// Reduce Motion is deliberately absent: the gradient is static, and the only motion is the
/// crossfade, which `motionAnimation` already gates at the call site.
internal enum ComposerHighlightPreference {
static func paintsHighlight(
enabled: Bool,
reduceTransparency: Bool,
contrast: ColorSchemeContrast
) -> Bool {
guard enabled else { return false }
return !SolidSurfacePreference.prefersSolid(reduceTransparency: reduceTransparency, contrast: contrast)
}

/// The composer draws exactly one focus affordance. When it paints its own, AppKit must not
/// add a second; when it does not, the system ring is the whole indication that the field has
/// keyboard focus, so it has to be on.
static func focusRingType(paintsHighlight: Bool) -> NSFocusRingType {
paintsHighlight ? .none : .exterior
}
}
90 changes: 84 additions & 6 deletions TablePro/Views/AIChat/ChatComposerTextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ struct ChatComposerTextView: NSViewRepresentable {
let maxLines: Int
let isCommittingMention: Bool
let acceptsImages: Bool
let paintsHighlight: Bool
let highlightEnabled: Bool
let onToggleHighlight: () -> Void
let onTextChange: (String, Int) -> Void
let onSubmit: () -> Void
let onCommitMention: () -> Bool
Expand All @@ -29,10 +32,13 @@ struct ChatComposerTextView: NSViewRepresentable {
textView.placeholder = placeholder
textView.acceptsImagePaste = acceptsImages
textView.onPasteImageData = onPasteImageData
textView.highlightEnabled = highlightEnabled
textView.onToggleHighlight = onToggleHighlight

let scrollView = ChatComposerScrollView.make(documentView: textView)
scrollView.minLines = minLines
scrollView.maxLines = maxLines
scrollView.focusRingType = ComposerHighlightPreference.focusRingType(paintsHighlight: paintsHighlight)

textView.onFocusChange = { [weak coordinator = context.coordinator] focused in
coordinator?.handleFocusChange(focused)
Expand All @@ -58,6 +64,14 @@ struct ChatComposerTextView: NSViewRepresentable {
context.coordinator.refresh(from: self)
scrollView.minLines = minLines
scrollView.maxLines = maxLines
textView.highlightEnabled = highlightEnabled
textView.onToggleHighlight = onToggleHighlight

let ringType = ComposerHighlightPreference.focusRingType(paintsHighlight: paintsHighlight)
if scrollView.focusRingType != ringType {
scrollView.focusRingType = ringType
scrollView.noteFocusRingMaskChanged()
}

// Replacing the string outright while an input method has marked text cancels the
// composition. Routing through shouldChangeText/didChangeText also keeps the undo
Expand All @@ -73,11 +87,7 @@ struct ChatComposerTextView: NSViewRepresentable {
textView.setSelectedRange(NSRange(location: clampedLocation, length: 0))
}

if textView.placeholder != placeholder {
textView.placeholder = placeholder
textView.setAccessibilityPlaceholderValue(placeholder)
textView.needsDisplay = true
}
textView.placeholder = placeholder

if isFocused, textView.window?.firstResponder !== textView {
DispatchQueue.main.async {
Expand Down Expand Up @@ -186,12 +196,26 @@ struct ChatComposerTextView: NSViewRepresentable {
}

final class ChatComposerNSTextView: NSTextView {
var placeholder: String = ""
/// The placeholder is painted in `draw(_:)`, which the accessibility tree never sees, so the
/// only thing that names this field to VoiceOver is the value set here. Keeping the two
/// together means no assignment path can leave the field nameless: guarding the call at one
/// caller is what left it unset since #2097, because `makeNSView` had already stored the same
/// string and the caller's comparison was never true again.
var placeholder: String = "" {
didSet {
guard oldValue != placeholder else { return }
setAccessibilityPlaceholderValue(placeholder)
needsDisplay = true
}
}

var placeholderColor: NSColor = .placeholderTextColor
var onFocusChange: ((Bool) -> Void)?
var onSizeChange: (() -> Void)?
var acceptsImagePaste: Bool = false
var onPasteImageData: ((Data, String) -> Void)?
var highlightEnabled: Bool = true
var onToggleHighlight: (() -> Void)?

static func make() -> ChatComposerNSTextView {
let textView = ChatComposerNSTextView()
Expand Down Expand Up @@ -221,6 +245,35 @@ final class ChatComposerNSTextView: NSTextView {
return resigned
}

/// Measured on macOS 27: unparenting the pane moves the window's first responder away without
/// ever sending `resignFirstResponder` here, so focus has to be re-read from the window rather
/// than waited for. Switching the trailing pane away and back otherwise left the composer
/// believing it still held focus, and the highlight painted over a field that did not.
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
onFocusChange?(window?.firstResponder === self)
}

override func menu(for event: NSEvent) -> NSMenu? {
let menu = super.menu(for: event) ?? NSMenu()
if !menu.items.isEmpty {
menu.addItem(.separator())
}
let item = NSMenuItem(
title: String(localized: "Highlight When Focused"),
action: #selector(toggleComposerHighlight(_:)),
keyEquivalent: ""
)
item.target = self
item.state = highlightEnabled ? .on : .off
menu.addItem(item)
return menu
}

@objc private func toggleComposerHighlight(_ sender: Any?) {
onToggleHighlight?()
}

override func didChangeText() {
super.didChangeText()
onSizeChange?()
Expand Down Expand Up @@ -313,6 +366,31 @@ final class ChatComposerScrollView: NSScrollView {
return scrollView
}

/// The composer's rounded surface is drawn by SwiftUI, so AppKit has to be told the shape to
/// wrap; left to itself it rings the square bounds. This is the shape `ShortcutRecorderNSView`
/// uses for the same job, and the radius is the one the SwiftUI background reads.
///
/// The document view carries the focus, but the ring belongs on the scroll view: measured on
/// macOS 27, `focusRingType` on the text view never produces a `drawFocusRingMask` call, while
/// on the scroll view it does, `.noBorder` included.
override func drawFocusRingMask() {
NSBezierPath(
roundedRect: bounds,
xRadius: ChatComposerMetrics.cornerRadius,
yRadius: ChatComposerMetrics.cornerRadius
).fill()
}

override var focusRingMaskBounds: NSRect { bounds }

/// The mask is derived from `bounds`, and the composer grows from one line to five as the user
/// types, so the cached ring has to be invalidated with the size that produced it.
override func setFrameSize(_ newSize: NSSize) {
let changed = newSize != frame.size
super.setFrameSize(newSize)
if changed { noteFocusRingMaskChanged() }
}

override var intrinsicContentSize: NSSize {
guard
let textView = documentView as? NSTextView,
Expand Down
40 changes: 35 additions & 5 deletions TablePro/Views/AIChat/ChatComposerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,17 @@ struct ChatComposerView: View {
let acceptsImages: Bool
let onAttachImages: ([ChatImageInput]) -> Void
let onImageAttachmentFailed: (String) -> Void
let highlightEnabled: Bool
let onToggleHighlight: () -> Void

@State private var isFocused: Bool = false
@State private var isCommittingMention = false
@State private var isDropTargeted: Bool = false

@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
@Environment(\.colorSchemeContrast) private var contrast
@Environment(\.controlActiveState) private var controlActiveState

init(
text: Binding<String>,
placeholder: String,
Expand All @@ -34,7 +40,9 @@ struct ChatComposerView: View {
onAttach: @escaping (ContextItem) -> Void,
acceptsImages: Bool = false,
onAttachImages: @escaping ([ChatImageInput]) -> Void = { _ in },
onImageAttachmentFailed: @escaping (String) -> Void = { _ in }
onImageAttachmentFailed: @escaping (String) -> Void = { _ in },
highlightEnabled: Bool = true,
onToggleHighlight: @escaping () -> Void = {}
) {
self._text = text
self.placeholder = placeholder
Expand All @@ -47,6 +55,8 @@ struct ChatComposerView: View {
self.acceptsImages = acceptsImages
self.onAttachImages = onAttachImages
self.onImageAttachmentFailed = onImageAttachmentFailed
self.highlightEnabled = highlightEnabled
self.onToggleHighlight = onToggleHighlight
}

var body: some View {
Expand All @@ -58,6 +68,9 @@ struct ChatComposerView: View {
maxLines: maxLines,
isCommittingMention: isCommittingMention,
acceptsImages: acceptsImages,
paintsHighlight: paintsHighlight,
highlightEnabled: highlightEnabled,
onToggleHighlight: onToggleHighlight,
onTextChange: { newText, caret in
guard !isCommittingMention else { return }
onTextChange(newText, caret)
Expand Down Expand Up @@ -92,7 +105,7 @@ struct ChatComposerView: View {
)
.overlay {
if isDropTargeted {
RoundedRectangle(cornerRadius: 16, style: .continuous)
RoundedRectangle(cornerRadius: ChatComposerMetrics.cornerRadius, style: .continuous)
.strokeBorder(Color.accentColor.opacity(0.6), lineWidth: 2)
.allowsHitTesting(false)
}
Expand Down Expand Up @@ -135,12 +148,29 @@ struct ChatComposerView: View {
}
}

/// The preference and the two system settings that override it, answered once for both the
/// SwiftUI overlay and the scroll view's focus ring type, so the two can never both paint.
private var paintsHighlight: Bool {
ComposerHighlightPreference.paintsHighlight(
enabled: highlightEnabled,
reduceTransparency: reduceTransparency,
contrast: contrast
)
}

/// A focus affordance belongs to the key window alone, which is why AppKit withdraws its own
/// ring the moment the window resigns key. The highlight is drawn by SwiftUI and gets no such
/// treatment for free, so it asks.
private var showsHighlight: Bool {
paintsHighlight && isFocused && controlActiveState == .key
}

private var composerBackground: some View {
let shape = RoundedRectangle(cornerRadius: 16, style: .continuous)
let shape = RoundedRectangle(cornerRadius: ChatComposerMetrics.cornerRadius, style: .continuous)
return shape
.fill(Color(nsColor: .textBackgroundColor))
.overlay {
if isFocused {
if showsHighlight {
IntelligenceFocusBorder(shape: shape)
.transition(.opacity)
.accessibilityHidden(true)
Expand All @@ -150,7 +180,7 @@ struct ChatComposerView: View {
.accessibilityHidden(true)
}
}
.animation(.easeOut(duration: 0.25), value: isFocused)
.motionAnimation(.easeOut(duration: 0.25), value: showsHighlight)
}

private var popoverBinding: Binding<Bool> {
Expand Down
Loading
Loading