Files
Sybil-2/ios/Apps/SybilMacQuickQuestion/Sources/QuickQuestionPanelView.swift
T

301 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
@MainActor
final class QuickQuestionPanelView: NSVisualEffectView, NSTextViewDelegate {
weak var delegate: (any SybilQuickQuestionPanelDelegate)?
var onDismiss: (() -> Void)?
let promptView = QuickQuestionPromptView()
private let stack = NSStackView()
private let answerView = NSTextView()
private let answerScroll = NSScrollView()
private let statusLabel = NSTextField(wrappingLabelWithString: "")
private let providerPopup = NSPopUpButton()
private let modelPopup = NSPopUpButton()
private let convertButton = NSButton(title: "Open in Chat", target: nil, action: nil)
private let openAppButton = NSButton(title: "Open Sybil", target: nil, action: nil)
private let sendButton = NSButton()
private let shortcutLabel = NSTextField(labelWithString: "⌥ Space")
private var panelState = SybilQuickQuestionPanelState()
var preferredHeight: CGFloat { stack.fittingSize.height + 40 }
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
material = .hudWindow
blendingMode = .behindWindow
state = .active
wantsLayer = true
layer?.cornerRadius = 18
layer?.masksToBounds = true
layer?.borderWidth = 1
layer?.borderColor = NSColor.white.withAlphaComponent(0.14).cgColor
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 12
stack.detachesHiddenViews = true
stack.translatesAutoresizingMaskIntoConstraints = false
addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20),
stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20),
stack.topAnchor.constraint(equalTo: topAnchor, constant: 20),
stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -20)
])
addRow(makeHeader())
configureTextView(promptView, fontSize: 22)
promptView.delegate = self
promptView.isRichText = false
promptView.allowsUndo = true
promptView.setAccessibilityLabel("Quick question prompt")
promptView.onSubmit = { [weak self] in self?.submit() }
promptView.onToggle = { [weak self] in self?.delegate?.quickQuestionToggleRequested() }
let promptScroll = makeScrollView(documentView: promptView)
promptScroll.heightAnchor.constraint(equalToConstant: 64).isActive = true
addRow(promptScroll)
configureTextView(answerView, fontSize: 15)
answerView.isEditable = false
answerView.isSelectable = true
answerView.setAccessibilityLabel("Quick question answer")
answerView.linkTextAttributes = [.foregroundColor: NSColor.systemPurple, .underlineStyle: NSUnderlineStyle.single.rawValue]
answerScroll.drawsBackground = false
answerScroll.hasVerticalScroller = true
answerScroll.autohidesScrollers = true
answerScroll.documentView = answerView
answerScroll.heightAnchor.constraint(equalToConstant: 280).isActive = true
addRow(answerScroll)
statusLabel.font = .systemFont(ofSize: 12)
statusLabel.textColor = .secondaryLabelColor
statusLabel.maximumNumberOfLines = 3
addRow(statusLabel)
addRow(makeControls())
let hint = NSTextField(labelWithString: "Return to ask · Shift+Return for a new line · Esc to close")
hint.font = .systemFont(ofSize: 11)
hint.textColor = .secondaryLabelColor
addRow(hint)
update(SybilQuickQuestionPanelState(), shortcutAvailable: true)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func update(_ next: SybilQuickQuestionPanelState, shortcutAvailable: Bool) {
let previous = panelState
panelState = next
if promptView.string != next.prompt, !promptView.hasMarkedText() {
promptView.string = next.prompt
promptView.setSelectedRange(NSRange(location: (next.prompt as NSString).length, length: 0))
}
promptView.isEditable = !next.isBusy
promptView.needsDisplay = true
if previous.answer != next.answer {
let wasAtBottom = answerScroll.contentView.bounds.maxY >= answerView.bounds.maxY - 36
answerView.textStorage?.setAttributedString(Self.renderAnswer(next.answer))
if wasAtBottom { answerView.scrollToEndOfDocument(nil) }
}
answerScroll.isHidden = next.answer.isEmpty
if providerPopup.numberOfItems == 0 || previous.providers != next.providers {
providerPopup.removeAllItems()
for provider in next.providers {
providerPopup.addItem(withTitle: provider.title)
providerPopup.lastItem?.representedObject = provider.id
}
}
if let index = next.providers.firstIndex(where: { $0.id == next.provider }) {
providerPopup.selectItem(at: index)
}
if modelPopup.numberOfItems == 0 || previous.models != next.models || previous.model != next.model {
modelPopup.removeAllItems()
var models = next.models
if !next.model.isEmpty, !models.contains(next.model) { models.insert(next.model, at: 0) }
modelPopup.addItems(withTitles: models)
modelPopup.selectItem(withTitle: next.model)
}
providerPopup.isEnabled = next.isAuthenticated && !next.isBusy
modelPopup.isEnabled = next.isAuthenticated && !next.isBusy
convertButton.isEnabled = next.canConvert
sendButton.isEnabled = next.canSend
openAppButton.isHidden = next.isAuthenticated
convertButton.isHidden = !next.isAuthenticated
shortcutLabel.stringValue = shortcutAvailable ? "⌥ Space" : "Shortcut unavailable"
shortcutLabel.toolTip = shortcutAvailable ? "Option+Space toggles Quick Question" : "Another app may be using Option+Space. Open Quick Question from Sybils File menu."
let status: String
if let error = next.error {
status = error
statusLabel.textColor = .systemRed
} else {
statusLabel.textColor = .secondaryLabelColor
if next.isCheckingSession {
status = "Connecting to Sybil…"
} else if !next.isAuthenticated {
status = "Connect in Sybil before asking a question."
} else if next.isConverting {
status = "Opening in chat…"
} else if next.isSending {
status = next.toolSummaries.last ?? "Thinking…"
} else {
status = ""
}
}
statusLabel.stringValue = status
statusLabel.isHidden = status.isEmpty
}
func preparePromptForPresentation() {
// Select only when invoked, never on streamed updates or normal edits.
promptView.selectAll(nil)
}
func textDidChange(_ notification: Notification) {
guard !panelState.isBusy else { return }
promptView.needsDisplay = true
delegate?.quickQuestionPromptChanged(promptView.string)
}
private func addRow(_ view: NSView) {
stack.addArrangedSubview(view)
view.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
}
private func makeHeader() -> NSView {
let icon = NSImageView(image: NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil)!)
icon.contentTintColor = .systemPurple
icon.widthAnchor.constraint(equalToConstant: 18).isActive = true
let title = NSTextField(labelWithString: "Quick Question")
title.font = .systemFont(ofSize: 14, weight: .semibold)
shortcutLabel.font = .monospacedSystemFont(ofSize: 11, weight: .medium)
shortcutLabel.textColor = .secondaryLabelColor
let close = NSButton(image: NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close Quick Question")!, target: self, action: #selector(closePanel))
close.isBordered = false
close.toolTip = "Close (Esc)"
close.setAccessibilityLabel("Close Quick Question")
close.widthAnchor.constraint(equalToConstant: 24).isActive = true
let row = NSStackView(views: [icon, title, NSView(), shortcutLabel, close])
row.spacing = 8
row.alignment = .centerY
row.heightAnchor.constraint(equalToConstant: 24).isActive = true
return row
}
private func makeControls() -> NSView {
providerPopup.target = self
providerPopup.action = #selector(changeProvider)
providerPopup.setAccessibilityLabel("Quick question provider")
providerPopup.widthAnchor.constraint(equalToConstant: 120).isActive = true
modelPopup.target = self
modelPopup.action = #selector(changeModel)
modelPopup.setAccessibilityLabel("Quick question model")
modelPopup.widthAnchor.constraint(lessThanOrEqualToConstant: 230).isActive = true
modelPopup.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
convertButton.target = self
convertButton.action = #selector(convertToChat)
convertButton.bezelStyle = .rounded
openAppButton.target = self
openAppButton.action = #selector(openApp)
openAppButton.bezelStyle = .rounded
sendButton.image = NSImage(systemSymbolName: "arrow.up", accessibilityDescription: "Ask Quick Question")
sendButton.imagePosition = .imageOnly
sendButton.bezelStyle = .circular
sendButton.contentTintColor = .systemPurple
sendButton.target = self
sendButton.action = #selector(submit)
sendButton.setAccessibilityLabel("Ask Quick Question")
sendButton.toolTip = "Ask (Return)"
sendButton.widthAnchor.constraint(equalToConstant: 32).isActive = true
let row = NSStackView(views: [providerPopup, modelPopup, NSView(), openAppButton, convertButton, sendButton])
row.spacing = 8
row.alignment = .centerY
row.detachesHiddenViews = true
row.heightAnchor.constraint(equalToConstant: 32).isActive = true
return row
}
private func configureTextView(_ view: NSTextView, fontSize: CGFloat) {
view.drawsBackground = false
view.textColor = .labelColor
view.insertionPointColor = .labelColor
view.font = .systemFont(ofSize: fontSize)
view.textContainerInset = NSSize(width: 0, height: 4)
view.isHorizontallyResizable = false
view.isVerticallyResizable = true
view.autoresizingMask = [.width]
view.textContainer?.widthTracksTextView = true
view.textContainer?.lineFragmentPadding = 0
}
private func makeScrollView(documentView: NSView) -> NSScrollView {
let scroll = NSScrollView()
scroll.drawsBackground = false
scroll.hasVerticalScroller = true
scroll.autohidesScrollers = true
scroll.documentView = documentView
return scroll
}
private static func renderAnswer(_ text: String) -> NSAttributedString {
let parsed = (try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(text)
let rendered = NSMutableAttributedString(attributedString: NSAttributedString(parsed))
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 4
rendered.addAttributes([.foregroundColor: NSColor.labelColor, .paragraphStyle: paragraph], range: NSRange(location: 0, length: rendered.length))
for run in parsed.runs {
let intent = run.inlinePresentationIntent ?? []
var font = intent.contains(.code) ? NSFont.monospacedSystemFont(ofSize: 14, weight: .regular) : NSFont.systemFont(ofSize: 15)
if intent.contains(.stronglyEmphasized) { font = NSFontManager.shared.convert(font, toHaveTrait: .boldFontMask) }
if intent.contains(.emphasized) { font = NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask) }
rendered.addAttribute(.font, value: font, range: NSRange(run.range, in: parsed))
}
return rendered
}
@objc private func closePanel() { onDismiss?() }
@objc private func changeProvider() {
guard let provider = providerPopup.selectedItem?.representedObject as? String else { return }
delegate?.quickQuestionProviderChanged(provider)
}
@objc private func changeModel() {
guard let model = modelPopup.titleOfSelectedItem else { return }
delegate?.quickQuestionModelChanged(model)
}
@objc private func convertToChat() { delegate?.quickQuestionConvertRequested() }
@objc private func openApp() { delegate?.quickQuestionOpenAppRequested() }
@objc private func submit() {
guard panelState.canSend, !promptView.hasMarkedText() else { return }
delegate?.quickQuestionSubmitRequested()
}
}
@MainActor
final class QuickQuestionPromptView: NSTextView {
var onSubmit: (() -> Void)?
var onToggle: (() -> Void)?
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
guard string.isEmpty else { return }
("Ask anything…" as NSString).draw(
at: NSPoint(x: textContainerInset.width, y: textContainerInset.height),
withAttributes: [.font: font ?? NSFont.systemFont(ofSize: 22), .foregroundColor: NSColor.placeholderTextColor]
)
}
override func keyDown(with event: NSEvent) {
let modifiers = event.modifierFlags.intersection([.command, .control, .option, .shift])
if event.keyCode == 49, modifiers == .option {
if !event.isARepeat { onToggle?() }
return
}
if (event.keyCode == 36 || event.keyCode == 76), modifiers.isEmpty, !hasMarkedText() {
if !event.isARepeat { onSubmit?() }
return
}
// Let the text input system handle Shift+Return and IME confirmation.
super.keyDown(with: event)
}
}