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
135 changes: 113 additions & 22 deletions Sources/HeardCore/MeetingNoteComposer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ import SwiftUI

/// Floating composer for in-meeting notes. Hotkey opens the panel; the panel
/// becomes key immediately so the first keystroke goes into the field. Esc
/// cancels, Cmd+Return submits.
/// cancels, Return submits, Cmd+Return inserts a newline.
@MainActor
public final class MeetingNoteComposer {
public static let shared = MeetingNoteComposer()

private var panel: KeyablePanel?
/// Captured at the instant the panel is shown so a slow typer's note still
/// anchors to when they reacted to what was being said, not when they hit
/// Cmd+Return.
/// Return.
private var openedAt: Date?
private var onSubmit: ((Date, String) -> Void)?
private var onCancel: (() -> Void)?
Expand Down Expand Up @@ -134,7 +134,6 @@ private struct MeetingNoteComposerView: View {
let onCancel: () -> Void

@State private var text: String = ""
@FocusState private var isFocused: Bool

var body: some View {
VStack(alignment: .leading, spacing: 12) {
Expand All @@ -149,42 +148,134 @@ private struct MeetingNoteComposerView: View {
}
}

TextEditor(text: $text)
.font(.body)
.focused($isFocused)
.frame(minHeight: 96)
.padding(6)
.background(Color(nsColor: .textBackgroundColor))
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.secondary.opacity(0.3))
)
NoteTextEditor(
text: $text,
onSubmit: { submitIfNotEmpty() },
onCancel: onCancel
)
.frame(minHeight: 96)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.secondary.opacity(0.3))
)

HStack(spacing: 8) {
Text("↩ to save, Esc to cancel")
Text("↩ to save · ⌘↩ new line · Esc to cancel")
.font(.caption)
.foregroundStyle(.tertiary)
Spacer()
Button("Cancel") { onCancel() }
.keyboardShortcut(.cancelAction)
Button("Save Note") { onSubmit(text) }
.keyboardShortcut(.return, modifiers: [.command])
Button("Save Note") { submitIfNotEmpty() }
.buttonStyle(.borderedProminent)
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
.padding(16)
.frame(width: 420, height: 200, alignment: .topLeading)
.onAppear {
// Defer one runloop tick so the panel is fully key before claiming
// first responder — without the delay the focus sometimes lands
// outside the editor on first present.
DispatchQueue.main.async { isFocused = true }
}
}

private func submitIfNotEmpty() {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
onSubmit(trimmed)
}

private var headerTitle: String {
let trimmed = meetingTitle.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? "Note" : "Note · \(trimmed)"
}
}

// MARK: - NSViewRepresentable text editor

/// Wraps NSTextView directly so we can intercept Return / Cmd+Return at the
/// AppKit level. SwiftUI's `onKeyPress` never fires inside a TextEditor because
/// the underlying NSTextView consumes Return before the SwiftUI event system
/// sees it.
private struct NoteTextEditor: NSViewRepresentable {
@Binding var text: String
var onSubmit: () -> Void
var onCancel: () -> Void

func makeNSView(context: Context) -> NSScrollView {
let tv = context.coordinator.textView
tv.isRichText = false
tv.font = .systemFont(ofSize: NSFont.systemFontSize)
tv.isEditable = true
tv.isSelectable = true
tv.allowsUndo = true
tv.drawsBackground = true
tv.backgroundColor = .textBackgroundColor
tv.textContainerInset = NSSize(width: 4, height: 4)

let scroll = NSScrollView()
scroll.hasVerticalScroller = true
scroll.hasHorizontalScroller = false
scroll.drawsBackground = false
scroll.documentView = tv
tv.autoresizingMask = [.width]
return scroll
}

func updateNSView(_ scrollView: NSScrollView, context: Context) {
let tv = context.coordinator.textView
if tv.string != text {
tv.string = text
}
context.coordinator.onSubmit = onSubmit
context.coordinator.onCancel = onCancel
}

func makeCoordinator() -> Coordinator { Coordinator(text: $text) }

final class Coordinator: NSObject, NSTextViewDelegate {
let textView = NoteNSTextView()
var text: Binding<String>
var onSubmit: () -> Void = {}
var onCancel: () -> Void = {}

init(text: Binding<String>) {
self.text = text
super.init()
textView.delegate = self
textView.onReturn = { [weak self] in self?.onSubmit() }
textView.onEscape = { [weak self] in self?.onCancel() }
}

func textDidChange(_ notification: Notification) {
guard let tv = notification.object as? NSTextView else { return }
text.wrappedValue = tv.string
}
}
}

/// NSTextView subclass that routes Return → submit and Cmd+Return → newline.
private final class NoteNSTextView: NSTextView {
var onReturn: (() -> Void)?
var onEscape: (() -> Void)?

override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
// Grab first-responder as soon as the view is in a key window so the
// first keystroke goes into the text field without a manual click.
if let w = window, w.isKeyWindow {
w.makeFirstResponder(self)
}
}

override func keyDown(with event: NSEvent) {
switch event.keyCode {
case 36: // Return
if event.modifierFlags.contains(.command) {
insertNewline(nil)
} else {
onReturn?()
}
case 53: // Escape
onEscape?()
default:
super.keyDown(with: event)
}
}
}
2 changes: 1 addition & 1 deletion handoff.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ The app builds cleanly with `swift build` and runs as a menu bar app on macOS 15

### In-Meeting Notes
- During an active recording, the user can press a global hotkey (default Ctrl+Shift+N, configurable in Settings → General → Meeting Notes) to open a small floating composer panel.
- Composer is an `NSPanel` subclass overriding `canBecomeKey` so the text editor takes focus immediately; first keystroke goes into the field. Esc cancels, Cmd+Return saves.
- Composer is an `NSPanel` subclass overriding `canBecomeKey` so the text editor takes focus immediately; first keystroke goes into the field. Return saves, Cmd+Return inserts a newline, Esc cancels. The text field is a custom `NSViewRepresentable` (`NoteTextEditor`/`NoteNSTextView`) that intercepts key events at the AppKit level — SwiftUI's `onKeyPress` is not used because `TextEditor`'s underlying `NSTextView` consumes Return before SwiftUI sees it.
- The note's recording-relative timestamp is captured at panel-open time (not submit time), so a slow typer's note still anchors to when they reacted.
- Notes are stored on `RecordingSession.notes` while the meeting is in progress; carried into the `PipelineJob` when recording stops; persisted in `pipeline_queue.json` (with backwards-compat decoding for pre-feature queue files).
- If the user submits *after* the meeting has ended, `PipelineProcessor.attachNoteToFinishedJob(at:text:)` finds the matching enqueued/processing job by wall-clock time and attaches there instead.
Expand Down
Loading