From 00ec70bfccce36cefd3e3501130a84c8a6065df4 Mon Sep 17 00:00:00 2001 From: jess Date: Sun, 5 Apr 2026 12:33:43 -0700 Subject: [PATCH] move editable title into macOS title bar via NSTitlebarAccessoryViewController --- src/AppDelegate.swift | 39 ++++++++-- src/EditorView.swift | 157 +++------------------------------------ src/TitleBarView.swift | 164 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 204 insertions(+), 156 deletions(-) create mode 100644 src/TitleBarView.swift diff --git a/src/AppDelegate.swift b/src/AppDelegate.swift index 6420152..3ccabf0 100644 --- a/src/AppDelegate.swift +++ b/src/AppDelegate.swift @@ -6,6 +6,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { var window: NSWindow! var appState: AppState! private var titleCancellable: AnyCancellable? + private var titleBarView: TitleBarView? + private var focusTitleObserver: NSObjectProtocol? func applicationDidFinishLaunching(_ notification: Notification) { appState = AppState() @@ -28,6 +30,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { window.setFrameAutosaveName("SwiftlyMainWindow") window.makeKeyAndOrderFront(nil) + setupTitleBar() setupMenuBar() observeDocumentTitle() } @@ -167,6 +170,28 @@ class AppDelegate: NSObject, NSApplicationDelegate { NotificationCenter.default.post(name: .toggleSidebar, object: nil) } + private func setupTitleBar() { + let accessory = TitleBarAccessoryController() + window.addTitlebarAccessoryViewController(accessory) + + let tbv = accessory.titleView + tbv.onCommit = { [weak self] rawTitle in + guard let self = self else { return } + let lines = self.appState.documentText.components(separatedBy: "\n") + var rest = Array(lines.dropFirst()) + if rest.isEmpty && self.appState.documentText.isEmpty { rest = [] } + self.appState.documentText = ([rawTitle] + rest).joined(separator: "\n") + } + + titleBarView = tbv + + focusTitleObserver = NotificationCenter.default.addObserver( + forName: .focusTitle, object: nil, queue: .main + ) { [weak self] _ in + self?.titleBarView?.beginEditing() + } + } + private func observeDocumentTitle() { titleCancellable = appState.$documentText .receive(on: RunLoop.main) @@ -174,14 +199,12 @@ class AppDelegate: NSObject, NSApplicationDelegate { guard let self = self else { return } let firstLine = text.components(separatedBy: "\n").first? .trimmingCharacters(in: .whitespaces) ?? "" - if firstLine.isEmpty { - self.window.title = "Swiftly" - } else { - let clean = firstLine.replacingOccurrences( - of: "^#+\\s*", with: "", options: .regularExpression - ) - self.window.title = clean.isEmpty ? "Swiftly" : String(clean.prefix(60)) - } + let clean = firstLine.replacingOccurrences( + of: "^#+\\s*", with: "", options: .regularExpression + ) + let displayTitle = clean.isEmpty ? "Swiftly" : String(clean.prefix(60)) + self.window.title = displayTitle + self.titleBarView?.title = firstLine } } } diff --git a/src/EditorView.swift b/src/EditorView.swift index 525cda1..5ee519a 100644 --- a/src/EditorView.swift +++ b/src/EditorView.swift @@ -530,142 +530,10 @@ class FileEmbedCell: NSTextAttachmentCell { } } -// MARK: - Title View - -struct TitleView: View { - @Binding var titleLine: String - @State private var isEditing = false - @State private var editText = "" - var onCommitAndFocusEditor: (() -> Void)? - - private var displayTitle: String { - let stripped = titleLine.trimmingCharacters(in: .whitespaces) - if stripped.hasPrefix("# ") { - return String(stripped.dropFirst(2)) - } - return stripped - } - - var body: some View { - HStack { - if isEditing { - TitleTextField( - text: $editText, - onCommit: { commitEdit() }, - onEscape: { commitEdit() } - ) - .font(.system(size: 24, weight: .bold)) - .foregroundColor(Color(ns: Theme.current.text)) - .padding(.horizontal, 58) - .padding(.top, 16) - .padding(.bottom, 8) - } else { - Text(displayTitle.isEmpty ? "Untitled" : displayTitle) - .font(.system(size: 24, weight: .bold)) - .foregroundColor(displayTitle.isEmpty - ? Color(ns: Theme.current.overlay0) - : Color(ns: Theme.current.text)) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 58) - .padding(.top, 16) - .padding(.bottom, 8) - .contentShape(Rectangle()) - .onTapGesture(count: 2) { - editText = titleLine - isEditing = true - } - } - Spacer() - } - .background(Color(ns: Theme.current.base)) - .onReceive(NotificationCenter.default.publisher(for: .focusTitle)) { _ in - editText = titleLine - isEditing = true - } - } - - private func commitEdit() { - titleLine = editText - isEditing = false - onCommitAndFocusEditor?() - } -} - -struct TitleTextField: NSViewRepresentable { - @Binding var text: String - var onCommit: () -> Void - var onEscape: () -> Void - - func makeCoordinator() -> Coordinator { Coordinator(self) } - - func makeNSView(context: Context) -> NSTextField { - let field = NSTextField() - field.isBordered = false - field.drawsBackground = false - field.font = NSFont.systemFont(ofSize: 24, weight: .bold) - field.textColor = Theme.current.text - field.focusRingType = .none - field.stringValue = text - field.delegate = context.coordinator - field.cell?.lineBreakMode = .byTruncatingTail - DispatchQueue.main.async { - field.window?.makeFirstResponder(field) - field.currentEditor()?.selectAll(nil) - } - return field - } - - func updateNSView(_ field: NSTextField, context: Context) { - if field.stringValue != text { - field.stringValue = text - } - } - - class Coordinator: NSObject, NSTextFieldDelegate { - var parent: TitleTextField - init(_ parent: TitleTextField) { self.parent = parent } - - func controlTextDidChange(_ obj: Notification) { - guard let field = obj.object as? NSTextField else { return } - parent.text = field.stringValue - } - - func control(_ control: NSControl, textView: NSTextView, doCommandBy sel: Selector) -> Bool { - if sel == #selector(NSResponder.insertNewline(_:)) { - parent.onCommit() - return true - } - if sel == #selector(NSResponder.cancelOperation(_:)) { - parent.onEscape() - return true - } - return false - } - } -} - // MARK: - Editor View struct EditorView: View { @ObservedObject var state: AppState - @State private var titleIsEditing = false - - private var titleBinding: Binding { - Binding( - get: { - let lines = state.documentText.components(separatedBy: "\n") - return lines.first ?? "" - }, - set: { newTitle in - let lines = state.documentText.components(separatedBy: "\n") - var rest = Array(lines.dropFirst()) - if rest.isEmpty && state.documentText.isEmpty { - rest = [] - } - state.documentText = ([newTitle] + rest).joined(separator: "\n") - } - ) - } private var bodyBinding: Binding { Binding( @@ -685,22 +553,15 @@ struct EditorView: View { } var body: some View { - VStack(spacing: 0) { - TitleView( - titleLine: titleBinding, - onCommitAndFocusEditor: { - NotificationCenter.default.post(name: .focusEditor, object: nil) - } - ) - EditorTextView( - text: bodyBinding, - evalResults: offsetEvalResults(state.evalResults), - onEvaluate: { state.evaluate() }, - onBackspaceAtStart: { - NotificationCenter.default.post(name: .focusTitle, object: nil) - } - ) - } + EditorTextView( + text: bodyBinding, + evalResults: offsetEvalResults(state.evalResults), + onEvaluate: { state.evaluate() }, + onBackspaceAtStart: { + NotificationCenter.default.post(name: .focusTitle, object: nil) + } + ) + .padding(.top, 4) } private func offsetEvalResults(_ results: [Int: String]) -> [Int: String] { diff --git a/src/TitleBarView.swift b/src/TitleBarView.swift new file mode 100644 index 0000000..0695275 --- /dev/null +++ b/src/TitleBarView.swift @@ -0,0 +1,164 @@ +import Cocoa + +class TitleBarAccessoryController: NSTitlebarAccessoryViewController { + let titleView = TitleBarView() + + override func loadView() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 400, height: 28)) + titleView.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(titleView) + NSLayoutConstraint.activate([ + titleView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + titleView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + titleView.topAnchor.constraint(equalTo: container.topAnchor), + titleView.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + self.view = container + } + + override func viewDidLoad() { + super.viewDidLoad() + layoutAttribute = .bottom + fullScreenMinHeight = 28 + } +} + +class TitleBarView: NSView { + private let label = NSTextField(labelWithString: "") + private let editor = NSTextField() + private(set) var isEditing = false + + var title: String = "" { + didSet { + if !isEditing { + let dt = displayTitle + label.stringValue = dt.isEmpty ? "Untitled" : dt + label.textColor = dt.isEmpty ? Theme.current.overlay0 : Theme.current.text + } + } + } + + var onCommit: ((String) -> Void)? + + private var displayTitle: String { + let trimmed = title.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("# ") { + return String(trimmed.dropFirst(2)) + } + return trimmed + } + + override init(frame: NSRect) { + super.init(frame: frame) + setup() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + private func setup() { + label.font = .systemFont(ofSize: 13, weight: .semibold) + label.textColor = Theme.current.overlay0 + label.backgroundColor = .clear + label.isBezeled = false + label.isEditable = false + label.isSelectable = false + label.alignment = .center + label.lineBreakMode = .byTruncatingTail + label.cell?.truncatesLastVisibleLine = true + label.translatesAutoresizingMaskIntoConstraints = false + label.stringValue = "Untitled" + + editor.font = .systemFont(ofSize: 13, weight: .semibold) + editor.textColor = Theme.current.text + editor.backgroundColor = Theme.current.surface0 + editor.isBezeled = false + editor.isEditable = true + editor.isSelectable = true + editor.alignment = .center + editor.focusRingType = .none + editor.cell?.lineBreakMode = .byTruncatingTail + editor.translatesAutoresizingMaskIntoConstraints = false + editor.isHidden = true + editor.delegate = self + + addSubview(label) + addSubview(editor) + + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: centerXAnchor), + label.centerYAnchor.constraint(equalTo: centerYAnchor), + label.widthAnchor.constraint(lessThanOrEqualTo: widthAnchor, multiplier: 0.5), + + editor.centerXAnchor.constraint(equalTo: centerXAnchor), + editor.centerYAnchor.constraint(equalTo: centerYAnchor), + editor.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.5), + ]) + + let dblClick = NSClickGestureRecognizer(target: self, action: #selector(handleDoubleClick(_:))) + dblClick.numberOfClicksRequired = 2 + dblClick.delaysPrimaryMouseButtonEvents = false + addGestureRecognizer(dblClick) + } + + @objc private func handleDoubleClick(_ sender: NSClickGestureRecognizer) { + if !isEditing { beginEditing() } + } + + override func mouseDown(with event: NSEvent) { + if event.clickCount == 2 && !isEditing { + beginEditing() + return + } + super.mouseDown(with: event) + } + + func beginEditing() { + isEditing = true + editor.stringValue = title + label.isHidden = true + editor.isHidden = false + window?.makeFirstResponder(editor) + editor.currentEditor()?.selectAll(nil) + } + + func endEditing() { + guard isEditing else { return } + isEditing = false + let raw = editor.stringValue + onCommit?(raw) + let dt = displayTitle + label.stringValue = dt.isEmpty ? "Untitled" : dt + label.textColor = dt.isEmpty ? Theme.current.overlay0 : Theme.current.text + editor.isHidden = true + label.isHidden = false + } + + func updateColors() { + let dt = displayTitle + label.textColor = dt.isEmpty ? Theme.current.overlay0 : Theme.current.text + editor.textColor = Theme.current.text + editor.backgroundColor = Theme.current.surface0 + } +} + +extension TitleBarView: NSTextFieldDelegate { + func controlTextDidEndEditing(_ obj: Notification) { + endEditing() + } + + func control(_ control: NSControl, textView: NSTextView, doCommandBy sel: Selector) -> Bool { + if sel == #selector(NSResponder.insertNewline(_:)) { + endEditing() + NotificationCenter.default.post(name: .focusEditor, object: nil) + return true + } + if sel == #selector(NSResponder.cancelOperation(_:)) { + endEditing() + return true + } + return false + } +}