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
21 changes: 11 additions & 10 deletions desktop/Desktop/Sources/FloatingControlBar/AskAIInputView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ struct AskAIInputView: View {
@Binding var userInput: String
@State private var localInput: String = ""
@State private var textHeight: CGFloat = 40
@State private var hasMarkedText = false

var canClearVisibleConversation: Bool = false
var onSend: ((String) -> Void)?
Expand All @@ -15,6 +16,8 @@ struct AskAIInputView: View {

private let minHeight: CGFloat = 40
private let maxHeight: CGFloat = 200
private var trimmedInput: String { localInput.trimmingCharacters(in: .whitespacesAndNewlines) }
private var canSend: Bool { !hasMarkedText && !trimmedInput.isEmpty }

var body: some View {
VStack(spacing: 0) {
Expand All @@ -40,7 +43,7 @@ struct AskAIInputView: View {

HStack(spacing: 6) {
ZStack(alignment: .topLeading) {
if localInput.isEmpty {
if localInput.isEmpty && !hasMarkedText {
Text("Ask a question...")
.scaledFont(size: 13)
.foregroundColor(.secondary)
Expand All @@ -52,11 +55,11 @@ struct AskAIInputView: View {
text: $localInput,
lineFragmentPadding: 8,
onSubmit: {
let trimmed = localInput.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
onSend?(trimmed)
guard canSend else { return }
onSend?(trimmedInput)
},
focusOnAppear: true,
onMarkedTextChange: { hasMarkedText = $0 },
minHeight: minHeight,
maxHeight: maxHeight,
onHeightChange: { newHeight in
Expand All @@ -77,18 +80,16 @@ struct AskAIInputView: View {
.frame(height: textHeight)

Button(action: {
let trimmed = localInput.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
onSend?(trimmed)
guard canSend else { return }
onSend?(trimmedInput)
}) {
Image(systemName: "arrow.up.circle.fill")
.scaledFont(size: 24)
.foregroundColor(
localInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? .secondary : .white
canSend ? .white : .secondary
)
}
.disabled(localInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
.disabled(!canSend)
.buttonStyle(.plain)
}
.padding(.horizontal, 16)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ struct ChatInputView: View {
@AppStorage("askModeEnabled") private var askModeEnabled = false
@Environment(\.fontScale) private var fontScale
@State private var isDropTargeted = false
@State private var hasMarkedText = false

private var hasText: Bool {
!inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
Expand Down Expand Up @@ -89,7 +90,7 @@ struct ChatInputView: View {
.accessibilityHidden(true)
.overlay(alignment: .topLeading) {
// Placeholder text — padding matches textContainerInset exactly
if inputText.isEmpty {
if inputText.isEmpty && !hasMarkedText {
Text(placeholder)
.scaledFont(size: 14)
.foregroundColor(OmiColors.textTertiary)
Expand All @@ -104,7 +105,8 @@ struct ChatInputView: View {
fontSize: round(14 * fontScale),
textColor: NSColor(OmiColors.textPrimary),
textContainerInset: NSSize(width: inputPaddingH, height: inputPaddingV),
onSubmit: handleSubmit
onSubmit: handleSubmit,
onMarkedTextChange: { hasMarkedText = $0 }
)
}
.frame(maxHeight: 200)
Expand Down Expand Up @@ -177,6 +179,7 @@ struct ChatInputView: View {
/// Send is enabled when there's text OR (when supported) any attachment ready
/// to ship — Flutter allows sending attachments without text.
private var canSend: Bool {
guard !hasMarkedText else { return false }
if hasText { return true }
if attachmentsEnabled && !currentAttachments.isEmpty { return true }
return false
Expand Down
55 changes: 53 additions & 2 deletions desktop/Desktop/Sources/Theme/OmiTextEditor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,30 @@ private class AutoFocusScrollView: NSScrollView {
}
}

/// NSTextView subclass that reports IME marked-text composition state.
private class OmiNSTextView: NSTextView {
var onMarkedTextStatusChange: ((Bool) -> Void)?

override func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {
super.setMarkedText(string, selectedRange: selectedRange, replacementRange: replacementRange)
publishMarkedTextStatus()
}

override func unmarkText() {
super.unmarkText()
publishMarkedTextStatus()
}

override func insertText(_ string: Any, replacementRange: NSRange) {
super.insertText(string, replacementRange: replacementRange)
publishMarkedTextStatus()
}

private func publishMarkedTextStatus() {
onMarkedTextStatusChange?(hasMarkedText())
}
}

/// Unified NSTextView wrapper used by both the main chat input and the floating control bar.
struct OmiTextEditor: NSViewRepresentable {
@Binding var text: String
Expand All @@ -30,14 +54,15 @@ struct OmiTextEditor: NSViewRepresentable {
// Behavior
var onSubmit: (() -> Void)? = nil
var focusOnAppear: Bool = true
var onMarkedTextChange: ((Bool) -> Void)? = nil

// Optional height tracking (for floating bar's window resize flow)
var minHeight: CGFloat? = nil
var maxHeight: CGFloat? = nil
var onHeightChange: ((CGFloat) -> Void)? = nil

func makeNSView(context: Context) -> NSScrollView {
let textView = NSTextView()
let textView = OmiNSTextView()
textView.font = .systemFont(ofSize: fontSize)
textView.textColor = textColor
textView.backgroundColor = .clear
Expand All @@ -48,6 +73,9 @@ struct OmiTextEditor: NSViewRepresentable {
textView.isAutomaticDashSubstitutionEnabled = false
textView.isAutomaticTextReplacementEnabled = false
textView.delegate = context.coordinator
textView.onMarkedTextStatusChange = { [weak coordinator = context.coordinator] hasMarkedText in
coordinator?.updateMarkedTextState(hasMarkedText)
}

textView.textContainer?.lineFragmentPadding = lineFragmentPadding
textView.textContainerInset = textContainerInset
Expand Down Expand Up @@ -87,7 +115,7 @@ struct OmiTextEditor: NSViewRepresentable {
// correct task's draftText when SwiftUI reuses this NSView across tasks.
context.coordinator.updateTextBinding($text)

if textView.string != text {
if textView.string != text, !textView.hasMarkedText() {
context.coordinator.isUpdating = true
textView.string = text
context.coordinator.isUpdating = false
Expand Down Expand Up @@ -124,11 +152,14 @@ struct OmiTextEditor: NSViewRepresentable {

// Keep closures fresh so they capture the latest SwiftUI state
context.coordinator.onSubmit = onSubmit
context.coordinator.onMarkedTextChange = onMarkedTextChange

let newFont = NSFont.systemFont(ofSize: fontSize)
if textView.font != newFont {
textView.font = newFont
}

context.coordinator.updateMarkedTextState(textView.hasMarkedText())
}

/// Return a concrete size to SwiftUI's layout engine so it doesn't have to
Expand Down Expand Up @@ -156,6 +187,7 @@ struct OmiTextEditor: NSViewRepresentable {
Coordinator(
text: $text,
onSubmit: onSubmit,
onMarkedTextChange: onMarkedTextChange,
minHeight: minHeight,
maxHeight: maxHeight,
onHeightChange: onHeightChange
Expand All @@ -165,6 +197,7 @@ struct OmiTextEditor: NSViewRepresentable {
class Coordinator: NSObject, NSTextViewDelegate {
@Binding var text: String
var onSubmit: (() -> Void)?
var onMarkedTextChange: ((Bool) -> Void)?
var isUpdating = false

func updateTextBinding(_ binding: Binding<String>) {
Expand All @@ -176,16 +209,19 @@ struct OmiTextEditor: NSViewRepresentable {
private let maxHeight: CGFloat?
private let onHeightChange: ((CGFloat) -> Void)?
private var lastHeight: CGFloat = 0
private var lastMarkedTextState = false

init(
text: Binding<String>,
onSubmit: (() -> Void)?,
onMarkedTextChange: ((Bool) -> Void)?,
minHeight: CGFloat?,
maxHeight: CGFloat?,
onHeightChange: ((CGFloat) -> Void)?
) {
self._text = text
self.onSubmit = onSubmit
self.onMarkedTextChange = onMarkedTextChange
self.minHeight = minHeight
self.maxHeight = maxHeight
self.onHeightChange = onHeightChange
Expand All @@ -198,9 +234,15 @@ struct OmiTextEditor: NSViewRepresentable {
if onHeightChange != nil, let scrollView = textView.enclosingScrollView {
updateHeight(for: textView, scrollView: scrollView)
}

updateMarkedTextState(textView.hasMarkedText())
}

func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
if textView.hasMarkedText() {
return false
}

if commandSelector == #selector(NSResponder.insertNewline(_:)) {
let flags = NSApp.currentEvent?.modifierFlags ?? []
if !flags.contains(.shift) {
Expand Down Expand Up @@ -228,5 +270,14 @@ struct OmiTextEditor: NSViewRepresentable {
onHeightChange(constrainedHeight)
}
}

func updateMarkedTextState(_ hasMarkedText: Bool) {
guard hasMarkedText != lastMarkedTextState else { return }
lastMarkedTextState = hasMarkedText
DispatchQueue.main.async { [weak self] in
guard self?.lastMarkedTextState == hasMarkedText else { return }
self?.onMarkedTextChange?(hasMarkedText)
}
}
Comment on lines +274 to +281
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 updateMarkedTextState is called both from the OmiNSTextView override callbacks (via onMarkedTextStatusChange) and unconditionally at the end of every updateNSView. Since AppKit delegate methods and updateNSView all run on the main thread, the DispatchQueue.main.async adds a single-runloop-cycle delay to every state change. In practice the cancellation guard (self?.lastMarkedTextState == hasMarkedText) prevents stale deliveries, but the async hop means there is a brief window between setMarkedText and the next main-queue drain where lastMarkedTextState is already true while SwiftUI still sees hasMarkedText == false. Calling onMarkedTextChange directly on the main thread (without the async hop) would make the state transition atomic with respect to the AppKit text-change event.

}
}
Loading