move editable title into macOS title bar via NSTitlebarAccessoryViewController
This commit is contained in:
parent
4ae9409eff
commit
00ec70bfcc
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,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] {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue