Compare commits
No commits in common. "28b03893bc0161f2d8ec8a4ec72175af534fe9e7" and "87e5c34fe38e7a861e60c76ebc8bcf9717896722" have entirely different histories.
28b03893bc
...
87e5c34fe3
13 changed files with 67 additions and 459 deletions
44
.github/workflows/claude-code-review.yml
vendored
44
.github/workflows/claude-code-review.yml
vendored
|
|
@ -1,44 +0,0 @@
|
||||||
name: Claude Code Review
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types: [opened, synchronize, ready_for_review, reopened]
|
|
||||||
# Optional: Only run on specific file changes
|
|
||||||
# paths:
|
|
||||||
# - "src/**/*.ts"
|
|
||||||
# - "src/**/*.tsx"
|
|
||||||
# - "src/**/*.js"
|
|
||||||
# - "src/**/*.jsx"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
claude-review:
|
|
||||||
# Optional: Filter by PR author
|
|
||||||
# if: |
|
|
||||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
|
||||||
# github.event.pull_request.user.login == 'new-developer' ||
|
|
||||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: read
|
|
||||||
issues: read
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: Run Claude Code Review
|
|
||||||
id: claude-review
|
|
||||||
uses: anthropics/claude-code-action@v1
|
|
||||||
with:
|
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
||||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
|
||||||
plugins: 'code-review@claude-code-plugins'
|
|
||||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
|
||||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
|
||||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
|
||||||
|
|
||||||
50
.github/workflows/claude.yml
vendored
50
.github/workflows/claude.yml
vendored
|
|
@ -1,50 +0,0 @@
|
||||||
name: Claude Code
|
|
||||||
|
|
||||||
on:
|
|
||||||
issue_comment:
|
|
||||||
types: [created]
|
|
||||||
pull_request_review_comment:
|
|
||||||
types: [created]
|
|
||||||
issues:
|
|
||||||
types: [opened, assigned]
|
|
||||||
pull_request_review:
|
|
||||||
types: [submitted]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
claude:
|
|
||||||
if: |
|
|
||||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
|
||||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
|
||||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
|
||||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: read
|
|
||||||
issues: read
|
|
||||||
id-token: write
|
|
||||||
actions: read # Required for Claude to read CI results on PRs
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: Run Claude Code
|
|
||||||
id: claude
|
|
||||||
uses: anthropics/claude-code-action@v1
|
|
||||||
with:
|
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
||||||
|
|
||||||
# This is an optional setting that allows Claude to read CI results on PRs
|
|
||||||
additional_permissions: |
|
|
||||||
actions: read
|
|
||||||
|
|
||||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
|
||||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
|
||||||
|
|
||||||
# Optional: Add claude_args to customize behavior and configuration
|
|
||||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
|
||||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
|
||||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
|
||||||
|
|
||||||
22
AGENTS.md
22
AGENTS.md
|
|
@ -1,22 +0,0 @@
|
||||||
# AGENTS
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
- macOS 13.0+ (Ventura)
|
|
||||||
- Xcode 15+
|
|
||||||
- Access to a self-hosted Memos instance for runtime testing
|
|
||||||
|
|
||||||
## Build
|
|
||||||
- Open `FastMemos.xcodeproj` in Xcode and build with `⌘B`
|
|
||||||
- CLI build:
|
|
||||||
```bash
|
|
||||||
xcodebuild -project FastMemos.xcodeproj -scheme FastMemos -configuration Debug build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Run
|
|
||||||
```bash
|
|
||||||
xcodebuild -project FastMemos.xcodeproj -scheme FastMemos -configuration Debug build
|
|
||||||
open ./build/Debug/FastMemos.app
|
|
||||||
```
|
|
||||||
|
|
||||||
## TODO
|
|
||||||
- Document any additional workflows or scripts not covered in README/CLAUDE.
|
|
||||||
65
CLAUDE.md
65
CLAUDE.md
|
|
@ -1,65 +0,0 @@
|
||||||
# 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
|
|
||||||
BIN
FastMemos-1.0.0.zip
Normal file
BIN
FastMemos-1.0.0.zip
Normal file
Binary file not shown.
|
|
@ -10,8 +10,6 @@
|
||||||
<string>FastMemos</string>
|
<string>FastMemos</string>
|
||||||
<key>CFBundleIconFile</key>
|
<key>CFBundleIconFile</key>
|
||||||
<string>AppIcon</string>
|
<string>AppIcon</string>
|
||||||
<key>CFBundleIconName</key>
|
|
||||||
<string>AppIcon</string>
|
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>me.orzech.FastMemos</string>
|
<string>me.orzech.FastMemos</string>
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
|
@ -21,7 +19,7 @@
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0.1</string>
|
<string>1.0.0</string>
|
||||||
<key>CFBundleSupportedPlatforms</key>
|
<key>CFBundleSupportedPlatforms</key>
|
||||||
<array>
|
<array>
|
||||||
<string>MacOSX</string>
|
<string>MacOSX</string>
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -4,29 +4,18 @@
|
||||||
<dict>
|
<dict>
|
||||||
<key>files</key>
|
<key>files</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>Resources/AppIcon.icns</key>
|
|
||||||
<data>
|
|
||||||
ORGbkp1S899rWbEjZ/tNkVVLdro=
|
|
||||||
</data>
|
|
||||||
<key>Resources/Assets.car</key>
|
<key>Resources/Assets.car</key>
|
||||||
<data>
|
<data>
|
||||||
cksUAC98EafZIfKsBaoS+aP805o=
|
CsD7XkAT/ARWQWJ99BetayOzLlE=
|
||||||
</data>
|
</data>
|
||||||
</dict>
|
</dict>
|
||||||
<key>files2</key>
|
<key>files2</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>Resources/AppIcon.icns</key>
|
|
||||||
<dict>
|
|
||||||
<key>hash2</key>
|
|
||||||
<data>
|
|
||||||
ME58VQGz3AD9X5q7Xj654HeiGHgQ4D2k8mSLplwLvc4=
|
|
||||||
</data>
|
|
||||||
</dict>
|
|
||||||
<key>Resources/Assets.car</key>
|
<key>Resources/Assets.car</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>hash2</key>
|
<key>hash2</key>
|
||||||
<data>
|
<data>
|
||||||
m3hT/zd8OdcdoapvIxDo+aPRvDvSej90Wfiy+aU6GlE=
|
k+rxT6Xd2FCQLfhgNlPCgS1IiDV1Klw0FQfNaCX1A3s=
|
||||||
</data>
|
</data>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
|
|
|
||||||
|
|
@ -343,7 +343,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.3;
|
MARKETING_VERSION = 1.0.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = me.orzech.FastMemos;
|
PRODUCT_BUNDLE_IDENTIFIER = me.orzech.FastMemos;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
|
@ -372,7 +372,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.3;
|
MARKETING_VERSION = 1.0.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = me.orzech.FastMemos;
|
PRODUCT_BUNDLE_IDENTIFIER = me.orzech.FastMemos;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
|
|
||||||
|
|
@ -2,37 +2,37 @@
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>en</string>
|
<string>en</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIconFile</key>
|
<key>CFBundleIconFile</key>
|
||||||
<string>AppIcon</string>
|
<string>AppIcon</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>$(PRODUCT_NAME)</string>
|
<string>$(PRODUCT_NAME)</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0.2</string>
|
<string>1.0.0</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1</string>
|
<string>1</string>
|
||||||
<key>LSApplicationCategoryType</key>
|
<key>LSApplicationCategoryType</key>
|
||||||
<string>public.app-category.productivity</string>
|
<string>public.app-category.productivity</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||||
<key>LSUIElement</key>
|
<key>LSUIElement</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSHighResolutionCapable</key>
|
<key>NSHighResolutionCapable</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSHumanReadableCopyright</key>
|
<key>NSHumanReadableCopyright</key>
|
||||||
<string>Copyright © 2026 Paweł Orzech. All rights reserved.</string>
|
<string>Copyright © 2026 Paweł Orzech. All rights reserved.</string>
|
||||||
<key>NSMainNibFile</key>
|
<key>NSMainNibFile</key>
|
||||||
<string></string>
|
<string></string>
|
||||||
<key>NSPrincipalClass</key>
|
<key>NSPrincipalClass</key>
|
||||||
<string>NSApplication</string>
|
<string>NSApplication</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,9 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import AppKit
|
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
|
/// Floating panel for quick note capture
|
||||||
class NotePanel: NSPanel {
|
class NotePanel: NSPanel {
|
||||||
private var hostingView: NoFocusRingHostingView<NoteWindowView>?
|
private var hostingView: NSHostingView<NoteWindowView>?
|
||||||
private let appState: AppState
|
private let appState: AppState
|
||||||
|
|
||||||
override var canBecomeKey: Bool { true }
|
override var canBecomeKey: Bool { true }
|
||||||
|
|
@ -61,8 +30,8 @@ class NotePanel: NSPanel {
|
||||||
self.backgroundColor = .clear
|
self.backgroundColor = .clear
|
||||||
self.isOpaque = false
|
self.isOpaque = false
|
||||||
self.hasShadow = true
|
self.hasShadow = true
|
||||||
self.autorecalculatesKeyViewLoop = false
|
|
||||||
|
|
||||||
// Set minimum size
|
// Set minimum size
|
||||||
self.minSize = NSSize(width: 400, height: 200)
|
self.minSize = NSSize(width: 400, height: 200)
|
||||||
self.maxSize = NSSize(width: 800, height: 600)
|
self.maxSize = NSSize(width: 800, height: 600)
|
||||||
|
|
@ -74,7 +43,7 @@ class NotePanel: NSPanel {
|
||||||
self?.orderOut(nil)
|
self?.orderOut(nil)
|
||||||
})
|
})
|
||||||
|
|
||||||
hostingView = NoFocusRingHostingView(rootView: contentView)
|
hostingView = NSHostingView(rootView: contentView)
|
||||||
self.contentView = hostingView
|
self.contentView = hostingView
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,180 +53,10 @@ class NotePanel: NSPanel {
|
||||||
self?.orderOut(nil)
|
self?.orderOut(nil)
|
||||||
})
|
})
|
||||||
hostingView?.rootView = contentView
|
hostingView?.rootView = contentView
|
||||||
|
|
||||||
self.center()
|
self.center()
|
||||||
self.makeKeyAndOrderFront(nil)
|
self.makeKeyAndOrderFront(nil)
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -265,25 +64,38 @@ class PlaceholderNSTextView: NSTextView {
|
||||||
struct NoteWindowView: View {
|
struct NoteWindowView: View {
|
||||||
@ObservedObject var appState: AppState
|
@ObservedObject var appState: AppState
|
||||||
let closeWindow: () -> Void
|
let closeWindow: () -> Void
|
||||||
|
|
||||||
@State private var content: String = ""
|
@State private var content: String = ""
|
||||||
@State private var visibility: MemoVisibility = .private
|
@State private var visibility: MemoVisibility = .private
|
||||||
@State private var isSending = false
|
@State private var isSending = false
|
||||||
@State private var showSuccess = false
|
@State private var showSuccess = false
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
|
|
||||||
|
@FocusState private var isTextEditorFocused: Bool
|
||||||
|
|
||||||
private let placeholder = "What's on your mind?"
|
private let placeholder = "What's on your mind?"
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Main text area
|
// Main text area
|
||||||
PlaceholderTextView(
|
ZStack(alignment: .topLeading) {
|
||||||
text: $content,
|
// Placeholder
|
||||||
placeholder: placeholder,
|
if content.isEmpty {
|
||||||
font: NSFont.systemFont(ofSize: 15),
|
Text(placeholder)
|
||||||
onSubmit: submitMemo
|
.foregroundColor(.secondary)
|
||||||
)
|
.padding(.horizontal, 5)
|
||||||
.frame(minHeight: 150)
|
.padding(.vertical, 8)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text editor
|
||||||
|
TextEditor(text: $content)
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.focused($isTextEditorFocused)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(Color.clear)
|
||||||
|
.frame(minHeight: 150)
|
||||||
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
|
|
||||||
// Bottom bar
|
// Bottom bar
|
||||||
|
|
@ -370,25 +182,15 @@ struct NoteWindowView: View {
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
}
|
}
|
||||||
.padding(4)
|
.padding(4)
|
||||||
.background(
|
.glassEffect(.regular, in: .rect(cornerRadius: 20))
|
||||||
RoundedRectangle(cornerRadius: 20)
|
|
||||||
.fill(.ultraThinMaterial)
|
|
||||||
)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
|
||||||
.focusable(false)
|
|
||||||
.focusEffectDisabled()
|
|
||||||
.frame(minWidth: 400, minHeight: 200)
|
.frame(minWidth: 400, minHeight: 200)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
visibility = appState.defaultVisibility
|
visibility = appState.defaultVisibility
|
||||||
|
isTextEditorFocused = true
|
||||||
}
|
}
|
||||||
.onExitCommand {
|
.onExitCommand {
|
||||||
closeWindow()
|
closeWindow()
|
||||||
}
|
}
|
||||||
.background(
|
|
||||||
Button("") { closeWindow() }
|
|
||||||
.keyboardShortcut("w", modifiers: .command)
|
|
||||||
.hidden()
|
|
||||||
)
|
|
||||||
.animation(Animation.easeInOut(duration: 0.2), value: showSuccess)
|
.animation(Animation.easeInOut(duration: 0.2), value: showSuccess)
|
||||||
.animation(Animation.easeInOut(duration: 0.2), value: errorMessage)
|
.animation(Animation.easeInOut(duration: 0.2), value: errorMessage)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue