2 Commits
Author SHA1 Message Date
TZGyn a09f7b3196 Make the composer wrap and grow, and surface Launch at Login
Long prompts hit a single-line field before; the composer is now an
NSTextView that wraps and grows up to seven lines, keeps Return as send,
and uses Shift+Return for a newline. Launch at Login already existed in
the menu bar icon's right-click menu, so it is now also a toggle in the
sync panel where it is easier to find.
2026-10-16 22:28:16 +08:00
TZGyn 569e56dbba Add delete to the selection bar and drop the in-header close button
The selection bar covered copying but not the other action people reach
for with a selection. The header also carried a second close button next
to the window's own traffic light, so it was removed in favor of that.
2026-10-09 22:45:16 +08:00
4 changed files with 220 additions and 28 deletions
+16 -7
View File
@@ -13,7 +13,8 @@ clicking an item copies it back to the clipboard.
`NSStatusItem Preferred Position Prompt`). Left click toggles the panel, `NSStatusItem Preferred Position Prompt`). Left click toggles the panel,
right click shows the menu (accessibility state, launch at login, open data right click shows the menu (accessibility state, launch at login, open data
folder, quit). Contains the single-instance guard and `LoginItem` folder, quit). Contains the single-instance guard and `LoginItem`
(`SMAppService`). Also sets `NSApp.mainMenu` with Hide Panel (Cmd+W) and Quit (`SMAppService`). The sync panel also carries a Launch at Login toggle (the
app's only settings surface). Also sets `NSApp.mainMenu` with Hide Panel (Cmd+W) and Quit
(Cmd+Q) — an accessory app shows no menu bar, but the main menu still (Cmd+Q) — an accessory app shows no menu bar, but the main menu still
services key equivalents, which is the only way to quit from the keyboard services key equivalents, which is the only way to quit from the keyboard
while the panel has focus. `applicationShouldHandleReopen` shows the panel so while the panel has focus. `applicationShouldHandleReopen` shows the panel so
@@ -37,13 +38,21 @@ clicking an item copies it back to the clipboard.
light mode keeps the system window colour. `Theme.background` is also handed light mode keeps the system window colour. `Theme.background` is also handed
to the `NSPanel` so the strip under the transparent titlebar matches, and the to the `NSPanel` so the strip under the transparent titlebar matches, and the
row colour stays slightly lighter than black so cards remain visible. row colour stays slightly lighter than black so cards remain visible.
- `Sources/Prompt/PanelView.swift` — the panel UI. Header (section switcher, - `Sources/Prompt/ComposerView.swift` — the chat-style composer
search, add photos, close), item list, composer. Composer `# Name` creates/ (`NSViewRepresentable` around an `NSTextView`): wraps long prompts, grows to
fit up to seven lines, then scrolls. Return submits, Shift+Return inserts a
newline, Esc forwards to the panel (clear selection, else hide). Height is
measured from the text and pushed into a `height` binding the panel applies
as a frame — a representable cannot resize itself through
`intrinsicContentSize` once SwiftUI offers a larger size.
- `Sources/Prompt/PanelView.swift` — the panel UI. Header (workspace switcher,
search, add photos), item list, composer. There is no in-header close button:
the window's own close button hides the panel. Composer `# Name` creates/
switches sections. Click = select (Shift+click for a range); the selection switches sections. Click = select (Shift+click for a range); the selection
bar offers Copy (single item), Copy as List and Copy as Lines; right-click = bar offers Copy (single item), Copy as List, Copy as Lines and Delete;
context menu acting on the whole selection (Copy, Copy as List, Copy as right-click = context menu acting on the whole selection (Copy, Copy as List,
Lines, Done, Edit, Open, Merge, Move To, Delete). Esc clears the selection Copy as Lines, Done, Edit, Open, Merge, Move To, Delete). Esc clears the
before it hides the panel. Drop target for images/files. selection before it hides the panel. Drop target for images/files.
- `Sources/Prompt/PanelController.swift` — owns the floating `NSPanel` - `Sources/Prompt/PanelController.swift` — owns the floating `NSPanel`
(`.nonactivatingPanel`, `.floating`, right-edge docked, frame autosaved as (`.nonactivatingPanel`, `.floating`, right-edge docked, frame autosaved as
`PromptPanel`), the Cmd+V image-paste monitor, and `EditWindowController` `PromptPanel`), the Cmd+V image-paste monitor, and `EditWindowController`
+7 -2
View File
@@ -5,12 +5,16 @@ A native macOS app for keeping prompts and photos, in the spirit of
to your work. Select what you need and send it back to your clipboard. to your work. Select what you need and send it back to your clipboard.
- **Click to select** — clicking a card selects it (Shift+click for a range), - **Click to select** — clicking a card selects it (Shift+click for a range),
and the selection bar copies a single item as-is, or the whole selection as a and the selection bar copies a single item as-is, the whole selection as a
numbered list or as plain newline-separated lines. numbered list or as plain newline-separated lines, or deletes it.
- **Text or photo** — copying sends text back as text and a photo back as an - **Text or photo** — copying sends text back as text and a photo back as an
image. image.
- **Capture from anywhere** — select text in any app and tap **Left Shift twice**. - **Capture from anywhere** — select text in any app and tap **Left Shift twice**.
- **Photos** — paste (Cmd+V), drag & drop, or add via the photo button. - **Photos** — paste (Cmd+V), drag & drop, or add via the photo button.
- **A composer that grows** — long prompts wrap and the input grows up to seven
lines, then scrolls. Return sends, Shift+Return inserts a newline.
- **Launch at Login** — toggle in the sync panel (Cmd+,) or the menu bar icon's
right-click menu, to start Prompt on boot.
- **Workspaces** — type `# Name` in the composer, or use "New Workspace" in - **Workspaces** — type `# Name` in the composer, or use "New Workspace" in
the Cmd+K switcher (which opens a name modal); Cmd+K switches, and the the Cmd+K switcher (which opens a name modal); Cmd+K switches, and the
up/down arrows in the switcher reorder them. (The code and the saved JSON up/down arrows in the switcher reorder them. (The code and the saved JSON
@@ -33,6 +37,7 @@ to your work. Select what you need and send it back to your clipboard.
| Switch/create workspace | Cmd+K | | Switch/create workspace | Cmd+K |
| Search | Cmd+F | | Search | Cmd+F |
| Focus composer | Cmd+N | | Focus composer | Cmd+N |
| New line in composer | Shift+Return |
| Sync settings | Cmd+, | | Sync settings | Cmd+, |
| Copy selection as numbered list | Shift+Cmd+C | | Copy selection as numbered list | Shift+Cmd+C |
| Copy selection as plain lines | Option+Cmd+C | | Copy selection as plain lines | Option+Cmd+C |
+157
View File
@@ -0,0 +1,157 @@
import AppKit
import SwiftUI
/// A chat-style composer backed by AppKit: wraps long prompts, grows to fit up
/// to seven lines, then scrolls. Return submits; Shift+Return inserts a
/// newline. A plain SwiftUI `TextField(axis: .vertical)` cannot keep Return as
/// "send", which is why this is not one.
///
/// Height is driven by the caller: the representable measures the text and
/// writes the clamped height into `height`, which the caller applies as a
/// `.frame(height:)`. Driving the frame is what actually resizes a
/// representable — `intrinsicContentSize` is ignored once SwiftUI offers a
/// bigger size.
struct ComposerView: NSViewRepresentable {
@Binding var text: String
@Binding var isFocused: Bool
@Binding var height: CGFloat
var placeholder: String
var onSubmit: () -> Void
var onEscape: (() -> Void)? = nil
private let maxLines: CGFloat = 7
/// The one-line height, so the composer starts at exactly one line instead
/// of snapping to its real height on the first keystroke.
static var oneLineHeight: CGFloat {
let font = NSFont.systemFont(ofSize: NSFont.systemFontSize)
return font.ascender - font.descender + font.leading + 10 // 2 x 5 inset
}
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSScrollView()
scrollView.drawsBackground = false
scrollView.borderType = .noBorder
scrollView.hasVerticalScroller = true
scrollView.autohidesScrollers = true
scrollView.horizontalScrollElasticity = .none
let textView = ComposerTextView()
textView.delegate = context.coordinator
textView.placeholderText = placeholder
textView.isRichText = false
textView.isVerticallyResizable = true
textView.isHorizontallyResizable = false
textView.autoresizingMask = [.width]
textView.textContainer?.widthTracksTextView = true
textView.textContainer?.lineFragmentPadding = 0
textView.textContainerInset = NSSize(width: 0, height: 5)
textView.font = .systemFont(ofSize: NSFont.systemFontSize)
textView.textColor = .labelColor
textView.drawsBackground = false
// No containerSize: with widthTracksTextView the container follows the
// view's width, which is what makes long lines wrap.
scrollView.documentView = textView
textView.frame = NSRect(origin: .zero, size: scrollView.contentSize)
context.coordinator.watchWindow(of: textView)
return scrollView
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {
guard let textView = scrollView.documentView as? ComposerTextView else { return }
context.coordinator.parent = self
if textView.string != text {
textView.string = text
updateHeight(textView)
}
textView.placeholderText = placeholder
if isFocused, scrollView.window?.firstResponder != textView {
scrollView.window?.makeFirstResponder(textView)
}
}
fileprivate func updateHeight(_ textView: ComposerTextView) {
guard let layoutManager = textView.layoutManager,
let textContainer = textView.textContainer else { return }
layoutManager.ensureLayout(for: textContainer)
let content = layoutManager.usedRect(for: textContainer).height + 2 * textView.textContainerInset.height
let line = Self.oneLineHeight
let clamped = max(line, min(content, line * maxLines + 1))
if abs(height - clamped) > 0.5 {
// Defer so the binding is not written from inside a layout pass.
DispatchQueue.main.async { self.height = clamped }
}
}
final class Coordinator: NSObject, NSTextViewDelegate {
var parent: ComposerView
private var windowObserver: Any?
init(_ parent: ComposerView) { self.parent = parent }
/// Re-assert focus every time the panel becomes key again. The focus
/// binding stays `true` while hidden, so without this a re-shown panel
/// would not refocus the composer.
fileprivate func watchWindow(of textView: ComposerTextView) {
windowObserver = NotificationCenter.default.addObserver(
forName: NSWindow.didBecomeKeyNotification, object: nil, queue: .main
) { [weak self, weak textView] note in
guard let self, let textView,
(note.object as? NSWindow) === textView.window,
self.parent.isFocused else { return }
textView.window?.makeFirstResponder(textView)
}
}
deinit {
if let windowObserver {
NotificationCenter.default.removeObserver(windowObserver)
}
}
func textDidChange(_ notification: Notification) {
guard let textView = notification.object as? ComposerTextView else { return }
if parent.text != textView.string {
parent.text = textView.string
}
parent.updateHeight(textView)
}
// Return sends, Shift+Return inserts a newline. Esc is forwarded so the
// panel can clear its selection or hide, which a TextField used to do
// via onExitCommand.
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
if commandSelector == #selector(NSResponder.cancelOperation(_:)) {
parent.onEscape?()
return true
}
guard commandSelector == #selector(NSResponder.insertNewline(_:)) else { return false }
if NSApp.currentEvent?.modifierFlags.contains(.shift) == true {
return false
}
parent.onSubmit()
return true
}
}
}
private final class ComposerTextView: NSTextView {
var placeholderText: String = "" {
didSet { needsDisplay = true }
}
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
guard string.isEmpty, !placeholderText.isEmpty else { return }
let attributes: [NSAttributedString.Key: Any] = [
.font: font as Any,
.foregroundColor: NSColor.placeholderTextColor,
]
let inset = textContainerInset
let origin = NSPoint(x: inset.width + (textContainer?.lineFragmentPadding ?? 0), y: inset.height)
placeholderText.draw(at: origin, withAttributes: attributes)
}
}
+40 -19
View File
@@ -7,6 +7,8 @@ struct PanelView: View {
@ObservedObject private var sync = SyncService.shared @ObservedObject private var sync = SyncService.shared
@State private var composer = "" @State private var composer = ""
@State private var composerFocused = false
@State private var composerHeight: CGFloat = ComposerView.oneLineHeight
@State private var search = "" @State private var search = ""
@State private var showSearch = false @State private var showSearch = false
@State private var showSectionSwitcher = false @State private var showSectionSwitcher = false
@@ -19,7 +21,7 @@ struct PanelView: View {
@State private var copiedID: UUID? @State private var copiedID: UUID?
@State private var accessibilityGranted = AXIsProcessTrusted() @State private var accessibilityGranted = AXIsProcessTrusted()
private enum Field: Hashable { case composer, search, newWorkspace } private enum Field: Hashable { case search, newWorkspace }
@FocusState private var focusedField: Field? @FocusState private var focusedField: Field?
var body: some View { var body: some View {
@@ -47,10 +49,10 @@ struct PanelView: View {
.overlay(newWorkspaceModal) .overlay(newWorkspaceModal)
.onDrop(of: [UTType.image.identifier, UTType.fileURL.identifier], isTargeted: nil, perform: handleDrop) .onDrop(of: [UTType.image.identifier, UTType.fileURL.identifier], isTargeted: nil, perform: handleDrop)
.onReceive(NotificationCenter.default.publisher(for: .promptPanelDidShow)) { _ in .onReceive(NotificationCenter.default.publisher(for: .promptPanelDidShow)) { _ in
focusedField = .composer composerFocused = true
accessibilityGranted = AXIsProcessTrusted() accessibilityGranted = AXIsProcessTrusted()
} }
.onAppear { focusedField = .composer } .onAppear { composerFocused = true }
} }
// MARK: - Header // MARK: - Header
@@ -83,9 +85,6 @@ struct PanelView: View {
.buttonStyle(.plain).foregroundStyle(.secondary).help("Search (Cmd+F)") .buttonStyle(.plain).foregroundStyle(.secondary).help("Search (Cmd+F)")
Button { addPhotos() } label: { Image(systemName: "photo.on.rectangle.angled") } Button { addPhotos() } label: { Image(systemName: "photo.on.rectangle.angled") }
.buttonStyle(.plain).foregroundStyle(.secondary).help("Add photos") .buttonStyle(.plain).foregroundStyle(.secondary).help("Add photos")
Button { PanelController.shared.hide() } label: { Image(systemName: "xmark") }
.buttonStyle(.plain).foregroundStyle(.secondary)
.help("Hide the panel (Cmd+W) — Prompt keeps running so Shift-Shift capture still works. Quit from the menu bar icon or Cmd+Q.")
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 10) .padding(.vertical, 10)
@@ -123,7 +122,7 @@ struct PanelView: View {
.onExitCommand { .onExitCommand {
search = "" search = ""
showSearch = false showSearch = false
focusedField = .composer composerFocused = true
} }
if !search.isEmpty { if !search.isEmpty {
Button { search = "" } label: { Image(systemName: "xmark.circle.fill") } Button { search = "" } label: { Image(systemName: "xmark.circle.fill") }
@@ -476,6 +475,12 @@ struct PanelView: View {
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Divider()
Toggle("Launch at Login", isOn: Binding(
get: { LoginItem.isEnabled },
set: { _ in _ = LoginItem.toggle() }
))
.font(.callout)
if let error = sync.lastError { if let error = sync.lastError {
Text(error) Text(error)
.font(.caption) .font(.caption)
@@ -551,7 +556,7 @@ struct PanelView: View {
private func dismissNewWorkspace() { private func dismissNewWorkspace() {
showNewWorkspace = false showNewWorkspace = false
newWorkspaceName = "" newWorkspaceName = ""
focusedField = .composer composerFocused = true
} }
// MARK: - Selection // MARK: - Selection
@@ -576,6 +581,12 @@ struct PanelView: View {
.font(.caption) .font(.caption)
.buttonStyle(.plain) .buttonStyle(.plain)
.foregroundStyle(Color.accentColor) .foregroundStyle(Color.accentColor)
Button { deleteSelection() } label: {
Image(systemName: "trash")
}
.buttonStyle(.plain)
.foregroundStyle(.red)
.help("Delete selected")
Button { clearSelection() } label: { Button { clearSelection() } label: {
Image(systemName: "xmark.circle.fill") Image(systemName: "xmark.circle.fill")
} }
@@ -588,6 +599,12 @@ struct PanelView: View {
.background(Color.accentColor.opacity(0.12)) .background(Color.accentColor.opacity(0.12))
} }
private func deleteSelection() {
guard !selection.isEmpty else { return }
store.delete(ids: selection)
clearSelection()
}
private func copySelection(asList: Bool) { private func copySelection(asList: Bool) {
guard !selection.isEmpty else { return } guard !selection.isEmpty else { return }
if asList { if asList {
@@ -606,28 +623,32 @@ struct PanelView: View {
// MARK: - Composer // MARK: - Composer
private var composerField: some View { private var composerField: some View {
HStack(spacing: 8) { HStack(alignment: .bottom, spacing: 8) {
TextField("Add a note or prompt (\(store.activeSection.name))", text: $composer) ComposerView(
.textFieldStyle(.plain) text: $composer,
.focused($focusedField, equals: .composer) isFocused: $composerFocused,
.onSubmit(submit) height: $composerHeight,
.onExitCommand { placeholder: "Add a note or prompt (\(store.activeSection.name))",
// Esc clears a selection first, and only hides the panel onSubmit: submit,
// once there is nothing selected. onEscape: {
if selection.isEmpty { if selection.isEmpty {
PanelController.shared.hide() PanelController.shared.hide()
} else { } else {
clearSelection() clearSelection()
} }
} }
)
.frame(maxWidth: .infinity)
.frame(height: composerHeight)
if !composer.isEmpty { if !composer.isEmpty {
Button(action: submit) { Image(systemName: "arrow.up.circle.fill") } Button(action: submit) { Image(systemName: "arrow.up.circle.fill") }
.buttonStyle(.plain) .buttonStyle(.plain)
.foregroundStyle(Color.accentColor) .foregroundStyle(Color.accentColor)
.help("Add (Return; Shift+Return for a new line)")
} }
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 10) .padding(.vertical, 8)
} }
private func submit() { private func submit() {
@@ -654,7 +675,7 @@ struct PanelView: View {
withAnimation(.easeInOut(duration: 0.15)) { showSectionSwitcher.toggle() } withAnimation(.easeInOut(duration: 0.15)) { showSectionSwitcher.toggle() }
} }
.keyboardShortcut("k", modifiers: .command) .keyboardShortcut("k", modifiers: .command)
Button("") { focusedField = .composer } Button("") { composerFocused = true }
.keyboardShortcut("n", modifiers: .command) .keyboardShortcut("n", modifiers: .command)
Button("") { Button("") {
withAnimation(.easeInOut(duration: 0.15)) { showSync.toggle() } withAnimation(.easeInOut(duration: 0.15)) { showSync.toggle() }
@@ -730,7 +751,7 @@ struct PanelView: View {
focusedField = .search focusedField = .search
} else { } else {
search = "" search = ""
focusedField = .composer composerFocused = true
} }
} }