ux: add hover/ edit close button and keep overlay until escape or send

This commit is contained in:
Peter Steinberger
2025-12-08 21:22:04 +01:00
parent ec046411f1
commit 7a82777fc5

View File

@@ -46,7 +46,13 @@ final class VoiceWakeOverlayController: ObservableObject {
self.updateWindowFrame(animate: true) self.updateWindowFrame(animate: true)
} }
func presentFinal(transcript: String, forwardConfig: VoiceWakeForwardConfig, delay: TimeInterval, attributed: NSAttributedString? = nil) { func presentFinal(
transcript: String,
forwardConfig: VoiceWakeForwardConfig,
delay: TimeInterval,
sendChime: VoiceWakeChime = .none,
attributed: NSAttributedString? = nil)
{
self.autoSendTask?.cancel() self.autoSendTask?.cancel()
self.forwardConfig = forwardConfig self.forwardConfig = forwardConfig
self.model.text = transcript self.model.text = transcript
@@ -56,7 +62,7 @@ final class VoiceWakeOverlayController: ObservableObject {
self.model.isEditing = false self.model.isEditing = false
self.model.attributed = attributed ?? self.makeAttributed(from: transcript) self.model.attributed = attributed ?? self.makeAttributed(from: transcript)
self.present() self.present()
self.scheduleAutoSend(after: delay) self.scheduleAutoSend(after: delay, sendChime: sendChime)
} }
func userBeganEditing() { func userBeganEditing() {
@@ -248,11 +254,14 @@ final class VoiceWakeOverlayController: ObservableObject {
} }
} }
private func scheduleAutoSend(after delay: TimeInterval) { private func scheduleAutoSend(after delay: TimeInterval, sendChime: VoiceWakeChime) {
guard let forwardConfig, forwardConfig.enabled else { return } guard let forwardConfig, forwardConfig.enabled else { return }
self.autoSendTask = Task { [weak self] in self.autoSendTask = Task { [weak self] in
let nanos = UInt64(delay * 1_000_000_000) let nanos = UInt64(delay * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanos) try? await Task.sleep(nanoseconds: nanos)
if sendChime != .none {
VoiceWakeChimePlayer.play(sendChime)
}
self?.sendNow() self?.sendNow()
} }
} }
@@ -267,75 +276,107 @@ final class VoiceWakeOverlayController: ObservableObject {
} }
} }
private struct CloseHoverButton: View {
var onClose: () -> Void
var body: some View {
Button(action: self.onClose) {
Image(systemName: "xmark")
.font(.system(size: 12, weight: .bold))
.foregroundColor(Color.white.opacity(0.85))
.frame(width: 22, height: 22)
.background(Color.black.opacity(0.35))
.clipShape(Circle())
.shadow(color: Color.black.opacity(0.35), radius: 6, y: 2)
}
.buttonStyle(.plain)
.focusable(false)
.contentShape(Circle())
.padding(6)
}
}
private struct VoiceWakeOverlayView: View { private struct VoiceWakeOverlayView: View {
@ObservedObject var controller: VoiceWakeOverlayController @ObservedObject var controller: VoiceWakeOverlayController
@FocusState private var textFocused: Bool @FocusState private var textFocused: Bool
@State private var isHovering: Bool = false
var body: some View { var body: some View {
HStack(alignment: .top, spacing: 8) { ZStack(alignment: .topLeading) {
if self.controller.model.isEditing { HStack(alignment: .top, spacing: 8) {
TranscriptTextView( if self.controller.model.isEditing {
text: Binding( TranscriptTextView(
get: { self.controller.model.text }, text: Binding(
set: { self.controller.updateText($0) }), get: { self.controller.model.text },
attributed: self.controller.model.attributed, set: { self.controller.updateText($0) }),
isFinal: self.controller.model.isFinal, attributed: self.controller.model.attributed,
isOverflowing: self.controller.model.isOverflowing, isFinal: self.controller.model.isFinal,
onBeginEditing: { isOverflowing: self.controller.model.isOverflowing,
self.controller.userBeganEditing() onBeginEditing: {
}, self.controller.userBeganEditing()
onEscape: { },
self.controller.cancelEditingAndDismiss() onEscape: {
}, self.controller.cancelEditingAndDismiss()
onEndEditing: { },
self.controller.endEditing() onEndEditing: {
}, self.controller.endEditing()
onSend: { },
self.controller.sendNow() onSend: {
}) self.controller.sendNow()
.focused(self.$textFocused) })
.frame(minHeight: 32, maxHeight: .infinity) .focused(self.$textFocused)
.id("editing") .frame(minHeight: 32, maxHeight: .infinity)
} else { .id("editing")
VibrantLabelView( } else {
attributed: self.controller.model.attributed, VibrantLabelView(
onTap: { attributed: self.controller.model.attributed,
self.controller.userBeganEditing() onTap: {
self.textFocused = true self.controller.userBeganEditing()
}) self.textFocused = true
.frame(minHeight: 32, maxHeight: .infinity) })
.id("display") .frame(minHeight: 32, maxHeight: .infinity)
} .id("display")
Button {
self.controller.sendNow()
} label: {
let sending = self.controller.model.isSending
ZStack {
Image(systemName: "paperplane.fill")
.opacity(sending ? 0 : 1)
.scaleEffect(sending ? 0.5 : 1)
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
.opacity(sending ? 1 : 0)
.scaleEffect(sending ? 1.05 : 0.8)
} }
.imageScale(.small)
.padding(.vertical, 6) Button {
.padding(.horizontal, 10) self.controller.sendNow()
.background(Color.accentColor.opacity(0.12)) } label: {
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) let sending = self.controller.model.isSending
.animation(.spring(response: 0.35, dampingFraction: 0.78), value: sending) ZStack {
Image(systemName: "paperplane.fill")
.opacity(sending ? 0 : 1)
.scaleEffect(sending ? 0.5 : 1)
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
.opacity(sending ? 1 : 0)
.scaleEffect(sending ? 1.05 : 0.8)
}
.imageScale(.small)
.padding(.vertical, 6)
.padding(.horizontal, 10)
.background(Color.accentColor.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.animation(.spring(response: 0.35, dampingFraction: 0.78), value: sending)
}
.buttonStyle(.plain)
.disabled(!self.controller.model.forwardEnabled || self.controller.model.isSending)
.keyboardShortcut(.return, modifiers: [.command])
}
.padding(.vertical, 8)
.padding(.horizontal, 10)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.onHover { self.isHovering = $0 }
if self.controller.model.isEditing || self.isHovering {
CloseHoverButton(onClose: {
self.controller.cancelEditingAndDismiss()
})
.offset(x: -10, y: -10)
.transition(AnyTransition.scale.combined(with: .opacity))
} }
.buttonStyle(.plain)
.disabled(!self.controller.model.forwardEnabled || self.controller.model.isSending)
.keyboardShortcut(.return, modifiers: [.command])
} }
.padding(.vertical, 8)
.padding(.horizontal, 10)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.onAppear { self.textFocused = false } .onAppear { self.textFocused = false }
.onChange(of: self.controller.model.text) { _, _ in .onChange(of: self.controller.model.text) { _, _ in
self.textFocused = self.controller.model.isEditing self.textFocused = self.controller.model.isEditing
@@ -488,20 +529,40 @@ private struct VibrantLabelView: NSViewRepresentable {
} }
private final class ClickCatcher: NSView { private final class ClickCatcher: NSView {
let onTap: () -> Void let onTap: () -> Void
init(onTap: @escaping () -> Void) { init(onTap: @escaping () -> Void) {
self.onTap = onTap self.onTap = onTap
super.init(frame: .zero) super.init(frame: .zero)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func mouseDown(with event: NSEvent) {
super.mouseDown(with: event)
self.onTap()
}
} }
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func mouseDown(with event: NSEvent) {
super.mouseDown(with: event)
self.onTap()
}
}
private struct CloseHoverButton: View {
var onClose: () -> Void
var body: some View {
Button(action: self.onClose) {
Image(systemName: "xmark")
.font(.system(size: 12, weight: .bold))
.foregroundColor(Color.white.opacity(0.85))
.frame(width: 22, height: 22)
.background(Color.black.opacity(0.35))
.clipShape(Circle())
.shadow(color: Color.black.opacity(0.35), radius: 6, y: 2)
}
.buttonStyle(.plain)
.focusable(false)
.contentShape(Circle())
.padding(6)
}
}
} }
private extension NSAttributedString { private extension NSAttributedString {