Skip to content
Draft
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
159 changes: 159 additions & 0 deletions Sources/UI/Settings/SpeakerNameAutocompleteField.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import SwiftUI
import AppKit
import TranscriptedCore

/// SwiftUI wrapper around the same `NSComboBox`-based autocomplete the
/// post-meeting speaker naming sheet uses (`SpeakerRowView`'s `nameField`).
/// It reuses `SpeakerNameSelectionPolicy` for label building, suggestion
/// ordering, and inline completion so the Speakers screen's "Who is this?"
/// field matches existing voices exactly the way naming does elsewhere.
struct SpeakerNameAutocompleteField: NSViewRepresentable {
@Binding var text: String
var placeholder: String
var options: [SpeakerIdentityOption]
var accessibilityIdentifier: String?
var onSubmit: () -> Void

func makeCoordinator() -> Coordinator { Coordinator(self) }

func makeNSView(context: Context) -> NSComboBox {
let combo = NSComboBox()
combo.isEditable = true
combo.completes = true
combo.usesDataSource = true
combo.dataSource = context.coordinator
combo.delegate = context.coordinator
combo.font = NSFont.systemFont(ofSize: 13)
combo.placeholderString = placeholder
combo.stringValue = text
if let accessibilityIdentifier {
combo.identifier = NSUserInterfaceItemIdentifier(accessibilityIdentifier)
combo.setAccessibilityIdentifier(accessibilityIdentifier)
}
context.coordinator.rebuild(options: options)
combo.numberOfVisibleItems = Self.visibleItemCount(for: options)
return combo
}

func updateNSView(_ combo: NSComboBox, context: Context) {
context.coordinator.parent = self
context.coordinator.rebuild(options: options)
if combo.stringValue != text {
combo.stringValue = text
}
combo.placeholderString = placeholder
combo.numberOfVisibleItems = Self.visibleItemCount(for: options)
}

private static func visibleItemCount(for options: [SpeakerIdentityOption]) -> Int {
min(max(options.count, 4), 8)
}

final class Coordinator: NSObject, NSComboBoxDataSource, NSComboBoxDelegate {
var parent: SpeakerNameAutocompleteField
private var labels: [String] = []
private var optionsByLabel: [String: SpeakerIdentityOption] = [:]

init(_ parent: SpeakerNameAutocompleteField) {
self.parent = parent
}

func rebuild(options: [SpeakerIdentityOption]) {
let built = SpeakerNameSelectionPolicy.makeIdentityLabels(
for: options,
id: { $0.id },
displayName: { $0.displayName },
callCount: { $0.callCount }
)
labels = built.labels
optionsByLabel = built.lookup
}

private func visibleLabels(for query: String) -> [String] {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return labels }
return SpeakerNameSelectionPolicy.sortedLabels(
matching: trimmed,
labels: labels,
optionsByLabel: optionsByLabel,
displayName: { $0.displayName },
callCount: { $0.callCount }
)
}

// MARK: NSComboBoxDataSource

func numberOfItems(in comboBox: NSComboBox) -> Int {
visibleLabels(for: comboBox.stringValue).count
}

func comboBox(_ comboBox: NSComboBox, objectValueForItemAt index: Int) -> Any? {
let visible = visibleLabels(for: comboBox.stringValue)
guard visible.indices.contains(index) else { return nil }
return visible[index]
}

func comboBox(_ comboBox: NSComboBox, completedString string: String) -> String? {
SpeakerNameSelectionPolicy.completedLabel(
for: string,
labels: labels,
optionsByLabel: optionsByLabel,
displayName: { $0.displayName },
callCount: { $0.callCount }
)
}

func comboBox(_ comboBox: NSComboBox, indexOfItemWithStringValue string: String) -> Int {
visibleLabels(for: comboBox.stringValue).firstIndex(of: string) ?? NSNotFound
}

// MARK: NSComboBoxDelegate

func controlTextDidChange(_ obj: Notification) {
guard let combo = obj.object as? NSComboBox else { return }
parent.text = combo.stringValue
combo.reloadData()
}

func comboBoxSelectionDidChange(_ notification: Notification) {
guard let combo = notification.object as? NSComboBox else { return }
// Resolve the picked label *now*, while `stringValue` still holds the
// typed query and the visible list matches what the user clicked. The
// selected index desyncs if we re-filter after the field commits the
// (decorated) label, so capturing here is what makes picking a
// non-first suggestion work.
let index = combo.indexOfSelectedItem
let visible = visibleLabels(for: combo.stringValue)
guard visible.indices.contains(index) else { return }
let label = visible[index]
// Map the (possibly decorated) dropdown label back to the plain
// display name, matching what the naming sheet stores.
let resolved = SpeakerNameSelectionPolicy.option(
matching: label,
optionsByLabel: optionsByLabel,
displayName: { $0.displayName }
)?.displayName ?? label
// Assign on the next tick so this lands *after* the combo commits its
// own selected value, overriding the decorated label with the plain
// name instead of being clobbered by it.
DispatchQueue.main.async { [weak self] in
combo.stringValue = resolved
self?.parent.text = resolved
}
}

func control(
_ control: NSControl,
textView: NSTextView,
doCommandBy commandSelector: Selector
) -> Bool {
guard let combo = control as? NSComboBox else { return false }
if commandSelector == #selector(NSResponder.insertNewline(_:)) {
parent.text = combo.stringValue
parent.onSubmit()
return true
}
return false
}
}
}
149 changes: 124 additions & 25 deletions Sources/UI/Settings/SpeakerPeopleSettingsSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,14 @@ final class SpeakerPeopleSettingsViewModel: ObservableObject {
}
}

