mirror of
https://github.com/pawelorzech/FastMemos.git
synced 2026-01-30 04:04:31 +00:00
Compare commits
10 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
524f3790c9 | ||
|
|
d3252d8599 | ||
|
|
84f413fdcf | ||
|
|
bf916bf636 | ||
|
|
3d710b8eeb | ||
|
|
87e5c34fe3 | ||
|
|
1f2a4bab1c | ||
|
|
9ea4974123 | ||
|
|
5d271e417c | ||
|
|
d7db888aad |
16 changed files with 581 additions and 74 deletions
65
CLAUDE.md
Normal file
65
CLAUDE.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Build & Development
|
||||
|
||||
This is a native macOS SwiftUI app. Open `FastMemos.xcodeproj` in Xcode and build with `⌘B`.
|
||||
|
||||
From the command line:
|
||||
```bash
|
||||
xcodebuild -project FastMemos.xcodeproj -scheme FastMemos -configuration Debug build
|
||||
```
|
||||
|
||||
To run:
|
||||
```bash
|
||||
xcodebuild -project FastMemos.xcodeproj -scheme FastMemos -configuration Debug build
|
||||
open ./build/Debug/FastMemos.app
|
||||
```
|
||||
|
||||
**Requirements:** macOS 13.0+ (Ventura), Xcode 15+
|
||||
|
||||
## Architecture
|
||||
|
||||
FastMemos is a menubar-only macOS app for quickly capturing notes to a self-hosted [Memos](https://github.com/usememos/memos) server.
|
||||
|
||||
### Key Components
|
||||
|
||||
**Entry Point (`FastMemosApp.swift`):**
|
||||
- `AppDelegate` sets up the menubar status item and popover
|
||||
- App runs as `.accessory` (no dock icon)
|
||||
- Global hotkey (⌘⇧M) triggers `showNoteWindow()`
|
||||
- `NotePanel` is a floating `NSPanel` for the note capture UI
|
||||
|
||||
**State Management:**
|
||||
- `AppState` (in `ViewModels/`) is the single source of truth, passed to all views
|
||||
- Publishes: `isLoggedIn`, `serverURL`, `defaultVisibility`, `isLoading`, `lastError`, `launchAtLogin`
|
||||
- Handles authentication flow and memo creation
|
||||
|
||||
**Services (in `Services/`):**
|
||||
- `MemosAPIService` - Communicates with Memos v1 API (`/api/v1/memos`, `/api/v1/auth/status`)
|
||||
- `KeychainService` - Stores access tokens securely in macOS Keychain (service: `me.orzech.FastMemos`)
|
||||
- `ShortcutService` - Registers global hotkey using Carbon Events API
|
||||
|
||||
**Views (in `Views/`):**
|
||||
- `MenuBarView` - Main popover shown from menubar icon
|
||||
- `NoteWindowView` - Floating note capture panel with visibility picker
|
||||
- `LoginView` - Access token authentication flow
|
||||
- `SettingsView` - App preferences (visibility, launch at login)
|
||||
|
||||
**Models (in `Models/`):**
|
||||
- `Memo`, `CreateMemoRequest` - API request/response structures
|
||||
- `MemoVisibility` - Enum: `.private`, `.protected`, `.public`
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. User triggers hotkey → `AppDelegate.showNoteWindow()` → `NotePanel` appears
|
||||
2. User writes note, selects visibility, presses ⌘Enter
|
||||
3. `NoteWindowView.submitMemo()` → `AppState.createMemo()` → `MemosAPIService.createMemo()`
|
||||
4. Token from `KeychainService` is used for Bearer auth
|
||||
|
||||
### Settings Storage
|
||||
|
||||
- `UserDefaults`: `serverURL`, `defaultVisibility`, `shortcutKeyCode`, `shortcutModifiers`
|
||||
- Keychain: `accessToken`, `username`
|
||||
- `SMAppService.mainApp` for launch at login
|
||||
Binary file not shown.
62
FastMemos.app/Contents/Info.plist
Normal file
62
FastMemos.app/Contents/Info.plist
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BuildMachineOSBuild</key>
|
||||
<string>25B78</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>FastMemos</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundleIconName</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>me.orzech.FastMemos</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>FastMemos</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.1</string>
|
||||
<key>CFBundleSupportedPlatforms</key>
|
||||
<array>
|
||||
<string>MacOSX</string>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>DTCompiler</key>
|
||||
<string>com.apple.compilers.llvm.clang.1_0</string>
|
||||
<key>DTPlatformBuild</key>
|
||||
<string>25C57</string>
|
||||
<key>DTPlatformName</key>
|
||||
<string>macosx</string>
|
||||
<key>DTPlatformVersion</key>
|
||||
<string>26.2</string>
|
||||
<key>DTSDKBuild</key>
|
||||
<string>25C57</string>
|
||||
<key>DTSDKName</key>
|
||||
<string>macosx26.2</string>
|
||||
<key>DTXcode</key>
|
||||
<string>2620</string>
|
||||
<key>DTXcodeBuild</key>
|
||||
<string>17C52</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.productivity</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>26.0</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSAccentColorName</key>
|
||||
<string>AccentColor</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © 2026 Paweł Orzech. All rights reserved.</string>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
FastMemos.app/Contents/MacOS/FastMemos
Executable file
BIN
FastMemos.app/Contents/MacOS/FastMemos
Executable file
Binary file not shown.
1
FastMemos.app/Contents/PkgInfo
Normal file
1
FastMemos.app/Contents/PkgInfo
Normal file
|
|
@ -0,0 +1 @@
|
|||
APPL????
|
||||
BIN
FastMemos.app/Contents/Resources/AppIcon.icns
Normal file
BIN
FastMemos.app/Contents/Resources/AppIcon.icns
Normal file
Binary file not shown.
BIN
FastMemos.app/Contents/Resources/Assets.car
Normal file
BIN
FastMemos.app/Contents/Resources/Assets.car
Normal file
Binary file not shown.
139
FastMemos.app/Contents/_CodeSignature/CodeResources
Normal file
139
FastMemos.app/Contents/_CodeSignature/CodeResources
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>files</key>
|
||||
<dict>
|
||||
<key>Resources/AppIcon.icns</key>
|
||||
<data>
|
||||
ORGbkp1S899rWbEjZ/tNkVVLdro=
|
||||
</data>
|
||||
<key>Resources/Assets.car</key>
|
||||
<data>
|
||||
cksUAC98EafZIfKsBaoS+aP805o=
|
||||
</data>
|
||||
</dict>
|
||||
<key>files2</key>
|
||||
<dict>
|
||||
<key>Resources/AppIcon.icns</key>
|
||||
<dict>
|
||||
<key>hash2</key>
|
||||
<data>
|
||||
ME58VQGz3AD9X5q7Xj654HeiGHgQ4D2k8mSLplwLvc4=
|
||||
</data>
|
||||
</dict>
|
||||
<key>Resources/Assets.car</key>
|
||||
<dict>
|
||||
<key>hash2</key>
|
||||
<data>
|
||||
m3hT/zd8OdcdoapvIxDo+aPRvDvSej90Wfiy+aU6GlE=
|
||||
</data>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>rules</key>
|
||||
<dict>
|
||||
<key>^Resources/</key>
|
||||
<true/>
|
||||
<key>^Resources/.*\.lproj/</key>
|
||||
<dict>
|
||||
<key>optional</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1000</real>
|
||||
</dict>
|
||||
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1100</real>
|
||||
</dict>
|
||||
<key>^Resources/Base\.lproj/</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>1010</real>
|
||||
</dict>
|
||||
<key>^version.plist$</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>rules2</key>
|
||||
<dict>
|
||||
<key>.*\.dSYM($|/)</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>11</real>
|
||||
</dict>
|
||||
<key>^(.*/)?\.DS_Store$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>2000</real>
|
||||
</dict>
|
||||
<key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
|
||||
<dict>
|
||||
<key>nested</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>10</real>
|
||||
</dict>
|
||||
<key>^.*</key>
|
||||
<true/>
|
||||
<key>^Info\.plist$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^PkgInfo$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^Resources/</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^Resources/.*\.lproj/</key>
|
||||
<dict>
|
||||
<key>optional</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1000</real>
|
||||
</dict>
|
||||
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1100</real>
|
||||
</dict>
|
||||
<key>^Resources/Base\.lproj/</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>1010</real>
|
||||
</dict>
|
||||
<key>^[^/]+$</key>
|
||||
<dict>
|
||||
<key>nested</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>10</real>
|
||||
</dict>
|
||||
<key>^embedded\.provisionprofile$</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^version\.plist$</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -343,7 +343,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
MARKETING_VERSION = 1.0.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = me.orzech.FastMemos;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
|
|
@ -372,7 +372,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
MARKETING_VERSION = 1.0.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = me.orzech.FastMemos;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
|
|
|
|||
|
|
@ -3,52 +3,62 @@
|
|||
{
|
||||
"idiom": "mac",
|
||||
"scale": "1x",
|
||||
"size": "16x16"
|
||||
"size": "16x16",
|
||||
"filename": "icon_16x16.png"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"scale": "2x",
|
||||
"size": "16x16"
|
||||
"size": "16x16",
|
||||
"filename": "icon_16x16@2x.png"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"scale": "1x",
|
||||
"size": "32x32"
|
||||
"size": "32x32",
|
||||
"filename": "icon_32x32.png"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"scale": "2x",
|
||||
"size": "32x32"
|
||||
"size": "32x32",
|
||||
"filename": "icon_32x32@2x.png"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"scale": "1x",
|
||||
"size": "128x128"
|
||||
"size": "128x128",
|
||||
"filename": "icon_128x128.png"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"scale": "2x",
|
||||
"size": "128x128"
|
||||
"size": "128x128",
|
||||
"filename": "icon_128x128@2x.png"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"scale": "1x",
|
||||
"size": "256x256"
|
||||
"size": "256x256",
|
||||
"filename": "icon_256x256.png"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"scale": "2x",
|
||||
"size": "256x256"
|
||||
"size": "256x256",
|
||||
"filename": "icon_256x256@2x.png"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"scale": "1x",
|
||||
"size": "512x512"
|
||||
"size": "512x512",
|
||||
"filename": "icon_512x512.png"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"scale": "2x",
|
||||
"size": "512x512"
|
||||
"size": "512x512",
|
||||
"filename": "icon_512x512@2x.png"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
|
|
|
|||
BIN
FastMemos/Assets.xcassets/AppIcon.appiconset/icon_16x16.png
Normal file
BIN
FastMemos/Assets.xcassets/AppIcon.appiconset/icon_16x16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1,001 B |
|
|
@ -17,7 +17,7 @@
|
|||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<string>1.0.2</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import Foundation
|
||||
import SwiftUI
|
||||
import ServiceManagement
|
||||
|
||||
/// Global app state that persists settings and manages authentication
|
||||
class AppState: ObservableObject {
|
||||
|
|
@ -10,11 +11,23 @@ class AppState: ObservableObject {
|
|||
@Published var isLoading: Bool = false
|
||||
@Published var lastError: String?
|
||||
|
||||
@Published var launchAtLogin: Bool = false {
|
||||
didSet {
|
||||
if launchAtLogin {
|
||||
try? SMAppService.mainApp.register()
|
||||
} else {
|
||||
try? SMAppService.mainApp.unregister()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let keychainService = KeychainService()
|
||||
private lazy var apiService = MemosAPIService()
|
||||
|
||||
init() {
|
||||
loadSettings()
|
||||
// Check current launch at login status
|
||||
launchAtLogin = SMAppService.mainApp.status == .enabled
|
||||
}
|
||||
|
||||
// MARK: - Settings Persistence
|
||||
|
|
|
|||
|
|
@ -127,6 +127,13 @@ struct MenuBarView: View {
|
|||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
|
||||
Button(action: { NSApplication.shared.terminate(nil) }) {
|
||||
Text("Quit App")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
|
|
|
|||
|
|
@ -1,9 +1,40 @@
|
|||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
/// Custom NSHostingView that never draws focus ring
|
||||
class NoFocusRingHostingView<Content: View>: NSHostingView<Content> {
|
||||
override var focusRingType: NSFocusRingType {
|
||||
get { .none }
|
||||
set { }
|
||||
}
|
||||
|
||||
override func drawFocusRingMask() {
|
||||
// Don't draw focus ring
|
||||
}
|
||||
|
||||
override var focusRingMaskBounds: NSRect {
|
||||
.zero
|
||||
}
|
||||
|
||||
override func viewDidMoveToWindow() {
|
||||
super.viewDidMoveToWindow()
|
||||
// Disable focus ring on all subviews after view hierarchy is set up
|
||||
DispatchQueue.main.async {
|
||||
self.disableFocusRingsRecursively(in: self)
|
||||
}
|
||||
}
|
||||
|
||||
private func disableFocusRingsRecursively(in view: NSView) {
|
||||
view.focusRingType = .none
|
||||
for subview in view.subviews {
|
||||
disableFocusRingsRecursively(in: subview)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Floating panel for quick note capture
|
||||
class NotePanel: NSPanel {
|
||||
private var hostingView: NSHostingView<NoteWindowView>?
|
||||
private var hostingView: NoFocusRingHostingView<NoteWindowView>?
|
||||
private let appState: AppState
|
||||
|
||||
override var canBecomeKey: Bool { true }
|
||||
|
|
@ -30,7 +61,7 @@ class NotePanel: NSPanel {
|
|||
self.backgroundColor = .clear
|
||||
self.isOpaque = false
|
||||
self.hasShadow = true
|
||||
|
||||
self.autorecalculatesKeyViewLoop = false
|
||||
|
||||
// Set minimum size
|
||||
self.minSize = NSSize(width: 400, height: 200)
|
||||
|
|
@ -43,7 +74,7 @@ class NotePanel: NSPanel {
|
|||
self?.orderOut(nil)
|
||||
})
|
||||
|
||||
hostingView = NSHostingView(rootView: contentView)
|
||||
hostingView = NoFocusRingHostingView(rootView: contentView)
|
||||
self.contentView = hostingView
|
||||
}
|
||||
|
||||
|
|
@ -57,6 +88,176 @@ class NotePanel: NSPanel {
|
|||
self.center()
|
||||
self.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
|
||||
// Disable focus rings on all subviews
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
if let contentView = self?.contentView {
|
||||
Self.disableFocusRingsRecursively(in: contentView)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func disableFocusRingsRecursively(in view: NSView) {
|
||||
view.focusRingType = .none
|
||||
for subview in view.subviews {
|
||||
disableFocusRingsRecursively(in: subview)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom NSTextView with placeholder support and no focus ring
|
||||
struct PlaceholderTextView: NSViewRepresentable {
|
||||
@Binding var text: String
|
||||
let placeholder: String
|
||||
let font: NSFont
|
||||
var onSubmit: (() -> Void)?
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> NSScrollView {
|
||||
let scrollView = NoFocusRingScrollView()
|
||||
scrollView.hasVerticalScroller = true
|
||||
scrollView.hasHorizontalScroller = false
|
||||
scrollView.autohidesScrollers = true
|
||||
scrollView.borderType = .noBorder
|
||||
scrollView.drawsBackground = false
|
||||
scrollView.focusRingType = .none
|
||||
scrollView.contentView.focusRingType = .none
|
||||
|
||||
let textView = PlaceholderNSTextView()
|
||||
textView.delegate = context.coordinator
|
||||
textView.isRichText = false
|
||||
textView.allowsUndo = true
|
||||
textView.font = font
|
||||
textView.backgroundColor = .clear
|
||||
textView.drawsBackground = false
|
||||
textView.isEditable = true
|
||||
textView.isSelectable = true
|
||||
textView.focusRingType = .none
|
||||
textView.textContainerInset = NSSize(width: 0, height: 0)
|
||||
textView.textContainer?.lineFragmentPadding = 0
|
||||
|
||||
// Placeholder setup
|
||||
textView.placeholderString = placeholder
|
||||
textView.placeholderFont = font
|
||||
textView.placeholderColor = NSColor.secondaryLabelColor
|
||||
|
||||
// Auto-resize
|
||||
textView.isVerticallyResizable = true
|
||||
textView.isHorizontallyResizable = false
|
||||
textView.autoresizingMask = [.width]
|
||||
textView.textContainer?.containerSize = NSSize(width: scrollView.contentSize.width, height: .greatestFiniteMagnitude)
|
||||
textView.textContainer?.widthTracksTextView = true
|
||||
|
||||
scrollView.documentView = textView
|
||||
|
||||
// Focus the text view and disable focus rings in hierarchy
|
||||
DispatchQueue.main.async {
|
||||
textView.window?.makeFirstResponder(textView)
|
||||
Self.disableFocusRingsRecursively(in: scrollView)
|
||||
}
|
||||
|
||||
return scrollView
|
||||
}
|
||||
|
||||
private static func disableFocusRingsRecursively(in view: NSView) {
|
||||
view.focusRingType = .none
|
||||
for subview in view.subviews {
|
||||
disableFocusRingsRecursively(in: subview)
|
||||
}
|
||||
}
|
||||
|
||||
func updateNSView(_ scrollView: NSScrollView, context: Context) {
|
||||
guard let textView = scrollView.documentView as? PlaceholderNSTextView else { return }
|
||||
|
||||
if textView.string != text {
|
||||
textView.string = text
|
||||
}
|
||||
textView.needsDisplay = true
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, NSTextViewDelegate {
|
||||
var parent: PlaceholderTextView
|
||||
|
||||
init(_ parent: PlaceholderTextView) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func textDidChange(_ notification: Notification) {
|
||||
guard let textView = notification.object as? NSTextView else { return }
|
||||
parent.text = textView.string
|
||||
}
|
||||
|
||||
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
|
||||
// Handle Cmd+Enter for submit
|
||||
if commandSelector == #selector(NSResponder.insertNewline(_:)) {
|
||||
if NSEvent.modifierFlags.contains(.command) {
|
||||
parent.onSubmit?()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom NSScrollView that never draws focus ring
|
||||
class NoFocusRingScrollView: NSScrollView {
|
||||
override var focusRingType: NSFocusRingType {
|
||||
get { .none }
|
||||
set { }
|
||||
}
|
||||
|
||||
override func drawFocusRingMask() {
|
||||
// Don't draw focus ring
|
||||
}
|
||||
|
||||
override var focusRingMaskBounds: NSRect {
|
||||
.zero
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom NSTextView that draws placeholder text
|
||||
class PlaceholderNSTextView: NSTextView {
|
||||
var placeholderString: String = ""
|
||||
var placeholderFont: NSFont = NSFont.systemFont(ofSize: 15)
|
||||
var placeholderColor: NSColor = NSColor.secondaryLabelColor
|
||||
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
super.draw(dirtyRect)
|
||||
|
||||
// Draw placeholder if text is empty
|
||||
if string.isEmpty {
|
||||
let attributes: [NSAttributedString.Key: Any] = [
|
||||
.font: placeholderFont,
|
||||
.foregroundColor: placeholderColor
|
||||
]
|
||||
let placeholderRect = NSRect(
|
||||
x: textContainerInset.width + (textContainer?.lineFragmentPadding ?? 0),
|
||||
y: textContainerInset.height,
|
||||
width: bounds.width - textContainerInset.width * 2,
|
||||
height: bounds.height - textContainerInset.height * 2
|
||||
)
|
||||
placeholderString.draw(in: placeholderRect, withAttributes: attributes)
|
||||
}
|
||||
}
|
||||
|
||||
override var needsPanelToBecomeKey: Bool { true }
|
||||
override var acceptsFirstResponder: Bool { true }
|
||||
|
||||
override var focusRingType: NSFocusRingType {
|
||||
get { .none }
|
||||
set { }
|
||||
}
|
||||
|
||||
override func drawFocusRingMask() {
|
||||
// Don't draw focus ring
|
||||
}
|
||||
|
||||
override var focusRingMaskBounds: NSRect {
|
||||
.zero
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -71,31 +272,18 @@ struct NoteWindowView: View {
|
|||
@State private var showSuccess = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
@FocusState private var isTextEditorFocused: Bool
|
||||
|
||||
private let placeholder = "What's on your mind?"
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Main text area
|
||||
ZStack(alignment: .topLeading) {
|
||||
// Placeholder
|
||||
if content.isEmpty {
|
||||
Text(placeholder)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 8)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
// Text editor
|
||||
TextEditor(text: $content)
|
||||
.font(.system(size: 15))
|
||||
.focused($isTextEditorFocused)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.clear)
|
||||
PlaceholderTextView(
|
||||
text: $content,
|
||||
placeholder: placeholder,
|
||||
font: NSFont.systemFont(ofSize: 15),
|
||||
onSubmit: submitMemo
|
||||
)
|
||||
.frame(minHeight: 150)
|
||||
}
|
||||
.padding(16)
|
||||
|
||||
// Bottom bar
|
||||
|
|
@ -182,15 +370,25 @@ struct NoteWindowView: View {
|
|||
.padding(.vertical, 12)
|
||||
}
|
||||
.padding(4)
|
||||
.glassEffect(.regular, in: .rect(cornerRadius: 20))
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.fill(.ultraThinMaterial)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
.focusable(false)
|
||||
.focusEffectDisabled()
|
||||
.frame(minWidth: 400, minHeight: 200)
|
||||
.onAppear {
|
||||
visibility = appState.defaultVisibility
|
||||
isTextEditorFocused = true
|
||||
}
|
||||
.onExitCommand {
|
||||
closeWindow()
|
||||
}
|
||||
.background(
|
||||
Button("") { closeWindow() }
|
||||
.keyboardShortcut("w", modifiers: .command)
|
||||
.hidden()
|
||||
)
|
||||
.animation(Animation.easeInOut(duration: 0.2), value: showSuccess)
|
||||
.animation(Animation.easeInOut(duration: 0.2), value: errorMessage)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,18 @@ struct SettingsView: View {
|
|||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
// General Section
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("General", systemImage: "gearshape")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
|
||||
Toggle("Launch at login", isOn: $appState.launchAtLogin)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Default Visibility Section
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("Default Visibility", systemImage: "eye")
|
||||
|
|
|
|||
Loading…
Reference in a new issue