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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## 1.2.0

- Added a native File > Open Recent menu with the standard Clear Menu command.
- Recorded only successfully opened or saved files, while preserving duplicate-open prevention and transactional file opening.
- Remembered the preferred editor font and size across launches, new windows, new tabs, and restored sessions.
- Validated persisted font settings and returned safely to the default monospaced font when saved settings are invalid or unavailable.
- Applied display-font changes without modifying document contents or dirty state.
- Added stable accessibility identifiers and explicit VoiceOver labels to the editor, status bar, Find and Replace, Save As encoding, and Go To Line controls.
- Added deterministic keyboard focus loops for the editor and Find and Replace panels.
- Expanded automated coverage to 55 core and AppKit tests.

## 1.1.0

- Added UTF-16 little-endian, UTF-16 big-endian, and Windows-1252 file support alongside UTF-8, UTF-8 BOM, and ISO-8859-1.
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,6 @@ Include:

Pull requests require review, passing CI, and resolved conversations before merge. GitHub may hold workflow runs from first-time contributors for maintainer approval; this is expected and does not block contribution.

Small tasks labeled [`good first issue`](https://github.com/anvilfilbert/MacPad/labels/good%20first%20issue) are intended as starting points for new contributors.
Known product work is normally implemented by the maintainers. Issues explicitly labeled [`help wanted`](https://github.com/anvilfilbert/MacPad/labels/help%20wanted) or [`good first issue`](https://github.com/anvilfilbert/MacPad/labels/good%20first%20issue) are available for community implementation. For anything else, discuss the proposal on the issue before writing code so work is not duplicated.

By contributing, you agree that your contribution is provided under this repository's GPL-3.0 license.
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,14 @@ If macOS warns that the app is from an unidentified developer, right-click `MacP

## Latest Changes

`1.1.0` adds UTF-16 and Windows-1252 support, selectable Save As encoding, faster large-file cursor updates, configurable Find and Replace, fuller window and tab restoration, menu-state indicators, and built-in Help links. Failed opens no longer leave empty editors.
`1.2.0` adds a native Open Recent menu, remembers the preferred editor font across every tab and launch, and improves VoiceOver descriptions and keyboard navigation in the editor, Find and Replace, Save As, and Go To controls.

See [CHANGELOG.md](CHANGELOG.md) for full release history.

## Features

- Plain-text editing with native undo, cut, copy, paste, delete, and select all
- New, open, save, save as, and print
- New, open, Open Recent, save, save as, and print
- Multiple windows, each with multiple tabs
- New tabs and new windows, including separate windows with their own tab groups
- Session restore for window positions, selected tabs, tab groups, saved file tabs, and editor UI state without storing document text in preferences
Expand All @@ -48,7 +48,8 @@ See [CHANGELOG.md](CHANGELOG.md) for full release history.
- Standard shortcuts including `Command-T` for a new tab, `Command-N` for a new window, and `Option-Command-F` for Replace
- Go to line and insert current time/date
- Word wrap toggle
- Font chooser and zoom controls
- App-wide persistent font chooser and per-tab zoom controls
- VoiceOver labels, stable accessibility identifiers, and predictable keyboard focus order
- Status bar showing line, column, zoom, line ending mode, and detected file encoding
- UTF-8, UTF-8 BOM, UTF-16 LE/BE, Windows-1252, and ISO-8859-1 detection, preservation, and Save As conversion
- Windows, Unix, and classic Mac line-ending detection and preservation
Expand Down
4 changes: 2 additions & 2 deletions Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.1.0</string>
<string>1.2.0</string>
<key>CFBundleVersion</key>
<string>11</string>
<string>12</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>NSHighResolutionCapable</key>
Expand Down
93 changes: 90 additions & 3 deletions Sources/NotepadMac/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,33 @@ enum EditorWindowResolver {
try controller.loadFile(url)
return controller
}

static func makeController(opening url: URL, baseFont: NSFont) throws -> EditorWindowController {
let controller = EditorWindowController(baseFont: baseFont)
try controller.loadFile(url)
return controller
}
}

@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation, NSMenuDelegate {
private static let preferencesLogger = Logger(
subsystem: "local.macpad.app",
category: "preferences"
)
private let sessionDefaultsKey = "MacPad.SessionState.v1"
private let sessionLogger = Logger(subsystem: "local.macpad.app", category: "session")
private var windows: [EditorWindowController] = []
private var isRestoringSession = false
private var pendingOpenURLs: [URL] = []
private var hasFinishedLaunching = false
private weak var lastActiveWindowController: EditorWindowController?
private var preferredFont = EditorWindowController.defaultEditorFont

override init() {
super.init()
preferredFont = Self.loadPreferredFont()
}

func applicationDidFinishLaunching(_ notification: Notification) {
NSWindow.allowsAutomaticWindowTabbing = false
Expand Down Expand Up @@ -130,6 +146,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
}
}

@objc func openRecentDocument(_ sender: Any?) {
guard let item = sender as? NSMenuItem,
let url = item.representedObject as? URL else {
assertionFailure("Open Recent requires a menu item containing a file URL.")
return
}
openDocument(url: url)
}

@objc func clearRecentDocuments(_ sender: Any?) {
NSDocumentController.shared.clearRecentDocuments(sender)
}

func menuNeedsUpdate(_ menu: NSMenu) {
guard menu.title == "Open Recent" else { return }
RecentDocumentsMenuBuilder.populate(
menu,
urls: NSDocumentController.shared.recentDocumentURLs,
target: self
)
}

@objc func clearSessionData(_ sender: Any?) {
cancelScheduledSessionSave()
UserDefaults.standard.removeObject(forKey: sessionDefaultsKey)
Expand All @@ -146,20 +184,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
lastActiveWindowController = existingController
existingController.showWindow(nil)
existingController.window?.makeKeyAndOrderFront(nil)
noteRecentDocument(existingController.fileURL ?? url)
return
}

do {
let controller = try EditorWindowResolver.makeController(opening: url)
let controller = try EditorWindowResolver.makeController(
opening: url,
baseFont: preferredFont
)
configure(controller)
present(controller, asTab: keyWindowController != nil)
noteRecentDocument(controller.fileURL ?? url)
} catch {
showOpenError(url: url, error: error)
}
}

private func makeWindowController() -> EditorWindowController {
let controller = EditorWindowController()
let controller = EditorWindowController(baseFont: preferredFont)
configure(controller)
return controller
}
Expand All @@ -176,6 +219,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
controller.onActivate = { [weak self, weak controller] in
self?.lastActiveWindowController = controller
}
controller.onFontChange = { [weak self] font in
self?.storePreferredFont(font)
}
controller.onSuccessfulSave = { [weak self] url in
self?.noteRecentDocument(url)
}
}

private func present(_ controller: EditorWindowController, asTab: Bool) {
Expand Down Expand Up @@ -466,6 +515,44 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
}
}

private static func loadPreferredFont() -> NSFont {
do {
return try EditorFontPreferences.load(from: .standard)
?? EditorWindowController.defaultEditorFont
} catch {
UserDefaults.standard.removeObject(forKey: EditorFontPreferences.defaultsKey)
preferencesLogger.error(
"Discarded invalid editor font preference: \(error.localizedDescription, privacy: .public)"
)
return EditorWindowController.defaultEditorFont
}
}

private func storePreferredFont(_ font: NSFont) {
do {
try EditorFontPreferences.save(font, to: .standard)
preferredFont = font
for controller in windows {
controller.applyPreferredFont(font)
}
} catch {
Self.preferencesLogger.error(
"Could not save editor font preference: \(error.localizedDescription, privacy: .public)"
)
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Could not save the editor font."
alert.informativeText = error.localizedDescription
alert.runModal()
}
}

private func noteRecentDocument(_ url: URL) {
NSDocumentController.shared.noteNewRecentDocumentURL(
url.resolvingSymlinksInPath().standardizedFileURL
)
}

private func windowFrameState(_ frame: NSRect) -> WindowFrameState {
WindowFrameState(
x: frame.origin.x,
Expand Down
38 changes: 38 additions & 0 deletions Sources/NotepadMac/EditorFontPreferences.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import AppKit
import NotepadMacCore

enum EditorFontPreferencesError: LocalizedError {
case unavailableFont(String)

var errorDescription: String? {
switch self {
case let .unavailableFont(fontName):
return "Saved editor font is not available: \(fontName)."
}
}
}

enum EditorFontPreferences {
static let defaultsKey = "MacPad.EditorFont.v1"

static func load(from defaults: UserDefaults) throws -> NSFont? {
guard let data = defaults.data(forKey: defaultsKey) else { return nil }

let preference = try JSONDecoder().decode(EditorFontPreference.self, from: data)
guard let font = NSFont(
name: preference.postScriptName,
size: CGFloat(preference.pointSize)
) else {
throw EditorFontPreferencesError.unavailableFont(preference.postScriptName)
}
return font
}

static func save(_ font: NSFont, to defaults: UserDefaults) throws {
let preference = try EditorFontPreference(
postScriptName: font.fontName,
pointSize: Double(font.pointSize)
)
defaults.set(try JSONEncoder().encode(preference), forKey: defaultsKey)
}
}
Loading