func delete(profile: SpeakerProfile) {
/// Deletes the voice behind a pending review group. Thin wrapper over
/// `delete(profile:)` so the "voices to name" overflow menu shares the same
/// real deletion path as the all-speakers list.
func deleteVoice(_ group: SpeakerPendingVoiceGroup, completion: ((Bool) -> Void)? = nil) {
delete(profile: group.representative.profile, completion: completion)
}

func delete(profile: SpeakerProfile, completion: ((Bool) -> Void)? = nil) {
let profileId = profile.id
let speakerDatabase = self.speakerDatabase
let preferredClipsDirectory = self.preferredClipsDirectory
Expand All @@ -302,13 +309,17 @@ final class SpeakerPeopleSettingsViewModel: ObservableObject {
preferredClipsDirectory: preferredClipsDirectory,
legacyClipsDirectory: legacyClipsDirectory
)
// Confirm the row is actually gone before reporting success so a
// failed delete surfaces an error instead of silently no-opping.
let didDelete = speakerDatabase.getSpeaker(id: profileId) == nil
let snapshot = Self.snapshot(
from: speakerDatabase,
preferredClipsDirectory: preferredClipsDirectory,
legacyClipsDirectory: legacyClipsDirectory
)
DispatchQueue.main.async {
self?.applySnapshot(snapshot)
completion?(didDelete)
}
}
}
Expand Down Expand Up @@ -733,13 +744,24 @@ private struct SpeakerVoiceToNameRow: View {
@State private var nameDraft = ""
@State private var isSaving = false
@State private var saveErrorMessage: String?
@State private var showDeleteConfirmation = false
@State private var isDeleting = false
@State private var deleteErrorMessage: String?

private var isPlaying: Bool {
model.isPlayingSample(for: group.representative)
}

private var hasClip: Bool {
group.representative.clipURL != nil
}

var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 12) {
SpeakerPlayClipButton(
hasClip: group.representative.clipURL != nil,
isPlaying: model.isPlayingSample(for: group.representative),
hasClip: hasClip,
isPlaying: isPlaying,
action: { model.playSample(for: group.representative) }
)

Expand Down Expand Up @@ -776,8 +798,25 @@ private struct SpeakerVoiceToNameRow: View {
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
}

if let deleteErrorMessage {
Text(deleteErrorMessage)
.font(.caption)
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(.vertical, 4)
.padding(.horizontal, isActiveHighlight ? 8 : 0)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color.accentColor.opacity(isActiveHighlight ? 0.08 : 0))
)
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(Color.accentColor.opacity(isActiveHighlight ? 0.35 : 0), lineWidth: 1)
)
.animation(.easeInOut(duration: 0.15), value: isActiveHighlight)
.onAppear {
if nameDraft.isEmpty {
nameDraft = group.representative.profile.displayName ?? ""
Expand All @@ -786,13 +825,39 @@ private struct SpeakerVoiceToNameRow: View {
.onChange(of: nameDraft) { _, _ in
saveErrorMessage = nil
}
.alert(
SpeakerVoiceRowMenuPolicy.deleteConfirmationTitle,
isPresented: $showDeleteConfirmation
) {
Button("Delete", role: .destructive) {
deleteVoice()
}
Button("Cancel", role: .cancel) {}
} message: {
Text(SpeakerVoiceRowMenuPolicy.deleteConfirmationMessage)
}
}

private var isActiveHighlight: Bool {
SpeakerClipPlaybackPresentation.isActiveHighlight(hasClip: hasClip, isPlaying: isPlaying)
}

private var nameSuggestions: [SpeakerIdentityOption] {
SpeakerNameSuggestionSource.options(
from: model.profiles,
excluding: group.representative.speakerId
)
}

private var nameField: some View {
TextField("Who is this?", text: $nameDraft)
.textFieldStyle(.roundedBorder)
.frame(minWidth: 200)
.onSubmit(saveName)
SpeakerNameAutocompleteField(
text: $nameDraft,
placeholder: "Who is this?",
options: nameSuggestions,
accessibilityIdentifier: "transcripted.speakers.voice-to-name.name",
onSubmit: saveName
)
.frame(minWidth: 200)
}

private var actionButtons: some View {
Expand All @@ -802,21 +867,38 @@ private struct SpeakerVoiceToNameRow: View {
}
.disabled(!canSave || isSaving)

Button {
overflowMenu
}
}

private var overflowMenu: some View {
Menu {
Button(SpeakerVoiceRowMenuAction.showTranscript.title) {
model.openTranscript(for: group.representative)
} label: {
Image(systemName: "doc.text")
.font(.system(size: 12, weight: .semibold))
.padding(7)
}
.buttonStyle(SettingsHoverButtonStyle(
cornerRadius: 8,
normalFill: Color.primary.opacity(0.025),
normalStroke: Color.primary.opacity(0.06)
))
.help("Open the meeting this voice was heard in")
.accessibilityLabel("Open meeting transcript")

Divider()

Button(SpeakerVoiceRowMenuAction.deleteVoice.title, role: .destructive) {
showDeleteConfirmation = true
}
.disabled(isDeleting)
} label: {
Image(systemName: "ellipsis")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.secondary)
.padding(8)
}
.buttonStyle(SettingsHoverButtonStyle(
cornerRadius: 8,
normalFill: Color.primary.opacity(0.025),
normalStroke: Color.primary.opacity(0.06)
))
.menuIndicator(.hidden)
.fixedSize()
.help("Show transcript or delete this voice")
.accessibilityLabel("Voice actions")
.accessibilityIdentifier("transcripted.speakers.voice-to-name.menu")
}

private var quoteLine: String {
Expand Down Expand Up @@ -858,6 +940,19 @@ private struct SpeakerVoiceToNameRow: View {
}
}

private func deleteVoice() {
guard !isDeleting else { return }
isDeleting = true
deleteErrorMessage = nil
model.deleteVoice(group) { didDelete in
isDeleting = false
// On success the row's voice group drops out of the refreshed
// snapshot, so this view disappears. Only a failed delete keeps the
// row around to show the surfaced error.
deleteErrorMessage = SpeakerVoiceRowMenuPolicy.deleteErrorMessage(didDelete: didDelete)
}
}

private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
Expand All @@ -873,21 +968,25 @@ private struct SpeakerPlayClipButton: View {

var body: some View {
Button(action: action) {
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
Image(systemName: SpeakerClipPlaybackPresentation.symbolName(isPlaying: isPlaying))
.font(.system(size: 13, weight: .bold))
.foregroundStyle(hasClip ? Color.white : Color.secondary)
.frame(width: 36, height: 36)
.background(Circle().fill(hasClip ? Color.accentColor : Color.primary.opacity(0.06)))
.overlay(
Circle()
.stroke(Color.accentColor.opacity(isActive ? 0.9 : 0), lineWidth: 2)
.padding(-2)
)
}
.buttonStyle(.plain)
.disabled(!hasClip)
.help(helpText)
.accessibilityLabel(isPlaying ? "Pause voice sample" : "Play voice sample")
.help(SpeakerClipPlaybackPresentation.helpText(hasClip: hasClip, isPlaying: isPlaying))
.accessibilityLabel(SpeakerClipPlaybackPresentation.accessibilityLabel(isPlaying: isPlaying))
}

private var helpText: String {
guard hasClip else { return "No voice clip was saved for this speaker" }
return isPlaying ? "Pause this voice sample" : "Play a short clip of this voice"
private var isActive: Bool {
SpeakerClipPlaybackPresentation.isActiveHighlight(hasClip: hasClip, isPlaying: isPlaying)
}
}

Expand Down
Loading
Loading