merge settings and title bar features into polish branch
This commit is contained in:
commit
1f610cb798
|
|
@ -6,6 +6,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
var window: NSWindow!
|
var window: NSWindow!
|
||||||
var appState: AppState!
|
var appState: AppState!
|
||||||
private var titleCancellable: AnyCancellable?
|
private var titleCancellable: AnyCancellable?
|
||||||
|
private var titleBarView: TitleBarView?
|
||||||
|
private var focusTitleObserver: NSObjectProtocol?
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
appState = AppState()
|
appState = AppState()
|
||||||
|
|
@ -29,6 +31,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
window.makeKeyAndOrderFront(nil)
|
window.makeKeyAndOrderFront(nil)
|
||||||
|
|
||||||
applyThemeAppearance()
|
applyThemeAppearance()
|
||||||
|
setupTitleBar()
|
||||||
setupMenuBar()
|
setupMenuBar()
|
||||||
observeDocumentTitle()
|
observeDocumentTitle()
|
||||||
|
|
||||||
|
|
@ -186,6 +189,28 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
NotificationCenter.default.post(name: .toggleSidebar, object: nil)
|
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() {
|
private func observeDocumentTitle() {
|
||||||
titleCancellable = appState.$documentText
|
titleCancellable = appState.$documentText
|
||||||
.receive(on: RunLoop.main)
|
.receive(on: RunLoop.main)
|
||||||
|
|
@ -193,14 +218,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
let firstLine = text.components(separatedBy: "\n").first?
|
let firstLine = text.components(separatedBy: "\n").first?
|
||||||
.trimmingCharacters(in: .whitespaces) ?? ""
|
.trimmingCharacters(in: .whitespaces) ?? ""
|
||||||
if firstLine.isEmpty {
|
let clean = firstLine.replacingOccurrences(
|
||||||
self.window.title = "Swiftly"
|
of: "^#+\\s*", with: "", options: .regularExpression
|
||||||
} else {
|
)
|
||||||
let clean = firstLine.replacingOccurrences(
|
let displayTitle = clean.isEmpty ? "Swiftly" : String(clean.prefix(60))
|
||||||
of: "^#+\\s*", with: "", options: .regularExpression
|
self.window.title = displayTitle
|
||||||
)
|
self.titleBarView?.title = firstLine
|
||||||
self.window.title = clean.isEmpty ? "Swiftly" : String(clean.prefix(60))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
// MARK: - Editor View
|
||||||
|
|
||||||
struct EditorView: View {
|
struct EditorView: View {
|
||||||
@ObservedObject var state: AppState
|
@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> {
|
private var bodyBinding: Binding<String> {
|
||||||
Binding(
|
Binding(
|
||||||
|
|
@ -685,22 +553,15 @@ struct EditorView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
EditorTextView(
|
||||||
TitleView(
|
text: bodyBinding,
|
||||||
titleLine: titleBinding,
|
evalResults: offsetEvalResults(state.evalResults),
|
||||||
onCommitAndFocusEditor: {
|
onEvaluate: { state.evaluate() },
|
||||||
NotificationCenter.default.post(name: .focusEditor, object: nil)
|
onBackspaceAtStart: {
|
||||||
}
|
NotificationCenter.default.post(name: .focusTitle, object: nil)
|
||||||
)
|
}
|
||||||
EditorTextView(
|
)
|
||||||
text: bodyBinding,
|
.padding(.top, 4)
|
||||||
evalResults: offsetEvalResults(state.evalResults),
|
|
||||||
onEvaluate: { state.evaluate() },
|
|
||||||
onBackspaceAtStart: {
|
|
||||||
NotificationCenter.default.post(name: .focusTitle, object: nil)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func offsetEvalResults(_ results: [Int: String]) -> [Int: String] {
|
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