move editable title into macOS title bar via NSTitlebarAccessoryViewController

This commit is contained in:
jess 2026-04-05 12:33:43 -07:00
parent 4ae9409eff
commit 00ec70bfcc
3 changed files with 204 additions and 156 deletions

View File

@ -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 displayTitle = clean.isEmpty ? "Swiftly" : String(clean.prefix(60))
self.window.title = displayTitle
self.titleBarView?.title = firstLine
}
}
}

View File

@ -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<String> {
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<String> {
Binding(
@ -685,13 +553,6 @@ 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),
@ -700,7 +561,7 @@ struct EditorView: View {
NotificationCenter.default.post(name: .focusTitle, object: nil)
}
)
}
.padding(.top, 4)
}
private func offsetEvalResults(_ results: [Int: String]) -> [Int: String] {

164
src/TitleBarView.swift Normal file
View File

@ -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
}
}