Compare commits
18 Commits
a3afbdc286
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 77ac319a34 | |||
| ef979d2838 | |||
| 47912d237c | |||
| c427803504 | |||
| c0a17f5efe | |||
| 1b171c7b52 | |||
| c48ef4c018 | |||
| be761b4704 | |||
| 1ec4c0d582 | |||
| 0a85d21801 | |||
| 751f22f6b1 | |||
| 5803a00944 | |||
| 923425826d | |||
| 615cbd6eb6 | |||
| 665691ddee | |||
| 5065ba59bd | |||
| 489fa84b3c | |||
| b5a0edd5bf |
+28
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "fc870f54e8ae0c2fe51ba1e87667691472a20f609075b305c4117df7d7db9336",
|
||||
"originHash" : "068211154e3a6007735a5b19402de83e9c3091c99e38c7be2bc2e85bc3259877",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "highlighterswift",
|
||||
@@ -9,6 +9,33 @@
|
||||
"revision" : "fe7aae9c9b31d3b296fd3d2dd575e1a207bb29e0",
|
||||
"version" : "3.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-cmark",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/swiftlang/swift-cmark.git",
|
||||
"state" : {
|
||||
"revision" : "08ddb528923cc1a6527e02b7a1aee9e516ca749a",
|
||||
"version" : "0.9.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-markdown",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/swiftlang/swift-markdown",
|
||||
"state" : {
|
||||
"revision" : "ea79e83c8744d2b50b0dc2d5bbd1e857e1253bf9",
|
||||
"version" : "0.6.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "tomldecoder",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/dduan/TOMLDecoder",
|
||||
"state" : {
|
||||
"revision" : "a2bbd2796fe3064e107de18cb56031052c4fa899",
|
||||
"version" : "0.4.5"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
|
||||
+9
-2
@@ -5,11 +5,18 @@ let package = Package(
|
||||
name: "Conjure",
|
||||
platforms: [.macOS(.v14)],
|
||||
products: [.executable(name: "Conjure", targets: ["Conjure"])],
|
||||
dependencies: [.package(url: "https://github.com/smittytone/HighlighterSwift", from: "3.1.0")],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/smittytone/HighlighterSwift", from: "3.1.0"),
|
||||
.package(url: "https://github.com/swiftlang/swift-markdown", exact: "0.6.0"),
|
||||
.package(url: "https://github.com/dduan/TOMLDecoder", exact: "0.4.5"),
|
||||
],
|
||||
targets: [
|
||||
.target(name: "ConjureKit", resources: [.process("Resources")]),
|
||||
.target(name: "ConjureKit", dependencies: [
|
||||
.product(name: "Markdown", package: "swift-markdown"),
|
||||
], resources: [.process("Resources")]),
|
||||
.executableTarget(name: "Conjure", dependencies: [
|
||||
"ConjureKit", .product(name: "Highlighter", package: "HighlighterSwift"),
|
||||
.product(name: "TOMLDecoder", package: "TOMLDecoder"),
|
||||
]),
|
||||
.testTarget(name: "ConjureKitTests", dependencies: ["ConjureKit", "Conjure"]),
|
||||
]
|
||||
|
||||
@@ -42,7 +42,7 @@ Favorited launcher entries show a small star before their **Application** or **C
|
||||
|
||||
Launcher results and the emoji grid start below the top fade; scrolling moves content naturally through it.
|
||||
|
||||
Conjure searches the system and user Applications folders. Hover over a result to see its path; duplicate names show their locations. Drag the launcher background or Notes title bar to reposition it; Conjure remembers the window's center and display, shared across the launcher and tools regardless of window size. If a window no longer fits onscreen, it returns to the default placement. Older corner-based saved positions reset once; drag again to save a center-based position.
|
||||
Conjure searches the system and user Applications folders, including `/System/Cryptexes/App/System/Applications` for Safari. Hover over a result to see its path; duplicate names show their locations. Drag the launcher background or Notes title bar to reposition it; Conjure remembers the window's center and display, shared across the launcher and tools regardless of window size. If a window no longer fits onscreen, it returns to the default placement. Older corner-based saved positions reset once; drag again to save a center-based position.
|
||||
|
||||
Conjure runs without a Dock icon. Dismissing the launcher leaves it running; reopen it from Finder or use your configured global shortcut.
|
||||
|
||||
@@ -70,13 +70,15 @@ Action and selection popups paint a popup-sized background using the launcher wi
|
||||
|
||||
The launcher, emoji picker, Translate, and Notes share `ActionPill` for footer styling, alignment, spacing, and keyboard indicators: primary action first, then Actions with separate Command and K keycaps. Notes places Browse Notes (Command-P) and Actions (Command-K) at the bottom right, with both popups opening upwards; New Note remains in Actions and on Command-N. Its formatting controls scroll horizontally when the remaining footer space is too narrow. Controllers supply the buttons and retain their targets, enabled state, and popup behavior.
|
||||
|
||||
Notes keeps its 36-point footer pill 8 points from the bottom and right window edges, matching the other views even at the minimum window size.
|
||||
|
||||
The header’s trailing edge is constrained to the footer’s trailing edge. The translation provider pill and footer pill therefore share the same 8-point right inset and 36-point height.
|
||||
|
||||
### Global shortcuts and launch at login
|
||||
|
||||
In Settings, choose a **Global shortcut destination**: **Applications**, **Conjure Emoji**, **Conjure Notes**, **Conjure Translate**, or **Toggle System Theme**. Choose **Record Shortcut…** and press a combination containing Command, Option, or Control. For example, select **Conjure Emoji** and record Command-Shift-Space to open the emoji picker directly. **Clear** disables only the selected destination's shortcut. No shortcuts are assigned by default; existing launcher bindings are preserved.
|
||||
|
||||
The Applications shortcut always targets the application launcher, not the last-used tool. View commands open their selected tool directly. Action-only shortcuts, such as Toggle System Theme, execute in the background without opening the launcher, taking focus, clearing a search, or closing an existing tool. Pressing a tool shortcut while its destination is already frontmost dismisses Conjure; switching view destinations opens a fresh view. Active confirmations and uninstall operations are not interrupted. All global shortcuts pause while recording and resume afterward. Conjure rejects shortcuts already assigned to another destination or used by its own menus and navigation.
|
||||
The Applications shortcut always targets the application launcher, not the last-used tool. View commands open their selected tool directly. Action-only shortcuts, such as Toggle System Theme, execute in the background without opening the launcher, taking focus, clearing a search, or closing an existing tool. Pressing a tool shortcut while its destination is already frontmost dismisses Conjure; switching view destinations opens the selected view, restoring any uncommitted Translate draft. Active confirmations and uninstall operations are not interrupted. All global shortcuts pause while recording and resume afterward. Conjure rejects shortcuts already assigned to another destination or used by its own menus and navigation.
|
||||
|
||||
Cancelling recording, changing destination, or leaving the recorder restores saved shortcuts. If a replacement cannot be registered, the previous saved binding is retained; any failure to restore a shortcut is reported in Settings.
|
||||
|
||||
@@ -98,6 +100,7 @@ sqrt(144)
|
||||
32 f to c
|
||||
1 GiB to MiB
|
||||
100 USD to DKK
|
||||
1000 yen to DKK
|
||||
0.5 BTC to ETH
|
||||
```
|
||||
|
||||
@@ -107,6 +110,8 @@ Convert length, mass, temperature, volume, duration, speed, area, and data with
|
||||
|
||||
Currency and crypto conversions require internet access and use [currency-api](https://github.com/fawazahmed0/exchange-api). These are **daily rates, not live market prices**; the snapshot date is shown with the answer. Treat them as estimates, not trading or payment quotes. Fetch failures show an error rather than silently using old rates; change the query to retry.
|
||||
|
||||
Currency codes and common English names work in either direction, case-insensitively: `10 euros to Danish kroner`, `1000 Japanese yen to DKK`, or `2 US dollars to British pounds`. Results use currency codes. Supported names include yen, euros, Danish/Norwegian kroner, Swedish kronor, US/Canadian/Australian/New Zealand dollars, British pounds, Swiss francs, Chinese yuan, Indian rupees, South Korean won, bitcoin, and ether/ethereum. Singular and plural forms are accepted. Bare `dollars` and `kroner` are ambiguous and require a qualifier or code; bare `pounds` remains a mass unit.
|
||||
|
||||
Rates are shared across queries and refreshed on the next currency query after the local day changes. While refreshing, a cached answer remains copyable and is labeled stale. Leaving the query or dismissing the launcher does not cancel that shared refresh.
|
||||
|
||||
`ConjureKit.CurrencyCalculation` owns this lifetime policy; the launcher renders its result. Its checks use an injected clock and scripted rate fetches.
|
||||
@@ -127,11 +132,48 @@ With Actions open, Escape or the Back chevron closes Actions first. Another Esca
|
||||
|
||||
The picker works offline with 1,870 emoji from [gemoji v4.1.0](https://github.com/github/gemoji/tree/v4.1.0). Its [MIT license](Sources/ConjureKit/Resources/LICENSE-gemoji.txt) is included in the application.
|
||||
|
||||
## Color scheme
|
||||
|
||||
Create `$XDG_CONFIG_HOME/conjure/config.toml`, or `~/.config/conjure/config.toml` when `XDG_CONFIG_HOME` is unset, empty, or relative. No configuration is required: Conjure keeps its built-in sunset colors by default. The editable configuration uses TOML, including comments and standard TOML quoting.
|
||||
|
||||
```toml
|
||||
# Override only the colors you want to change.
|
||||
[colors]
|
||||
background = "#171b24"
|
||||
text = "#e0e6f0"
|
||||
secondaryText = "#a0adc0"
|
||||
accent = "#82aaff"
|
||||
glow = "#c792ea"
|
||||
keyBackground = "#303848"
|
||||
error = "#ff5370"
|
||||
```
|
||||
|
||||
Every color is optional and must be an opaque `#RRGGBB` value. `accent` colors selections, borders, carets, and the primary background glows; `glow` colors the secondary background glow. `keyBackground` colors application-number shortcut badges, and `error` colors errors and destructive actions. Overrides apply throughout the launcher, tools, popups, and Settings wherever those shared colors are used.
|
||||
|
||||
Only colors are configurable. Frosted glass, glow positions and sizes, selection treatment, typography, and layout keep the same design. Native controls and Notes syntax highlighting follow the palette's light/dark appearance. Light palettes use a denser translucent base, softer glows, and contrast-corrected secondary text so placeholders and muted labels remain readable; the dark rendering is unchanged. Application and emoji icons are not recolored.
|
||||
|
||||
### Follow tint
|
||||
|
||||
The sibling [tint](../tint) tool generates `conjure/theme.json` in the same configuration directory when it applies a theme. This machine-generated palette stays JSON and is not intended for editing. Rebuild/install the updated tint tool with `make install` in its repository, then apply your chosen pair, for example `tint swamp`. Opt in from Conjure's `config.toml`:
|
||||
|
||||
```toml
|
||||
theme = "tint"
|
||||
|
||||
[colors]
|
||||
accent = "#82aaff" # Optional personal override
|
||||
```
|
||||
|
||||
Precedence is personal `[colors]` → tint-generated colors → built-in sunset colors. Omit `[colors]` to follow tint without overrides. Omit `theme`, or set it to `"sunset"` before any table, to ignore the generated palette. tint never overwrites `config.toml`; edit that file, not the generated `theme.json`. Both processes must use the same configuration directory.
|
||||
|
||||
The generated palette includes tint's `light`/`dark` variant, which selects the matching native appearance for the launcher, Notes, Settings, popups, and sheets. For manually configured colors or a palette without variant metadata, Conjure infers appearance by comparing background and foreground luminance. There is no separate appearance, opacity, or glow-strength setting. Low-contrast secondary colors are adjusted only in the light-mode presentation; source palette files are not rewritten.
|
||||
|
||||
Conjure watches `config.toml` and the generated `theme.json` while running. Applying a tint theme, including automatic system light/dark switches, updates existing windows and tools without restarting or clearing searches, notes, selections, or translation results. Personal configuration edits also apply live. In-place saves and atomic file replacements are supported, including configuration directories created after launch. Invalid configuration at startup falls back to sunset and reports a file-specific error in Settings; invalid live edits keep the last valid palette and log the diagnostic, then recover automatically when the file is fixed.
|
||||
|
||||
## System theme
|
||||
|
||||
Search for **Toggle System Theme**, identified by its centered 🌗 icon (try `theme` or `system`), and press Return to switch macOS from light to dark appearance or back. Selecting it in the launcher dismisses Conjure on success. Its global shortcut runs in the background without presenting a window or changing the current launcher view. You can favorite it or assign a shortcut in Settings; existing theme keybindings and favorites survive the rename.
|
||||
|
||||
The first use may ask permission to control **System Events**. Allow it under **System Settings → Privacy & Security → Automation → Conjure**. If permission is denied or macOS reports an error, Conjure shows the failure in the visible launcher or a standalone alert when the launcher is hidden. Conjure's own launcher keeps its existing dark styling.
|
||||
The first use may ask permission to control **System Events**. Allow it under **System Settings → Privacy & Security → Automation → Conjure**. If permission is denied or macOS reports an error, Conjure shows the failure in the visible launcher or a standalone alert when the launcher is hidden. With `theme = "tint"`, Conjure follows the palette generated by tint's automatic appearance switching; its frosted-glass treatment and layout remain unchanged.
|
||||
|
||||
Commands explicitly declare `Application.Command.presentsView`. The shared dispatcher routes action-only commands before any launcher presentation; new commands must choose their behavior in the exhaustive metadata switch. Command IDs remain independent of display names so renaming does not reset saved shortcuts, favorites, or usage.
|
||||
|
||||
@@ -139,7 +181,7 @@ Commands explicitly declare `Application.Command.presentsView`. The shared dispa
|
||||
|
||||
Search for **Conjure Notes**, identified by its centered 📔 icon, or assign it a global shortcut in Settings. Notes starts with a compact 570 × 404 layout inspired by Raycast Notes, with rounded corners and a centered muted note title, while using Conjure’s shared warm gradient background, cream text, and amber list markers and caret. Drag a window edge or corner to resize it; Notes remembers its size across reopening and app restarts, independently of other launcher views. The minimum size is 570 × 260, and restored dimensions fit the current screen’s visible area. The title bar offers Close; the bottom-right action pill offers **Browse Notes** and **Actions**, with **New Note** in Actions and on Command-N. Browse Notes (Command-P) opens Conjure’s styled, scrollable searchable picker upwards from the footer, shared with translation language selection—not a native menu. Fuzzy-search note titles, then select with Up/Down, Control-J/K, and Return or by clicking a note. A checkmark marks the current note, which is selected and scrolled into view on opening. Reopening Notes restores the last selection. Each note’s first nonempty line becomes its title; duplicate titles remain independently selectable.
|
||||
|
||||
Notes uses a standard macOS window with system-inherited appearance and only the native Close button visible; minimize and zoom are hidden. The editor uses 15-point monospaced body text, including bold, italic, and code, while retaining Conjure’s warm palette. Heading sizes decrease from H1 through H6 (26, 23, 21, 19, 17, and 15 points); inline emphasis retains the heading’s size, and fenced code is not treated as headings. Closing still saves first and keeps the window open if saving fails. Other launcher views remain borderless with their existing dark appearance.
|
||||
Notes uses a standard macOS window matching Conjure's palette appearance and only the native Close button visible; minimize and zoom are hidden. The editor uses 15-point monospaced body text, including bold, italic, and code, with light/dark syntax highlighting following the selected palette. Heading sizes decrease from H1 through H6 (26, 23, 21, 19, 17, and 15 points); inline emphasis retains the heading's size, and code-block contents remain literal. Closing still saves first and keeps the window open if saving fails. Other launcher views remain borderless and use the same palette appearance.
|
||||
|
||||
Actions (Command-K) opens the same searchable popup style used throughout Conjure, with **Browse Notes** (Command-P), **New Note** (Command-N), and **Delete Note**. Delete Note asks for confirmation before permanently deleting the currently selected note and its embedded images. Delete is focused by default: Return confirms and Escape cancels; deletion cannot be undone. After deletion, Notes selects a remaining note or creates a fresh blank note if none remain. Save or deletion failures preserve the current note and editor buffer. Command-N creates a note while the editor or either popup is focused. Command-P toggles Browse Notes even while its title-bar controls are covered. Escape or clicking outside closes an open popup and returns focus to the editor without leaving Notes; another Escape dismisses the launcher. Control-J/K navigates only while a popup is open, preserving normal editor commands when it is closed.
|
||||
|
||||
@@ -147,9 +189,9 @@ Notes actions are ordered Browse Notes, New Note, Copy as Markdown, Export as Ma
|
||||
|
||||
Notes Actions also includes **Copy as Markdown** and **Export as Markdown…**. Both export the whole note, regardless of the selection, without modifying the editor or RTFD autosaves. Clipboard exports replace images with inline-code filename placeholders such as `<photo.png>`. File exports use a native save dialog: notes without images become a single `.md` file; notes with images become a new folder containing `note.md` and the original image files, linked with relative paths. Unsafe or duplicate image filenames are made safe and unique. Image folders must not already exist; a failed export does not leave a partially written folder. Unsupported or unreadable images report an error rather than being dropped.
|
||||
|
||||
The bottom strip applies Markdown headings, bold, italic, strikethrough, inline code, links, fenced code blocks, quotes, numbered lists, bullet lists, and checklists. Formatting preserves embedded images and supports Undo. List buttons convert existing list types; clicking the current type removes its marker. The formatting strip is always visible. Successful autosaves stay unobtrusive; only save, load, deletion, or export errors appear below the editor.
|
||||
The bottom strip applies headings, bold, italic, underline, strikethrough, inline code, links, code blocks, quotes, numbered lists, bullet lists, and checklists. Formatting is stored as native attributed text and paragraph metadata, not hidden Markdown characters. Underline exports as `<u>…</u>`. Formatting preserves embedded images and supports Undo. List buttons convert existing list types; clicking the current type returns the paragraphs to body text. Successful autosaves stay unobtrusive; only save, load, deletion, or export errors appear below the editor.
|
||||
|
||||
Markdown highlighting and list alignment update in one formatting transaction per edit, so typing does not expose an intermediate unformatted layout.
|
||||
Typing, toolbar actions, and Vim share one attributed-document edit and Undo path. Completed shortcuts publish their final formatting and list layout before autosaving; edits do not serialize and reconstruct the document as Markdown.
|
||||
|
||||
Note text fades over 16 points at the editor’s scroll edges rather than clipping sharply above the formatting strip. Matching 16-point scroll insets keep the first and last lines clear of the fade when scrolled to either end. The 36-point title header and footer meet the editor without extra vertical gaps.
|
||||
|
||||
@@ -160,36 +202,60 @@ Formatting shortcuts use the same actions as the bottom strip. They apply to the
|
||||
| Bold | Command-B |
|
||||
| Italic | Command-I |
|
||||
| Inline code | Command-E |
|
||||
| Strikethrough | Command-Shift-X |
|
||||
| Strikethrough | Command-Shift-S |
|
||||
| Underline | Command-Shift-U |
|
||||
| Link | Command-Shift-K |
|
||||
| Code block | Command-Shift-E |
|
||||
| Heading | Command-Shift-H |
|
||||
| Quote | Command-Shift-B |
|
||||
| Numbered list | Command-Shift-O |
|
||||
| Bullet list | Command-Shift-U |
|
||||
| Bullet list | Command-Shift-L |
|
||||
| Checklist | Command-Shift-T |
|
||||
|
||||
Edit Markdown directly: headings, bold, italic, strikethrough, links, quotes, inline code, and fenced code blocks render without their recognized formatting delimiters. Heading `#` prefixes, emphasis markers, link wrappers and destinations, quote prefixes, and code fences are hidden rather than deleted. Unmatched or escaped punctuation and literal code remain visible; fence lines retain their line breaks. Copying a selection containing hidden syntax and saving notes preserve the original Markdown. Arrow navigation and typing skip hidden syntax, while the formatting strip still toggles it. Bullet (`-`, `*`, `+`), numbered (`1.`, `1)`), and native checkbox markers share a marker column, so item text aligns at the same nesting depth even with long numbers; wrapped lines align with the item text, not the marker. Paste images or drag image files into the editor to embed them locally; no file-picker button is needed. Return continues lists at the same indentation, incrementing numbers and resetting task checkboxes. On an empty item, Return exits one nesting level; repeat to leave the list entirely. Nesting has no fixed depth limit. Prose and fenced code retain ordinary newlines. Command-Z undoes edits; Command-Shift-Z redoes them. Undo history is cleared when switching notes so edits cannot spill into another note.
|
||||
Notes uses Notion-style Markdown input shortcuts: completing `**bold**`, `*italic*`, `~~strike~~`, inline backticks, `<u>underline</u>`, or `[label](url)` consumes the syntax and creates formatting on the visible text. At paragraph start, `# ` through `###### ` set the absolute heading level; `> `, `- `, `1. `, and `- [ ] ` create quotes and lists. There are no hidden delimiter positions for the caret to enter or accidentally delete. Unmatched syntax stays literal. Plain-text paste stays literal rather than being parsed as Markdown; rich-text copy/paste between Notes editors preserves semantic formatting and image bytes. Whole-note Markdown export generates normalized syntax rather than preserving the original delimiter spelling.
|
||||
|
||||
Typing `# ` through `###### ` at the visible start of an existing heading replaces its level rather than adding to it. Backspace at a heading's visible start removes the preceding newline, leaving a heading at the start of the document unchanged. Removing an empty line above preserves the heading; joining onto nonempty text inherits the preceding paragraph's type (`Text\n## Heading` becomes `TextHeading`). These edits support Undo and retain Markdown serialization inside the existing RTFD autosaves.
|
||||
Backspace at a heading's visible start removes the preceding newline, leaving a heading at the document start unchanged. Removing an empty line above preserves its heading type; joining onto nonempty prose inherits the preceding paragraph's type. Return at a heading's start inserts an empty line above it; Return within or at the end of a heading creates a body paragraph. These operations use the same visible text positions as native selection and support Undo.
|
||||
|
||||
Tab anywhere on a bullet, numbered, or task item indents that line by two spaces, preserving the caret or selection within the line. Shift-Tab removes one leading tab or up to two spaces; at the root it does nothing and never removes the item. These commands change only the current line’s indentation, not nested children. Moving an item into or out of a numbered list updates the affected sibling sequences: a new nested sequence starts at 1, an existing sequence continues from its preceding sibling, and later siblings are renumbered in the saved and copied Markdown too. Existing standalone starting numbers and each marker’s delimiter are preserved. Indentation and numbering changes undo together. Return on a whitespace-only item outdents to its actual parent, or removes one indent unit when there is no parent; only Return on an empty root item removes the marker. Repeated Return therefore exits even a standalone nested list one level at a time.
|
||||
Tab on a bullet, numbered, or task item increases its nesting depth; Shift-Tab decreases it, stopping at the root. Carets and Unicode selections remain at the same text offsets. A new nested numbered sequence starts at 1 and following peers are renumbered. Return continues a nonempty list item, resets a new task to unchecked, and outdents an empty item one level before returning a root-level empty item to body text.
|
||||
|
||||
Checkbox conversion waits for a space or tab after the closing bracket: type `- [ ] `, `- [] `, or `- [x] `. A closing bracket alone remains literal, so typing directly after it does not briefly create and then remove a checkbox. Both `[]` and `[ ]` mean unchecked; toggling a compact `[]` task on produces `[x]`, and toggling it off produces `[ ]`.
|
||||
Type a space after `[ ]`, `[]`, or `[x]` at a bullet's start to make a task. Checklist buttons toggle semantic checked state without changing text or moving the caret. Bullets and numbers are drawn in the paragraph gutter, not stored as text or synthetic attachments. Arrow keys, selections, typing, and Vim operate on the actual item body. Plain copying copies visible text; **Copy as Markdown** includes the list syntax.
|
||||
|
||||
When typing a list marker before existing whitespace on an otherwise blank line, the marker stays literal until you explicitly type Space or Tab immediately after it. Existing whitespace does not complete the marker for you. Tab completes a still-literal marker; on an already rendered list item it indents the line. Loading or pasting valid list Markdown still renders it directly.
|
||||
Bullets and checkboxes are centered in their shared gutter; numbered markers are right-aligned on the body text baseline. Wrapped lines share the same text column, including nested items and gutters widened for longer numbers.
|
||||
|
||||
List markers are atomic inline tokens, including nested bullets and numbers; task markers such as `- [ ]` and `- [x]` use native checkboxes. Left/Right crosses a marker and its single required separator (a space or tab) in one visible step, between the marker's start and the item body. Only that separator is hidden: additional spaces and tabs are visible body text with normal cursor stops. Shift-Left/Right uses the same boundaries and preserves the selection anchor when extending or reversing direction. Visible indentation and body whitespace retain normal character stops. Selecting only a token copies only its original marker; a keyboard selection across the marker also includes its required separator. Click a checkbox to toggle completion without moving the selection. Backspace at the start of the body expands the original marker and deletes its final character, preserving the separator; forward Delete immediately before the token expands the marker and deletes its first character. Each edit is one Undo step. Removing an item's preceding newline after prose makes its marker literal inline text; joining onto an empty or indentation-only line keeps a valid list token. Saved notes and copied text retain the original Markdown spelling, numbers, case, and spacing, alongside embedded images; alignment formatting is display-only. Thematic breaks and list syntax inside fenced code or ordinary prose stay literal.
|
||||
|
||||
Unordered markers display as `•`; bullets and checkboxes are centered in the shared marker column. Checkboxes align vertically with the text’s cap-height center, using its baseline rather than the attachment frame. Numbered markers display their original number and delimiter, right-aligned before the item text and resting on the same text baseline. Item text and wrapped lines retain their shared alignment, including nested lists and columns widened by longer numbers. The stored and copied Markdown retains the original bullet spelling.
|
||||
|
||||
Typing, pasting, and Return at the visible start of an item use the position after its preserved Markdown separator. Continuing to type after a marker converts therefore keeps the item rendered, rather than inserting characters into the marker syntax.
|
||||
|
||||
Fenced code uses [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) (Highlight.js) for language-aware token colors. Add a language after the opening backticks or tildes, for example `py`, `python`, `swift`, `js`, or `json`. Unlabeled fences use automatic detection; unknown languages remain plain monospace. Highlighting runs entirely offline.
|
||||
Type three backticks, optionally followed by a language such as `swift` or `python`, then Return to enter a code block. The input fence is consumed; code content stays literal and uses [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) for offline token colors. Return on an empty code paragraph returns to body text; the Code block button also toggles the paragraph type. Unspecified languages use automatic detection, and unknown languages remain plain monospace. Markdown export regenerates fences and language labels.
|
||||
|
||||
Embedded images display with 8-point rounded corners. Rounding only clips their rendering in Notes; saving and copying retain the original image data.
|
||||
|
||||
Notes autosave locally as RTFD packages, including embedded images, under `~/Library/Application Support/Conjure/Notes`. No account, network access, or cloud sync is involved. Save failures appear below the editor and prevent switching, closing, or quitting until saving succeeds, preserving the unsaved buffer. The Close button and Command-W dismiss Conjure, including any open popup; leaving Notes or switching destinations removes its popup completely and restores the launcher’s original dimensions.
|
||||
Paste images or drag image files into the editor to embed them locally. Command-Z undoes edits; Command-Shift-Z redoes them. Switching notes clears Undo history so edits cannot spill into another note.
|
||||
|
||||
Notes autosave locally as RTFD packages, including embedded images, under `~/Library/Application Support/Conjure/Notes`. Each package also contains versioned `ConjureNote.json` metadata for paragraph types and inline marks that RTFD does not preserve by itself. Metadata is validated against the stored text; corrupt or unsupported metadata reports an error instead of being interpreted as legacy Markdown. Older Markdown-backed RTFD notes are converted in memory on load. Their first save preserves the original package in `LegacyBackups/<note-id>.rtfd` before atomically writing the structured package; backup failures prevent migration. No account, network access, or cloud sync is involved. Save failures preserve the unsaved buffer and prevent switching, closing, or quitting until saving succeeds. The Close button and Command-W dismiss Conjure, including open popups; leaving Notes removes its popup and restores the launcher’s original dimensions.
|
||||
|
||||
### Vim editing
|
||||
|
||||
Click **Vim Off** at the top-right of the Notes header to enable modal editing. The button shows **NORMAL**, **INSERT**, **VISUAL**, or **VISUAL LINE**; click it again to disable Vim. This preference persists across reopening and app restarts. Opening or switching notes starts in Normal mode when enabled; ordinary editing is unchanged when disabled.
|
||||
|
||||
Normal mode uses a translucent, one-character-wide block cursor. Insert mode and Vim-disabled editing keep the native thin caret.
|
||||
|
||||
| Operation | Keys |
|
||||
| --- | --- |
|
||||
| Move by visible character / logical line | `h` / `l`, `j` / `k` (or arrow keys) |
|
||||
| Next word / previous word / word end | `w` / `b` / `e`; uppercase `W` / `B` / `E` treats punctuation as part of a word |
|
||||
| Line start / first nonblank / line end | `0` / `^` / `$` |
|
||||
| First / last line; numbered line | `gg` / `G`; `12gg` or `12G` |
|
||||
| Insert before / after; first nonblank / line end | `i` / `a`; `I` / `A` |
|
||||
| Open a line below / above | `o` / `O` |
|
||||
| Characterwise / linewise selection | `v` / `V`; repeat to leave that selection mode |
|
||||
| Delete / change / yank | `d` / `c` / `y` followed by a motion, or applied to a Visual selection |
|
||||
| Delete / change / yank whole lines | `dd` / `cc` / `yy` |
|
||||
| Delete characters; delete / change through line end | `x`; `D` / `C` |
|
||||
| Paste after / before | `p` / `P` |
|
||||
| Native Undo / Redo | `u` / Control-R; Command-Z / Command-Shift-Z remain available |
|
||||
| Return to Normal (from Insert/Visual) | Escape or Control-[ |
|
||||
|
||||
Counts work before motions and operators, including `3j`, `2dd`, and `2d3w`. Vertical movement uses logical lines rather than wrapped display rows and retains the desired column across shorter lines. Character motions respect composed Unicode characters and use the actual attributed text offsets. Yank/delete/change use a local unnamed register that preserves formatting and embedded images for `p`/`P` without replacing the system clipboard. A change command and its replacement typing undo together across real key events; focus changes, mode changes, and Command shortcuts finish the insertion group. Vim and ordinary editing use the same document and Undo manager.
|
||||
|
||||
Escape closes an open picker first. With Vim enabled, Escape returns Insert or Visual mode to Normal; in Normal mode it dismisses Notes, just as when Vim is disabled. Command-W still dismisses the window. Command shortcuts, formatting controls, and native Insert-mode input remain available. Pending commands cancel on focus changes and Command shortcuts. This is a supported Vim subset, not an embedded Vim: search, text objects, named registers, macros, and dot-repeat are not implemented.
|
||||
|
||||
|
||||
## Translation
|
||||
|
||||
@@ -201,6 +267,8 @@ The translation uses 20-point primary text; OpenAI's explanation below the blank
|
||||
|
||||
When the translation is available, the action changes to **Copy**: press Return or click it to copy the full output (including OpenAI's explanation) and close Conjure. Command-C still copies selected output. Editing the current input clears the result; editing context keeps the original text. The action is disabled while translating.
|
||||
|
||||
Closing Translate, losing focus, returning to Applications, or switching tools keeps the uncommitted source text, optional context, and current input step in memory. Reopening Translate restores that draft without sending a request; results are not retained. A successful **Copy** clears the draft. Drafts are not written to disk and disappear when Conjure quits.
|
||||
|
||||
The footer places the current primary action and its Return keycap before **Actions**, whose Command and K keys use separate keycaps, matching the launcher.
|
||||
|
||||
Press Command-P or click the provider selector to expand a searchable action-style popup from its top edge downwards, hiding the indicator until the popup closes. A checkmark marks the current provider. Use Up/Down and Return or click a provider to select it; Escape, Back, or clicking outside closes the popup. Changing providers clears the previous result without submitting a request. Switching away from OpenAI during the context step restores the original source text; context is not sent to MyMemory.
|
||||
|
||||
@@ -2,17 +2,18 @@ import AppKit
|
||||
|
||||
@MainActor
|
||||
final class ActionPill: NSView {
|
||||
private var themeObservation: ThemeObservation?
|
||||
init(primaryButton: NSButton, actionsButton: NSButton, primaryCommand: String? = nil) {
|
||||
super.init(frame: .zero)
|
||||
wantsLayer = true
|
||||
layer?.cornerRadius = 18
|
||||
layer?.borderWidth = 0.5
|
||||
layer?.borderColor = SunsetPalette.accent.withAlphaComponent(0.35).cgColor
|
||||
layer?.backgroundColor = SunsetPalette.background.withAlphaComponent(0.25).cgColor
|
||||
layer?.borderColor = ThemePalette.accent.withAlphaComponent(0.35).cgColor
|
||||
layer?.backgroundColor = ThemePalette.background.withAlphaComponent(0.25).cgColor
|
||||
for button in [primaryButton, actionsButton] {
|
||||
button.isBordered = false
|
||||
button.font = .systemFont(ofSize: 12, weight: .medium)
|
||||
button.contentTintColor = SunsetPalette.text
|
||||
button.contentTintColor = ThemePalette.text
|
||||
}
|
||||
primaryButton.keyEquivalent = primaryCommand ?? "\r"
|
||||
primaryButton.keyEquivalentModifierMask = primaryCommand == nil ? [] : .command
|
||||
@@ -37,7 +38,46 @@ final class ActionPill: NSView {
|
||||
actionKeys.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
actionKeys.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
|
||||
])
|
||||
themeObservation = ThemeObservation { [weak self, weak primaryButton, weak actionsButton] in
|
||||
self?.layer?.borderColor = ThemePalette.accent.withAlphaComponent(0.35).cgColor
|
||||
self?.layer?.backgroundColor = ThemePalette.background.withAlphaComponent(0.25).cgColor
|
||||
primaryButton?.contentTintColor = ThemePalette.text
|
||||
actionsButton?.contentTintColor = ThemePalette.text
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("Conjure creates action pills programmatically") }
|
||||
}
|
||||
|
||||
/// Keeps palette subscriptions scoped to the existing control's lifetime.
|
||||
@MainActor
|
||||
final class ThemeObservation {
|
||||
nonisolated(unsafe) private let token: NSObjectProtocol
|
||||
|
||||
init(_ apply: @escaping @MainActor () -> Void) {
|
||||
ThemePalette.appearance.performAsCurrentDrawingAppearance(apply)
|
||||
token = NotificationCenter.default.addObserver(
|
||||
forName: ThemePalette.didChangeNotification, object: nil, queue: .main
|
||||
) { _ in
|
||||
MainActor.assumeIsolated { ThemePalette.appearance.performAsCurrentDrawingAppearance(apply) }
|
||||
}
|
||||
}
|
||||
|
||||
deinit { NotificationCenter.default.removeObserver(token) }
|
||||
}
|
||||
|
||||
extension NSSearchField {
|
||||
/// Recolors the current presentation without changing the query or field-editor selection.
|
||||
func applyTheme() {
|
||||
textColor = ThemePalette.text
|
||||
if let placeholderAttributedString {
|
||||
let placeholder = NSMutableAttributedString(attributedString: placeholderAttributedString)
|
||||
placeholder.addAttribute(.foregroundColor, value: ThemePalette.secondaryText,
|
||||
range: NSRange(location: 0, length: placeholder.length))
|
||||
self.placeholderAttributedString = placeholder
|
||||
}
|
||||
if let editor = currentEditor() as? NSTextView {
|
||||
editor.textColor = ThemePalette.text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import ConjureKit
|
||||
@MainActor
|
||||
final class ApplicationActionsController: NSViewController, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate {
|
||||
private var search: ActionsSearchField!
|
||||
private var themeObservation: ThemeObservation?
|
||||
private var background: LauncherBackground!
|
||||
private var entries: [ActionEntry] = []
|
||||
private var visibleEntries: [Int] = []
|
||||
@@ -74,25 +75,26 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
||||
selectionScroll = nil
|
||||
selectionViewportHeight = nil
|
||||
entries = [
|
||||
ActionEntry(title: "Open", symbol: "arrow.up.forward.app", color: SunsetPalette.text),
|
||||
ActionEntry(title: "Favorite", symbol: "star", color: SunsetPalette.text),
|
||||
ActionEntry(title: "Show in Finder", symbol: "finder", color: SunsetPalette.text),
|
||||
ActionEntry(title: "Reset ranking", symbol: "arrow.counterclockwise", color: SunsetPalette.text),
|
||||
ActionEntry(title: "Quit application", symbol: "stop.circle", color: SunsetPalette.text),
|
||||
ActionEntry(title: "Uninstall application", symbol: "trash", color: .systemRed),
|
||||
ActionEntry(title: "Copy Emoji", symbol: "doc.on.doc", color: SunsetPalette.text),
|
||||
ActionEntry(title: "Change source language…", symbol: "character.bubble", color: SunsetPalette.text),
|
||||
ActionEntry(title: "Change target language…", symbol: "character.bubble", color: SunsetPalette.text),
|
||||
ActionEntry(title: "Swap languages", symbol: "arrow.left.arrow.right", color: SunsetPalette.text),
|
||||
ActionEntry(title: "MyMemory (no key)", symbol: "checkmark", color: SunsetPalette.text),
|
||||
ActionEntry(title: "OpenAI · gpt-6-luna", symbol: "checkmark", color: SunsetPalette.text),
|
||||
ActionEntry(title: "Browse Notes", symbol: "list.bullet", color: SunsetPalette.text, commandKey: "P"),
|
||||
ActionEntry(title: "New Note", symbol: "square.and.pencil", color: SunsetPalette.text, commandKey: "N"),
|
||||
ActionEntry(title: "Delete Note", symbol: "trash", color: .systemRed),
|
||||
ActionEntry(title: "Copy as Markdown", symbol: "doc.on.doc", color: SunsetPalette.text),
|
||||
ActionEntry(title: "Export as Markdown…", symbol: "square.and.arrow.up", color: SunsetPalette.text)
|
||||
ActionEntry(title: "Open", symbol: "arrow.up.forward.app"),
|
||||
ActionEntry(title: "Favorite", symbol: "star"),
|
||||
ActionEntry(title: "Show in Finder", symbol: "finder"),
|
||||
ActionEntry(title: "Reset ranking", symbol: "arrow.counterclockwise"),
|
||||
ActionEntry(title: "Quit application", symbol: "stop.circle"),
|
||||
ActionEntry(title: "Uninstall application", symbol: "trash", destructive: true),
|
||||
ActionEntry(title: "Copy Emoji", symbol: "doc.on.doc"),
|
||||
ActionEntry(title: "Change source language…", symbol: "character.bubble"),
|
||||
ActionEntry(title: "Change target language…", symbol: "character.bubble"),
|
||||
ActionEntry(title: "Swap languages", symbol: "arrow.left.arrow.right"),
|
||||
ActionEntry(title: "MyMemory (no key)", symbol: "checkmark"),
|
||||
ActionEntry(title: "OpenAI · gpt-6-luna", symbol: "checkmark"),
|
||||
ActionEntry(title: "Browse Notes", symbol: "list.bullet", commandKey: "P"),
|
||||
ActionEntry(title: "New Note", symbol: "square.and.pencil", commandKey: "N"),
|
||||
ActionEntry(title: "Delete Note", symbol: "trash", destructive: true),
|
||||
ActionEntry(title: "Copy as Markdown", symbol: "doc.on.doc"),
|
||||
ActionEntry(title: "Export as Markdown…", symbol: "square.and.arrow.up")
|
||||
]
|
||||
view = ActionsPanelView()
|
||||
view.appearance = ThemePalette.appearance
|
||||
view.wantsLayer = true
|
||||
view.layer?.shadowColor = NSColor.black.cgColor
|
||||
view.layer?.shadowOpacity = 0.35
|
||||
@@ -106,13 +108,13 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
||||
content.layer?.borderWidth = 0.5
|
||||
background.setAccessibilityElement(false)
|
||||
content.addSubview(background)
|
||||
applicationLabel.textColor = SunsetPalette.secondaryText
|
||||
applicationLabel.textColor = ThemePalette.secondaryText
|
||||
applicationLabel.lineBreakMode = .byTruncatingTail
|
||||
applicationLabel.font = .systemFont(ofSize: 12, weight: .medium)
|
||||
search.font = .systemFont(ofSize: LauncherRowView.textSize)
|
||||
search.textColor = SunsetPalette.text
|
||||
search.textColor = ThemePalette.text
|
||||
search.placeholderAttributedString = NSAttributedString(string: "Search for actions…", attributes: [
|
||||
.foregroundColor: SunsetPalette.secondaryText, .font: NSFont.systemFont(ofSize: LauncherRowView.textSize)
|
||||
.foregroundColor: ThemePalette.secondaryText, .font: NSFont.systemFont(ofSize: LauncherRowView.textSize)
|
||||
])
|
||||
search.isBordered = false
|
||||
search.drawsBackground = false
|
||||
@@ -126,7 +128,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
||||
search.setAccessibilityLabel("Search application actions")
|
||||
search.closeActions = { [weak self] in self?.close() }
|
||||
search.commandKey = { [weak self] key in self?.handleCommandKey(key) ?? false }
|
||||
empty.textColor = SunsetPalette.secondaryText
|
||||
empty.textColor = ThemePalette.secondaryText
|
||||
empty.font = .systemFont(ofSize: 14)
|
||||
empty.isHidden = true
|
||||
content.translatesAutoresizingMaskIntoConstraints = false
|
||||
@@ -173,7 +175,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
||||
search.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -8),
|
||||
search.heightAnchor.constraint(equalToConstant: 24)
|
||||
])
|
||||
content.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.22).cgColor
|
||||
content.layer?.borderColor = ThemePalette.secondaryText.withAlphaComponent(0.22).cgColor
|
||||
if choices != nil {
|
||||
height.isActive = false
|
||||
let table = NSTableView()
|
||||
@@ -212,6 +214,14 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
||||
selectionViewportHeight = viewportHeight
|
||||
}
|
||||
updateSelection()
|
||||
themeObservation = ThemeObservation { [weak self, weak content] in
|
||||
guard let self else { return }
|
||||
self.view.appearance = ThemePalette.appearance
|
||||
self.search.applyTheme()
|
||||
self.applicationLabel.textColor = ThemePalette.secondaryText
|
||||
self.empty.textColor = ThemePalette.secondaryText
|
||||
content?.layer?.borderColor = ThemePalette.secondaryText.withAlphaComponent(0.22).cgColor
|
||||
}
|
||||
}
|
||||
|
||||
func show(for application: Application, relativeTo anchor: NSView) {
|
||||
@@ -290,7 +300,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
||||
let favorite = entries[1]
|
||||
let favoriteTitle = isFavorite ? "Remove Favorite" : "Favorite"
|
||||
favorite.button.attributedTitle = NSAttributedString(string: favoriteTitle, attributes: [
|
||||
.foregroundColor: SunsetPalette.text, .font: NSFont.systemFont(ofSize: LauncherRowView.textSize, weight: .medium)
|
||||
.foregroundColor: ThemePalette.text, .font: NSFont.systemFont(ofSize: LauncherRowView.textSize, weight: .medium)
|
||||
])
|
||||
favorite.button.setAccessibilityLabel(favoriteTitle)
|
||||
favorite.icon.image = NSImage(systemSymbolName: isFavorite ? "star.fill" : "star", accessibilityDescription: nil)
|
||||
@@ -299,7 +309,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
||||
}
|
||||
search.placeholderAttributedString = NSAttributedString(
|
||||
string: choices != nil ? "Search for \(selectionName)…" : belowAnchor ? "Search for providers…" : "Search for actions…",
|
||||
attributes: [.foregroundColor: SunsetPalette.secondaryText,
|
||||
attributes: [.foregroundColor: ThemePalette.secondaryText,
|
||||
.font: NSFont.systemFont(ofSize: LauncherRowView.textSize)])
|
||||
search.stringValue = ""
|
||||
controlTextDidChange(Notification(name: NSControl.textDidChangeNotification))
|
||||
@@ -332,7 +342,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
||||
coveredPill?.animator().alphaValue = 0
|
||||
}
|
||||
window.makeFirstResponder(search)
|
||||
(search.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent
|
||||
(search.currentEditor() as? NSTextView)?.insertionPointColor = ThemePalette.accent
|
||||
mouseMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self, weak pill] event in
|
||||
MainActor.assumeIsolated {
|
||||
guard let self, self.isShown, event.window === self.view.window else { return }
|
||||
@@ -558,8 +568,10 @@ private struct ActionEntry {
|
||||
let icon: IconView
|
||||
let returnKey: ShortcutIndicator
|
||||
let hasCommandKey: Bool
|
||||
private var themeObservation: ThemeObservation?
|
||||
|
||||
init(title: String, symbol: String, color: NSColor, commandKey: String? = nil) {
|
||||
init(title: String, symbol: String, destructive: Bool = false, commandKey: String? = nil) {
|
||||
let color = destructive ? ThemePalette.error : ThemePalette.text
|
||||
hasCommandKey = commandKey != nil
|
||||
returnKey = commandKey.map { ShortcutIndicator("⌘", $0) } ?? ShortcutIndicator("↵")
|
||||
let font = NSFont.systemFont(ofSize: LauncherRowView.textSize, weight: .medium)
|
||||
@@ -590,12 +602,22 @@ private struct ActionEntry {
|
||||
returnKey.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -12),
|
||||
returnKey.centerYAnchor.constraint(equalTo: view.centerYAnchor)
|
||||
])
|
||||
themeObservation = ThemeObservation { [weak button, weak icon] in
|
||||
let color = destructive ? ThemePalette.error : ThemePalette.text
|
||||
button?.contentTintColor = color
|
||||
if let button {
|
||||
let title = NSMutableAttributedString(attributedString: button.attributedTitle)
|
||||
title.addAttribute(.foregroundColor, value: color, range: NSRange(location: 0, length: title.length))
|
||||
button.attributedTitle = title
|
||||
}
|
||||
icon?.contentTintColor = color
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class SelectionCell: NSView {
|
||||
let entry = ActionEntry(title: "", symbol: "checkmark", color: SunsetPalette.text)
|
||||
let entry = ActionEntry(title: "", symbol: "checkmark")
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import AppKit
|
||||
import TOMLDecoder
|
||||
|
||||
@MainActor
|
||||
struct ColorScheme {
|
||||
var background: NSColor
|
||||
var text: NSColor
|
||||
var secondaryText: NSColor
|
||||
var accent: NSColor
|
||||
var glow: NSColor
|
||||
var keyBackground: NSColor
|
||||
var error: NSColor
|
||||
enum Variant: String {
|
||||
case light, dark
|
||||
}
|
||||
|
||||
var variant: Variant? = nil
|
||||
|
||||
var isLight: Bool {
|
||||
if let variant { return variant == .light }
|
||||
return Self.relativeLuminance(background) > Self.relativeLuminance(text)
|
||||
}
|
||||
|
||||
static func relativeLuminance(_ color: NSColor) -> CGFloat {
|
||||
let rgb = color.usingColorSpace(.sRGB)!
|
||||
return relativeLuminance(red: rgb.redComponent, green: rgb.greenComponent, blue: rgb.blueComponent)
|
||||
}
|
||||
|
||||
static func relativeLuminance(red: CGFloat, green: CGFloat, blue: CGFloat) -> CGFloat {
|
||||
func linear(_ value: CGFloat) -> CGFloat {
|
||||
value <= 0.04045 ? value / 12.92 : pow((value + 0.055) / 1.055, 2.4)
|
||||
}
|
||||
return 0.2126 * linear(red) + 0.7152 * linear(green) + 0.0722 * linear(blue)
|
||||
}
|
||||
|
||||
static let sunset = ColorScheme(
|
||||
background: NSColor(srgbRed: 0.12, green: 0.09, blue: 0.08, alpha: 1),
|
||||
text: NSColor(srgbRed: 1.00, green: 0.89, blue: 0.71, alpha: 1),
|
||||
secondaryText: NSColor(srgbRed: 0.85, green: 0.78, blue: 0.65, alpha: 1),
|
||||
accent: NSColor(srgbRed: 0.95, green: 0.58, blue: 0.23, alpha: 1),
|
||||
glow: NSColor(srgbRed: 0.76, green: 0.27, blue: 0.18, alpha: 1),
|
||||
keyBackground: NSColor(srgbRed: 0.26, green: 0.22, blue: 0.19, alpha: 1),
|
||||
error: .systemRed
|
||||
)
|
||||
|
||||
static var configurationDirectory: URL {
|
||||
let base: URL
|
||||
if let xdg = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"], xdg.hasPrefix("/") {
|
||||
base = URL(fileURLWithPath: xdg, isDirectory: true)
|
||||
} else {
|
||||
base = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config", isDirectory: true)
|
||||
}
|
||||
return base.appendingPathComponent("conjure", isDirectory: true)
|
||||
}
|
||||
|
||||
static func load(from directory: URL) throws -> ColorScheme {
|
||||
let configURL = directory.appendingPathComponent("config.toml")
|
||||
guard let config = try readObject(at: configURL, missingAllowed: true) else { return sunset }
|
||||
try validateKeys(config, allowed: ["theme", "colors"], at: configURL, prefix: "")
|
||||
let theme: String
|
||||
if let value = config["theme"] {
|
||||
guard let name = value as? String, ["sunset", "tint"].contains(name) else {
|
||||
throw ColorSchemeError(path: configURL.path, reason: "theme must be \"sunset\" or \"tint\".")
|
||||
}
|
||||
theme = name
|
||||
} else {
|
||||
theme = "sunset"
|
||||
}
|
||||
|
||||
var result = sunset
|
||||
if theme == "tint" {
|
||||
let themeURL = directory.appendingPathComponent("theme.json")
|
||||
if let generated = try readObject(at: themeURL, missingAllowed: false) {
|
||||
try validateKeys(generated, allowed: ["variant", "colors"], at: themeURL, prefix: "")
|
||||
if let value = generated["variant"] {
|
||||
guard let name = value as? String, let variant = Variant(rawValue: name) else {
|
||||
throw ColorSchemeError(path: themeURL.path, reason: "variant must be \"light\" or \"dark\".")
|
||||
}
|
||||
result.variant = variant
|
||||
}
|
||||
try result.applyColors(from: generated, at: themeURL)
|
||||
}
|
||||
}
|
||||
try result.applyColors(from: config, at: configURL)
|
||||
return result
|
||||
}
|
||||
|
||||
private static let colorFields: [String: WritableKeyPath<ColorScheme, NSColor>] = [
|
||||
"background": \.background,
|
||||
"text": \.text,
|
||||
"secondaryText": \.secondaryText,
|
||||
"accent": \.accent,
|
||||
"glow": \.glow,
|
||||
"keyBackground": \.keyBackground,
|
||||
"error": \.error,
|
||||
]
|
||||
|
||||
private mutating func applyColors(from object: [String: Any], at url: URL) throws {
|
||||
guard let value = object["colors"] else { return }
|
||||
guard let colors = value as? [String: Any] else {
|
||||
throw ColorSchemeError(path: url.path, reason: "colors must contain named color values.")
|
||||
}
|
||||
try Self.validateKeys(colors, allowed: Set(Self.colorFields.keys), at: url, prefix: "colors.")
|
||||
for (name, value) in colors {
|
||||
guard let hex = value as? String, hex.utf8.count == 7, hex.first == "#",
|
||||
hex.utf8.dropFirst().allSatisfy({ (48...57).contains($0) || (65...70).contains($0) || (97...102).contains($0) }),
|
||||
let rgb = UInt32(hex.dropFirst(), radix: 16) else {
|
||||
throw ColorSchemeError(path: url.path, reason: "colors.\(name) must be #RRGGBB ASCII hex (no alpha).")
|
||||
}
|
||||
if let field = Self.colorFields[name] {
|
||||
self[keyPath: field] = NSColor(
|
||||
srgbRed: CGFloat((rgb >> 16) & 0xff) / 255,
|
||||
green: CGFloat((rgb >> 8) & 0xff) / 255,
|
||||
blue: CGFloat(rgb & 0xff) / 255,
|
||||
alpha: 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func validateKeys(_ object: [String: Any], allowed: Set<String>, at url: URL, prefix: String) throws {
|
||||
if let key = object.keys.sorted().first(where: { !allowed.contains($0) }) {
|
||||
throw ColorSchemeError(path: url.path, reason: "Unknown key \"\(prefix)\(key)\".")
|
||||
}
|
||||
}
|
||||
|
||||
private static func readObject(at url: URL, missingAllowed: Bool) throws -> [String: Any]? {
|
||||
let data: Data
|
||||
do {
|
||||
data = try Data(contentsOf: url)
|
||||
} catch {
|
||||
let cocoa = error as NSError
|
||||
if missingAllowed, cocoa.domain == NSCocoaErrorDomain, cocoa.code == NSFileReadNoSuchFileError {
|
||||
return nil
|
||||
}
|
||||
throw ColorSchemeError(path: url.path, reason: error.localizedDescription)
|
||||
}
|
||||
let value: Any
|
||||
let isTOML = url.pathExtension == "toml"
|
||||
do {
|
||||
if isTOML { return try Dictionary(TOMLTable(source: data)) }
|
||||
value = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])
|
||||
} catch {
|
||||
let detail = isTOML ? String(describing: error) : error.localizedDescription
|
||||
throw ColorSchemeError(path: url.path, reason: "Invalid \(isTOML ? "TOML" : "JSON"): \(detail)")
|
||||
}
|
||||
guard let object = value as? [String: Any] else {
|
||||
throw ColorSchemeError(path: url.path, reason: "Expected a JSON object.")
|
||||
}
|
||||
return object
|
||||
}
|
||||
}
|
||||
|
||||
private struct ColorSchemeError: LocalizedError {
|
||||
let path: String
|
||||
let reason: String
|
||||
|
||||
var errorDescription: String? { "\(path): \(reason)" }
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import ConjureKit
|
||||
struct ConjureApp {
|
||||
static func main() {
|
||||
let application = NSApplication.shared
|
||||
ThemePalette.startObserving()
|
||||
let delegate = ApplicationDelegate()
|
||||
application.delegate = delegate
|
||||
application.setActivationPolicy(.accessory)
|
||||
@@ -108,7 +109,7 @@ private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
|
||||
controller.retryUninstallRecovery = { [uninstaller] in await uninstaller.recover() }
|
||||
return controller
|
||||
}
|
||||
private var startupShortcutError: String?
|
||||
private var startupError = ThemePalette.configurationError
|
||||
private var startupRecoveryMessage: String?
|
||||
|
||||
private var isLoginLaunch: Bool {
|
||||
@@ -137,24 +138,23 @@ private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
|
||||
launcher.present()
|
||||
}
|
||||
let shortcutErrors = shortcuts.start()
|
||||
if !shortcutErrors.isEmpty {
|
||||
startupShortcutError = shortcutErrors.joined(separator: "\n")
|
||||
if !isLoginLaunch { showSettings(nil) }
|
||||
}
|
||||
startupError = ([startupError].compactMap { $0 } + shortcutErrors).joined(separator: "\n")
|
||||
if startupError?.isEmpty == true { startupError = nil }
|
||||
if startupError != nil && !loginLaunch { showSettings(nil) }
|
||||
}
|
||||
|
||||
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool {
|
||||
guard !isLoginLaunch else { return false }
|
||||
launcher.present()
|
||||
if startupShortcutError != nil || startupRecoveryMessage != nil {
|
||||
if startupError != nil || startupRecoveryMessage != nil {
|
||||
showSettings(nil)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@objc func showSettings(_ sender: Any?) {
|
||||
settings.present(error: startupShortcutError)
|
||||
startupShortcutError = nil
|
||||
settings.present(error: startupError)
|
||||
startupError = nil
|
||||
startupRecoveryMessage = nil
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
|
||||
private let category = NSPopUpButton()
|
||||
private let status = NSTextField(labelWithString: "")
|
||||
private let empty = NSTextField(labelWithString: "No matching emoji")
|
||||
private var themeObservation: ThemeObservation?
|
||||
private let copyButton = NSButton(title: "Copy and Paste", target: nil, action: nil)
|
||||
private let actionsButton = NSButton(title: "Actions", target: nil, action: nil)
|
||||
private let actions = ApplicationActionsController()
|
||||
@@ -38,7 +39,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
|
||||
category.action = #selector(categoryChanged)
|
||||
category.font = .systemFont(ofSize: 12)
|
||||
category.isBordered = false
|
||||
category.contentTintColor = SunsetPalette.secondaryText
|
||||
category.contentTintColor = ThemePalette.secondaryText
|
||||
category.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
layout.itemSize = NSSize(width: 80, height: 80)
|
||||
layout.minimumInteritemSpacing = 8
|
||||
@@ -59,9 +60,9 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
|
||||
scroll.scrollerStyle = .overlay
|
||||
scroll.drawsBackground = false
|
||||
scroll.contentView.drawsBackground = false
|
||||
empty.textColor = SunsetPalette.secondaryText
|
||||
empty.textColor = ThemePalette.secondaryText
|
||||
status.font = .systemFont(ofSize: 12)
|
||||
status.textColor = SunsetPalette.secondaryText
|
||||
status.textColor = ThemePalette.secondaryText
|
||||
status.lineBreakMode = .byTruncatingTail
|
||||
status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
copyButton.target = self
|
||||
@@ -93,6 +94,11 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
|
||||
footer.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -8),
|
||||
footer.heightAnchor.constraint(equalToConstant: 36),
|
||||
])
|
||||
themeObservation = ThemeObservation { [weak self] in
|
||||
self?.category.contentTintColor = ThemePalette.secondaryText
|
||||
self?.empty.textColor = ThemePalette.secondaryText
|
||||
self?.status.textColor = ThemePalette.secondaryText
|
||||
}
|
||||
inputChanged()
|
||||
}
|
||||
|
||||
@@ -308,6 +314,9 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
|
||||
@MainActor
|
||||
private final class EmojiSectionHeader: NSView {
|
||||
private let label = NSTextField(labelWithString: "")
|
||||
private var themeObservation: ThemeObservation?
|
||||
private var title = ""
|
||||
private var count = 0
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
@@ -317,16 +326,22 @@ private final class EmojiSectionHeader: NSView {
|
||||
label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
|
||||
label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8),
|
||||
])
|
||||
themeObservation = ThemeObservation { [weak self] in
|
||||
guard let self else { return }
|
||||
self.setTitle(self.title, count: self.count)
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("Conjure creates emoji headers programmatically") }
|
||||
|
||||
func setTitle(_ title: String, count: Int) {
|
||||
self.title = title
|
||||
self.count = count
|
||||
let text = NSMutableAttributedString(string: title, attributes: [
|
||||
.font: NSFont.systemFont(ofSize: 13), .foregroundColor: SunsetPalette.secondaryText,
|
||||
.font: NSFont.systemFont(ofSize: 13), .foregroundColor: ThemePalette.secondaryText,
|
||||
])
|
||||
text.append(NSAttributedString(string: " \(count)", attributes: [
|
||||
.font: NSFont.systemFont(ofSize: 12), .foregroundColor: SunsetPalette.secondaryText.withAlphaComponent(0.55),
|
||||
.font: NSFont.systemFont(ofSize: 12), .foregroundColor: ThemePalette.secondaryText.withAlphaComponent(0.55),
|
||||
]))
|
||||
label.attributedStringValue = text
|
||||
}
|
||||
@@ -348,7 +363,6 @@ private final class EmojiItem: NSCollectionViewItem {
|
||||
}
|
||||
|
||||
func highlight(_ selected: Bool) {
|
||||
button.layer?.backgroundColor = (selected ? SunsetPalette.selectionFill : SunsetPalette.text.withAlphaComponent(0.045)).cgColor
|
||||
button.setAccessibilitySelected(selected)
|
||||
button.selected = selected
|
||||
}
|
||||
@@ -361,6 +375,14 @@ private final class EmojiButton: NSButton {
|
||||
var selected = false { didSet { updateBorder() } }
|
||||
private var hovered = false { didSet { updateBorder() } }
|
||||
private var hoverTracking: NSTrackingArea?
|
||||
private var themeObservation: ThemeObservation?
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
themeObservation = ThemeObservation { [weak self] in self?.updateBorder() }
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("Conjure creates emoji buttons programmatically") }
|
||||
|
||||
override func updateTrackingAreas() {
|
||||
super.updateTrackingAreas()
|
||||
@@ -377,8 +399,9 @@ private final class EmojiButton: NSButton {
|
||||
override func mouseExited(with event: NSEvent) { hovered = false }
|
||||
|
||||
private func updateBorder() {
|
||||
layer?.backgroundColor = (selected ? ThemePalette.selectionFill : ThemePalette.text.withAlphaComponent(0.045)).cgColor
|
||||
layer?.borderWidth = selected || hovered ? 0.5 : 0
|
||||
layer?.borderColor = (selected ? SunsetPalette.accent.withAlphaComponent(0.8)
|
||||
: SunsetPalette.secondaryText.withAlphaComponent(0.35)).cgColor
|
||||
layer?.borderColor = (selected ? ThemePalette.accent.withAlphaComponent(0.8)
|
||||
: ThemePalette.secondaryText.withAlphaComponent(0.35)).cgColor
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ final class LauncherAlignmentGuides {
|
||||
private let view = AlignmentGuideView()
|
||||
private lazy var overlay: NSPanel = {
|
||||
let panel = NSPanel(contentRect: .zero, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
|
||||
panel.appearance = ThemePalette.appearance
|
||||
panel.backgroundColor = .clear
|
||||
panel.isOpaque = false
|
||||
panel.hasShadow = false
|
||||
@@ -20,6 +21,7 @@ final class LauncherAlignmentGuides {
|
||||
|
||||
func begin(for window: NSWindow) {
|
||||
guard !dragging, window.screen != nil else { return }
|
||||
overlay.appearance = ThemePalette.appearance
|
||||
dragging = true
|
||||
update(for: window)
|
||||
overlay.level = window.level
|
||||
@@ -58,9 +60,22 @@ private final class AlignmentGuideView: NSView {
|
||||
var snapsHorizontally = false
|
||||
var snapsVertically = false
|
||||
private static let dash: [CGFloat] = [1, 5]
|
||||
private static let muted = SunsetPalette.accent.withAlphaComponent(0.4).cgColor
|
||||
private static let aligned = SunsetPalette.accent.withAlphaComponent(0.95).cgColor
|
||||
private var muted = ThemePalette.accent.withAlphaComponent(0.4).cgColor
|
||||
private var aligned = ThemePalette.accent.withAlphaComponent(0.95).cgColor
|
||||
private static let shadow = NSColor.black.withAlphaComponent(0.6).cgColor
|
||||
private var themeObservation: ThemeObservation?
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
themeObservation = ThemeObservation { [weak self] in
|
||||
self?.window?.appearance = ThemePalette.appearance
|
||||
self?.muted = ThemePalette.accent.withAlphaComponent(0.4).cgColor
|
||||
self?.aligned = ThemePalette.accent.withAlphaComponent(0.95).cgColor
|
||||
self?.needsDisplay = true
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("Conjure creates alignment guides programmatically") }
|
||||
|
||||
override func hitTest(_ point: NSPoint) -> NSView? { nil }
|
||||
|
||||
@@ -75,14 +90,14 @@ private final class AlignmentGuideView: NSView {
|
||||
context.setLineDash(phase: 0, lengths: Self.dash)
|
||||
context.setShadow(offset: .zero, blur: 2, color: Self.shadow)
|
||||
|
||||
context.setStrokeColor(snapsHorizontally ? Self.aligned : Self.muted)
|
||||
context.setStrokeColor(snapsHorizontally ? aligned : muted)
|
||||
context.move(to: NSPoint(x: targetFrame.minX, y: bounds.minY))
|
||||
context.addLine(to: NSPoint(x: targetFrame.minX, y: bounds.maxY))
|
||||
context.move(to: NSPoint(x: targetFrame.maxX, y: bounds.minY))
|
||||
context.addLine(to: NSPoint(x: targetFrame.maxX, y: bounds.maxY))
|
||||
context.strokePath()
|
||||
|
||||
context.setStrokeColor(snapsVertically ? Self.aligned : Self.muted)
|
||||
context.setStrokeColor(snapsVertically ? aligned : muted)
|
||||
context.move(to: NSPoint(x: bounds.minX, y: targetFrame.maxY))
|
||||
context.addLine(to: NSPoint(x: bounds.maxX, y: targetFrame.maxY))
|
||||
context.strokePath()
|
||||
|
||||
@@ -1,34 +1,191 @@
|
||||
import AppKit
|
||||
|
||||
@MainActor
|
||||
enum SunsetPalette {
|
||||
static let background = NSColor(srgbRed: 0.12, green: 0.09, blue: 0.08, alpha: 0.55)
|
||||
static let text = NSColor(srgbRed: 1.00, green: 0.89, blue: 0.71, alpha: 1)
|
||||
static let secondaryText = NSColor(srgbRed: 0.85, green: 0.78, blue: 0.65, alpha: 1)
|
||||
static let accent = NSColor(srgbRed: 0.95, green: 0.58, blue: 0.23, alpha: 1)
|
||||
static let selectionFill = accent.withAlphaComponent(0.12)
|
||||
static let selectionBorder = accent.withAlphaComponent(0.30)
|
||||
static let ember = NSColor(srgbRed: 0.76, green: 0.27, blue: 0.18, alpha: 1)
|
||||
enum ThemePalette {
|
||||
@MainActor
|
||||
struct State {
|
||||
let scheme: ColorScheme
|
||||
var error: String?
|
||||
var usesFallback: Bool
|
||||
let isLight: Bool
|
||||
let appearance: NSAppearance
|
||||
let secondaryText: NSColor
|
||||
let glowStrength: CGFloat
|
||||
let background: NSColor
|
||||
let selectionFill: NSColor
|
||||
let selectionBorder: NSColor
|
||||
let keyBackground: NSColor
|
||||
|
||||
init(scheme: ColorScheme, error: String? = nil, usesFallback: Bool = false) {
|
||||
self.scheme = scheme
|
||||
self.error = error
|
||||
self.usesFallback = usesFallback
|
||||
let light = scheme.isLight
|
||||
isLight = light
|
||||
appearance = NSAppearance(named: light ? .aqua : .darkAqua)!
|
||||
glowStrength = light ? 0.35 : 1
|
||||
background = scheme.background.withAlphaComponent(light ? 0.82 : 0.55)
|
||||
secondaryText = light ? Self.readableSecondaryText(scheme) : scheme.secondaryText
|
||||
selectionFill = scheme.accent.withAlphaComponent(0.12)
|
||||
selectionBorder = scheme.accent.withAlphaComponent(0.30)
|
||||
keyBackground = scheme.keyBackground.withAlphaComponent(0.5)
|
||||
}
|
||||
|
||||
private static func readableSecondaryText(_ scheme: ColorScheme) -> NSColor {
|
||||
let background = scheme.background.usingColorSpace(.sRGB)!
|
||||
let accent = scheme.accent.usingColorSpace(.sRGB)!
|
||||
let glow = scheme.glow.usingColorSpace(.sRGB)!
|
||||
// Black behind the translucent fill bounds every desktop. Each gradient may
|
||||
// darken a channel independently, including the third, dimmed accent glow.
|
||||
func darkestChannel(_ base: CGFloat, _ accent: CGFloat, _ glow: CGFloat) -> CGFloat {
|
||||
var value = base * 0.82
|
||||
value = min(value, value * (1 - 0.26 * 0.35) + glow * 0.26 * 0.35)
|
||||
value = min(value, value * (1 - 0.18 * 0.35) + accent * 0.18 * 0.35)
|
||||
return min(value, value * (1 - 0.18 * 0.35 * 0.45) + accent * 0.18 * 0.35 * 0.45)
|
||||
}
|
||||
let surface = ColorScheme.relativeLuminance(
|
||||
red: darkestChannel(background.redComponent, accent.redComponent, glow.redComponent),
|
||||
green: darkestChannel(background.greenComponent, accent.greenComponent, glow.greenComponent),
|
||||
blue: darkestChannel(background.blueComponent, accent.blueComponent, glow.blueComponent))
|
||||
// A small margin covers 8-bit surface rounding.
|
||||
let ceiling = max(0, (surface + 0.05) / 4.55 - 0.05)
|
||||
guard ColorScheme.relativeLuminance(scheme.secondaryText) > ceiling else {
|
||||
return scheme.secondaryText
|
||||
}
|
||||
let source = scheme.secondaryText.usingColorSpace(.sRGB)!
|
||||
let target = (ColorScheme.relativeLuminance(scheme.text) <= ceiling ? scheme.text : .black)
|
||||
.usingColorSpace(.sRGB)!
|
||||
func component(_ from: CGFloat, _ to: CGFloat, _ fraction: CGFloat) -> CGFloat {
|
||||
from + (to - from) * fraction
|
||||
}
|
||||
var lower: CGFloat = 0
|
||||
var upper: CGFloat = 1
|
||||
for _ in 0..<24 {
|
||||
let fraction = (lower + upper) / 2
|
||||
let luminance = ColorScheme.relativeLuminance(
|
||||
red: component(source.redComponent, target.redComponent, fraction),
|
||||
green: component(source.greenComponent, target.greenComponent, fraction),
|
||||
blue: component(source.blueComponent, target.blueComponent, fraction))
|
||||
if luminance > ceiling { lower = fraction } else { upper = fraction }
|
||||
}
|
||||
return NSColor(srgbRed: component(source.redComponent, target.redComponent, upper),
|
||||
green: component(source.greenComponent, target.greenComponent, upper),
|
||||
blue: component(source.blueComponent, target.blueComponent, upper), alpha: 1)
|
||||
}
|
||||
}
|
||||
|
||||
static var loaded: State = {
|
||||
do {
|
||||
return State(scheme: try ColorScheme.load(from: ColorScheme.configurationDirectory))
|
||||
} catch {
|
||||
return State(scheme: .sunset,
|
||||
error: "Color configuration: \(error.localizedDescription)\nUsing the built-in sunset palette. Fix the file to apply changes.",
|
||||
usesFallback: true)
|
||||
}
|
||||
}()
|
||||
private static var observation: ColorSchemeObservation?
|
||||
|
||||
static let didChangeNotification = Notification.Name("ConjureThemePaletteDidChange")
|
||||
static var configurationError: String? { loaded.error }
|
||||
static var isLight: Bool { loaded.isLight }
|
||||
static var appearance: NSAppearance { loaded.appearance }
|
||||
static var background: NSColor { loaded.background }
|
||||
static var text: NSColor { loaded.scheme.text }
|
||||
static var secondaryText: NSColor { loaded.secondaryText }
|
||||
static var accent: NSColor { loaded.scheme.accent }
|
||||
static var selectionFill: NSColor { loaded.selectionFill }
|
||||
static var selectionBorder: NSColor { loaded.selectionBorder }
|
||||
static var glow: NSColor { loaded.scheme.glow }
|
||||
static var glowStrength: CGFloat { loaded.glowStrength }
|
||||
static var keyBackground: NSColor { loaded.keyBackground }
|
||||
static var error: NSColor { loaded.scheme.error }
|
||||
|
||||
static func startObserving() {
|
||||
guard observation == nil else { return }
|
||||
let directory = ColorScheme.configurationDirectory
|
||||
observation = ColorSchemeObservation(directory: directory) {
|
||||
let previousError = configurationError
|
||||
reload(from: directory)
|
||||
if let diagnostic = configurationError, diagnostic != previousError {
|
||||
NSLog("%@", diagnostic)
|
||||
}
|
||||
}
|
||||
reload(from: directory)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func reload(from directory: URL = ColorScheme.configurationDirectory) -> Bool {
|
||||
do {
|
||||
let scheme = try ColorScheme.load(from: directory)
|
||||
let previous = loaded.scheme
|
||||
guard scheme.background != previous.background || scheme.text != previous.text
|
||||
|| scheme.secondaryText != previous.secondaryText || scheme.accent != previous.accent
|
||||
|| scheme.glow != previous.glow || scheme.keyBackground != previous.keyBackground
|
||||
|| scheme.error != previous.error || scheme.variant != previous.variant else {
|
||||
loaded.error = nil
|
||||
loaded.usesFallback = false
|
||||
return false
|
||||
}
|
||||
loaded = State(scheme: scheme)
|
||||
NotificationCenter.default.post(name: didChangeNotification, object: nil)
|
||||
return true
|
||||
} catch {
|
||||
let recovery = loaded.usesFallback
|
||||
? "Using the built-in sunset palette."
|
||||
: "Keeping the last valid palette."
|
||||
loaded.error = "Color configuration: \(error.localizedDescription)\n\(recovery) Fix the file to apply changes."
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class LauncherBackground: NSView {
|
||||
weak var canvas: NSView?
|
||||
private let amber = NSGradient(colors: [
|
||||
SunsetPalette.accent.withAlphaComponent(0.18),
|
||||
SunsetPalette.accent.withAlphaComponent(0.065),
|
||||
SunsetPalette.accent.withAlphaComponent(0),
|
||||
private var amber = NSGradient(colors: [
|
||||
ThemePalette.accent.withAlphaComponent(0.18 * ThemePalette.glowStrength),
|
||||
ThemePalette.accent.withAlphaComponent(0.065 * ThemePalette.glowStrength),
|
||||
ThemePalette.accent.withAlphaComponent(0),
|
||||
])!
|
||||
private let ember = NSGradient(colors: [
|
||||
SunsetPalette.ember.withAlphaComponent(0.26),
|
||||
SunsetPalette.ember.withAlphaComponent(0.10),
|
||||
SunsetPalette.ember.withAlphaComponent(0),
|
||||
private var ember = NSGradient(colors: [
|
||||
ThemePalette.glow.withAlphaComponent(0.26 * ThemePalette.glowStrength),
|
||||
ThemePalette.glow.withAlphaComponent(0.10 * ThemePalette.glowStrength),
|
||||
ThemePalette.glow.withAlphaComponent(0),
|
||||
])!
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
observePalette()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
observePalette()
|
||||
}
|
||||
|
||||
private func observePalette() {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(paletteChanged),
|
||||
name: ThemePalette.didChangeNotification, object: nil)
|
||||
}
|
||||
|
||||
@objc private func paletteChanged() {
|
||||
amber = NSGradient(colors: [
|
||||
ThemePalette.accent.withAlphaComponent(0.18 * ThemePalette.glowStrength),
|
||||
ThemePalette.accent.withAlphaComponent(0.065 * ThemePalette.glowStrength),
|
||||
ThemePalette.accent.withAlphaComponent(0),
|
||||
])!
|
||||
ember = NSGradient(colors: [
|
||||
ThemePalette.glow.withAlphaComponent(0.26 * ThemePalette.glowStrength),
|
||||
ThemePalette.glow.withAlphaComponent(0.10 * ThemePalette.glowStrength),
|
||||
ThemePalette.glow.withAlphaComponent(0),
|
||||
])!
|
||||
needsDisplay = true
|
||||
}
|
||||
|
||||
override func hitTest(_ point: NSPoint) -> NSView? { nil }
|
||||
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
SunsetPalette.background.setFill()
|
||||
ThemePalette.background.setFill()
|
||||
dirtyRect.fill()
|
||||
let canvasBounds = canvas.map { convert($0.bounds, from: $0) } ?? bounds
|
||||
let upperGlow = NSPoint(x: canvasBounds.minX + canvasBounds.width * 0.58,
|
||||
|
||||
@@ -6,6 +6,7 @@ import ApplicationServices
|
||||
@MainActor
|
||||
final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate {
|
||||
private let searchField = NSSearchField()
|
||||
private var themeObservation: ThemeObservation?
|
||||
private let searchSymbol = IconView(font: .systemFont(ofSize: 20))
|
||||
private let commandBack = NSButton(title: "", target: nil, action: nil)
|
||||
private let translationHeader = NSStackView()
|
||||
@@ -36,6 +37,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
private var launcherViews: [NSView] = []
|
||||
private var launcherFrameBeforeReview: NSRect?
|
||||
private var translation: TranslationController?
|
||||
private var translationDraft: TranslationController.Draft?
|
||||
private var emojiPicker: EmojiPickerController?
|
||||
private var notes: NotesController?
|
||||
private let notesDirectory: URL?
|
||||
@@ -80,7 +82,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
window.titleVisibility = .hidden
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.animationBehavior = .none
|
||||
window.appearance = NSAppearance(named: .darkAqua)
|
||||
window.appearance = ThemePalette.appearance
|
||||
window.backgroundColor = .clear
|
||||
window.isOpaque = false
|
||||
window.hasShadow = true
|
||||
@@ -169,7 +171,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
if let notes { notes.focusEditor() }
|
||||
else { window?.makeFirstResponder(searchField) }
|
||||
(window as? LauncherWindow)?.sampleNumberHints()
|
||||
(searchField.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent
|
||||
(searchField.currentEditor() as? NSTextView)?.insertionPointColor = ThemePalette.accent
|
||||
refreshApplications()
|
||||
}
|
||||
|
||||
@@ -276,9 +278,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
searchField.font = searchFont
|
||||
searchField.placeholderAttributedString = NSAttributedString(
|
||||
string: "Search apps, calculate, or convert…",
|
||||
attributes: [.foregroundColor: SunsetPalette.secondaryText, .font: searchFont]
|
||||
attributes: [.foregroundColor: ThemePalette.secondaryText, .font: searchFont]
|
||||
)
|
||||
searchField.textColor = SunsetPalette.text
|
||||
searchField.textColor = ThemePalette.text
|
||||
searchField.isBordered = false
|
||||
searchField.drawsBackground = false
|
||||
searchField.focusRingType = .none
|
||||
@@ -292,11 +294,11 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
searchField.setAccessibilityLabel("Search applications, calculate, or convert units and currencies")
|
||||
|
||||
searchSymbol.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil)
|
||||
searchSymbol.contentTintColor = SunsetPalette.accent
|
||||
searchSymbol.contentTintColor = ThemePalette.accent
|
||||
commandBack.image = IconStyle.symbol("chevron.left", for: searchFont)
|
||||
commandBack.imageScaling = .scaleProportionallyDown
|
||||
commandBack.isBordered = false
|
||||
commandBack.contentTintColor = SunsetPalette.secondaryText
|
||||
commandBack.contentTintColor = ThemePalette.secondaryText
|
||||
commandBack.setAccessibilityLabel("Back to applications")
|
||||
commandBack.toolTip = "Back to applications"
|
||||
commandBack.isHidden = true
|
||||
@@ -311,7 +313,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
header.spacing = 12
|
||||
|
||||
sectionLabel.font = .systemFont(ofSize: 12, weight: .semibold)
|
||||
sectionLabel.textColor = SunsetPalette.secondaryText
|
||||
sectionLabel.textColor = ThemePalette.secondaryText
|
||||
|
||||
let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("application"))
|
||||
column.resizingMask = .autoresizingMask
|
||||
@@ -350,9 +352,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
scrollView.contentInsets = NSEdgeInsets(top: LauncherScrollView.fadeHeight, left: 0, bottom: 16, right: 0)
|
||||
|
||||
statusLabel.font = .systemFont(ofSize: 12)
|
||||
statusLabel.textColor = SunsetPalette.secondaryText
|
||||
statusLabel.textColor = ThemePalette.secondaryText
|
||||
statusLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
emptyLabel.textColor = SunsetPalette.secondaryText
|
||||
emptyLabel.textColor = ThemePalette.secondaryText
|
||||
emptyLabel.alignment = .center
|
||||
|
||||
openButton.target = self
|
||||
@@ -395,11 +397,24 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
footer.heightAnchor.constraint(equalToConstant: 36),
|
||||
])
|
||||
window.effectiveAppearance.performAsCurrentDrawingAppearance {
|
||||
let border = SunsetPalette.secondaryText.withAlphaComponent(0.22).cgColor
|
||||
let border = ThemePalette.secondaryText.withAlphaComponent(0.22).cgColor
|
||||
content.layer?.borderColor = border
|
||||
}
|
||||
window.initialFirstResponder = searchField
|
||||
launcherViews = [header, sectionLabel, scrollView, emptyLabel, footer]
|
||||
themeObservation = ThemeObservation { [weak self, weak content] in
|
||||
guard let self else { return }
|
||||
self.window?.appearance = ThemePalette.appearance
|
||||
self.window?.attachedSheet?.appearance = ThemePalette.appearance
|
||||
self.searchField.applyTheme()
|
||||
(self.searchField.currentEditor() as? NSTextView)?.insertionPointColor = ThemePalette.accent
|
||||
self.searchSymbol.contentTintColor = ThemePalette.accent
|
||||
self.commandBack.contentTintColor = ThemePalette.secondaryText
|
||||
for label in [self.sectionLabel, self.statusLabel, self.emptyLabel] {
|
||||
label.textColor = ThemePalette.secondaryText
|
||||
}
|
||||
content?.layer?.borderColor = ThemePalette.secondaryText.withAlphaComponent(0.22).cgColor
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshApplications() {
|
||||
@@ -569,7 +584,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
cell.kindLabel.stringValue = isCurrencyCalculation ? "Daily rates"
|
||||
: calculation.copyValue == nil ? "Error" : "Calculator"
|
||||
cell.imageView?.image = NSImage(systemSymbolName: "equal", accessibilityDescription: nil)
|
||||
cell.imageView?.contentTintColor = SunsetPalette.accent
|
||||
cell.imageView?.contentTintColor = ThemePalette.accent
|
||||
cell.toolTip = calculation.detail
|
||||
} else if results[row].command != nil {
|
||||
let entry = results[row]
|
||||
@@ -755,6 +770,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
dismiss()
|
||||
} catch {
|
||||
let alert = NSAlert(error: error)
|
||||
alert.window.appearance = ThemePalette.appearance
|
||||
alert.messageText = "Couldn’t quit \(application.name)"
|
||||
if let window { alert.beginSheetModal(for: window) }
|
||||
}
|
||||
@@ -776,6 +792,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
dismiss(restoringFocus: false)
|
||||
} catch {
|
||||
let alert = NSAlert(error: error)
|
||||
alert.window.appearance = ThemePalette.appearance
|
||||
alert.messageText = "Couldn’t open \(application.name)"
|
||||
if let window {
|
||||
await alert.beginSheetModal(for: window)
|
||||
@@ -846,6 +863,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
if dismissOnSuccess { dismiss() }
|
||||
} catch {
|
||||
let alert = NSAlert(error: error)
|
||||
alert.window.appearance = ThemePalette.appearance
|
||||
alert.messageText = "Couldn’t run \(Application(command: command).name)"
|
||||
if let window, window.isVisible {
|
||||
await alert.beginSheetModal(for: window)
|
||||
@@ -869,7 +887,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
let accessibilityLabel: String
|
||||
switch command {
|
||||
case .translate:
|
||||
let tool = TranslationController(defaults: defaults, pasteboard: pasteboard)
|
||||
let tool = TranslationController(defaults: defaults, pasteboard: pasteboard, draft: translationDraft)
|
||||
tool.dismiss = { [weak self] in self?.dismiss() }
|
||||
translation = tool
|
||||
tool.input = { [weak self] in self?.searchField.stringValue ?? "" }
|
||||
@@ -914,6 +932,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
guard let self, let controller else { return }
|
||||
controller.view.removeFromSuperview()
|
||||
self.translation?.providerControl.removeFromSuperview()
|
||||
if let translation = self.translation { self.translationDraft = translation.draft }
|
||||
self.translationHeader.isHidden = true
|
||||
self.translation = nil
|
||||
self.emojiPicker = nil
|
||||
@@ -927,7 +946,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
windowSize: previousFrame.size, visibleFrame: window.screen?.visibleFrame ?? previousFrame)
|
||||
window.styleMask = [.borderless]
|
||||
window.minSize = .zero
|
||||
window.appearance = NSAppearance(named: .darkAqua)
|
||||
window.appearance = ThemePalette.appearance
|
||||
window.setFrame(frame, display: true)
|
||||
if let material = content as? NSVisualEffectView { roundMaterialCorners(material, radius: 24) }
|
||||
}
|
||||
@@ -959,7 +978,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
commandBack.target = self
|
||||
commandBack.action = #selector(goBackCommand)
|
||||
commandBack.isHidden = false
|
||||
searchField.stringValue = ""
|
||||
searchField.stringValue = command == .translate ? translationDraft?.input ?? "" : ""
|
||||
searchField.placeholderAttributedString = NSAttributedString(
|
||||
string: placeholder,
|
||||
attributes: previousPlaceholder?.attributes(at: 0, effectiveRange: nil) ?? [:]
|
||||
@@ -968,7 +987,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
if command == .notes {
|
||||
let savedSize = defaults.string(forKey: Self.notesSizeKey).map(NSSizeFromString)
|
||||
?? NSSize(width: 570, height: 404)
|
||||
window?.appearance = nil
|
||||
window?.appearance = ThemePalette.appearance
|
||||
window?.styleMask = [.titled, .closable, .resizable, .fullSizeContentView]
|
||||
window?.minSize = NSSize(width: 570, height: 260)
|
||||
window?.standardWindowButton(.miniaturizeButton)?.isHidden = true
|
||||
@@ -994,6 +1013,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
content.addSubview(controller.view)
|
||||
if let translation {
|
||||
translationHeader.addArrangedSubview(translation.providerControl)
|
||||
commandBack.setAccessibilityLabel(translation.backAccessibilityLabel)
|
||||
commandBack.toolTip = translation.backAccessibilityLabel
|
||||
translationHeader.isHidden = false
|
||||
}
|
||||
NSLayoutConstraint.activate([
|
||||
@@ -1015,12 +1036,28 @@ final class ApplicationCell: NSTableCellView {
|
||||
private static let opaqueMask = [NSColor.black.cgColor, NSColor.black.cgColor]
|
||||
private static let fadedMask = [NSColor.black.cgColor, NSColor.clear.cgColor]
|
||||
private static let timing = CAMediaTimingFunction(name: .easeOut)
|
||||
static let applicationKind = NSAttributedString(string: "Application", attributes: [
|
||||
.font: NSFont.systemFont(ofSize: LauncherRowView.textSize), .foregroundColor: SunsetPalette.secondaryText
|
||||
private static var kindColor = ThemePalette.secondaryText
|
||||
private static var cachedApplicationKind = NSAttributedString(string: "Application", attributes: [
|
||||
.font: NSFont.systemFont(ofSize: LauncherRowView.textSize), .foregroundColor: ThemePalette.secondaryText
|
||||
])
|
||||
static let commandKind = NSAttributedString(string: "Command", attributes: applicationKind.attributes(at: 0, effectiveRange: nil))
|
||||
static let favoriteKind = favorited(applicationKind)
|
||||
static let favoriteCommandKind = favorited(commandKind)
|
||||
private static var cachedCommandKind = NSAttributedString(string: "Command", attributes: cachedApplicationKind.attributes(at: 0, effectiveRange: nil))
|
||||
private static var cachedFavoriteKind = favorited(cachedApplicationKind)
|
||||
private static var cachedFavoriteCommandKind = favorited(cachedCommandKind)
|
||||
static var applicationKind: NSAttributedString { refreshKinds(); return cachedApplicationKind }
|
||||
static var commandKind: NSAttributedString { refreshKinds(); return cachedCommandKind }
|
||||
static var favoriteKind: NSAttributedString { refreshKinds(); return cachedFavoriteKind }
|
||||
static var favoriteCommandKind: NSAttributedString { refreshKinds(); return cachedFavoriteCommandKind }
|
||||
|
||||
private static func refreshKinds() {
|
||||
guard kindColor != ThemePalette.secondaryText else { return }
|
||||
kindColor = ThemePalette.secondaryText
|
||||
cachedApplicationKind = NSAttributedString(string: "Application", attributes: [
|
||||
.font: NSFont.systemFont(ofSize: LauncherRowView.textSize), .foregroundColor: kindColor
|
||||
])
|
||||
cachedCommandKind = NSAttributedString(string: "Command", attributes: cachedApplicationKind.attributes(at: 0, effectiveRange: nil))
|
||||
cachedFavoriteKind = favorited(cachedApplicationKind)
|
||||
cachedFavoriteCommandKind = favorited(cachedCommandKind)
|
||||
}
|
||||
|
||||
private static func favorited(_ kind: NSAttributedString) -> NSAttributedString {
|
||||
let font = kind.attribute(.font, at: 0, effectiveRange: nil) as! NSFont
|
||||
@@ -1028,7 +1065,7 @@ final class ApplicationCell: NSTableCellView {
|
||||
attachment.image = NSImage(systemSymbolName: "star.fill", accessibilityDescription: "Favorite")?
|
||||
.withSymbolConfiguration(IconStyle.symbolConfiguration(for: font)
|
||||
.applying(.init(scale: .small))
|
||||
.applying(.init(paletteColors: [SunsetPalette.secondaryText])))
|
||||
.applying(.init(paletteColors: [ThemePalette.secondaryText])))
|
||||
let size = attachment.image?.size ?? .zero
|
||||
attachment.bounds = NSRect(x: 0, y: (font.capHeight - size.height) / 2,
|
||||
width: size.width, height: size.height)
|
||||
@@ -1041,12 +1078,13 @@ final class ApplicationCell: NSTableCellView {
|
||||
let locationLabel = NSTextField(labelWithString: "")
|
||||
let kindLabel = NSTextField(labelWithString: "Application")
|
||||
let numberHint = ShortcutIndicator(
|
||||
"1", backgroundColor: NSColor(srgbRed: 0.26, green: 0.22, blue: 0.19, alpha: 0.5),
|
||||
"1", hasBackground: true,
|
||||
fontSize: 10, keySize: 20
|
||||
)
|
||||
private let kindFade = CAGradientLayer()
|
||||
private var numberHintVisible = false
|
||||
private var hintGeneration = 0
|
||||
private var themeObservation: ThemeObservation?
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
@@ -1054,10 +1092,10 @@ final class ApplicationCell: NSTableCellView {
|
||||
let icon = IconView(font: font)
|
||||
let label = NSTextField(labelWithString: "")
|
||||
label.font = font
|
||||
label.textColor = SunsetPalette.text
|
||||
label.textColor = ThemePalette.text
|
||||
label.lineBreakMode = .byTruncatingTail
|
||||
locationLabel.font = .systemFont(ofSize: 12)
|
||||
locationLabel.textColor = SunsetPalette.secondaryText
|
||||
locationLabel.textColor = ThemePalette.secondaryText
|
||||
locationLabel.lineBreakMode = .byTruncatingMiddle
|
||||
locationLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
locationLabel.isHidden = true
|
||||
@@ -1066,7 +1104,7 @@ final class ApplicationCell: NSTableCellView {
|
||||
title.alignment = .centerY
|
||||
title.spacing = 8
|
||||
kindLabel.font = .systemFont(ofSize: font.pointSize)
|
||||
kindLabel.textColor = SunsetPalette.secondaryText
|
||||
kindLabel.textColor = ThemePalette.secondaryText
|
||||
kindLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
kindLabel.setAccessibilityElement(false)
|
||||
kindLabel.wantsLayer = true
|
||||
@@ -1094,10 +1132,28 @@ final class ApplicationCell: NSTableCellView {
|
||||
numberHint.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
|
||||
numberHint.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
])
|
||||
themeObservation = ThemeObservation { [weak self] in self?.applyTheme() }
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("Conjure creates application cells programmatically") }
|
||||
|
||||
private func applyTheme() {
|
||||
Self.refreshKinds()
|
||||
textField?.textColor = ThemePalette.text
|
||||
locationLabel.textColor = ThemePalette.secondaryText
|
||||
let kind = kindLabel.stringValue
|
||||
let favorite = kindLabel.attributedStringValue.length > 0
|
||||
&& kindLabel.attributedStringValue.attribute(.attachment, at: 0, effectiveRange: nil) != nil
|
||||
if kind.hasSuffix("Application") {
|
||||
kindLabel.attributedStringValue = favorite ? Self.favoriteKind : Self.applicationKind
|
||||
} else if kind.hasSuffix("Command") {
|
||||
kindLabel.attributedStringValue = favorite ? Self.favoriteCommandKind : Self.commandKind
|
||||
} else {
|
||||
kindLabel.textColor = ThemePalette.secondaryText
|
||||
}
|
||||
if imageView?.contentTintColor != nil { imageView?.contentTintColor = ThemePalette.accent }
|
||||
}
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
updateKindFadeGeometry()
|
||||
@@ -1338,6 +1394,10 @@ final class LauncherWindow: NSWindow {
|
||||
override func sendEvent(_ event: NSEvent) {
|
||||
if event.type == .keyDown, event.keyCode == 53, attachedSheet == nil,
|
||||
event.modifierFlags.intersection([.command, .control, .option, .shift]).isEmpty {
|
||||
if let editor = firstResponder as? NotesTextView, editor.vimEnabled, editor.hasMarkedText() {
|
||||
super.sendEvent(event)
|
||||
return
|
||||
}
|
||||
cancelOperation(nil)
|
||||
return
|
||||
}
|
||||
@@ -1396,6 +1456,14 @@ final class LauncherRowView: NSTableRowView {
|
||||
static let rowHeight: CGFloat = 38
|
||||
static let textSize: CGFloat = 14
|
||||
static let iconToTextSpacing: CGFloat = 12
|
||||
private var themeObservation: ThemeObservation?
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
themeObservation = ThemeObservation { [weak self] in self?.needsDisplay = true }
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("Conjure creates result rows programmatically") }
|
||||
var topSpacing: CGFloat = 0 {
|
||||
didSet { needsLayout = true; needsDisplay = true }
|
||||
}
|
||||
@@ -1412,10 +1480,10 @@ final class LauncherRowView: NSTableRowView {
|
||||
selectionBounds.origin.y += topSpacing
|
||||
selectionBounds.size.height -= topSpacing
|
||||
let selection = NSBezierPath(roundedRect: selectionBounds.insetBy(dx: 0.75, dy: 0.75), xRadius: 10, yRadius: 10)
|
||||
SunsetPalette.selectionFill.setFill()
|
||||
ThemePalette.selectionFill.setFill()
|
||||
selection.fill()
|
||||
let increasedContrast = NSWorkspace.shared.accessibilityDisplayShouldIncreaseContrast
|
||||
(increasedContrast ? SunsetPalette.accent : SunsetPalette.selectionBorder).setStroke()
|
||||
(increasedContrast ? ThemePalette.accent : ThemePalette.selectionBorder).setStroke()
|
||||
selection.lineWidth = increasedContrast ? 1.5 : 0.5
|
||||
selection.stroke()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import AppKit
|
||||
import ConjureKit
|
||||
import Highlighter
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
@MainActor
|
||||
@@ -21,13 +20,16 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
private let actions = ApplicationActionsController()
|
||||
private let editor = NotesTextView()
|
||||
private let status = NSTextField(labelWithString: "")
|
||||
private var themeObservation: ThemeObservation?
|
||||
private let noteTitle = NSTextField(labelWithString: "Untitled Note")
|
||||
private let vimButton = NSButton(title: "Vim Off", target: nil, action: nil)
|
||||
private let formatting = NSStackView()
|
||||
private static let formattingKeys: [(key: String, modifiers: NSEvent.ModifierFlags)] = [
|
||||
("h", [.command, .shift]), ("b", .command), ("i", .command),
|
||||
("x", [.command, .shift]), ("e", .command), ("k", [.command, .shift]),
|
||||
("s", [.command, .shift]), ("e", .command), ("k", [.command, .shift]),
|
||||
("e", [.command, .shift]), ("b", [.command, .shift]),
|
||||
("o", [.command, .shift]), ("u", [.command, .shift]), ("t", [.command, .shift]),
|
||||
("o", [.command, .shift]), ("l", [.command, .shift]), ("t", [.command, .shift]),
|
||||
("u", [.command, .shift]),
|
||||
]
|
||||
private let defaults: UserDefaults
|
||||
private var store: NotesStore?
|
||||
@@ -58,20 +60,31 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
actionsButton.action = #selector(showActions)
|
||||
let controls = ActionPill(primaryButton: browseButton, actionsButton: actionsButton, primaryCommand: "p")
|
||||
noteTitle.font = .systemFont(ofSize: 13)
|
||||
noteTitle.textColor = SunsetPalette.secondaryText
|
||||
noteTitle.textColor = ThemePalette.secondaryText
|
||||
noteTitle.alignment = .center
|
||||
noteTitle.lineBreakMode = .byTruncatingTail
|
||||
noteTitle.setAccessibilityLabel("Note title")
|
||||
let header = NSView()
|
||||
for control in [noteTitle] {
|
||||
vimButton.target = self
|
||||
vimButton.action = #selector(toggleVim)
|
||||
vimButton.bezelStyle = .accessoryBarAction
|
||||
vimButton.font = .monospacedSystemFont(ofSize: 10, weight: .medium)
|
||||
vimButton.contentTintColor = ThemePalette.secondaryText
|
||||
vimButton.setAccessibilityLabel("Toggle Vim editing")
|
||||
vimButton.toolTip = "Toggle Vim editing. Escape: Normal mode, then close; i: Insert; v: Visual."
|
||||
editor.vimModeChanged = { [weak self] in self?.updateVimIndicator() }
|
||||
editor.vimEnabled = defaults.bool(forKey: "notesVimEnabled")
|
||||
for control in [noteTitle, vimButton] {
|
||||
control.translatesAutoresizingMaskIntoConstraints = false
|
||||
header.addSubview(control)
|
||||
control.centerYAnchor.constraint(equalTo: header.centerYAnchor).isActive = true
|
||||
}
|
||||
NSLayoutConstraint.activate([
|
||||
noteTitle.centerXAnchor.constraint(equalTo: header.centerXAnchor),
|
||||
vimButton.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -12),
|
||||
vimButton.widthAnchor.constraint(equalToConstant: 90),
|
||||
noteTitle.leadingAnchor.constraint(greaterThanOrEqualTo: header.leadingAnchor, constant: 44),
|
||||
noteTitle.trailingAnchor.constraint(lessThanOrEqualTo: header.trailingAnchor, constant: -44),
|
||||
noteTitle.trailingAnchor.constraint(lessThanOrEqualTo: vimButton.leadingAnchor, constant: -8),
|
||||
])
|
||||
actions.closed = { [weak self] in self?.focusEditor() }
|
||||
actions.selectNote = { [weak self] in self?.switchNote($0) }
|
||||
@@ -85,6 +98,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
("strikethrough", "Strikethrough"), ("chevron.left.forwardslash.chevron.right", "Inline code"),
|
||||
("link", "Link"), ("curlybraces.square", "Code block"), ("text.quote", "Quote"),
|
||||
("list.number", "Ordered list"), ("list.bullet", "Bullet list"), ("checklist", "Checklist"),
|
||||
("underline", "Underline"),
|
||||
]
|
||||
for (index, tool) in tools.enumerated() {
|
||||
let button = iconButton(tool.0, label: tool.1, action: #selector(formatNote(_:)))
|
||||
@@ -117,9 +131,9 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
editor.importsGraphics = true
|
||||
editor.allowsUndo = true
|
||||
editor.drawsBackground = false
|
||||
editor.textColor = SunsetPalette.text
|
||||
editor.insertionPointColor = SunsetPalette.accent
|
||||
editor.selectedTextAttributes = [.backgroundColor: SunsetPalette.selectionFill]
|
||||
editor.textColor = ThemePalette.text
|
||||
editor.insertionPointColor = ThemePalette.accent
|
||||
editor.selectedTextAttributes = [.backgroundColor: ThemePalette.selectionFill]
|
||||
editor.font = NotesAppearance.font
|
||||
editor.defaultParagraphStyle = NotesAppearance.paragraph
|
||||
editor.isAutomaticQuoteSubstitutionEnabled = false
|
||||
@@ -142,7 +156,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
scroll.contentInsets = NSEdgeInsets(top: scroll.edgeFadeHeight, left: 0,
|
||||
bottom: scroll.edgeFadeHeight, right: 0)
|
||||
status.font = .systemFont(ofSize: 12)
|
||||
status.textColor = SunsetPalette.secondaryText
|
||||
status.textColor = ThemePalette.secondaryText
|
||||
status.setAccessibilityLabel("Notes save status")
|
||||
status.lineBreakMode = .byTruncatingTail
|
||||
status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
@@ -163,7 +177,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
body.topAnchor.constraint(equalTo: header.bottomAnchor),
|
||||
body.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
body.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
body.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
body.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -8),
|
||||
scroll.widthAnchor.constraint(equalTo: body.widthAnchor),
|
||||
footer.leadingAnchor.constraint(equalTo: body.leadingAnchor, constant: 24),
|
||||
footer.trailingAnchor.constraint(equalTo: body.trailingAnchor, constant: -8),
|
||||
@@ -172,6 +186,15 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 80),
|
||||
footer.heightAnchor.constraint(equalToConstant: 36),
|
||||
])
|
||||
themeObservation = ThemeObservation { [weak self] in
|
||||
guard let self else { return }
|
||||
self.noteTitle.textColor = ThemePalette.secondaryText
|
||||
self.vimButton.contentTintColor = ThemePalette.secondaryText
|
||||
self.status.textColor = ThemePalette.error
|
||||
for case let button as NSButton in self.formatting.arrangedSubviews {
|
||||
button.contentTintColor = ThemePalette.secondaryText
|
||||
}
|
||||
}
|
||||
guard let store else {
|
||||
editor.isEditable = false
|
||||
for control in [browseButton, actionsButton] { control.isEnabled = false }
|
||||
@@ -188,6 +211,18 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
showError(error)
|
||||
}
|
||||
}
|
||||
@objc private func toggleVim() {
|
||||
editor.vimEnabled.toggle()
|
||||
defaults.set(editor.vimEnabled, forKey: "notesVimEnabled")
|
||||
focusEditor()
|
||||
}
|
||||
|
||||
private func updateVimIndicator() {
|
||||
vimButton.title = editor.vimEnabled ? editor.vimMode.rawValue : "Vim Off"
|
||||
vimButton.setAccessibilityValue(editor.vimEnabled ? editor.vimMode.rawValue : "Off")
|
||||
vimButton.state = editor.vimEnabled ? .on : .off
|
||||
}
|
||||
|
||||
|
||||
private func configureIcon(_ button: NSButton, symbol: String, label: String) {
|
||||
button.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil)?
|
||||
@@ -195,7 +230,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
button.imagePosition = .imageOnly
|
||||
button.bezelStyle = .accessoryBarAction
|
||||
button.showsBorderOnlyWhileMouseInside = true
|
||||
button.contentTintColor = SunsetPalette.secondaryText
|
||||
button.contentTintColor = ThemePalette.secondaryText
|
||||
button.toolTip = label
|
||||
button.setAccessibilityLabel(label)
|
||||
NSLayoutConstraint.activate([button.widthAnchor.constraint(equalToConstant: 28), button.heightAnchor.constraint(equalToConstant: 28)])
|
||||
@@ -210,66 +245,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
|
||||
|
||||
@objc private func formatNote(_ sender: NSButton) {
|
||||
guard editor.isEditable else { return }
|
||||
let contents = editor.markdownContents
|
||||
let source = contents.string as NSString
|
||||
var selection = editor.markdownInsertionRange(for: editor.selectedRange())
|
||||
if sender.tag == 6 {
|
||||
selection = source.lineRange(for: selection)
|
||||
if selection.length > 0, source.character(at: NSMaxRange(selection) - 1) == 10 { selection.length -= 1 }
|
||||
}
|
||||
let wrappers = [1: ("**", "**"), 2: ("*", "*"), 3: ("~~", "~~"), 4: ("`", "`"),
|
||||
5: ("[", "](url)"), 6: ("```\n", "\n```")]
|
||||
if let (open, close) = wrappers[sender.tag] {
|
||||
let before = selection.location - open.utf16.count
|
||||
let after = NSMaxRange(selection)
|
||||
var stars = 0
|
||||
if open == "*" {
|
||||
while selection.location > stars, after + stars < source.length,
|
||||
source.character(at: selection.location - stars - 1) == 42,
|
||||
source.character(at: after + stars) == 42 { stars += 1 }
|
||||
}
|
||||
if before >= 0, after + close.utf16.count <= source.length,
|
||||
source.substring(with: NSRange(location: before, length: open.utf16.count)) == open,
|
||||
source.substring(with: NSRange(location: after, length: close.utf16.count)) == close,
|
||||
open != "*" || stars % 2 == 1 {
|
||||
let body = contents.attributedSubstring(from: selection)
|
||||
selection = NSRange(location: before, length: selection.length + open.utf16.count + close.utf16.count)
|
||||
replaceMarkdown(body, range: selection, selection: NSRange(location: before, length: body.length))
|
||||
} else {
|
||||
let body = NSMutableAttributedString(string: open, attributes: editor.typingAttributes)
|
||||
body.append(contents.attributedSubstring(from: selection))
|
||||
body.append(NSAttributedString(string: close, attributes: editor.typingAttributes))
|
||||
replaceMarkdown(body, range: selection,
|
||||
selection: NSRange(location: selection.location + open.utf16.count, length: selection.length))
|
||||
}
|
||||
} else {
|
||||
let prefixes = [0: "# ", 7: "> ", 8: "1. ", 9: "- ", 10: "- [ ] "]
|
||||
guard let prefix = prefixes[sender.tag] else { return }
|
||||
let length = selection.length > 0 && source.character(at: NSMaxRange(selection) - 1) == 10
|
||||
? selection.length - 1 : selection.length
|
||||
let lines = source.lineRange(for: NSRange(location: selection.location, length: length))
|
||||
let body = NSMutableAttributedString(attributedString: contents.attributedSubstring(from: lines))
|
||||
let rows = body.string.components(separatedBy: "\n")
|
||||
var offset = body.length, firstDelta = 0
|
||||
for (index, line) in rows.enumerated().reversed() {
|
||||
offset -= line.utf16.count + (index < rows.count - 1 ? 1 : 0)
|
||||
if index == rows.count - 1, index > 0, line.isEmpty { continue }
|
||||
let indent = String(line.prefix { $0 == " " || $0 == "\t" })
|
||||
let content = String(line.dropFirst(indent.count))
|
||||
let item = Self.listMatch(in: content as NSString)
|
||||
let kind = item.map { $0.range(at: 6).location != NSNotFound ? 10 : $0.range(at: 3).location != NSNotFound ? 8 : 9 }
|
||||
let removing = kind == sender.tag || (item == nil && content.hasPrefix(prefix))
|
||||
let oldPrefix = item?.range.length ?? (removing ? prefix.utf16.count : 0)
|
||||
let newPrefix = removing ? "" : sender.tag == 8 ? "\(index + 1). " : prefix
|
||||
body.replaceCharacters(in: NSRange(location: offset + indent.utf16.count, length: oldPrefix),
|
||||
with: NSAttributedString(string: newPrefix, attributes: editor.typingAttributes))
|
||||
if index == 0 { firstDelta = newPrefix.utf16.count - oldPrefix }
|
||||
}
|
||||
let start = max(lines.location, selection.location + firstDelta)
|
||||
let end = max(start, NSMaxRange(selection) + body.length - lines.length)
|
||||
replaceMarkdown(body, range: lines, selection: NSRange(location: start, length: end - start))
|
||||
}
|
||||
editor.format(sender.tag)
|
||||
focusEditor()
|
||||
}
|
||||
|
||||
@@ -280,203 +256,12 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
_ = save()
|
||||
}
|
||||
|
||||
private static let listItem = try! NSRegularExpression(
|
||||
pattern: "^([ \\t]*)([-+*]|([0-9]+)([.)]))([ \\t]+|$)(?:\\[([ xX]?)\\]([ \\t]+|$))?"
|
||||
)
|
||||
|
||||
private static func listMatch(in line: NSString) -> NSTextCheckingResult? {
|
||||
guard let item = listItem.firstMatch(in: line as String, range: NSRange(location: 0, length: line.length)) else { return nil }
|
||||
let marker = line.substring(with: item.range(at: 2))
|
||||
// Spaced thematic breaks are not list items or list ancestors.
|
||||
if marker == "-" || marker == "*" {
|
||||
let characters = (line as String).lazy.filter { !$0.isWhitespace }
|
||||
let bullet = marker.first!
|
||||
if characters.count >= 3, characters.allSatisfy({ $0 == bullet }) { return nil }
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
private static func indentationWidth(_ indentation: String) -> Int {
|
||||
indentation.reduce(0) { $1 == "\t" ? $0 + 4 - $0 % 4 : $0 + 1 }
|
||||
}
|
||||
|
||||
private static func outdentLength(_ indentation: String) -> Int {
|
||||
indentation.hasPrefix("\t") ? 1 : indentation.prefix(2).prefix { $0 == " " }.count
|
||||
}
|
||||
|
||||
private static func continuedPrefix(_ item: NSTextCheckingResult, in line: NSString) -> String? {
|
||||
var marker = line.substring(with: item.range(at: 2))
|
||||
if item.range(at: 3).location != NSNotFound {
|
||||
guard let number = UInt64(line.substring(with: item.range(at: 3))), number < UInt64.max else { return nil }
|
||||
marker = "\(number + 1)" + line.substring(with: item.range(at: 4))
|
||||
}
|
||||
var prefix = line.substring(with: item.range(at: 1)) + marker + line.substring(with: item.range(at: 5))
|
||||
if item.range(at: 6).location != NSNotFound {
|
||||
prefix += "[ ]" + line.substring(with: item.range(at: 7))
|
||||
}
|
||||
return prefix
|
||||
}
|
||||
|
||||
private func setSelectionWithUndo(_ selection: NSRange) {
|
||||
let previous = editor.selectedRange()
|
||||
editor.undoManager?.registerUndo(withTarget: self) { $0.setSelectionWithUndo(previous) }
|
||||
editor.setSelectedRange(selection)
|
||||
}
|
||||
|
||||
private func replaceListMarkdown(_ replacement: String, range: NSRange, selection: NSRange) {
|
||||
replaceMarkdown(NSAttributedString(string: replacement, attributes: editor.typingAttributes), range: range, selection: selection)
|
||||
}
|
||||
|
||||
private func replaceMarkdown(_ replacement: NSAttributedString, range: NSRange, selection: NSRange) {
|
||||
editor.undoManager?.beginUndoGrouping()
|
||||
// Native text undo restores its diff boundary, not the user's selection.
|
||||
setSelectionWithUndo(editor.selectedRange())
|
||||
editor.replaceMarkdown(replacement, range: range)
|
||||
setSelectionWithUndo(editor.renderedRange(for: selection))
|
||||
editor.undoManager?.endUndoGrouping()
|
||||
}
|
||||
|
||||
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
|
||||
let newline = commandSelector == #selector(NSResponder.insertNewline(_:))
|
||||
let indenting = commandSelector == #selector(NSResponder.insertTab(_:))
|
||||
let outdenting = commandSelector == #selector(NSResponder.insertBacktab(_:))
|
||||
guard textView === editor, editor.isEditable, newline || indenting || outdenting else { return false }
|
||||
let source = editor.markdownString as NSString
|
||||
let selection = editor.markdownInsertionRange(for: editor.selectedRange())
|
||||
var start = 0, end = 0
|
||||
source.getLineStart(&start, end: nil, contentsEnd: &end,
|
||||
for: NSRange(location: selection.location, length: 0))
|
||||
let line = source.substring(with: NSRange(location: start, length: end - start)) as NSString
|
||||
guard let item = Self.listMatch(in: line), NSMaxRange(selection) <= end else { return false }
|
||||
let codeBlocks = MarkdownHighlighting.codeBlocks(in: source as String)
|
||||
guard !codeBlocks.contains(where: {
|
||||
NSIntersectionRange($0.range, NSRange(location: start, length: end - start)).length > 0
|
||||
}) else { return false }
|
||||
let indent = line.substring(with: item.range(at: 1))
|
||||
if indenting {
|
||||
let marker = editor.renderedRange(for:
|
||||
NSRange(location: start + item.range(at: 2).location, length: 0)).location
|
||||
// A literal marker still needs native Tab to complete its separator.
|
||||
guard editor.textStorage?.attribute(.attachment, at: marker, effectiveRange: nil) is NSTextAttachment else { return false }
|
||||
}
|
||||
if indenting || outdenting {
|
||||
let removed = outdenting ? Self.outdentLength(indent) : 0
|
||||
guard indenting || removed > 0 else { return true }
|
||||
let replacement = indenting ? " " : ""
|
||||
let delta = replacement.utf16.count - removed
|
||||
var selectedStart = max(start, selection.location + delta)
|
||||
var selectedEnd = max(start, NSMaxRange(selection) + delta)
|
||||
func isListLine(_ range: NSRange) -> Bool {
|
||||
Self.listMatch(in: source.substring(with: range) as NSString) != nil
|
||||
&& !codeBlocks.contains { NSIntersectionRange($0.range, range).length > 0 }
|
||||
}
|
||||
var block = source.lineRange(for: NSRange(location: start, length: 0))
|
||||
while block.location > 0 {
|
||||
let previous = source.lineRange(for: NSRange(location: block.location - 1, length: 0))
|
||||
guard isListLine(previous) else { break }
|
||||
block = NSUnionRange(previous, block)
|
||||
}
|
||||
while NSMaxRange(block) < source.length {
|
||||
let next = source.lineRange(for: NSRange(location: NSMaxRange(block), length: 0))
|
||||
guard isListLine(next) else { break }
|
||||
block = NSUnionRange(block, next)
|
||||
}
|
||||
let body = NSMutableAttributedString(attributedString: editor.markdownContents.attributedSubstring(from: block))
|
||||
body.replaceCharacters(in: NSRange(location: start - block.location, length: removed), with: replacement)
|
||||
let changed = body.string as NSString
|
||||
let oldDepth = Self.indentationWidth(indent)
|
||||
let newDepth = Self.indentationWidth(replacement + (indent as NSString).substring(from: removed))
|
||||
var ancestors: [(depth: Int, number: UInt64?, delimiter: String)] = []
|
||||
var edits: [(range: NSRange, number: String)] = []
|
||||
var oldLevel = true, newLevel = true
|
||||
var offset = 0
|
||||
while offset < changed.length {
|
||||
let row = changed.lineRange(for: NSRange(location: offset, length: 0))
|
||||
let text = changed.substring(with: row) as NSString
|
||||
guard let match = Self.listMatch(in: text) else { break }
|
||||
let depth = Self.indentationWidth(text.substring(with: match.range(at: 1)))
|
||||
if offset + block.location > start {
|
||||
if depth < oldDepth { oldLevel = false }
|
||||
if depth < newDepth { newLevel = false }
|
||||
}
|
||||
while let last = ancestors.last, last.depth > depth { ancestors.removeLast() }
|
||||
let peer = ancestors.last?.depth == depth ? ancestors.removeLast() : nil
|
||||
var number: UInt64?
|
||||
var delimiter = ""
|
||||
if match.range(at: 3).location != NSNotFound {
|
||||
number = UInt64(text.substring(with: match.range(at: 3)))
|
||||
delimiter = text.substring(with: match.range(at: 4))
|
||||
if offset + block.location >= start,
|
||||
(oldLevel && depth == oldDepth) || (newLevel && depth == newDepth) {
|
||||
if let peer, peer.delimiter == delimiter, let previous = peer.number, previous < UInt64.max {
|
||||
number = previous + 1
|
||||
} else if !ancestors.isEmpty || offset + block.location > start {
|
||||
number = 1
|
||||
}
|
||||
if let number, UInt64(text.substring(with: match.range(at: 3))) != number {
|
||||
let digits = match.range(at: 3)
|
||||
edits.append((NSRange(location: offset + digits.location, length: digits.length), String(number)))
|
||||
}
|
||||
}
|
||||
}
|
||||
ancestors.append((depth, number, delimiter))
|
||||
offset = NSMaxRange(row)
|
||||
}
|
||||
for edit in edits.reversed() {
|
||||
body.replaceCharacters(in: edit.range, with: edit.number)
|
||||
let location = block.location + edit.range.location
|
||||
let difference = edit.number.utf16.count - edit.range.length
|
||||
func adjusted(_ position: Int) -> Int {
|
||||
if position <= location { return position }
|
||||
if position < location + edit.range.length { return location + min(position - location, edit.number.utf16.count) }
|
||||
return position + difference
|
||||
}
|
||||
selectedStart = adjusted(selectedStart)
|
||||
selectedEnd = adjusted(selectedEnd)
|
||||
}
|
||||
replaceMarkdown(body, range: block,
|
||||
selection: NSRange(location: selectedStart, length: selectedEnd - selectedStart))
|
||||
return true
|
||||
}
|
||||
let markerEnd = item.range(at: 6).location == NSNotFound
|
||||
? NSMaxRange(item.range(at: 2)) : NSMaxRange(item.range(at: 6)) + 1
|
||||
guard selection.location >= start + markerEnd else { return false }
|
||||
let body = line.substring(from: item.range.length)
|
||||
if body.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
var replacement = indent.isEmpty ? "" : line.substring(from: Self.outdentLength(indent))
|
||||
var previous = start
|
||||
let depth = Self.indentationWidth(indent)
|
||||
while depth > 0, previous > 0 {
|
||||
let range = source.lineRange(for: NSRange(location: previous - 1, length: 0))
|
||||
previous = range.location
|
||||
let ancestor = source.substring(with: range) as NSString
|
||||
if let parent = Self.listMatch(in: ancestor),
|
||||
!codeBlocks.contains(where: { NSIntersectionRange($0.range, range).length > 0 }) {
|
||||
let parentIndent = ancestor.substring(with: parent.range(at: 1))
|
||||
if Self.indentationWidth(parentIndent) < depth {
|
||||
replacement = Self.continuedPrefix(parent, in: ancestor) ?? ""
|
||||
break
|
||||
}
|
||||
} else if !(ancestor as String).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
let leading = (ancestor as String).prefix { $0 == " " || $0 == "\t" }
|
||||
if Self.indentationWidth(String(leading)) < depth { break }
|
||||
}
|
||||
}
|
||||
replaceListMarkdown(replacement, range: NSRange(location: start, length: end - start),
|
||||
selection: NSRange(location: start + replacement.utf16.count, length: 0))
|
||||
} else {
|
||||
guard let prefix = Self.continuedPrefix(item, in: line) else { return false }
|
||||
replaceListMarkdown("\n" + prefix, range: selection,
|
||||
selection: NSRange(location: selection.location + 1 + prefix.utf16.count, length: 0))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func save() -> Bool {
|
||||
guard isDirty, let store, let selectedID else { return true }
|
||||
do {
|
||||
try store.update(id: selectedID, contents: editor.markdownContents)
|
||||
try store.update(id: selectedID, contents: editor.documentContents)
|
||||
isDirty = false
|
||||
noteTitle.stringValue = store.notes.first { $0.id == selectedID }?.title ?? "Untitled Note"
|
||||
status.isHidden = true
|
||||
@@ -488,7 +273,11 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
}
|
||||
|
||||
func handle(_ selector: Selector) -> Bool {
|
||||
actions.isShown && actions.handle(selector)
|
||||
if actions.isShown { return actions.handle(selector) }
|
||||
if selector == #selector(NSResponder.cancelOperation(_:)), view.window?.firstResponder === editor {
|
||||
return editor.handleVimEscape()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func handleControlKey(_ key: String) -> Bool {
|
||||
@@ -500,6 +289,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
func handleCommandKey(_ event: NSEvent) -> Bool {
|
||||
guard store != nil, view.window?.attachedSheet == nil else { return false }
|
||||
let modifiers = event.modifierFlags.intersection([.command, .control, .option, .shift])
|
||||
if modifiers.contains(.command) { editor.endInsertionSession(); editor.clearVimCommand() }
|
||||
let key = event.charactersIgnoringModifiers?.lowercased() ?? ""
|
||||
if let index = Self.formattingKeys.firstIndex(where: { $0.key == key && $0.modifiers == modifiers }) {
|
||||
guard editor.isEditable, !actions.isShown, view.window?.firstResponder === editor,
|
||||
@@ -540,7 +330,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
private func copyAsMarkdown() {
|
||||
actions.close(animated: false)
|
||||
do {
|
||||
let export = try NoteMarkdownExport(contents: editor.markdownContents)
|
||||
let export = try NoteMarkdownExport(contents: editor.documentContents)
|
||||
let clipboard = NSPasteboard.general
|
||||
clipboard.clearContents()
|
||||
guard clipboard.setString(export.clipboardText, forType: .string) else {
|
||||
@@ -553,8 +343,9 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
guard let window = view.window, window.attachedSheet == nil else { return }
|
||||
actions.close(animated: false)
|
||||
do {
|
||||
let export = try NoteMarkdownExport(contents: editor.markdownContents)
|
||||
let export = try NoteMarkdownExport(contents: editor.documentContents)
|
||||
let panel = NSSavePanel()
|
||||
panel.appearance = ThemePalette.appearance
|
||||
panel.title = "Export as Markdown"
|
||||
panel.prompt = "Export"
|
||||
panel.canCreateDirectories = true
|
||||
@@ -578,7 +369,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
status.isHidden = false
|
||||
status.stringValue = "Couldn’t export note: \(error.localizedDescription)"
|
||||
status.toolTip = status.stringValue
|
||||
status.textColor = .systemRed
|
||||
status.textColor = ThemePalette.error
|
||||
}
|
||||
|
||||
|
||||
@@ -586,16 +377,18 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
guard let window = view.window, window.attachedSheet == nil, let id = selectedID else { return }
|
||||
actions.close(animated: false)
|
||||
let alert = NSAlert()
|
||||
alert.window.appearance = ThemePalette.appearance
|
||||
alert.alertStyle = .warning
|
||||
alert.messageText = "Delete “\(noteTitle.stringValue)”?"
|
||||
alert.informativeText = "This permanently deletes the note and its embedded images. This cannot be undone."
|
||||
alert.addButton(withTitle: "Cancel").keyEquivalent = "\u{1b}"
|
||||
let delete = alert.addButton(withTitle: "Delete Note")
|
||||
delete.contentTintColor = .systemRed
|
||||
let theme = ThemeObservation { [weak delete] in delete?.contentTintColor = ThemePalette.error }
|
||||
delete.keyEquivalent = "\r"
|
||||
alert.window.initialFirstResponder = delete
|
||||
alert.window.defaultButtonCell = delete.cell as? NSButtonCell
|
||||
alert.beginSheetModal(for: window) { [weak self] response in
|
||||
alert.beginSheetModal(for: window) { [weak self, theme] response in
|
||||
defer { withExtendedLifetime(theme) {} }
|
||||
guard let self else { return }
|
||||
defer { self.focusEditor() }
|
||||
guard response == .alertSecondButtonReturn, self.selectedID == id,
|
||||
@@ -604,7 +397,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
try store.delete(id: id)
|
||||
self.selectedID = nil
|
||||
self.defaults.removeObject(forKey: "selectedNote")
|
||||
self.editor.setMarkdownContents(NSAttributedString(string: ""))
|
||||
self.editor.setDocumentContents(NSAttributedString(string: ""))
|
||||
self.editor.undoManager?.removeAllActions()
|
||||
self.editor.isEditable = false
|
||||
self.noteTitle.stringValue = "Untitled Note"
|
||||
@@ -623,7 +416,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
guard let note = store?.notes.first(where: { $0.id == id }) else { return }
|
||||
selectedID = id
|
||||
defaults.set(id.uuidString, forKey: "selectedNote")
|
||||
editor.setMarkdownContents(note.contents)
|
||||
editor.setDocumentContents(note.contents)
|
||||
editor.undoManager?.removeAllActions()
|
||||
editor.isEditable = true
|
||||
editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0))
|
||||
@@ -636,7 +429,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
status.isHidden = false
|
||||
status.stringValue = "Couldn’t save, load, or delete notes: \(error.localizedDescription)"
|
||||
status.toolTip = status.stringValue
|
||||
status.textColor = .systemRed
|
||||
status.textColor = ThemePalette.error
|
||||
}
|
||||
|
||||
@objc func browseNotes() {
|
||||
@@ -663,147 +456,3 @@ private final class NotesView: NSView {
|
||||
}
|
||||
}
|
||||
|
||||
private final class RoundedImageAttachmentCell: NSTextAttachmentCell {
|
||||
override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
defer { NSGraphicsContext.restoreGraphicsState() }
|
||||
NSBezierPath(roundedRect: cellFrame, xRadius: 8, yRadius: 8).addClip()
|
||||
super.draw(withFrame: cellFrame, in: controlView)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@MainActor
|
||||
enum MarkdownHighlighting {
|
||||
// ponytail: whole-note highlighting; incremental parsing if large notes make typing slow.
|
||||
static let hiddenMarkup = NSAttributedString.Key("Conjure.hiddenMarkup")
|
||||
private static let inlineCode = try! NSRegularExpression(pattern: "(?<![\\\\`])(?<marker>`+)(?<body>[^`\\n]+)\\k<marker>(?!`)")
|
||||
private static let rules: [(NSRegularExpression, [NSAttributedString.Key: Any])] = [
|
||||
(try! NSRegularExpression(pattern: "(?<![\\\\*_])(?<marker>\\*{2,3}|_{2,3})(?<body>\\S(?:[^\\n]*?\\S)?)\\k<marker>(?![*_])"), [.font: NSFont.monospacedSystemFont(ofSize: NotesAppearance.font.pointSize, weight: .bold)]),
|
||||
(try! NSRegularExpression(pattern: "(?<![\\\\*_])(?<marker>[*_])(?<body>[^\\s*_](?:[^*_\\n]*?[^\\s*_])?)\\k<marker>(?![*_])"), [.font: NSFontManager.shared.convert(NotesAppearance.font, toHaveTrait: .italicFontMask)]),
|
||||
(try! NSRegularExpression(pattern: "(?<!\\\\)!?\\[(?<body>[^\\]\\n]+)\\]\\([^\\n)]+\\)|(?m)^\\s*(?:[-+*]|[0-9]+\\.)\\s"), [.foregroundColor: SunsetPalette.accent]),
|
||||
(try! NSRegularExpression(pattern: "(?<!\\\\)~~(?<body>\\S(?:[^\\n]*?\\S)?)~~"), [.strikethroughStyle: NSUnderlineStyle.single.rawValue]),
|
||||
(try! NSRegularExpression(pattern: "(?m)^(?:>[ \\t]*)+(?<body>[^\\n]+)"), [:]),
|
||||
]
|
||||
private static let heading = try! NSRegularExpression(pattern: "(?m)^(#{1,6}) (?<body>.+)$")
|
||||
private static let headingFonts = [11.0, 8, 6, 4, 2, 0].map {
|
||||
NSFont.monospacedSystemFont(ofSize: NotesAppearance.font.pointSize + $0, weight: .semibold)
|
||||
}
|
||||
|
||||
private static let highlighter: Highlighter = {
|
||||
guard let value = Highlighter() else { preconditionFailure("Missing bundled syntax highlighter") }
|
||||
value.setTheme("atom-one-dark", withFont: "Menlo-Regular", ofSize: NotesAppearance.font.pointSize)
|
||||
return value
|
||||
}()
|
||||
private static let openingFence = try! NSRegularExpression(pattern: "(?m)^ {0,3}(`{3,}|~{3,})([^\\r\\n]*)\\r?\\n")
|
||||
|
||||
static func codeBlocks(in text: String) -> [(range: NSRange, body: NSRange, language: String?)] {
|
||||
let source = text as NSString
|
||||
var blocks: [(NSRange, NSRange, String?)] = []
|
||||
var cursor = 0
|
||||
while let opening = openingFence.firstMatch(in: text, range: NSRange(location: cursor, length: source.length - cursor)) {
|
||||
let fence = source.substring(with: opening.range(at: 1))
|
||||
let marker = NSRegularExpression.escapedPattern(for: String(fence.first!))
|
||||
let closing = try! NSRegularExpression(pattern: "(?m)^ {0,3}\(marker){\(fence.count),}[ \\t]*\\r?$")
|
||||
let start = NSMaxRange(opening.range)
|
||||
let end = closing.firstMatch(in: text, range: NSRange(location: start, length: source.length - start))
|
||||
let bodyEnd = end?.range.location ?? source.length
|
||||
cursor = end.map { NSMaxRange($0.range) } ?? source.length
|
||||
let language = source.substring(with: opening.range(at: 2)).split(whereSeparator: { $0.isWhitespace }).first.map(String.init)
|
||||
blocks.append((NSRange(location: opening.range.location, length: cursor - opening.range.location),
|
||||
NSRange(location: start, length: bodyEnd - start), language))
|
||||
if cursor == source.length { break }
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
static func apply(to editor: NSTextView) {
|
||||
guard let storage = editor.textStorage else { return }
|
||||
let text = editor.string
|
||||
let range = NSRange(location: 0, length: text.utf16.count)
|
||||
storage.beginEditing()
|
||||
defer { storage.endEditing() }
|
||||
storage.enumerateAttribute(.attachment, in: range) { value, _, _ in
|
||||
guard let attachment = value as? NSTextAttachment,
|
||||
let cell = attachment.attachmentCell as? NSTextAttachmentCell,
|
||||
let image = cell.image else { return }
|
||||
let size = image.size
|
||||
let scale = min(1, 460 / max(size.width, 1), 240 / max(size.height, 1))
|
||||
if scale < 1 { image.size = NSSize(width: size.width * scale, height: size.height * scale) }
|
||||
if !(cell is RoundedImageAttachmentCell) {
|
||||
attachment.attachmentCell = RoundedImageAttachmentCell(imageCell: image)
|
||||
}
|
||||
}
|
||||
storage.removeAttribute(.strikethroughStyle, range: range)
|
||||
storage.addAttribute(hiddenMarkup, value: false, range: range)
|
||||
storage.addAttributes([.font: NotesAppearance.font, .foregroundColor: SunsetPalette.text,
|
||||
.paragraphStyle: NotesAppearance.paragraph], range: range)
|
||||
let blocks = codeBlocks(in: text)
|
||||
func hideSyntax(_ match: NSTextCheckingResult) {
|
||||
let body = match.range(withName: "body")
|
||||
guard body.location != NSNotFound else { return }
|
||||
if body.location > match.range.location {
|
||||
storage.addAttribute(hiddenMarkup, value: true,
|
||||
range: NSRange(location: match.range.location, length: body.location - match.range.location))
|
||||
}
|
||||
if NSMaxRange(body) < NSMaxRange(match.range) {
|
||||
storage.addAttribute(hiddenMarkup, value: true,
|
||||
range: NSRange(location: NSMaxRange(body), length: NSMaxRange(match.range) - NSMaxRange(body)))
|
||||
}
|
||||
}
|
||||
let codeSpans = inlineCode.matches(in: text, range: range).filter { match in
|
||||
!blocks.contains { NSIntersectionRange($0.range, match.range).length > 0 }
|
||||
}
|
||||
for match in codeSpans {
|
||||
storage.addAttributes([.font: NotesAppearance.font, .foregroundColor: NSColor.systemGreen], range: match.range)
|
||||
hideSyntax(match)
|
||||
}
|
||||
for (expression, attributes) in rules {
|
||||
for match in expression.matches(in: text, range: range) {
|
||||
guard !blocks.contains(where: { NSIntersectionRange($0.range, match.range).length > 0 }),
|
||||
!codeSpans.contains(where: { NSIntersectionRange($0.range, match.range).length > 0 }) else { continue }
|
||||
storage.addAttributes(attributes, range: match.range)
|
||||
if match.range(withName: "marker").length == 3,
|
||||
let font = attributes[.font] as? NSFont {
|
||||
storage.addAttribute(.font, value: NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask),
|
||||
range: match.range)
|
||||
}
|
||||
hideSyntax(match)
|
||||
}
|
||||
}
|
||||
for match in heading.matches(in: text, range: range) {
|
||||
guard !blocks.contains(where: { NSIntersectionRange($0.range, match.range).length > 0 }) else { continue }
|
||||
hideSyntax(match)
|
||||
let font = headingFonts[match.range(at: 1).length - 1]
|
||||
storage.enumerateAttribute(.font, in: match.range) { value, run, _ in
|
||||
let traits = (value as? NSFont).map { NSFontManager.shared.traits(of: $0) } ?? []
|
||||
storage.addAttribute(.font, value: NSFontManager.shared.convert(font, toHaveTrait: traits), range: run)
|
||||
}
|
||||
}
|
||||
for block in blocks {
|
||||
let source = text as NSString
|
||||
let opening = source.lineRange(for: NSRange(location: block.range.location, length: 0))
|
||||
let openingText = source.substring(with: opening).trimmingCharacters(in: .newlines)
|
||||
storage.addAttribute(hiddenMarkup, value: true,
|
||||
range: NSRange(location: opening.location, length: openingText.utf16.count))
|
||||
if NSMaxRange(block.body) < NSMaxRange(block.range) {
|
||||
storage.addAttribute(hiddenMarkup, value: true,
|
||||
range: NSRange(location: NSMaxRange(block.body), length: NSMaxRange(block.range) - NSMaxRange(block.body)))
|
||||
}
|
||||
storage.addAttributes([.font: NotesAppearance.font,
|
||||
.foregroundColor: SunsetPalette.secondaryText], range: block.range)
|
||||
storage.addAttribute(.foregroundColor, value: SunsetPalette.text, range: block.body)
|
||||
let code = (text as NSString).substring(with: block.body)
|
||||
guard let highlighted = highlighter.highlight(code, as: block.language),
|
||||
highlighted.string == code else { continue }
|
||||
highlighted.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: highlighted.length)) { color, range, _ in
|
||||
if let color {
|
||||
storage.addAttribute(.foregroundColor, value: color,
|
||||
range: NSRange(location: block.body.location + range.location, length: range.length))
|
||||
}
|
||||
}
|
||||
}
|
||||
editor.typingAttributes = [.font: NotesAppearance.font, .foregroundColor: SunsetPalette.text,
|
||||
.paragraphStyle: NotesAppearance.paragraph]
|
||||
}
|
||||
}
|
||||
|
||||
+587
-591
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,447 @@
|
||||
import AppKit
|
||||
|
||||
enum NotesVimMode: String {
|
||||
case normal = "NORMAL", insert = "INSERT", visual = "VISUAL", visualLine = "VISUAL LINE"
|
||||
}
|
||||
|
||||
struct NotesVimState {
|
||||
var mode: NotesVimMode = .normal
|
||||
var count = 0
|
||||
var operatorCount = 1
|
||||
var pending: Character?
|
||||
var awaitingG = false
|
||||
var anchor: Int?
|
||||
var head: Int?
|
||||
var column: Int?
|
||||
var register: NSAttributedString?
|
||||
var linewiseRegister = false
|
||||
}
|
||||
|
||||
// ponytail: scan the whole note for each command; cache character offsets if large notes make motions slow.
|
||||
@MainActor
|
||||
private struct VimBuffer {
|
||||
var characters: [Character] = []
|
||||
var ranges: [NSRange] = []
|
||||
let length: Int
|
||||
|
||||
init(_ contents: NSAttributedString) {
|
||||
length = contents.length
|
||||
var offset = 0
|
||||
for character in contents.string {
|
||||
let size = String(character).utf16.count
|
||||
characters.append(character)
|
||||
ranges.append(NSRange(location: offset, length: size))
|
||||
offset += size
|
||||
}
|
||||
}
|
||||
|
||||
var count: Int { characters.count }
|
||||
func offset(_ index: Int) -> Int { index < count ? ranges[max(0, index)].location : length }
|
||||
func index(_ offset: Int) -> Int { ranges.firstIndex { NSMaxRange($0) > offset } ?? count }
|
||||
func newline(_ index: Int) -> Bool { index < count && characters[index].isNewline }
|
||||
func lineStart(_ index: Int) -> Int {
|
||||
var result = min(index, count)
|
||||
while result > 0 && !newline(result - 1) { result -= 1 }
|
||||
return result
|
||||
}
|
||||
func lineEnd(_ index: Int) -> Int {
|
||||
var result = min(index, count)
|
||||
while result < count && !newline(result) { result += 1 }
|
||||
return result
|
||||
}
|
||||
func normal(_ index: Int) -> Int {
|
||||
let index = min(max(0, index), count)
|
||||
let start = lineStart(index), end = lineEnd(index)
|
||||
return end > start ? min(index, end - 1) : start
|
||||
}
|
||||
func firstNonblank(_ index: Int) -> Int {
|
||||
var result = lineStart(index)
|
||||
let end = lineEnd(index)
|
||||
while result < end && characters[result].isWhitespace { result += 1 }
|
||||
return normal(result)
|
||||
}
|
||||
func wordKind(_ index: Int, big: Bool) -> Int {
|
||||
guard index < count else { return 0 }
|
||||
let character = characters[index]
|
||||
if character.isWhitespace { return 0 }
|
||||
if big { return 1 }
|
||||
return character.isLetter || character.isNumber || character == "_" ? 1 : 2
|
||||
}
|
||||
func word(_ origin: Int, key: Character) -> Int {
|
||||
let big = key.isUppercase
|
||||
var index = min(origin, count)
|
||||
if key == "b" || key == "B" {
|
||||
if index > 0 { index -= 1 }
|
||||
while index > 0 && wordKind(index, big: big) == 0 { index -= 1 }
|
||||
let kind = wordKind(index, big: big)
|
||||
while index > 0 && wordKind(index - 1, big: big) == kind { index -= 1 }
|
||||
} else if key == "w" || key == "W" {
|
||||
let kind = wordKind(index, big: big)
|
||||
while index < count && wordKind(index, big: big) == kind { index += 1 }
|
||||
while index < count && wordKind(index, big: big) == 0 { index += 1 }
|
||||
} else {
|
||||
if index < count { index += 1 }
|
||||
while index < count && wordKind(index, big: big) == 0 { index += 1 }
|
||||
let kind = wordKind(index, big: big)
|
||||
while index + 1 < count && wordKind(index + 1, big: big) == kind { index += 1 }
|
||||
index = min(index, max(0, count - 1))
|
||||
}
|
||||
return index
|
||||
}
|
||||
}
|
||||
|
||||
extension NotesTextView {
|
||||
var vimMode: NotesVimMode { vimState.mode }
|
||||
|
||||
func resetVim() {
|
||||
endInsertionSession()
|
||||
let register = vimState.register, linewise = vimState.linewiseRegister
|
||||
vimState = NotesVimState()
|
||||
vimState.register = register
|
||||
vimState.linewiseRegister = linewise
|
||||
needsDisplay = true
|
||||
vimModeChanged?()
|
||||
}
|
||||
|
||||
func clearVimCommand() {
|
||||
vimState.count = 0
|
||||
vimState.operatorCount = 1
|
||||
vimState.pending = nil
|
||||
vimState.awaitingG = false
|
||||
}
|
||||
|
||||
private func setVimMode(_ mode: NotesVimMode) {
|
||||
if vimState.mode == .insert { endInsertionSession() }
|
||||
breakUndoCoalescing()
|
||||
clearVimCommand()
|
||||
vimState.mode = mode
|
||||
vimState.anchor = nil
|
||||
vimState.head = nil
|
||||
vimState.column = nil
|
||||
if mode == .insert { beginInsertionSession() }
|
||||
needsDisplay = true
|
||||
vimModeChanged?()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func handleVimEscape() -> Bool {
|
||||
guard vimEnabled, vimState.mode != .normal, !hasMarkedText() else { return false }
|
||||
let buffer = VimBuffer(documentContents)
|
||||
var head = vimState.head ?? buffer.index(selectedRange().location)
|
||||
if vimState.mode == .insert && head > buffer.lineStart(head) { head -= 1 }
|
||||
setVimMode(.normal)
|
||||
setVimCaret(buffer.normal(head), buffer: buffer)
|
||||
return true
|
||||
}
|
||||
|
||||
private func setVimCaret(_ index: Int, buffer: VimBuffer) {
|
||||
setSelectedRange(NSRange(location: buffer.offset(index), length: 0), affinity: .downstream, stillSelecting: false)
|
||||
scrollRangeToVisible(selectedRange())
|
||||
}
|
||||
|
||||
private func showVimSelection(_ head: Int, buffer: VimBuffer) {
|
||||
let anchor = vimState.anchor ?? head
|
||||
vimState.head = head
|
||||
let low = min(anchor, head), high = max(anchor, head)
|
||||
let range: NSRange
|
||||
if vimState.mode == .visualLine {
|
||||
let start = buffer.offset(buffer.lineStart(low))
|
||||
let end = buffer.lineEnd(high)
|
||||
let stop = buffer.offset(min(buffer.count, end + (buffer.newline(end) ? 1 : 0)))
|
||||
range = NSRange(location: start, length: stop - start)
|
||||
} else {
|
||||
let start = buffer.offset(low)
|
||||
let stop = high < buffer.count ? NSMaxRange(buffer.ranges[high]) : buffer.length
|
||||
range = NSRange(location: start, length: stop - start)
|
||||
}
|
||||
setSelectedRange(range)
|
||||
scrollRangeToVisible(NSRange(location: buffer.offset(head), length: 0))
|
||||
}
|
||||
|
||||
func handleVimKey(_ event: NSEvent) -> Bool {
|
||||
guard vimEnabled, isEditable, !hasMarkedText() else { return false }
|
||||
let flags = event.modifierFlags.intersection([.command, .control, .option, .shift])
|
||||
// Command shortcuts remain native; Option text input belongs only to Insert mode.
|
||||
if flags.contains(.command) { clearVimCommand(); return false }
|
||||
if flags.contains(.option) { clearVimCommand(); return vimState.mode != .insert }
|
||||
if event.keyCode == 53 { return handleVimEscape() }
|
||||
if flags == .control {
|
||||
if event.charactersIgnoringModifiers == "[" { return handleVimEscape() }
|
||||
if vimState.mode != .insert && event.charactersIgnoringModifiers?.lowercased() == "r" {
|
||||
clearVimCommand()
|
||||
undoManager?.redo()
|
||||
normalizeVimCaret()
|
||||
return true
|
||||
}
|
||||
return vimState.mode != .insert
|
||||
}
|
||||
guard vimState.mode != .insert else { return false }
|
||||
let key: Character
|
||||
switch event.keyCode {
|
||||
case 123: key = "h"
|
||||
case 124: key = "l"
|
||||
case 125: key = "j"
|
||||
case 126: key = "k"
|
||||
case 51: key = "h"
|
||||
case 36, 76: key = "j"
|
||||
default:
|
||||
guard let text = event.characters, text.count == 1, let character = text.first else { return true }
|
||||
key = character
|
||||
}
|
||||
if let digit = key.wholeNumberValue, digit < 10, digit > 0 || vimState.count > 0 {
|
||||
// Saturate before arithmetic; a held numeric key must not overflow or create unbounded work.
|
||||
vimState.count = min(9999, vimState.count * 10 + digit)
|
||||
return true
|
||||
}
|
||||
let buffer = VimBuffer(documentContents)
|
||||
if vimState.mode == .normal, selectedRange().length > 0 {
|
||||
vimState.mode = .visual
|
||||
vimState.anchor = buffer.index(selectedRange().location)
|
||||
vimState.head = max(vimState.anchor!, buffer.index(NSMaxRange(selectedRange())) - 1)
|
||||
needsDisplay = true
|
||||
vimModeChanged?()
|
||||
}
|
||||
let origin = buffer.normal(vimState.head ?? buffer.index(selectedRange().location))
|
||||
let count = min(9999, max(1, vimState.count) * vimState.operatorCount)
|
||||
let visual = vimState.mode == .visual || vimState.mode == .visualLine
|
||||
|
||||
if vimState.awaitingG {
|
||||
vimState.awaitingG = false
|
||||
guard key == "g" else { clearVimCommand(); return true }
|
||||
let target = vimLine(count: count, buffer: buffer)
|
||||
finishVimMotion(origin: origin, target: target, inclusive: false, linewise: true, buffer: buffer)
|
||||
return true
|
||||
}
|
||||
if key == "g" { vimState.awaitingG = true; return true }
|
||||
if "dcy".contains(key) {
|
||||
if visual {
|
||||
let range = selectedRange()
|
||||
applyVimOperator(key, range: range, linewise: vimState.mode == .visualLine)
|
||||
} else if vimState.pending == key {
|
||||
var end = buffer.lineEnd(origin)
|
||||
for _ in 1..<count where buffer.newline(end) { end = buffer.lineEnd(end + 1) }
|
||||
applyVimLineOperator(key, start: origin, end: end, buffer: buffer)
|
||||
} else if vimState.pending == nil {
|
||||
vimState.pending = key
|
||||
vimState.operatorCount = max(1, vimState.count)
|
||||
vimState.count = 0
|
||||
} else { clearVimCommand() }
|
||||
return true
|
||||
}
|
||||
if "hljkwWbBeE0^$G".contains(key) {
|
||||
var target = origin
|
||||
var inclusive = false
|
||||
let linewise = key == "j" || key == "k" || key == "G"
|
||||
switch key {
|
||||
case "h": target = max(buffer.lineStart(origin), origin - count)
|
||||
case "l": target = min(buffer.lineEnd(origin) - (vimState.pending == nil ? 1 : 0), origin + count)
|
||||
target = max(buffer.lineStart(origin), target)
|
||||
case "j", "k":
|
||||
let column = vimState.column ?? (origin - buffer.lineStart(origin))
|
||||
vimState.column = column
|
||||
var start = buffer.lineStart(origin)
|
||||
for _ in 0..<count {
|
||||
if key == "j" {
|
||||
let end = buffer.lineEnd(start)
|
||||
if buffer.newline(end) { start = end + 1 }
|
||||
} else if start > 0 { start = buffer.lineStart(start - 1) }
|
||||
}
|
||||
target = buffer.normal(min(start + column, buffer.lineEnd(start)))
|
||||
case "0": target = buffer.lineStart(origin)
|
||||
case "^": target = buffer.firstNonblank(origin)
|
||||
case "$":
|
||||
var end = buffer.lineEnd(origin)
|
||||
for _ in 1..<count where buffer.newline(end) { end = buffer.lineEnd(end + 1) }
|
||||
target = max(buffer.lineStart(end), end - 1)
|
||||
inclusive = true
|
||||
case "G": target = vimState.count > 0 || vimState.operatorCount > 1
|
||||
? vimLine(count: count, buffer: buffer) : buffer.firstNonblank(buffer.count)
|
||||
default:
|
||||
var motion = key
|
||||
var repetitions = count
|
||||
// cw includes this word's end even when the cursor is already on its last character.
|
||||
if vimState.pending == "c", (key == "w" || key == "W"), buffer.wordKind(origin, big: key == "W") != 0 {
|
||||
motion = key == "w" ? "e" : "E"
|
||||
let kind = buffer.wordKind(origin, big: key == "W")
|
||||
while target + 1 < buffer.count && buffer.wordKind(target + 1, big: key == "W") == kind { target += 1 }
|
||||
repetitions -= 1
|
||||
}
|
||||
for step in 0..<repetitions {
|
||||
let previous = target
|
||||
target = buffer.word(target, key: motion)
|
||||
if step == repetitions - 1, vimState.pending != nil, motion == "w" || motion == "W",
|
||||
buffer.wordKind(previous, big: motion == "W") != 0, target > buffer.lineEnd(previous) {
|
||||
target = buffer.lineEnd(previous)
|
||||
}
|
||||
}
|
||||
inclusive = motion == "e" || motion == "E"
|
||||
}
|
||||
if key != "j" && key != "k" { vimState.column = nil }
|
||||
finishVimMotion(origin: origin, target: target, inclusive: inclusive, linewise: linewise, buffer: buffer)
|
||||
return true
|
||||
}
|
||||
guard vimState.pending == nil else { clearVimCommand(); return true }
|
||||
clearVimCommand()
|
||||
switch key {
|
||||
case "v", "V":
|
||||
let mode: NotesVimMode = key == "v" ? .visual : .visualLine
|
||||
if vimState.mode == mode { setVimMode(.normal); setVimCaret(origin, buffer: buffer) }
|
||||
else {
|
||||
let anchor = vimState.anchor ?? origin
|
||||
setVimMode(mode)
|
||||
vimState.anchor = anchor
|
||||
showVimSelection(origin, buffer: buffer)
|
||||
}
|
||||
case "i", "a", "I", "A":
|
||||
guard !visual else { return true }
|
||||
var target = origin
|
||||
if key == "a" && origin < buffer.lineEnd(origin) { target += 1 }
|
||||
if key == "I" { target = buffer.firstNonblank(origin) }
|
||||
if key == "A" { target = buffer.lineEnd(origin) }
|
||||
setVimMode(.insert)
|
||||
setVimCaret(target, buffer: buffer)
|
||||
case "o", "O":
|
||||
guard !visual else { return true }
|
||||
let target = key == "o" ? buffer.lineEnd(origin) : buffer.lineStart(origin)
|
||||
setVimMode(.insert)
|
||||
setVimCaret(target, buffer: buffer)
|
||||
insertNewline(nil)
|
||||
if key == "O" {
|
||||
let updated = VimBuffer(documentContents)
|
||||
let caret = updated.index(selectedRange().location)
|
||||
setVimCaret(updated.lineStart(max(0, caret - 1)), buffer: updated)
|
||||
}
|
||||
case "x":
|
||||
if visual { applyVimOperator("d", range: selectedRange(), linewise: vimState.mode == .visualLine) }
|
||||
else {
|
||||
let end = min(buffer.lineEnd(origin), origin + count)
|
||||
let startOffset = buffer.offset(origin), endOffset = buffer.offset(end)
|
||||
if end > origin { applyVimOperator("d", range: NSRange(location: startOffset, length: endOffset - startOffset), linewise: false) }
|
||||
}
|
||||
case "D", "C":
|
||||
var end = buffer.lineEnd(origin)
|
||||
for _ in 1..<count where buffer.newline(end) { end = buffer.lineEnd(end + 1) }
|
||||
let startOffset = buffer.offset(origin), endOffset = buffer.offset(end)
|
||||
applyVimOperator(key == "D" ? "d" : "c", range: NSRange(location: startOffset, length: endOffset - startOffset), linewise: false)
|
||||
case "p", "P": pasteVimRegister(before: key == "P", count: count, origin: origin, buffer: buffer)
|
||||
case "u":
|
||||
setVimMode(.normal)
|
||||
for _ in 0..<count where undoManager?.canUndo == true { undoManager?.undo() }
|
||||
normalizeVimCaret()
|
||||
default: break // Unsupported Normal-mode keys never become note text.
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func vimLine(count: Int, buffer: VimBuffer) -> Int {
|
||||
var start = 0
|
||||
for _ in 1..<count {
|
||||
let end = buffer.lineEnd(start)
|
||||
if buffer.newline(end) { start = end + 1 }
|
||||
}
|
||||
return buffer.firstNonblank(start)
|
||||
}
|
||||
|
||||
private func finishVimMotion(origin: Int, target: Int, inclusive: Bool, linewise: Bool, buffer: VimBuffer) {
|
||||
if let operation = vimState.pending {
|
||||
if linewise { applyVimLineOperator(operation, start: min(origin, target), end: max(origin, target), buffer: buffer) }
|
||||
else {
|
||||
let low = min(origin, target)
|
||||
let high = min(buffer.count, max(origin, target) + (inclusive ? 1 : 0))
|
||||
let start = buffer.offset(low), end = buffer.offset(high)
|
||||
// A motion against a boundary is a no-op, not a character deletion.
|
||||
if end > start { applyVimOperator(operation, range: NSRange(location: start, length: end - start), linewise: false) }
|
||||
else { clearVimCommand() }
|
||||
}
|
||||
} else {
|
||||
clearVimCommand()
|
||||
if vimState.mode == .visual || vimState.mode == .visualLine { showVimSelection(buffer.normal(target), buffer: buffer) }
|
||||
else { setVimCaret(buffer.normal(target), buffer: buffer) }
|
||||
}
|
||||
}
|
||||
|
||||
private func applyVimLineOperator(_ operation: Character, start: Int, end: Int, buffer: VimBuffer) {
|
||||
let first = buffer.offset(buffer.lineStart(start))
|
||||
let lastLineEnd = buffer.lineEnd(end)
|
||||
let last = buffer.offset(lastLineEnd + (buffer.newline(lastLineEnd) ? 1 : 0))
|
||||
applyVimOperator(operation, range: NSRange(location: first, length: last - first), linewise: true)
|
||||
}
|
||||
|
||||
private func applyVimOperator(_ operation: Character, range: NSRange, linewise: Bool) {
|
||||
let source = documentContents
|
||||
let register = NSMutableAttributedString(attributedString: source.attributedSubstring(from: range))
|
||||
if linewise && !register.string.hasSuffix("\n") {
|
||||
var attributes = register.length > 0 ? register.attributes(at: register.length - 1, effectiveRange: nil) : [:]
|
||||
attributes[.attachment] = nil
|
||||
register.append(NSAttributedString(string: "\n", attributes: attributes))
|
||||
}
|
||||
vimState.register = register
|
||||
vimState.linewiseRegister = linewise
|
||||
let originalCaret = selectedRange().location
|
||||
setVimMode(operation == "c" ? .insert : .normal)
|
||||
if operation == "y" {
|
||||
setSelectedRange(NSRange(location: originalCaret, length: 0))
|
||||
normalizeVimCaret()
|
||||
return
|
||||
}
|
||||
var deletion = range
|
||||
var replacement = NSAttributedString(string: "")
|
||||
var attributes = range.location < source.length ? source.attributes(at: range.location, effectiveRange: nil) : typingAttributes
|
||||
attributes[.attachment] = nil
|
||||
if linewise {
|
||||
let text = source.string as NSString
|
||||
if operation == "c" {
|
||||
// cc keeps the following line separate while opening a blank line for insertion.
|
||||
if NSMaxRange(range) < text.length { replacement = NSAttributedString(string: "\n", attributes: attributes) }
|
||||
} else if NSMaxRange(range) == text.length, range.location > 0 {
|
||||
let previous = text.rangeOfComposedCharacterSequence(at: range.location - 1)
|
||||
if text.substring(with: previous).allSatisfy(\.isNewline) {
|
||||
deletion.location = previous.location
|
||||
deletion.length += previous.length
|
||||
}
|
||||
}
|
||||
}
|
||||
replaceDocument(replacement, range: deletion, selection: NSRange(location: deletion.location, length: 0))
|
||||
if operation == "c" { typingAttributes = attributes }
|
||||
else { normalizeVimCaret() }
|
||||
}
|
||||
|
||||
private func pasteVimRegister(before: Bool, count: Int, origin: Int, buffer: VimBuffer) {
|
||||
guard let register = vimState.register else { return }
|
||||
let insertion = NSMutableAttributedString(string: "")
|
||||
for _ in 0..<count { insertion.append(register) }
|
||||
var range: NSRange
|
||||
if vimState.mode == .visual || vimState.mode == .visualLine {
|
||||
range = selectedRange()
|
||||
} else if vimState.linewiseRegister {
|
||||
let source = documentContents.string as NSString
|
||||
let line = source.lineRange(for: NSRange(location: buffer.offset(origin), length: 0))
|
||||
let offset = before ? line.location : NSMaxRange(line)
|
||||
range = NSRange(location: offset, length: 0)
|
||||
if offset == source.length, offset == 0 || !source.substring(from: offset - 1).allSatisfy(\.isNewline) {
|
||||
if offset > 0 {
|
||||
insertion.insert(NSAttributedString(string: "\n"), at: 0)
|
||||
}
|
||||
if insertion.string.hasSuffix("\n") { insertion.deleteCharacters(in: NSRange(location: insertion.length - 1, length: 1)) }
|
||||
}
|
||||
} else {
|
||||
let index = before ? origin : min(buffer.lineEnd(origin), origin + 1)
|
||||
range = NSRange(location: buffer.offset(index), length: 0)
|
||||
}
|
||||
setVimMode(.normal)
|
||||
replaceDocument(insertion, range: range)
|
||||
let caret = vimState.linewiseRegister ? range.location + (insertion.string.hasPrefix("\n") ? 1 : 0) : range.location + insertion.length
|
||||
setSelectedRange(NSRange(location: caret, length: 0))
|
||||
if !vimState.linewiseRegister {
|
||||
let updated = VimBuffer(documentContents)
|
||||
setVimCaret(updated.normal(max(0, updated.index(selectedRange().location) - 1)), buffer: updated)
|
||||
} else { normalizeVimCaret() }
|
||||
}
|
||||
|
||||
private func normalizeVimCaret() {
|
||||
let buffer = VimBuffer(documentContents)
|
||||
setVimCaret(buffer.normal(buffer.index(selectedRange().location)), buffer: buffer)
|
||||
vimState.anchor = nil
|
||||
vimState.head = nil
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
private let recoveryLabel = NSTextField(wrappingLabelWithString: "")
|
||||
private let recoveryButton = NSButton(title: "Retry uninstall recovery…", target: nil, action: nil)
|
||||
private var recoveryReport: UninstallReport?
|
||||
private var themeObservation: ThemeObservation?
|
||||
private var loginStatusIsError = false
|
||||
var retryUninstallRecovery: (() async -> UninstallReport)?
|
||||
|
||||
init(shortcuts: GlobalShortcuts) {
|
||||
@@ -30,7 +32,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
defer: false
|
||||
)
|
||||
window.title = "Conjure Settings"
|
||||
window.appearance = NSAppearance(named: .darkAqua)
|
||||
window.appearance = ThemePalette.appearance
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.backgroundColor = .clear
|
||||
window.isOpaque = false
|
||||
@@ -110,8 +112,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
instructions.textColor = .secondaryLabelColor
|
||||
statusLabel.textColor = .secondaryLabelColor
|
||||
statusLabel.setAccessibilityLabel("Global shortcut registration status")
|
||||
errorLabel.textColor = .systemRed
|
||||
errorLabel.setAccessibilityLabel("Shortcut feedback")
|
||||
errorLabel.textColor = ThemePalette.error
|
||||
errorLabel.setAccessibilityLabel("Settings feedback")
|
||||
|
||||
let spotlight = NSTextField(wrappingLabelWithString:
|
||||
"To use ⌘Space, first free it in System Settings → Keyboard → Keyboard Shortcuts → Spotlight: turn off or change “Show Spotlight search.” Check Input Sources for other reserved shortcuts. Conjure never changes these settings."
|
||||
@@ -128,7 +130,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
loginSettings.action = #selector(openLoginSettings)
|
||||
loginSettings.bezelStyle = .rounded
|
||||
loginStatus.setAccessibilityLabel("Launch at login status")
|
||||
recoveryLabel.textColor = SunsetPalette.secondaryText
|
||||
recoveryLabel.textColor = ThemePalette.secondaryText
|
||||
recoveryLabel.isHidden = true
|
||||
recoveryLabel.setAccessibilityLabel("Uninstall recovery status")
|
||||
recoveryButton.isHidden = true
|
||||
@@ -137,19 +139,19 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
recoveryButton.action = #selector(showUninstallRecovery)
|
||||
|
||||
heading.font = .systemFont(ofSize: 20, weight: .semibold)
|
||||
heading.textColor = SunsetPalette.text
|
||||
heading.textColor = ThemePalette.text
|
||||
for label in [instructions, statusLabel, spotlight, loginStatus] {
|
||||
label.textColor = SunsetPalette.secondaryText
|
||||
label.textColor = ThemePalette.secondaryText
|
||||
}
|
||||
for button in [recorder, commandSpaceButton, clearButton, keyboardSettings, loginToggle, loginSettings, recoveryButton] {
|
||||
button.contentTintColor = SunsetPalette.accent
|
||||
button.contentTintColor = ThemePalette.accent
|
||||
}
|
||||
let loginHeading = NSTextField(labelWithString: "Startup")
|
||||
loginHeading.font = .systemFont(ofSize: 20, weight: .semibold)
|
||||
loginHeading.textColor = SunsetPalette.text
|
||||
loginHeading.textColor = ThemePalette.text
|
||||
let translationHeading = NSTextField(labelWithString: "Conjure Translate")
|
||||
translationHeading.font = .systemFont(ofSize: 20, weight: .semibold)
|
||||
translationHeading.textColor = SunsetPalette.text
|
||||
translationHeading.textColor = ThemePalette.text
|
||||
|
||||
let stack = NSStackView(views: [
|
||||
heading, destinationSelector, controls, instructions, statusLabel, errorLabel, spotlight, keyboardSettings,
|
||||
@@ -197,6 +199,20 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
for label in [instructions, statusLabel, errorLabel, spotlight, loginStatus, recoveryLabel] {
|
||||
label.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
|
||||
}
|
||||
themeObservation = ThemeObservation { [weak self] in
|
||||
guard let self else { return }
|
||||
self.window?.appearance = ThemePalette.appearance
|
||||
self.window?.attachedSheet?.appearance = ThemePalette.appearance
|
||||
for label in [heading, loginHeading, translationHeading] { label.textColor = ThemePalette.text }
|
||||
for label in [instructions, self.statusLabel, spotlight] { label.textColor = ThemePalette.secondaryText }
|
||||
self.errorLabel.textColor = ThemePalette.error
|
||||
self.loginStatus.textColor = self.loginStatusIsError ? ThemePalette.error : ThemePalette.secondaryText
|
||||
self.recoveryLabel.textColor = self.recoveryReport?.needsRecovery == true ? ThemePalette.error : ThemePalette.secondaryText
|
||||
for button in [self.recorder, self.commandSpaceButton, self.clearButton, keyboardSettings,
|
||||
self.loginToggle, self.loginSettings, self.recoveryButton] {
|
||||
button.contentTintColor = ThemePalette.accent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setUninstallRecoveryReport(_ report: UninstallReport) {
|
||||
@@ -206,7 +222,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
recoveryLabel.stringValue = report.needsRecovery
|
||||
? "An interrupted uninstall needs attention. Files have been retained for recovery."
|
||||
: "Interrupted uninstall recovery completed."
|
||||
recoveryLabel.textColor = report.needsRecovery ? .systemRed : SunsetPalette.secondaryText
|
||||
recoveryLabel.textColor = report.needsRecovery ? ThemePalette.error : ThemePalette.secondaryText
|
||||
recoveryButton.title = report.needsRecovery ? "Retry uninstall recovery…" : "View recovery details…"
|
||||
}
|
||||
|
||||
@@ -220,6 +236,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
setUninstallRecoveryReport(result)
|
||||
} else { result = report }
|
||||
let alert = NSAlert()
|
||||
alert.window.appearance = ThemePalette.appearance
|
||||
alert.messageText = result.needsRecovery ? "Recovery needs attention" : "Recovery complete"
|
||||
alert.informativeText = result.messages.isEmpty ? "No pending files need recovery." : result.messages.joined(separator: "\n")
|
||||
await alert.beginSheetModal(for: window)
|
||||
@@ -229,7 +246,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
|
||||
private func refreshLogin() {
|
||||
let status = SMAppService.mainApp.status
|
||||
loginStatus.textColor = SunsetPalette.secondaryText
|
||||
loginStatus.textColor = ThemePalette.secondaryText
|
||||
loginStatusIsError = false
|
||||
loginToggle.state = (status == .enabled || status == .requiresApproval) ? .on : .off
|
||||
loginSettings.isHidden = status != .requiresApproval
|
||||
switch status {
|
||||
@@ -243,7 +261,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
loginStatus.stringValue = "Install Conjure in Applications before enabling launch at login."
|
||||
@unknown default:
|
||||
loginStatus.stringValue = "Launch at login status is unavailable."
|
||||
loginStatus.textColor = .systemRed
|
||||
loginStatus.textColor = ThemePalette.error
|
||||
loginStatusIsError = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +278,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
} catch {
|
||||
refreshLogin()
|
||||
loginStatus.stringValue = "Could not change launch at login: \(error.localizedDescription)"
|
||||
loginStatus.textColor = .systemRed
|
||||
loginStatus.textColor = ThemePalette.error
|
||||
loginStatusIsError = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import CoreText
|
||||
|
||||
@MainActor
|
||||
final class ShortcutIndicator: NSStackView {
|
||||
init(_ keys: String..., backgroundColor: NSColor? = nil, fontSize: CGFloat = 12, keySize: CGFloat = 18) {
|
||||
private var themeObservation: ThemeObservation?
|
||||
init(_ keys: String..., hasBackground: Bool = false, fontSize: CGFloat = 12, keySize: CGFloat = 18) {
|
||||
super.init(frame: .zero)
|
||||
orientation = .horizontal
|
||||
alignment = .centerY
|
||||
@@ -15,13 +16,13 @@ final class ShortcutIndicator: NSStackView {
|
||||
for key in keys {
|
||||
let badge = ShortcutKeyBadge(labelWithString: key)
|
||||
badge.font = .systemFont(ofSize: fontSize, weight: .medium)
|
||||
badge.textColor = SunsetPalette.secondaryText
|
||||
badge.textColor = ThemePalette.secondaryText
|
||||
badge.alignment = .center
|
||||
badge.wantsLayer = true
|
||||
badge.layer?.cornerRadius = 5
|
||||
badge.layer?.borderWidth = 1
|
||||
badge.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.35).cgColor
|
||||
badge.layer?.backgroundColor = backgroundColor?.cgColor
|
||||
badge.layer?.borderColor = ThemePalette.secondaryText.withAlphaComponent(0.35).cgColor
|
||||
badge.layer?.backgroundColor = hasBackground ? ThemePalette.keyBackground.cgColor : nil
|
||||
badge.setAccessibilityElement(false)
|
||||
badge.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
@@ -30,6 +31,15 @@ final class ShortcutIndicator: NSStackView {
|
||||
])
|
||||
addArrangedSubview(badge)
|
||||
}
|
||||
themeObservation = ThemeObservation { [weak self] in
|
||||
guard let self else { return }
|
||||
for case let badge as NSTextField in self.arrangedSubviews {
|
||||
badge.textColor = ThemePalette.secondaryText
|
||||
badge.layer?.borderColor = ThemePalette.secondaryText.withAlphaComponent(0.35).cgColor
|
||||
badge.layer?.backgroundColor = hasBackground ? ThemePalette.keyBackground.cgColor : nil
|
||||
badge.needsDisplay = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setSingleKey(_ key: String) {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import AppKit
|
||||
import Darwin
|
||||
|
||||
@MainActor
|
||||
final class ColorSchemeObservation {
|
||||
private struct Watch: @unchecked Sendable {
|
||||
let source: any DispatchSourceFileSystemObject
|
||||
let device: dev_t
|
||||
let inode: ino_t
|
||||
}
|
||||
|
||||
private let directory: URL
|
||||
private let onChange: @MainActor () -> Void
|
||||
private var watches: [String: Watch] = [:]
|
||||
|
||||
init(directory: URL, onChange: @escaping @MainActor () -> Void) {
|
||||
self.directory = directory.standardizedFileURL
|
||||
self.onChange = onChange
|
||||
refreshWatches()
|
||||
}
|
||||
|
||||
deinit {
|
||||
for watch in watches.values { watch.source.cancel() }
|
||||
}
|
||||
|
||||
private func filesChanged() {
|
||||
refreshWatches()
|
||||
onChange()
|
||||
}
|
||||
|
||||
private func refreshWatches() {
|
||||
var ancestor = directory
|
||||
var isDirectory: ObjCBool = false
|
||||
while !FileManager.default.fileExists(atPath: ancestor.path, isDirectory: &isDirectory)
|
||||
|| !isDirectory.boolValue {
|
||||
let parent = ancestor.deletingLastPathComponent()
|
||||
guard parent.path != ancestor.path else { break }
|
||||
ancestor = parent
|
||||
}
|
||||
let urls = [ancestor, ancestor.deletingLastPathComponent(),
|
||||
directory.appendingPathComponent("config.toml"), directory.appendingPathComponent("theme.json")]
|
||||
var obsolete = Set(watches.keys)
|
||||
for url in urls {
|
||||
let path = url.path
|
||||
let descriptor = open(path, O_EVTONLY | O_CLOEXEC)
|
||||
guard descriptor >= 0 else { continue }
|
||||
var identity = stat()
|
||||
guard fstat(descriptor, &identity) == 0 else {
|
||||
close(descriptor)
|
||||
continue
|
||||
}
|
||||
obsolete.remove(path)
|
||||
if let existing = watches[path], existing.device == identity.st_dev, existing.inode == identity.st_ino {
|
||||
close(descriptor)
|
||||
continue
|
||||
}
|
||||
watches.removeValue(forKey: path)?.source.cancel()
|
||||
let source = DispatchSource.makeFileSystemObjectSource(fileDescriptor: descriptor,
|
||||
eventMask: [.write, .extend, .delete, .rename, .attrib, .revoke], queue: .main)
|
||||
source.setEventHandler { [weak self] in
|
||||
Task { @MainActor [weak self] in self?.filesChanged() }
|
||||
}
|
||||
source.setCancelHandler { close(descriptor) }
|
||||
watches[path] = Watch(source: source, device: identity.st_dev, inode: identity.st_ino)
|
||||
source.resume()
|
||||
}
|
||||
for path in obsolete { watches.removeValue(forKey: path)?.source.cancel() }
|
||||
}
|
||||
}
|
||||
@@ -32,10 +32,21 @@ final class TranslationController: NSViewController {
|
||||
private func languageName(_ code: String) -> String { Self.languageName(code) }
|
||||
private var usesMyMemory: Bool
|
||||
private var textToTranslate: String?
|
||||
struct Draft {
|
||||
let input: String
|
||||
let textToTranslate: String?
|
||||
}
|
||||
private var committed = false
|
||||
var draft: Draft? {
|
||||
committed ? nil : Draft(input: input?() ?? "", textToTranslate: textToTranslate)
|
||||
}
|
||||
private let sourcePreview = NSTextField(wrappingLabelWithString: "")
|
||||
private let output = NSTextView()
|
||||
private let settingsButton = NSButton(title: "Open Translate Settings", target: nil, action: nil)
|
||||
private let status = NSTextField(wrappingLabelWithString: "")
|
||||
private var statusIsError = false
|
||||
private var outputSecondaryRange: NSRange?
|
||||
private var themeObservation: ThemeObservation?
|
||||
private let translateButton = NSButton(title: "Translate", target: nil, action: nil)
|
||||
private var task: Task<Void, Never>?
|
||||
var input: (() -> String)?
|
||||
@@ -57,13 +68,15 @@ final class TranslationController: NSViewController {
|
||||
openAI: @escaping @MainActor (String, String, String, String, String) async throws -> String = {
|
||||
try await TranslationAPI.translate($0, apiKey: $3, sourceLanguage: $1, targetLanguage: $2, context: $4)
|
||||
},
|
||||
pasteboard: NSPasteboard = .general
|
||||
pasteboard: NSPasteboard = .general,
|
||||
draft: Draft? = nil
|
||||
) {
|
||||
self.defaults = defaults
|
||||
self.credentialsStore = credentials
|
||||
self.myMemory = myMemory
|
||||
self.openAI = openAI
|
||||
self.pasteboard = pasteboard
|
||||
textToTranslate = draft?.textToTranslate
|
||||
usesMyMemory = defaults.string(forKey: "translationProvider") != "openai"
|
||||
sourceLanguage = defaults.string(forKey: "translationSourceLanguage")
|
||||
.flatMap { Self.languages.contains($0) ? $0 : nil } ?? "en"
|
||||
@@ -85,11 +98,11 @@ final class TranslationController: NSViewController {
|
||||
providerButton.toolTip = "Choose translation provider (Command-P)"
|
||||
providerButton.isBordered = false
|
||||
providerButton.font = .systemFont(ofSize: 12, weight: .medium)
|
||||
providerButton.contentTintColor = SunsetPalette.text
|
||||
providerButton.contentTintColor = ThemePalette.text
|
||||
let chevron = NSButton(image: NSImage(systemSymbolName: "chevron.down", accessibilityDescription: nil)!,
|
||||
target: self, action: #selector(showProviders))
|
||||
chevron.isBordered = false
|
||||
chevron.contentTintColor = SunsetPalette.secondaryText
|
||||
chevron.contentTintColor = ThemePalette.secondaryText
|
||||
chevron.setAccessibilityElement(false)
|
||||
stylePill(providerControl)
|
||||
for control in [providerButton, chevron] {
|
||||
@@ -109,7 +122,7 @@ final class TranslationController: NSViewController {
|
||||
languageButton.action = #selector(showActions)
|
||||
languageButton.isBordered = false
|
||||
languageButton.font = .systemFont(ofSize: 12, weight: .medium)
|
||||
languageButton.contentTintColor = SunsetPalette.text
|
||||
languageButton.contentTintColor = ThemePalette.text
|
||||
languageButton.toolTip = "Change or swap translation languages (Command-K)"
|
||||
languageButton.setAccessibilityLabel("Translation languages")
|
||||
actionsButton.target = self
|
||||
@@ -140,7 +153,7 @@ final class TranslationController: NSViewController {
|
||||
output.isEditable = false
|
||||
output.isSelectable = true
|
||||
output.drawsBackground = false
|
||||
output.textColor = SunsetPalette.text
|
||||
output.textColor = ThemePalette.text
|
||||
output.font = .systemFont(ofSize: 20)
|
||||
output.textContainerInset = .zero
|
||||
output.textContainer?.lineFragmentPadding = 0
|
||||
@@ -156,7 +169,7 @@ final class TranslationController: NSViewController {
|
||||
status.setAccessibilityLabel("Translation status and errors")
|
||||
status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
sourcePreview.font = .systemFont(ofSize: 14)
|
||||
sourcePreview.textColor = SunsetPalette.secondaryText
|
||||
sourcePreview.textColor = ThemePalette.secondaryText
|
||||
sourcePreview.maximumNumberOfLines = 3
|
||||
sourcePreview.lineBreakMode = .byTruncatingTail
|
||||
sourcePreview.setAccessibilityLabel("Original text to translate")
|
||||
@@ -179,6 +192,24 @@ final class TranslationController: NSViewController {
|
||||
footer.heightAnchor.constraint(equalToConstant: 36),
|
||||
scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 100),
|
||||
])
|
||||
themeObservation = ThemeObservation { [weak self, weak chevron] in
|
||||
guard let self else { return }
|
||||
self.providerButton.contentTintColor = ThemePalette.text
|
||||
self.languageButton.contentTintColor = ThemePalette.text
|
||||
chevron?.contentTintColor = ThemePalette.secondaryText
|
||||
self.stylePill(self.providerControl)
|
||||
self.sourcePreview.textColor = ThemePalette.secondaryText
|
||||
self.status.textColor = self.statusIsError ? ThemePalette.error : ThemePalette.secondaryText
|
||||
if let storage = self.output.textStorage {
|
||||
storage.beginEditing()
|
||||
storage.addAttribute(.foregroundColor, value: ThemePalette.text,
|
||||
range: NSRange(location: 0, length: storage.length))
|
||||
if let range = self.outputSecondaryRange {
|
||||
storage.addAttribute(.foregroundColor, value: ThemePalette.secondaryText, range: range)
|
||||
}
|
||||
storage.endEditing()
|
||||
}
|
||||
}
|
||||
inputChanged()
|
||||
}
|
||||
|
||||
@@ -192,8 +223,8 @@ final class TranslationController: NSViewController {
|
||||
pill.wantsLayer = true
|
||||
pill.layer?.cornerRadius = 18
|
||||
pill.layer?.borderWidth = 0.5
|
||||
pill.layer?.borderColor = SunsetPalette.accent.withAlphaComponent(0.35).cgColor
|
||||
pill.layer?.backgroundColor = SunsetPalette.background.withAlphaComponent(0.25).cgColor
|
||||
pill.layer?.borderColor = ThemePalette.accent.withAlphaComponent(0.35).cgColor
|
||||
pill.layer?.backgroundColor = ThemePalette.background.withAlphaComponent(0.25).cgColor
|
||||
}
|
||||
|
||||
@objc func showProviders() {
|
||||
@@ -264,8 +295,10 @@ final class TranslationController: NSViewController {
|
||||
}
|
||||
|
||||
func inputChanged() {
|
||||
committed = false
|
||||
cancelRequest()
|
||||
output.string = ""
|
||||
outputSecondaryRange = nil
|
||||
let isEmpty = (input?() ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
if textToTranslate != nil {
|
||||
translateButton.title = "Translate"
|
||||
@@ -307,8 +340,9 @@ final class TranslationController: NSViewController {
|
||||
}
|
||||
|
||||
private func setStatus(_ message: String, isError: Bool = false) {
|
||||
statusIsError = isError
|
||||
status.stringValue = message
|
||||
status.textColor = isError ? .systemRed : SunsetPalette.secondaryText
|
||||
status.textColor = isError ? ThemePalette.error : ThemePalette.secondaryText
|
||||
}
|
||||
|
||||
@objc func performPrimaryAction() {
|
||||
@@ -331,6 +365,7 @@ final class TranslationController: NSViewController {
|
||||
}
|
||||
pasteboard.clearContents()
|
||||
if pasteboard.setString(output.string, forType: .string) {
|
||||
committed = true
|
||||
dismiss?()
|
||||
} else {
|
||||
setStatus("Could not copy the translation. Try again.", isError: true)
|
||||
@@ -362,6 +397,7 @@ final class TranslationController: NSViewController {
|
||||
}
|
||||
}
|
||||
output.string = ""
|
||||
outputSecondaryRange = nil
|
||||
setStatus("Translating…")
|
||||
translateButton.isEnabled = false
|
||||
let myMemoryTranslate = self.myMemory
|
||||
@@ -376,11 +412,12 @@ final class TranslationController: NSViewController {
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
let rendered = NSMutableAttributedString(string: translation, attributes: [
|
||||
.font: NSFont.systemFont(ofSize: 20),
|
||||
.foregroundColor: SunsetPalette.text,
|
||||
.foregroundColor: ThemePalette.text,
|
||||
])
|
||||
if !myMemory, let separator = translation.range(of: "\n\n") {
|
||||
self.outputSecondaryRange = NSRange(separator.upperBound..<translation.endIndex, in: translation)
|
||||
rendered.addAttributes([
|
||||
.foregroundColor: SunsetPalette.secondaryText,
|
||||
.foregroundColor: ThemePalette.secondaryText,
|
||||
.font: NSFont.systemFont(ofSize: 14),
|
||||
], range: NSRange(separator.upperBound..<translation.endIndex, in: translation))
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import AppKit
|
||||
final class TranslationSettingsController: NSViewController {
|
||||
private let keyField = NSSecureTextField()
|
||||
private let status = NSTextField(wrappingLabelWithString: "")
|
||||
private var statusIsError = false
|
||||
private var themeObservation: ThemeObservation?
|
||||
private let credentials: TranslationController.Credentials
|
||||
|
||||
init(credentials: TranslationController.Credentials = .keychain) {
|
||||
@@ -21,7 +23,7 @@ final class TranslationSettingsController: NSViewController {
|
||||
let forget = NSButton(title: "Forget Key", target: self, action: #selector(forgetKey))
|
||||
for button in [save, forget] {
|
||||
button.bezelStyle = .rounded
|
||||
button.contentTintColor = SunsetPalette.accent
|
||||
button.contentTintColor = ThemePalette.accent
|
||||
}
|
||||
let controls = NSStackView(views: [keyField, save, forget])
|
||||
controls.orientation = .horizontal
|
||||
@@ -37,7 +39,7 @@ final class TranslationSettingsController: NSViewController {
|
||||
"OpenAI · gpt-6-luna · Text and optional situation/context are sent to OpenAI only when you explicitly translate, not when advancing to context. Saving a key does not send your text. Your API key is stored in Keychain."
|
||||
)
|
||||
for label in [status, myMemory, openAI] {
|
||||
label.textColor = SunsetPalette.secondaryText
|
||||
label.textColor = ThemePalette.secondaryText
|
||||
}
|
||||
let stack = NSStackView(views: [controls, status, myMemory, openAI])
|
||||
stack.orientation = .vertical
|
||||
@@ -55,6 +57,14 @@ final class TranslationSettingsController: NSViewController {
|
||||
for label in [status, myMemory, openAI] {
|
||||
label.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
|
||||
}
|
||||
themeObservation = ThemeObservation { [weak self] in
|
||||
save.contentTintColor = ThemePalette.accent
|
||||
forget.contentTintColor = ThemePalette.accent
|
||||
myMemory.textColor = ThemePalette.secondaryText
|
||||
openAI.textColor = ThemePalette.secondaryText
|
||||
guard let self else { return }
|
||||
self.status.textColor = self.statusIsError ? ThemePalette.error : ThemePalette.secondaryText
|
||||
}
|
||||
do {
|
||||
setStatus(try credentials.load() == nil ? "No OpenAI API key saved." : "OpenAI API key saved in Keychain.")
|
||||
} catch { setStatus(error.localizedDescription, isError: true) }
|
||||
@@ -84,7 +94,8 @@ final class TranslationSettingsController: NSViewController {
|
||||
}
|
||||
|
||||
private func setStatus(_ message: String, isError: Bool = false) {
|
||||
statusIsError = isError
|
||||
status.stringValue = message
|
||||
status.textColor = isError ? .systemRed : SunsetPalette.secondaryText
|
||||
status.textColor = isError ? ThemePalette.error : ThemePalette.secondaryText
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
|
||||
private let status = NSTextField(wrappingLabelWithString: "Finding associated files…")
|
||||
private let remove = NSButton(title: "Uninstall application", target: nil, action: nil)
|
||||
private let back = NSButton(title: "Back", target: nil, action: nil)
|
||||
private var themeObservation: ThemeObservation?
|
||||
private var statusIsError = false
|
||||
private var candidates: [UninstallCandidate] = []
|
||||
private var visible: [UninstallCandidate] = []
|
||||
private var selected: Set<URL> = []
|
||||
@@ -41,7 +43,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
|
||||
discoveryState = .loading
|
||||
updateStatus()
|
||||
window.makeFirstResponder(search)
|
||||
(search.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent
|
||||
(search.currentEditor() as? NSTextView)?.insertionPointColor = ThemePalette.accent
|
||||
scanTask = Task { [weak self, application] in
|
||||
do {
|
||||
let found = try await Task.detached(priority: .userInitiated) {
|
||||
@@ -62,13 +64,13 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
|
||||
view = content
|
||||
let context = NSTextField(wrappingLabelWithString: "Uninstall \(application.name)")
|
||||
context.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
context.textColor = SunsetPalette.text
|
||||
context.textColor = ThemePalette.text
|
||||
let guidance = NSTextField(wrappingLabelWithString: "Return toggles file inclusion · Command-Return opens confirmation")
|
||||
guidance.font = .systemFont(ofSize: 12)
|
||||
guidance.textColor = SunsetPalette.secondaryText
|
||||
guidance.textColor = ThemePalette.secondaryText
|
||||
search.placeholderAttributedString = NSAttributedString(
|
||||
string: "Filter files and folders by name or path…",
|
||||
attributes: [.foregroundColor: SunsetPalette.secondaryText, .font: NSFont.systemFont(ofSize: 20)]
|
||||
attributes: [.foregroundColor: ThemePalette.secondaryText, .font: NSFont.systemFont(ofSize: 20)]
|
||||
)
|
||||
if let cell = search.cell as? NSSearchFieldCell {
|
||||
cell.searchButtonCell = nil
|
||||
@@ -79,12 +81,12 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
|
||||
search.setAccessibilityLabel("Filter uninstall files for \(application.name)")
|
||||
search.setAccessibilityHelp(guidance.stringValue)
|
||||
search.font = .systemFont(ofSize: 20)
|
||||
search.textColor = SunsetPalette.text
|
||||
search.textColor = ThemePalette.text
|
||||
search.isBordered = false
|
||||
search.drawsBackground = false
|
||||
search.focusRingType = .none
|
||||
status.font = .systemFont(ofSize: 12)
|
||||
status.textColor = SunsetPalette.secondaryText
|
||||
status.textColor = ThemePalette.secondaryText
|
||||
status.setAccessibilityLabel("Uninstall selection and progress")
|
||||
back.target = self
|
||||
back.action = #selector(goBack)
|
||||
@@ -92,7 +94,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
|
||||
back.image = IconStyle.symbol("chevron.left", for: search.font!)
|
||||
back.imageScaling = .scaleProportionallyDown
|
||||
back.isBordered = false
|
||||
back.contentTintColor = SunsetPalette.secondaryText
|
||||
back.contentTintColor = ThemePalette.secondaryText
|
||||
back.setAccessibilityLabel("Back to applications")
|
||||
let header = NSStackView(views: [back, search])
|
||||
header.orientation = .horizontal
|
||||
@@ -100,7 +102,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
|
||||
header.spacing = 12
|
||||
remove.target = self
|
||||
remove.action = #selector(confirmUninstall)
|
||||
remove.contentTintColor = .systemRed
|
||||
remove.contentTintColor = ThemePalette.error
|
||||
remove.isBordered = false
|
||||
remove.font = .systemFont(ofSize: 12, weight: .medium)
|
||||
remove.keyEquivalent = "\r"
|
||||
@@ -128,8 +130,8 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
|
||||
pill.wantsLayer = true
|
||||
pill.layer?.cornerRadius = 18
|
||||
pill.layer?.borderWidth = 0.5
|
||||
pill.layer?.borderColor = SunsetPalette.accent.withAlphaComponent(0.35).cgColor
|
||||
pill.layer?.backgroundColor = SunsetPalette.background.withAlphaComponent(0.25).cgColor
|
||||
pill.layer?.borderColor = ThemePalette.accent.withAlphaComponent(0.35).cgColor
|
||||
pill.layer?.backgroundColor = ThemePalette.background.withAlphaComponent(0.25).cgColor
|
||||
let shortcut = ShortcutIndicator("⌘", "↵")
|
||||
for child in [remove, shortcut] {
|
||||
child.translatesAutoresizingMaskIntoConstraints = false
|
||||
@@ -176,6 +178,18 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
|
||||
footer.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -8),
|
||||
footer.heightAnchor.constraint(equalToConstant: 36)
|
||||
])
|
||||
themeObservation = ThemeObservation { [weak self, weak context, weak guidance, weak pill] in
|
||||
guard let self else { return }
|
||||
context?.textColor = ThemePalette.text
|
||||
guidance?.textColor = ThemePalette.secondaryText
|
||||
self.search.applyTheme()
|
||||
(self.search.currentEditor() as? NSTextView)?.insertionPointColor = ThemePalette.accent
|
||||
self.status.textColor = self.statusIsError ? ThemePalette.error : ThemePalette.secondaryText
|
||||
self.back.contentTintColor = ThemePalette.secondaryText
|
||||
self.remove.contentTintColor = ThemePalette.error
|
||||
pill?.layer?.borderColor = ThemePalette.accent.withAlphaComponent(0.35).cgColor
|
||||
pill?.layer?.backgroundColor = ThemePalette.background.withAlphaComponent(0.25).cgColor
|
||||
}
|
||||
}
|
||||
|
||||
func applyDiscoveryResult(_ result: Result<[UninstallCandidate], Error>, preservingSelection: Bool = false) {
|
||||
@@ -204,14 +218,16 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
|
||||
}
|
||||
|
||||
private func updateStatus() {
|
||||
status.textColor = SunsetPalette.secondaryText
|
||||
status.textColor = ThemePalette.secondaryText
|
||||
statusIsError = false
|
||||
remove.isEnabled = false
|
||||
switch discoveryState {
|
||||
case .loading:
|
||||
status.stringValue = "Finding associated files…"
|
||||
case .failed(let message):
|
||||
status.stringValue = message
|
||||
status.textColor = .systemRed
|
||||
status.textColor = ThemePalette.error
|
||||
statusIsError = true
|
||||
case .ready:
|
||||
let chosen = candidates.filter { selected.contains($0.url) }
|
||||
let size = chosen.compactMap(\.size).reduce(Int64(0), +)
|
||||
@@ -260,7 +276,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
|
||||
cell.toggle.isEnabled = candidate.isSelectable && !busy
|
||||
cell.toggle.setAccessibilityLabel("Select \(candidate.url.lastPathComponent)")
|
||||
cell.name.stringValue = candidate.url.lastPathComponent
|
||||
cell.name.textColor = candidate.isSelectable ? SunsetPalette.text : SunsetPalette.secondaryText
|
||||
cell.selectable = candidate.isSelectable
|
||||
let path = (candidate.url.deletingLastPathComponent().path as NSString).abbreviatingWithTildeInPath
|
||||
cell.detail.stringValue = candidate.reason.map { "\(path) · \($0)" } ?? path
|
||||
cell.size.stringValue = candidate.size.map { ByteCountFormatter.string(fromByteCount: $0, countStyle: .file) } ?? "—"
|
||||
@@ -305,6 +321,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
|
||||
guard !urls.isEmpty else { return }
|
||||
let running = runningApplications()
|
||||
let alert = NSAlert()
|
||||
alert.window.appearance = ThemePalette.appearance
|
||||
alert.alertStyle = .warning
|
||||
alert.messageText = "Uninstall \(application.name)?"
|
||||
alert.informativeText = "Move \(urls.count) selected files and folders to Trash."
|
||||
@@ -318,7 +335,8 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
|
||||
return
|
||||
}
|
||||
do {
|
||||
status.textColor = SunsetPalette.secondaryText
|
||||
status.textColor = ThemePalette.secondaryText
|
||||
statusIsError = false
|
||||
status.stringValue = "Waiting for application to quit…"
|
||||
for app in running where !app.isTerminated {
|
||||
guard app.terminate() else { throw ReviewError("\(app.localizedName ?? application.name) could not quit. No files were moved.") }
|
||||
@@ -363,12 +381,14 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
|
||||
var canRecover = allowRecovery
|
||||
while true {
|
||||
let alert = NSAlert()
|
||||
alert.window.appearance = ThemePalette.appearance
|
||||
alert.messageText = title
|
||||
alert.informativeText = message
|
||||
alert.addButton(withTitle: "OK")
|
||||
if canRecover { alert.addButton(withTitle: "Retry recovery") }
|
||||
guard await alert.beginSheetModal(for: window) == .alertSecondButtonReturn, canRecover else { return }
|
||||
status.textColor = SunsetPalette.secondaryText
|
||||
status.textColor = ThemePalette.secondaryText
|
||||
statusIsError = false
|
||||
status.stringValue = "Recovering pending uninstall…"
|
||||
let report = await uninstaller.recover()
|
||||
changed?()
|
||||
@@ -425,6 +445,14 @@ private final class UninstallFileCell: NSTableCellView {
|
||||
let detail = NSTextField(labelWithString: "")
|
||||
let size = NSTextField(labelWithString: "")
|
||||
let fileIcon: IconView
|
||||
private var themeObservation: ThemeObservation?
|
||||
var selectable = true { didSet { applyTheme() } }
|
||||
|
||||
private func applyTheme() {
|
||||
name.textColor = selectable ? ThemePalette.text : ThemePalette.secondaryText
|
||||
detail.textColor = ThemePalette.secondaryText
|
||||
size.textColor = ThemePalette.secondaryText
|
||||
}
|
||||
|
||||
init() {
|
||||
let font = NSFont.systemFont(ofSize: 14, weight: .medium)
|
||||
@@ -432,7 +460,7 @@ private final class UninstallFileCell: NSTableCellView {
|
||||
super.init(frame: .zero)
|
||||
name.font = font
|
||||
detail.font = .systemFont(ofSize: 11)
|
||||
detail.textColor = SunsetPalette.secondaryText
|
||||
detail.textColor = ThemePalette.secondaryText
|
||||
for label in [name, detail] {
|
||||
label.lineBreakMode = .byTruncatingMiddle
|
||||
label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
@@ -441,7 +469,7 @@ private final class UninstallFileCell: NSTableCellView {
|
||||
labels.orientation = .vertical
|
||||
labels.alignment = .leading
|
||||
labels.spacing = 3
|
||||
size.textColor = SunsetPalette.secondaryText
|
||||
size.textColor = ThemePalette.secondaryText
|
||||
size.font = .systemFont(ofSize: 12)
|
||||
size.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
let row = NSStackView(views: [toggle, labels, NSView(), size, fileIcon])
|
||||
@@ -456,6 +484,7 @@ private final class UninstallFileCell: NSTableCellView {
|
||||
row.topAnchor.constraint(equalTo: topAnchor, constant: 4),
|
||||
row.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4)
|
||||
])
|
||||
themeObservation = ThemeObservation { [weak self] in self?.applyTheme() }
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("Conjure creates uninstall rows programmatically") }
|
||||
|
||||
@@ -49,6 +49,7 @@ public enum ApplicationCatalog {
|
||||
[
|
||||
URL(fileURLWithPath: "/Applications", isDirectory: true),
|
||||
URL(fileURLWithPath: "/System/Applications", isDirectory: true),
|
||||
URL(fileURLWithPath: "/System/Cryptexes/App/System/Applications", isDirectory: true),
|
||||
URL(fileURLWithPath: "/System/Library/CoreServices/Applications", isDirectory: true),
|
||||
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Applications", isDirectory: true),
|
||||
]
|
||||
|
||||
@@ -103,8 +103,28 @@ public enum Calculator {
|
||||
"wbtc", "weth",
|
||||
]
|
||||
|
||||
private static let currencyNames: [String: String] = [
|
||||
"yen": "jpy", "japanese yen": "jpy",
|
||||
"euro": "eur", "euros": "eur",
|
||||
"danish krone": "dkk", "danish kroner": "dkk",
|
||||
"swedish krona": "sek", "swedish kronor": "sek",
|
||||
"norwegian krone": "nok", "norwegian kroner": "nok",
|
||||
"us dollar": "usd", "us dollars": "usd",
|
||||
"american dollar": "usd", "american dollars": "usd",
|
||||
"canadian dollar": "cad", "canadian dollars": "cad",
|
||||
"australian dollar": "aud", "australian dollars": "aud",
|
||||
"new zealand dollar": "nzd", "new zealand dollars": "nzd",
|
||||
"british pound": "gbp", "british pounds": "gbp", "pound sterling": "gbp",
|
||||
"swiss franc": "chf", "swiss francs": "chf",
|
||||
"chinese yuan": "cny", "yuan": "cny",
|
||||
"indian rupee": "inr", "indian rupees": "inr",
|
||||
"south korean won": "krw",
|
||||
"bitcoin": "btc", "bitcoins": "btc", "ether": "eth", "ethereum": "eth",
|
||||
]
|
||||
|
||||
private static func currencyCode(_ text: String, rates: CurrencyRates?) -> String? {
|
||||
let code = text.lowercased()
|
||||
if let code = currencyNames[code] { return code }
|
||||
guard (2...12).contains(code.utf8.count),
|
||||
code.utf8.allSatisfy({ (97...122).contains($0) || (48...57).contains($0) }),
|
||||
ConversionUnit.parse(text) == nil else { return nil }
|
||||
@@ -115,22 +135,26 @@ public enum Calculator {
|
||||
private static func currencyQuery(_ query: String, rates: CurrencyRates?) -> CurrencyQuery? {
|
||||
guard query.utf8.count <= 512 else { return nil }
|
||||
let words = query.split(whereSeparator: \.isWhitespace)
|
||||
guard words.count >= 3,
|
||||
["to", "in", "as"].contains(words[words.count - 2].lowercased()),
|
||||
let target = currencyCode(String(words[words.count - 1]), rates: rates) else { return nil }
|
||||
let sourceText = words.dropLast(2).joined(separator: " ")
|
||||
guard words.count >= 3 else { return nil }
|
||||
for separator in 1..<(words.count - 1)
|
||||
where ["to", "in", "as"].contains(words[separator].lowercased()) {
|
||||
guard let target = currencyCode(words[(separator + 1)...].joined(separator: " "), rates: rates) else {
|
||||
continue
|
||||
}
|
||||
let sourceText = words[..<separator].joined(separator: " ")
|
||||
let characters = Array(sourceText)
|
||||
guard characters.count > 1 else { return nil }
|
||||
for length in stride(from: min(12, characters.count - 1), through: 2, by: -1) {
|
||||
guard characters.count > 1 else { continue }
|
||||
for length in stride(from: min(32, characters.count - 1), through: 2, by: -1) {
|
||||
let suffix = String(characters.suffix(length))
|
||||
guard let source = currencyCode(suffix, rates: rates) else { continue }
|
||||
let boundary = characters[characters.count - length - 1]
|
||||
// Digit-bearing tickers need a space so 21inch cannot silently become 2 × 1INCH.
|
||||
guard boundary.isWhitespace || (suffix.allSatisfy(\.isLetter) && !boundary.isLetter) else { continue }
|
||||
guard boundary.isWhitespace || (suffix.allSatisfy { $0.isLetter || $0.isWhitespace } && !boundary.isLetter) else { continue }
|
||||
let expression = String(characters.dropLast(length)).trimmingCharacters(in: .whitespaces)
|
||||
guard let amount = parse(expression) else { continue }
|
||||
return CurrencyQuery(expression: expression, source: source, target: target, amount: amount)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
import AppKit
|
||||
import CryptoKit
|
||||
import Markdown
|
||||
|
||||
public enum NoteAttributes {
|
||||
public static let block = NSAttributedString.Key("ConjureNote.block")
|
||||
public static let headingLevel = NSAttributedString.Key("ConjureNote.headingLevel")
|
||||
public static let depth = NSAttributedString.Key("ConjureNote.depth")
|
||||
public static let number = NSAttributedString.Key("ConjureNote.number")
|
||||
public static let checked = NSAttributedString.Key("ConjureNote.checked")
|
||||
public static let language = NSAttributedString.Key("ConjureNote.language")
|
||||
public static let inlineCode = NSAttributedString.Key("ConjureNote.inlineCode")
|
||||
public static let bold = NSAttributedString.Key("ConjureNote.bold")
|
||||
public static let italic = NSAttributedString.Key("ConjureNote.italic")
|
||||
}
|
||||
|
||||
/// The attributed string is the document; Markdown is only an import/export format.
|
||||
@MainActor
|
||||
public enum NoteDocument {
|
||||
static let metadataFilename = "ConjureNote.json"
|
||||
|
||||
public static func importMarkdown(_ source: NSAttributedString) -> NSAttributedString {
|
||||
MarkdownImporter(source).contents()
|
||||
}
|
||||
|
||||
public static func markdown(_ contents: NSAttributedString) -> NSAttributedString {
|
||||
let result = NSMutableAttributedString(string: "")
|
||||
let text = contents.string as NSString
|
||||
var cursor = 0
|
||||
while cursor < contents.length {
|
||||
let range = text.paragraphRange(for: NSRange(location: cursor, length: 0))
|
||||
let attributes = contents.attributes(at: cursor, effectiveRange: nil)
|
||||
let block = attributes[NoteAttributes.block] as? String
|
||||
if block == "code" {
|
||||
let language = attributes[NoteAttributes.language] as? String ?? ""
|
||||
var end = NSMaxRange(range)
|
||||
while end < contents.length,
|
||||
contents.attribute(NoteAttributes.block, at: end, effectiveRange: nil) as? String == "code",
|
||||
(contents.attribute(NoteAttributes.language, at: end, effectiveRange: nil) as? String ?? "") == language {
|
||||
end = NSMaxRange(text.paragraphRange(for: NSRange(location: end, length: 0)))
|
||||
}
|
||||
let code = contents.attributedSubstring(from: NSRange(location: cursor, length: end - cursor))
|
||||
let fence = String(repeating: "`", count: max(3, longestRun(of: "`", in: code.string) + 1))
|
||||
result.append(NSAttributedString(string: fence + language.replacingOccurrences(of: "\n", with: " ") + "\n"))
|
||||
result.append(code)
|
||||
if !code.string.hasSuffix("\n") { result.append(NSAttributedString(string: "\n")) }
|
||||
result.append(NSAttributedString(string: fence))
|
||||
if code.string.hasSuffix("\n") || end < contents.length { result.append(NSAttributedString(string: "\n")) }
|
||||
cursor = end
|
||||
continue
|
||||
}
|
||||
let depth = max(0, attributes[NoteAttributes.depth] as? Int ?? 0)
|
||||
var prefix = ""
|
||||
switch block {
|
||||
case "heading": prefix = String(repeating: "#", count: min(6, max(1, attributes[NoteAttributes.headingLevel] as? Int ?? 1))) + " "
|
||||
case "quote": prefix = String(repeating: "> ", count: depth + 1)
|
||||
case "bullet": prefix = String(repeating: " ", count: depth) + "- "
|
||||
case "ordered": prefix = String(repeating: " ", count: depth) + "\(attributes[NoteAttributes.number] as? Int ?? 1). "
|
||||
case "task": prefix = String(repeating: " ", count: depth) + ((attributes[NoteAttributes.checked] as? Bool ?? false) ? "- [x] " : "- [ ] ")
|
||||
default: break
|
||||
}
|
||||
result.append(NSAttributedString(string: prefix))
|
||||
var bodyEnd = NSMaxRange(range)
|
||||
while bodyEnd > range.location, [10, 13, 0x2028, 0x2029].contains(text.character(at: bodyEnd - 1)) { bodyEnd -= 1 }
|
||||
appendInline(contents, range: NSRange(location: cursor, length: bodyEnd - cursor), to: result)
|
||||
if bodyEnd < NSMaxRange(range) {
|
||||
result.append(contents.attributedSubstring(from: NSRange(location: bodyEnd, length: NSMaxRange(range) - bodyEnd)))
|
||||
}
|
||||
cursor = NSMaxRange(range)
|
||||
}
|
||||
return NSAttributedString(attributedString: result)
|
||||
}
|
||||
|
||||
public static func fileWrapper(_ contents: NSAttributedString) throws -> FileWrapper {
|
||||
let wrapper = try contents.fileWrapper(
|
||||
from: NSRange(location: 0, length: contents.length),
|
||||
documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd]
|
||||
)
|
||||
guard wrapper.fileWrappers?[metadataFilename] == nil else { throw DocumentError.reservedMetadataFilename }
|
||||
// Bind metadata to the text AppKit actually stores (RTF normalizes line endings).
|
||||
guard let stored = NSAttributedString(rtfdFileWrapper: wrapper, documentAttributes: nil) else {
|
||||
throw DocumentError.corruptMetadata
|
||||
}
|
||||
guard stored.length == contents.length, stored.string == contents.string else {
|
||||
throw DocumentError.textChangedDuringEncoding
|
||||
}
|
||||
let metadata = Metadata(version: 1, length: stored.length, digest: digest(stored.string), runs: runs(in: contents))
|
||||
let json = FileWrapper(regularFileWithContents: try JSONEncoder().encode(metadata))
|
||||
json.preferredFilename = metadataFilename
|
||||
wrapper.addFileWrapper(json)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
public static func contents(from wrapper: FileWrapper) throws -> NSAttributedString {
|
||||
guard wrapper.isDirectory else { throw DocumentError.corruptMetadata }
|
||||
try validate(wrapper)
|
||||
guard let file = wrapper.fileWrappers?[metadataFilename], file.isRegularFile,
|
||||
let data = file.regularFileContents else { throw DocumentError.corruptMetadata }
|
||||
let metadata: Metadata
|
||||
do { metadata = try JSONDecoder().decode(Metadata.self, from: data) }
|
||||
catch { throw DocumentError.corruptMetadata }
|
||||
guard metadata.version == 1 else { throw DocumentError.unsupportedVersion(metadata.version) }
|
||||
guard let decoded = NSAttributedString(rtfdFileWrapper: wrapper, documentAttributes: nil),
|
||||
metadata.length == decoded.length, metadata.digest == digest(decoded.string) else {
|
||||
throw DocumentError.corruptMetadata
|
||||
}
|
||||
let result = NSMutableAttributedString(attributedString: decoded)
|
||||
var end = 0
|
||||
for run in metadata.runs {
|
||||
guard run.location == end, run.length > 0, run.location >= 0,
|
||||
run.location <= result.length, run.length <= result.length - run.location,
|
||||
run.values.isValid else { throw DocumentError.corruptMetadata }
|
||||
result.addAttributes(run.values.attributes, range: NSRange(location: run.location, length: run.length))
|
||||
end += run.length
|
||||
}
|
||||
guard end == result.length else { throw DocumentError.corruptMetadata }
|
||||
return NSAttributedString(attributedString: result)
|
||||
}
|
||||
|
||||
private static func validate(_ wrapper: FileWrapper) throws {
|
||||
guard !wrapper.isSymbolicLink else { throw DocumentError.corruptMetadata }
|
||||
if wrapper.isDirectory {
|
||||
guard let children = wrapper.fileWrappers else { throw DocumentError.corruptMetadata }
|
||||
for (name, child) in children {
|
||||
guard !name.isEmpty, name != ".", name != "..",
|
||||
!name.contains("/"), !name.contains("\\"),
|
||||
child.preferredFilename.map({ !$0.isEmpty && $0 != "." && $0 != ".." && !$0.contains("/") && !$0.contains("\\") }) ?? true else { throw DocumentError.corruptMetadata }
|
||||
try validate(child)
|
||||
}
|
||||
} else if !wrapper.isRegularFile { throw DocumentError.corruptMetadata }
|
||||
}
|
||||
|
||||
public enum DocumentError: LocalizedError {
|
||||
case corruptMetadata
|
||||
case unsupportedVersion(Int)
|
||||
case textChangedDuringEncoding
|
||||
case reservedMetadataFilename
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .corruptMetadata: "This note's structured metadata is missing, corrupt, or does not match its text. The original note was not changed."
|
||||
case .unsupportedVersion(let version): "This note uses unsupported structured metadata version \(version). The original note was not changed."
|
||||
case .textChangedDuringEncoding: "The note could not be saved without changing its text. The original note was not changed."
|
||||
case .reservedMetadataFilename: "An attachment uses the reserved filename ConjureNote.json. Rename the attachment before saving. The original note was not changed."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func digest(_ string: String) -> String {
|
||||
SHA256.hash(data: Data(string.utf8)).map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
private struct Metadata: Codable {
|
||||
let version: Int
|
||||
let length: Int
|
||||
let digest: String
|
||||
let runs: [Run]
|
||||
}
|
||||
|
||||
private struct Run: Codable {
|
||||
let location: Int
|
||||
let length: Int
|
||||
let values: Values
|
||||
}
|
||||
|
||||
private struct Values: Codable {
|
||||
var block: String?
|
||||
var headingLevel: Int?
|
||||
var depth: Int?
|
||||
var number: Int?
|
||||
var checked: Bool?
|
||||
var language: String?
|
||||
var inlineCode: Bool?
|
||||
var bold: Bool?
|
||||
var italic: Bool?
|
||||
var underline: Int?
|
||||
var strike: Int?
|
||||
var link: String?
|
||||
|
||||
init(_ attributes: [NSAttributedString.Key: Any]) {
|
||||
block = attributes[NoteAttributes.block] as? String
|
||||
headingLevel = attributes[NoteAttributes.headingLevel] as? Int
|
||||
depth = attributes[NoteAttributes.depth] as? Int
|
||||
number = attributes[NoteAttributes.number] as? Int
|
||||
checked = attributes[NoteAttributes.checked] as? Bool
|
||||
language = attributes[NoteAttributes.language] as? String
|
||||
inlineCode = attributes[NoteAttributes.inlineCode] as? Bool
|
||||
bold = attributes[NoteAttributes.bold] as? Bool
|
||||
italic = attributes[NoteAttributes.italic] as? Bool
|
||||
underline = attributes[.underlineStyle] as? Int
|
||||
strike = attributes[.strikethroughStyle] as? Int
|
||||
link = (attributes[.link] as? URL)?.absoluteString ?? attributes[.link] as? String
|
||||
}
|
||||
|
||||
var isValid: Bool {
|
||||
(block == nil || ["heading", "quote", "bullet", "ordered", "task", "code"].contains(block!))
|
||||
&& (headingLevel == nil || (1...6).contains(headingLevel!))
|
||||
&& (depth == nil || depth! >= 0)
|
||||
&& (number == nil || number! >= 0)
|
||||
}
|
||||
|
||||
var attributes: [NSAttributedString.Key: Any] {
|
||||
var result: [NSAttributedString.Key: Any] = [:]
|
||||
result[NoteAttributes.block] = block
|
||||
result[NoteAttributes.headingLevel] = headingLevel
|
||||
result[NoteAttributes.depth] = depth
|
||||
result[NoteAttributes.number] = number
|
||||
result[NoteAttributes.checked] = checked
|
||||
result[NoteAttributes.language] = language
|
||||
result[NoteAttributes.inlineCode] = inlineCode
|
||||
result[NoteAttributes.bold] = bold
|
||||
result[NoteAttributes.italic] = italic
|
||||
result[.underlineStyle] = underline
|
||||
result[.strikethroughStyle] = strike
|
||||
result[.link] = link
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private static func runs(in contents: NSAttributedString) -> [Run] {
|
||||
var result: [Run] = []
|
||||
contents.enumerateAttributes(in: NSRange(location: 0, length: contents.length)) { attributes, range, _ in
|
||||
result.append(Run(location: range.location, length: range.length, values: Values(attributes)))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func longestRun(of character: Character, in text: String) -> Int {
|
||||
var longest = 0
|
||||
var count = 0
|
||||
for value in text {
|
||||
count = value == character ? count + 1 : 0
|
||||
longest = max(longest, count)
|
||||
}
|
||||
return longest
|
||||
}
|
||||
|
||||
private static func appendInline(_ contents: NSAttributedString, range: NSRange, to output: NSMutableAttributedString) {
|
||||
let semantic = NSMutableAttributedString(string: (contents.string as NSString).substring(with: range))
|
||||
contents.enumerateAttributes(in: range) { attributes, run, _ in
|
||||
let traits = (attributes[.font] as? NSFont).map { NSFontManager.shared.traits(of: $0) } ?? []
|
||||
var marks: [NSAttributedString.Key: Any] = [:]
|
||||
if attributes[NoteAttributes.bold] as? Bool ?? traits.contains(.boldFontMask) { marks[NoteAttributes.bold] = true }
|
||||
if attributes[NoteAttributes.italic] as? Bool ?? traits.contains(.italicFontMask) { marks[NoteAttributes.italic] = true }
|
||||
if attributes[NoteAttributes.inlineCode] as? Bool ?? false { marks[NoteAttributes.inlineCode] = true }
|
||||
if (attributes[.underlineStyle] as? Int ?? 0) != 0 { marks[.underlineStyle] = 1 }
|
||||
if (attributes[.strikethroughStyle] as? Int ?? 0) != 0 { marks[.strikethroughStyle] = 1 }
|
||||
marks[.link] = (attributes[.link] as? URL)?.absoluteString ?? attributes[.link] as? String
|
||||
semantic.addAttributes(marks, range: NSRange(location: run.location - range.location, length: run.length))
|
||||
}
|
||||
// Markdown emphasis cannot open/close against whitespace. Trim each semantic
|
||||
// span, not each display-font/color run; underline and links retain spaces.
|
||||
let string = semantic.string as NSString
|
||||
for key in [NoteAttributes.bold, NoteAttributes.italic, .strikethroughStyle] {
|
||||
var edges: [NSRange] = []
|
||||
semantic.enumerateAttribute(key, in: NSRange(location: 0, length: semantic.length)) { value, span, _ in
|
||||
guard value != nil else { return }
|
||||
var start = span.location
|
||||
var end = NSMaxRange(span)
|
||||
while start < end, string.character(at: start) == 32 || string.character(at: start) == 9 { start += 1 }
|
||||
while end > start, string.character(at: end - 1) == 32 || string.character(at: end - 1) == 9 { end -= 1 }
|
||||
if start > span.location { edges.append(NSRange(location: span.location, length: start - span.location)) }
|
||||
if end < NSMaxRange(span) { edges.append(NSRange(location: end, length: NSMaxRange(span) - end)) }
|
||||
}
|
||||
for edge in edges { semantic.removeAttribute(key, range: edge) }
|
||||
}
|
||||
var active: [(open: String, close: String)] = []
|
||||
semantic.enumerateAttributes(in: NSRange(location: 0, length: semantic.length)) { attributes, run, _ in
|
||||
let value = contents.attributedSubstring(from: NSRange(location: range.location + run.location, length: run.length))
|
||||
let text = value.string
|
||||
let code = attributes[NoteAttributes.inlineCode] as? Bool ?? false
|
||||
var desired: [(open: String, close: String)] = []
|
||||
if let link = attributes[.link] as? String {
|
||||
let destination = link.replacingOccurrences(of: "\\", with: "%5C").replacingOccurrences(of: " ", with: "%20").replacingOccurrences(of: "<", with: "%3C").replacingOccurrences(of: ">", with: "%3E").replacingOccurrences(of: "\n", with: "%0A")
|
||||
desired.append(("[", "](<\(destination)>)"))
|
||||
}
|
||||
for (enabled, delimiter) in [(attributes[NoteAttributes.bold] as? Bool ?? false, "**"), (attributes[NoteAttributes.italic] as? Bool ?? false, "*"), ((attributes[.underlineStyle] as? Int ?? 0) != 0, "<u>"), ((attributes[.strikethroughStyle] as? Int ?? 0) != 0, "~~")] where enabled {
|
||||
desired.append((delimiter, delimiter == "<u>" ? "</u>" : delimiter))
|
||||
}
|
||||
var common = 0
|
||||
while common < min(active.count, desired.count),
|
||||
active[common].open == desired[common].open, active[common].close == desired[common].close { common += 1 }
|
||||
for mark in active.dropFirst(common).reversed() { output.append(NSAttributedString(string: mark.close)) }
|
||||
for mark in desired.dropFirst(common) { output.append(NSAttributedString(string: mark.open)) }
|
||||
active = desired
|
||||
if code {
|
||||
let fence = String(repeating: "`", count: max(1, longestRun(of: "`", in: text) + 1))
|
||||
let pad = text.hasPrefix("`") || text.hasSuffix("`") || (text.hasPrefix(" ") && text.hasSuffix(" ") && !text.allSatisfy({ $0 == " " }))
|
||||
output.append(NSAttributedString(string: fence + (pad ? " " : "")))
|
||||
output.append(value)
|
||||
output.append(NSAttributedString(string: (pad ? " " : "") + fence))
|
||||
} else {
|
||||
var index = 0
|
||||
for character in text {
|
||||
let length = String(character).utf16.count
|
||||
if length == 1, "\\`*_{}[]<>()#+-.!|>~&".contains(character) { output.append(NSAttributedString(string: "\\")) }
|
||||
output.append(value.attributedSubstring(from: NSRange(location: index, length: length)))
|
||||
index += length
|
||||
}
|
||||
}
|
||||
}
|
||||
for mark in active.reversed() { output.append(NSAttributedString(string: mark.close)) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@MainActor
|
||||
private final class MarkdownImporter {
|
||||
private let source: NSAttributedString
|
||||
private let text: NSString
|
||||
private let bytes: [UInt8]
|
||||
private let lineStarts: [Int]
|
||||
private let utf16Offsets: [Int]
|
||||
private let result: NSMutableAttributedString
|
||||
private var deletions: [NSRange] = []
|
||||
private var replacements: [(NSRange, NSAttributedString)] = []
|
||||
|
||||
init(_ source: NSAttributedString) {
|
||||
self.source = source
|
||||
text = source.string as NSString
|
||||
bytes = Array(source.string.utf8)
|
||||
var starts = [0]
|
||||
for (index, byte) in bytes.enumerated() where byte == 10 { starts.append(index + 1) }
|
||||
lineStarts = starts
|
||||
var offsets = Array(repeating: 0, count: bytes.count + 1)
|
||||
var byteOffset = 0
|
||||
var utf16Offset = 0
|
||||
for scalar in source.string.unicodeScalars {
|
||||
for index in 0..<scalar.utf8.count { offsets[byteOffset + index] = utf16Offset }
|
||||
byteOffset += scalar.utf8.count
|
||||
utf16Offset += scalar.utf16.count
|
||||
}
|
||||
offsets[bytes.count] = utf16Offset
|
||||
utf16Offsets = offsets
|
||||
result = NSMutableAttributedString(attributedString: source)
|
||||
}
|
||||
|
||||
func contents() -> NSAttributedString {
|
||||
let document = Document(parsing: source.string)
|
||||
visit(document)
|
||||
var merged: [NSRange] = []
|
||||
for range in deletions.filter({ $0.length > 0 }).sorted(by: { $0.location < $1.location }) {
|
||||
if let last = merged.last, NSMaxRange(last) >= range.location {
|
||||
merged[merged.count - 1] = NSUnionRange(last, range)
|
||||
} else { merged.append(range) }
|
||||
}
|
||||
var edits = replacements
|
||||
for range in merged where !replacements.contains(where: { NSIntersectionRange($0.0, range).length > 0 }) {
|
||||
edits.append((range, NSAttributedString(string: "")))
|
||||
}
|
||||
for (range, value) in edits.sorted(by: { $0.0.location > $1.0.location }) {
|
||||
result.replaceCharacters(in: range, with: value)
|
||||
}
|
||||
return NSAttributedString(attributedString: result)
|
||||
}
|
||||
|
||||
private func offset(_ location: SourceLocation) -> Int {
|
||||
guard location.line > 0, location.line <= lineStarts.count else { return text.length }
|
||||
let byte = min(bytes.count, lineStarts[location.line - 1] + max(0, location.column - 1))
|
||||
return utf16Offsets[byte]
|
||||
}
|
||||
|
||||
private func range(_ markup: any Markup) -> NSRange? {
|
||||
guard let range = markup.range else { return nil }
|
||||
let start = offset(range.lowerBound)
|
||||
let end = offset(range.upperBound)
|
||||
guard end >= start else { return nil }
|
||||
return NSRange(location: start, length: end - start)
|
||||
}
|
||||
|
||||
private func mark(_ range: NSRange, _ attributes: [NSAttributedString.Key: Any], paragraph: Bool = false) {
|
||||
let target = paragraph ? text.paragraphRange(for: range) : range
|
||||
result.addAttributes(attributes, range: target)
|
||||
}
|
||||
|
||||
private func inlineContents(_ node: any Markup, sourceRange: NSRange) -> NSAttributedString {
|
||||
var cursor = sourceRange.location
|
||||
var attachments: [NSTextAttachment] = []
|
||||
source.enumerateAttribute(.attachment, in: sourceRange) { value, _, _ in
|
||||
if let attachment = value as? NSTextAttachment { attachments.append(attachment) }
|
||||
}
|
||||
var attachmentIndex = 0
|
||||
func literal(_ string: String) -> NSMutableAttributedString {
|
||||
let remaining = NSRange(location: cursor, length: max(0, NSMaxRange(sourceRange) - cursor))
|
||||
let match = text.range(of: string, options: [], range: remaining)
|
||||
let value: NSMutableAttributedString
|
||||
if !string.isEmpty, match.location != NSNotFound {
|
||||
value = NSMutableAttributedString(attributedString: result.attributedSubstring(from: match))
|
||||
cursor = NSMaxRange(match)
|
||||
} else {
|
||||
let attributes = cursor < result.length ? result.attributes(at: cursor, effectiveRange: nil) : [:]
|
||||
value = NSMutableAttributedString(string: string, attributes: attributes)
|
||||
}
|
||||
let characters = string as NSString
|
||||
for index in 0..<characters.length where characters.character(at: index) == 0xfffc {
|
||||
if attachmentIndex < attachments.count {
|
||||
value.addAttribute(.attachment, value: attachments[attachmentIndex], range: NSRange(location: index, length: 1))
|
||||
}
|
||||
attachmentIndex += 1
|
||||
}
|
||||
return value
|
||||
}
|
||||
func children(_ container: any Markup) -> NSMutableAttributedString {
|
||||
let output = NSMutableAttributedString(string: "")
|
||||
var underline: [(offset: Int, literal: String)] = []
|
||||
for child in container.children {
|
||||
if let html = child as? InlineHTML {
|
||||
if html.rawHTML.lowercased() == "<u>" { underline.append((output.length, html.rawHTML)); continue }
|
||||
if html.rawHTML.lowercased() == "</u>", let start = underline.popLast() {
|
||||
output.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: NSRange(location: start.offset, length: output.length - start.offset))
|
||||
continue
|
||||
}
|
||||
}
|
||||
output.append(render(child))
|
||||
}
|
||||
// Unmatched HTML is unsupported literal content, not a disappearing delimiter.
|
||||
for start in underline.reversed() { output.insert(NSAttributedString(string: start.literal), at: start.offset) }
|
||||
return output
|
||||
}
|
||||
func render(_ child: any Markup) -> NSMutableAttributedString {
|
||||
switch child {
|
||||
case let value as Text: return literal(value.string)
|
||||
case let value as InlineCode:
|
||||
let output = literal(value.code)
|
||||
output.addAttribute(NoteAttributes.inlineCode, value: true, range: NSRange(location: 0, length: output.length))
|
||||
return output
|
||||
case is SoftBreak, is LineBreak:
|
||||
let remaining = NSRange(location: cursor, length: max(0, NSMaxRange(sourceRange) - cursor))
|
||||
let newline = text.rangeOfCharacter(from: .newlines, options: [], range: remaining)
|
||||
let output = NSMutableAttributedString(string: "")
|
||||
if newline.location != NSNotFound {
|
||||
var start = newline.location
|
||||
while start > cursor, text.character(at: start - 1) == 32 || text.character(at: start - 1) == 9 { start -= 1 }
|
||||
if start < newline.location { output.append(result.attributedSubstring(from: NSRange(location: start, length: newline.location - start))) }
|
||||
cursor = newline.location
|
||||
}
|
||||
output.append(literal("\n"))
|
||||
return output
|
||||
case let html as InlineHTML: return literal(html.rawHTML)
|
||||
case is Strong, is Emphasis, is Strikethrough, is Link:
|
||||
let output = children(child)
|
||||
let range = NSRange(location: 0, length: output.length)
|
||||
if child is Strong { output.addAttribute(NoteAttributes.bold, value: true, range: range) }
|
||||
if child is Emphasis { output.addAttribute(NoteAttributes.italic, value: true, range: range) }
|
||||
if child is Strikethrough { output.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: range) }
|
||||
if let link = child as? Link, let destination = link.destination { output.addAttribute(.link, value: destination, range: range) }
|
||||
return output
|
||||
default: return literal(child.format())
|
||||
}
|
||||
}
|
||||
let output = children(node)
|
||||
// The parser deliberately omits terminal Markdown hard-break whitespace.
|
||||
// It is still native note text and must survive migration/import.
|
||||
var suffix = NSMaxRange(sourceRange)
|
||||
while suffix > sourceRange.location, text.character(at: suffix - 1) == 32 || text.character(at: suffix - 1) == 9 { suffix -= 1 }
|
||||
let rendered = output.string as NSString
|
||||
var renderedSuffix = rendered.length
|
||||
while renderedSuffix > 0, rendered.character(at: renderedSuffix - 1) == 32 || rendered.character(at: renderedSuffix - 1) == 9 { renderedSuffix -= 1 }
|
||||
let missing = NSMaxRange(sourceRange) - suffix - (rendered.length - renderedSuffix)
|
||||
if missing > 0 { output.append(result.attributedSubstring(from: NSRange(location: NSMaxRange(sourceRange) - missing, length: missing))) }
|
||||
return output
|
||||
}
|
||||
|
||||
private func visit(_ node: any Markup, listDepth: Int = 0) {
|
||||
guard let whole = range(node) else {
|
||||
for child in node.children { visit(child, listDepth: listDepth) }
|
||||
return
|
||||
}
|
||||
switch node {
|
||||
case let heading as Heading:
|
||||
mark(whole, [NoteAttributes.block: "heading", NoteAttributes.headingLevel: heading.level], paragraph: true)
|
||||
replacements.append((whole, inlineContents(node, sourceRange: whole)))
|
||||
return
|
||||
case let code as CodeBlock:
|
||||
let full = text.paragraphRange(for: whole)
|
||||
var value = code.code
|
||||
if NSMaxRange(full) == text.length, !source.string.hasSuffix("\n"), value.hasSuffix("\n") { value.removeLast() }
|
||||
// An empty code paragraph still needs a real character on which semantics can live.
|
||||
if value.isEmpty { value = "\n" }
|
||||
var attributes: [NSAttributedString.Key: Any] = [NoteAttributes.block: "code"]
|
||||
if let language = code.language { attributes[NoteAttributes.language] = language }
|
||||
let valueContents = NSMutableAttributedString(string: value, attributes: whole.location < result.length ? result.attributes(at: whole.location, effectiveRange: nil) : [:])
|
||||
valueContents.addAttributes(attributes, range: NSRange(location: 0, length: valueContents.length))
|
||||
var attachmentIndex = 0
|
||||
var attachments: [NSTextAttachment] = []
|
||||
source.enumerateAttribute(.attachment, in: full) { value, _, _ in
|
||||
if let attachment = value as? NSTextAttachment { attachments.append(attachment) }
|
||||
}
|
||||
let characters = value as NSString
|
||||
for index in 0..<characters.length where characters.character(at: index) == 0xfffc {
|
||||
if attachmentIndex < attachments.count { valueContents.addAttribute(.attachment, value: attachments[attachmentIndex], range: NSRange(location: index, length: 1)) }
|
||||
attachmentIndex += 1
|
||||
}
|
||||
replacements.append((full, valueContents))
|
||||
return
|
||||
case is BlockQuote:
|
||||
var quoteDepth = 0
|
||||
var ancestor = node.parent
|
||||
while let current = ancestor {
|
||||
if current is BlockQuote { quoteDepth += 1 }
|
||||
ancestor = current.parent
|
||||
}
|
||||
mark(whole, [NoteAttributes.block: "quote", NoteAttributes.depth: quoteDepth], paragraph: true)
|
||||
var cursor = text.paragraphRange(for: NSRange(location: whole.location, length: 0)).location
|
||||
while cursor < NSMaxRange(whole) {
|
||||
let line = text.paragraphRange(for: NSRange(location: cursor, length: 0))
|
||||
var end = cursor
|
||||
while end < NSMaxRange(line), text.character(at: end) == 32 { end += 1 }
|
||||
while end < NSMaxRange(line), text.character(at: end) == 62 {
|
||||
end += 1
|
||||
if end < NSMaxRange(line), text.character(at: end) == 32 { end += 1 }
|
||||
}
|
||||
if end > cursor { deletions.append(NSRange(location: cursor, length: end - cursor)) }
|
||||
cursor = NSMaxRange(line)
|
||||
}
|
||||
case is OrderedList, is UnorderedList:
|
||||
let ordered = node as? OrderedList
|
||||
var number = Int(ordered?.startIndex ?? 1)
|
||||
for child in node.children {
|
||||
guard let item = child as? ListItem else { continue }
|
||||
for block in item.children {
|
||||
if block is OrderedList || block is UnorderedList {
|
||||
visit(block, listDepth: listDepth + 1)
|
||||
} else {
|
||||
if let itemRange = range(item), let body = range(block) {
|
||||
var attributes: [NSAttributedString.Key: Any] = [NoteAttributes.block: ordered == nil ? "bullet" : "ordered", NoteAttributes.depth: listDepth]
|
||||
if ordered != nil { attributes[NoteAttributes.number] = number }
|
||||
if let checked = item.checkbox {
|
||||
attributes[NoteAttributes.block] = "task"
|
||||
switch checked { case .checked: attributes[NoteAttributes.checked] = true; case .unchecked: attributes[NoteAttributes.checked] = false }
|
||||
}
|
||||
mark(body, attributes, paragraph: true)
|
||||
if body.location > itemRange.location, block.indexInParent == 0 {
|
||||
let start = text.paragraphRange(for: NSRange(location: itemRange.location, length: 0)).location
|
||||
deletions.append(NSRange(location: start, length: body.location - start))
|
||||
}
|
||||
}
|
||||
visit(block, listDepth: listDepth)
|
||||
}
|
||||
}
|
||||
number += 1
|
||||
}
|
||||
return
|
||||
case is Paragraph:
|
||||
replacements.append((whole, inlineContents(node, sourceRange: whole)))
|
||||
return
|
||||
case is Document, is ListItem:
|
||||
break
|
||||
default:
|
||||
// Tables, HTML, remote image syntax and other unsupported constructs stay literal.
|
||||
return
|
||||
}
|
||||
for child in node.children { visit(child, listDepth: listDepth) }
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ public struct NoteMarkdownExport {
|
||||
private let images: [(name: String, data: Data)]
|
||||
|
||||
public init(contents: NSAttributedString) throws {
|
||||
let contents = NoteDocument.markdown(contents)
|
||||
let source = contents.string as NSString
|
||||
var markdown = ""
|
||||
var clipboard = ""
|
||||
|
||||
@@ -24,6 +24,7 @@ public struct Note: Identifiable {
|
||||
public final class NotesStore {
|
||||
public private(set) var notes: [Note] = []
|
||||
private let directory: URL
|
||||
private var legacyIDs: Set<UUID> = []
|
||||
|
||||
public init(directory: URL = FileManager.default.urls(
|
||||
for: .applicationSupportDirectory, in: .userDomainMask
|
||||
@@ -34,16 +35,26 @@ public final class NotesStore {
|
||||
at: directory, includingPropertiesForKeys: nil
|
||||
).filter { $0.pathExtension.lowercased() == "rtfd" }
|
||||
.sorted { $0.lastPathComponent < $1.lastPathComponent }
|
||||
notes = try files.map { url in
|
||||
var loaded: [Note] = []
|
||||
for url in files {
|
||||
guard let id = UUID(uuidString: url.deletingPathExtension().lastPathComponent) else {
|
||||
throw CocoaError(.fileReadCorruptFile)
|
||||
}
|
||||
let contents = try NSAttributedString(
|
||||
url: url, options: [.documentType: NSAttributedString.DocumentType.rtfd],
|
||||
documentAttributes: nil
|
||||
)
|
||||
return Note(id: id, contents: contents)
|
||||
let wrapper = try FileWrapper(url: url, options: .immediate)
|
||||
guard wrapper.isDirectory else { throw CocoaError(.fileReadCorruptFile) }
|
||||
let contents: NSAttributedString
|
||||
if wrapper.fileWrappers?[NoteDocument.metadataFilename] != nil {
|
||||
contents = try NoteDocument.contents(from: wrapper)
|
||||
} else {
|
||||
guard let legacy = NSAttributedString(rtfdFileWrapper: wrapper, documentAttributes: nil) else {
|
||||
throw CocoaError(.fileReadCorruptFile)
|
||||
}
|
||||
contents = NoteDocument.importMarkdown(legacy)
|
||||
legacyIDs.insert(id)
|
||||
}
|
||||
loaded.append(Note(id: id, contents: contents))
|
||||
}
|
||||
notes = loaded
|
||||
}
|
||||
|
||||
public func create() throws -> UUID {
|
||||
@@ -69,14 +80,29 @@ public final class NotesStore {
|
||||
}
|
||||
try FileManager.default.removeItem(at: directory.appendingPathComponent(id.uuidString + ".rtfd"))
|
||||
notes.remove(at: index)
|
||||
legacyIDs.remove(id)
|
||||
}
|
||||
|
||||
private func save(_ note: Note) throws {
|
||||
let wrapper = try note.contents.fileWrapper(
|
||||
from: NSRange(location: 0, length: note.contents.length),
|
||||
documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd]
|
||||
)
|
||||
let wrapper = try NoteDocument.fileWrapper(note.contents)
|
||||
let url = directory.appendingPathComponent(note.id.uuidString + ".rtfd", isDirectory: true)
|
||||
if legacyIDs.contains(note.id) {
|
||||
let backups = directory.appendingPathComponent("LegacyBackups", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: backups, withIntermediateDirectories: true)
|
||||
let backup = backups.appendingPathComponent(note.id.uuidString + ".rtfd", isDirectory: true)
|
||||
if !FileManager.default.fileExists(atPath: backup.path) {
|
||||
// copyItem leaves the live legacy package untouched if backup creation fails.
|
||||
try FileManager.default.copyItem(at: url, to: backup)
|
||||
} else {
|
||||
// A previous failed save may already have preserved the original package.
|
||||
let original = try FileWrapper(url: backup, options: .immediate)
|
||||
guard original.isDirectory,
|
||||
NSAttributedString(rtfdFileWrapper: original, documentAttributes: nil) != nil else {
|
||||
throw CocoaError(.fileWriteFileExists)
|
||||
}
|
||||
}
|
||||
}
|
||||
try wrapper.write(to: url, options: .atomic, originalContentsURL: url)
|
||||
legacyIDs.remove(note.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
- [x] conjure notes:
|
||||
- [x] window should be resizable (and set size should be remembered)
|
||||
- [x] make font for note/input text monospaced
|
||||
- [x] bug: looks like dragging around the conjure windows can cause other conjure windows of difference window sizes to spawn offset the corner instead of from the center of the windows
|
||||
- when moving a conjure window, the saved position should always be the center of the window, and windows should always be spawned at some position taking to mean their center.
|
||||
- this should keep window placement invariant of corner window sizes
|
||||
- [x] conjure notes:
|
||||
- [x] add action to delete a note (currently selected note)
|
||||
- [x] make images have rounded corners
|
||||
- [x] bug: text flashes when inserting characters, which is visually disturbing.
|
||||
- [x] fix heading editing using paragraph semantics:
|
||||
- typing `#{1,6} ` at the visible paragraph start sets the absolute heading level, replacing any existing level; fenced code remains literal.
|
||||
- Backspace at heading start deletes the preceding newline, not the heading prefix; at document start it does nothing.
|
||||
- joining onto nonempty prose inherits the preceding paragraph's type: `Text\n## Heading` becomes `TextHeading`; removing an empty line above preserves the heading.
|
||||
- keep native attributed-text editing, Undo, images, and Markdown serialization; retain RTFD autosaves rather than introducing a full document-model rewrite.
|
||||
- [x] add whole-note Markdown export actions to the Notes Actions menu:
|
||||
- Copy as Markdown: replace images with inline-code placeholders such as `<photo.png>`.
|
||||
- Export as Markdown…: save a single `.md` file without images; with images, create a folder containing the Markdown and image files, using relative links.
|
||||
- exports leave the original note and RTFD autosaves unchanged.
|
||||
@@ -1,17 +1,18 @@
|
||||
import AppKit
|
||||
import ConjureKit
|
||||
import Testing
|
||||
@testable import Conjure
|
||||
|
||||
@Test @MainActor func fencedCodeHighlightsTokensWithoutChangingSourceOrApplyingMarkdown() throws {
|
||||
@Test @MainActor
|
||||
func codeBlocksHighlightTokensWithoutInterpretingTheirLiteralMarkdown() throws {
|
||||
_ = NSApplication.shared
|
||||
for fence in ["```", "~~~~"] {
|
||||
let text = "# Heading 🦊\n\(fence)py\ndef main():\n # comment\n return \"**not bold**\"\n\(fence)\nAfter\n```unknown-language\n# literal\n```"
|
||||
let editor = NSTextView()
|
||||
editor.string = text
|
||||
MarkdownHighlighting.apply(to: editor)
|
||||
let source = "# Heading 🦊\n\(fence)py\ndef main():\n # comment\n return \"**not bold**\"\n\(fence)\nAfter\n```unknown-language\n# literal\n```"
|
||||
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 500))
|
||||
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: source)))
|
||||
let storage = try #require(editor.textStorage)
|
||||
func attributes(_ token: String) -> [NSAttributedString.Key: Any] {
|
||||
storage.attributes(at: (text as NSString).range(of: token).location, effectiveRange: nil)
|
||||
storage.attributes(at: (editor.string as NSString).range(of: token).location, effectiveRange: nil)
|
||||
}
|
||||
let keyword = try #require(attributes("def")[.foregroundColor] as? NSColor)
|
||||
let name = try #require(attributes("main")[.foregroundColor] as? NSColor)
|
||||
@@ -19,20 +20,23 @@ import Testing
|
||||
#expect(keyword != name)
|
||||
#expect(comment != keyword)
|
||||
#expect((attributes("**not bold**")[.font] as? NSFont)?.isFixedPitch == true)
|
||||
#expect(storage.string == text)
|
||||
editor.string = "```python\ndef main():\n pass"
|
||||
MarkdownHighlighting.apply(to: editor)
|
||||
#expect(attributes("**not bold**")[NoteAttributes.bold] as? Bool != true)
|
||||
#expect(attributes("# literal")[NoteAttributes.headingLevel] == nil)
|
||||
#expect(attributes("# literal")[NoteAttributes.block] as? String == "code")
|
||||
#expect(editor.string == "Heading 🦊\ndef main():\n # comment\n return \"**not bold**\"\nAfter\n# literal")
|
||||
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: "```python\ndef main():\n pass")))
|
||||
let unfinished = try #require(editor.textStorage)
|
||||
#expect(unfinished.attribute(.foregroundColor, at: 10, effectiveRange: nil) as? NSColor == keyword)
|
||||
#expect(unfinished.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor == keyword)
|
||||
#expect(editor.string == "def main():\n pass")
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func noteHeadingsShrinkByLevelWithoutChangingMarkdownOrCode() throws {
|
||||
@Test @MainActor
|
||||
func headingLevelsUseDescendingSizesAndCodeRemainsBodySized() throws {
|
||||
_ = NSApplication.shared
|
||||
let editor = NSTextView()
|
||||
let text = "# One\n## Two\n### Three\n#### Four\n##### Five\n###### Six\nBody\n## **Bold** and *italic*\n```text\n# Literal\n```\n####### Not a heading"
|
||||
editor.string = text
|
||||
MarkdownHighlighting.apply(to: editor)
|
||||
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 600))
|
||||
let source = "# One\n## Two\n### Three\n#### Four\n##### Five\n###### Six\nBody\n## **Bold** and *italic*\n```text\n# Literal\n```\n####### Not a heading"
|
||||
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: source)))
|
||||
let storage = try #require(editor.textStorage)
|
||||
func font(_ token: String) throws -> NSFont {
|
||||
try #require(storage.attribute(.font, at: (editor.string as NSString).range(of: token).location,
|
||||
@@ -41,13 +45,14 @@ import Testing
|
||||
let sizes = try ["One", "Two", "Three", "Four", "Five", "Six"].map { try font($0).pointSize }
|
||||
for (larger, smaller) in zip(sizes, sizes.dropFirst()) { #expect(larger > smaller) }
|
||||
#expect(try font("Six").pointSize >= font("Body").pointSize)
|
||||
#expect(try font("**Bold**").pointSize == font("Two").pointSize)
|
||||
#expect(try font("*italic*").pointSize == font("Two").pointSize)
|
||||
#expect(try font("Bold").pointSize == font("Two").pointSize)
|
||||
#expect(try font("italic").pointSize == font("Two").pointSize)
|
||||
#expect(try font("Literal").pointSize == font("Body").pointSize)
|
||||
#expect(try font("Not a heading").pointSize == font("Body").pointSize)
|
||||
#expect(try font("Three").isFixedPitch)
|
||||
#expect(editor.string == text)
|
||||
storage.replaceCharacters(in: (editor.string as NSString).range(of: "### Three"), with: "Three")
|
||||
MarkdownHighlighting.apply(to: editor)
|
||||
let three = (editor.string as NSString).range(of: "Three")
|
||||
editor.setSelectedRange(three)
|
||||
for _ in 0..<4 { editor.format(0) }
|
||||
#expect(editor.string.contains("Three"))
|
||||
#expect(try font("Three").pointSize == font("Body").pointSize)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import AppKit
|
||||
import Testing
|
||||
@testable import Conjure
|
||||
|
||||
@Test @MainActor
|
||||
func missingColorConfigurationUsesSunsetWithoutReadingGeneratedTheme() throws {
|
||||
let directory = try colorConfigurationDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
try Data("not JSON".utf8).write(to: directory.appendingPathComponent("theme.json"))
|
||||
|
||||
let loaded = try ColorScheme.load(from: directory)
|
||||
expectSameColors(loaded, ColorScheme.sunset)
|
||||
|
||||
try Data("# Comments-only configuration\n".utf8).write(to: directory.appendingPathComponent("config.toml"))
|
||||
expectSameColors(try ColorScheme.load(from: directory), ColorScheme.sunset)
|
||||
|
||||
try Data("theme = 'sunset'\n".utf8).write(to: directory.appendingPathComponent("config.toml"))
|
||||
expectSameColors(try ColorScheme.load(from: directory), ColorScheme.sunset)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func partialManualColorsOverrideSunsetWithoutChangingOtherFields() throws {
|
||||
let directory = try colorConfigurationDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
try Data("""
|
||||
# Personal color overrides
|
||||
[colors]
|
||||
accent = '#aB1234' # Keep the hash inside the quoted color
|
||||
error = "#123456"
|
||||
""".utf8).write(to: directory.appendingPathComponent("config.toml"))
|
||||
|
||||
var expected = ColorScheme.sunset
|
||||
expected.accent = NSColor(srgbRed: 171.0 / 255, green: 18.0 / 255, blue: 52.0 / 255, alpha: 1)
|
||||
expected.error = NSColor(srgbRed: 18.0 / 255, green: 52.0 / 255, blue: 86.0 / 255, alpha: 1)
|
||||
expectSameColors(try ColorScheme.load(from: directory), expected)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func tintColorsLayerBetweenDefaultsAndManualOverrides() throws {
|
||||
let directory = try colorConfigurationDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
try Data("""
|
||||
theme = "tint"
|
||||
[colors]
|
||||
accent = "#ffffff"
|
||||
text = "#000000"
|
||||
""".utf8).write(to: directory.appendingPathComponent("config.toml"))
|
||||
try Data(##"{"colors":{"accent":"#ff0000","background":"#123456","glow":"#00ff00"}}"##.utf8)
|
||||
.write(to: directory.appendingPathComponent("theme.json"))
|
||||
|
||||
var expected = ColorScheme.sunset
|
||||
expected.accent = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
|
||||
expected.text = NSColor(srgbRed: 0, green: 0, blue: 0, alpha: 1)
|
||||
expected.background = NSColor(srgbRed: 18.0 / 255, green: 52.0 / 255, blue: 86.0 / 255, alpha: 1)
|
||||
expected.glow = NSColor(srgbRed: 0, green: 1, blue: 0, alpha: 1)
|
||||
expectSameColors(try ColorScheme.load(from: directory), expected)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func malformedColorConfigurationReportsItsPathAndReason() throws {
|
||||
let directory = try colorConfigurationDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let config = directory.appendingPathComponent("config.toml")
|
||||
let invalid: [(String, String?)] = [
|
||||
("colors.accent = '#12345678'", "colors.accent"),
|
||||
("colors.accent = '#fff'", "colors.accent"),
|
||||
("colors.accent = '#12gg56'", "colors.accent"),
|
||||
("colors.accent = '#123456'", "colors.accent"),
|
||||
("colors.accent = '123456'", "colors.accent"),
|
||||
("colors.accent = ' #123456'", "colors.accent"),
|
||||
("colors.accent = 42", "colors.accent"),
|
||||
("colors.accnet = '#123456'", "colors.accnet"),
|
||||
("opacity = 0.5", "opacity"),
|
||||
("variant = 'light'", "variant"),
|
||||
("colors = []", "colors"),
|
||||
("colors = false", "colors"),
|
||||
("theme = 'unknown'", "theme"),
|
||||
("theme = true", "theme"),
|
||||
("[colors", nil),
|
||||
("colors.accent = '#123456'\ncolors.accent = '#abcdef'", nil),
|
||||
("[colors]\n[colors]", nil),
|
||||
("theme = 'sunset'\ntheme = 'tint'", nil),
|
||||
]
|
||||
for (toml, reason) in invalid {
|
||||
try Data(toml.utf8).write(to: config)
|
||||
expectColorSchemeFailure(in: directory, path: config, reason: reason)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func selectedTintRequiresAnExistingValidGeneratedTheme() throws {
|
||||
let directory = try colorConfigurationDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let theme = directory.appendingPathComponent("theme.json")
|
||||
try Data("theme = 'tint'\ncolors.accent = '#ffffff'\n".utf8)
|
||||
.write(to: directory.appendingPathComponent("config.toml"))
|
||||
expectColorSchemeFailure(in: directory, path: theme)
|
||||
|
||||
let invalid: [(String, String?)] = [
|
||||
("not JSON", nil),
|
||||
("[]", nil),
|
||||
(#"{"theme":"sunset"}"#, "theme"),
|
||||
(##"{"colors":{"accent":"#12345678"}}"##, "colors.accent"),
|
||||
(##"{"colors":{"glwo":"#123456"}}"##, "colors.glwo"),
|
||||
(#"{"colors":false}"#, "colors"),
|
||||
]
|
||||
for (json, reason) in invalid {
|
||||
try Data(json.utf8).write(to: theme)
|
||||
expectColorSchemeFailure(in: directory, path: theme, reason: reason)
|
||||
}
|
||||
|
||||
try Data(#"{"colors":{}}"#.utf8).write(to: theme)
|
||||
var expected = ColorScheme.sunset
|
||||
expected.accent = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
|
||||
expectSameColors(try ColorScheme.load(from: directory), expected)
|
||||
}
|
||||
|
||||
private func colorConfigurationDirectory() throws -> URL {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
return directory
|
||||
}
|
||||
|
||||
@MainActor private func expectSameColors(_ actual: ColorScheme, _ expected: ColorScheme) {
|
||||
#expect(actual.background == expected.background)
|
||||
#expect(actual.text == expected.text)
|
||||
#expect(actual.secondaryText == expected.secondaryText)
|
||||
#expect(actual.accent == expected.accent)
|
||||
#expect(actual.glow == expected.glow)
|
||||
#expect(actual.keyBackground == expected.keyBackground)
|
||||
#expect(actual.error == expected.error)
|
||||
}
|
||||
|
||||
@MainActor private func expectColorSchemeFailure(in directory: URL, path: URL, reason: String? = nil) {
|
||||
do {
|
||||
_ = try ColorScheme.load(from: directory)
|
||||
Issue.record("Expected invalid color configuration to throw: \(path.path)")
|
||||
} catch {
|
||||
#expect(error is any LocalizedError)
|
||||
#expect(error.localizedDescription.contains(path.path))
|
||||
if let reason { #expect(error.localizedDescription.contains(reason)) }
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,42 @@ func calculatorCrossConvertsCurrencyExpressionsAndPreservesSnapshotDate() throws
|
||||
#expect(result.detail == "2*50 USD → DKK · Daily snapshot 2026-01-02")
|
||||
}
|
||||
|
||||
@Test
|
||||
func calculatorConvertsYenAsJPYInEitherDirection() {
|
||||
let snapshot = CurrencyRates(date: "2026-01-02", rates: ["eur": 1, "jpy": 160, "dkk": 8])
|
||||
for (query, expected) in [
|
||||
("1000 yen to dkk", "50 DKK"),
|
||||
("1000JPY to DKK", "50 DKK"),
|
||||
("1000YeN in dkk", "50 DKK"),
|
||||
("50 DKK as yen", "1000 JPY"),
|
||||
("2*500 yen to JPY", "1000 JPY"),
|
||||
] {
|
||||
#expect(Calculator.isCurrencyQuery(query), "\(query)")
|
||||
#expect(Calculator.evaluate(query, currencyRates: snapshot)?.copyValue == expected, "\(query)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func calculatorConvertsCurrencyNamesWithoutGuessingAmbiguousNames() {
|
||||
let snapshot = CurrencyRates(date: "2026-01-02", rates: [
|
||||
"eur": 1, "dkk": 8, "usd": 2, "gbp": 0.5, "btc": 0.00002,
|
||||
])
|
||||
for (query, expected) in [
|
||||
("10 euros to Danish kroner", "80 DKK"),
|
||||
("80 DANISH KRONER in euro", "10 EUR"),
|
||||
("2*5 US dollars as British pounds", "2.5 GBP"),
|
||||
("1 bitcoin to euros", "50000 EUR"),
|
||||
("10euros to DKK", "80 DKK"),
|
||||
] {
|
||||
#expect(Calculator.isCurrencyQuery(query))
|
||||
#expect(Calculator.evaluate(query, currencyRates: snapshot)?.copyValue == expected)
|
||||
}
|
||||
for query in ["10 dollars to EUR", "10 kroner to EUR", "10 pounds to EUR", "10 EUR to dollars"] {
|
||||
#expect(!Calculator.isCurrencyQuery(query, currencyRates: snapshot))
|
||||
}
|
||||
#expect(Calculator.evaluate("2 pounds to oz", currencyRates: snapshot)?.copyValue == "32 oz")
|
||||
}
|
||||
|
||||
@Test
|
||||
func calculatorConvertsFiatAndCryptoWithCaseInsensitiveSeparators() {
|
||||
let snapshot = CurrencyRates(date: "2026-01-02", rates: [
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import AppKit
|
||||
import Testing
|
||||
@testable import Conjure
|
||||
|
||||
// Conjure roles generated by tint from base16-swamp-light.
|
||||
private let swampLightTheme = ##"{"variant":"light","colors":{"background":"#f1e3d1","text":"#64513e","secondaryText":"#a0907d","accent":"#bf7979","glow":"#d09700","keyBackground":"#ddcebc","error":"#d09700"}}"##
|
||||
|
||||
@Test @MainActor
|
||||
func generatedLightMetadataAndManualLuminanceChooseTheMatchingAppearance() throws {
|
||||
let directory = try lightConfigurationDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let theme = directory.appendingPathComponent("theme.json")
|
||||
try Data("theme = 'tint'\n".utf8).write(to: directory.appendingPathComponent("config.toml"))
|
||||
try Data(swampLightTheme.utf8).write(to: theme)
|
||||
let light = try ColorScheme.load(from: directory)
|
||||
#expect(light.isLight)
|
||||
#expect(ThemePalette.State(scheme: light).appearance.name == .aqua)
|
||||
|
||||
// Metadata wins even when manual colors disagree with the generated variant.
|
||||
try Data("theme = 'tint'\ncolors.background = '#000000'\ncolors.text = '#ffffff'\n".utf8)
|
||||
.write(to: directory.appendingPathComponent("config.toml"))
|
||||
#expect(try ColorScheme.load(from: directory).isLight)
|
||||
|
||||
try Data("colors.background = '#f1e3d1'\ncolors.text = '#64513e'\n".utf8)
|
||||
.write(to: directory.appendingPathComponent("config.toml"))
|
||||
let manual = try ColorScheme.load(from: directory)
|
||||
#expect(manual.isLight)
|
||||
#expect(ThemePalette.State(scheme: manual).appearance.name == .aqua)
|
||||
|
||||
try Data("theme = 'tint'\n".utf8).write(to: directory.appendingPathComponent("config.toml"))
|
||||
try Data(swampLightTheme.replacingOccurrences(of: "\"variant\":\"light\",", with: "").utf8).write(to: theme)
|
||||
#expect(try ColorScheme.load(from: directory).isLight)
|
||||
try FileManager.default.removeItem(at: directory.appendingPathComponent("config.toml"))
|
||||
#expect(try !ColorScheme.load(from: directory).isLight)
|
||||
}
|
||||
|
||||
@Test(arguments: ["null", "true", "42", "[]", "{}", "\"Light\"", "\"darkAqua\"", "\"\""]) @MainActor
|
||||
func invalidGeneratedVariantsReportTheThemePath(_ value: String) throws {
|
||||
let directory = try lightConfigurationDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
try Data("theme = 'tint'\n".utf8).write(to: directory.appendingPathComponent("config.toml"))
|
||||
let theme = directory.appendingPathComponent("theme.json")
|
||||
try Data("{\"variant\":\(value),\"colors\":{}}".utf8).write(to: theme)
|
||||
do {
|
||||
_ = try ColorScheme.load(from: directory)
|
||||
Issue.record("Expected invalid generated variant to be rejected")
|
||||
} catch {
|
||||
#expect(error.localizedDescription.contains(theme.path))
|
||||
#expect(error.localizedDescription.contains("variant"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func variantOnlyReloadNotifiesConsumersAndChangesRenderedAppearance() throws {
|
||||
let original = ThemePalette.loaded
|
||||
let directory = try lightConfigurationDirectory()
|
||||
defer {
|
||||
ThemePalette.loaded = original
|
||||
try? FileManager.default.removeItem(at: directory)
|
||||
}
|
||||
try Data("theme = 'tint'\n".utf8).write(to: directory.appendingPathComponent("config.toml"))
|
||||
let theme = directory.appendingPathComponent("theme.json")
|
||||
try Data(swampLightTheme.replacingOccurrences(of: "\"light\"", with: "\"dark\"").utf8).write(to: theme)
|
||||
ThemePalette.reload(from: directory)
|
||||
let changes = LightPaletteChanges()
|
||||
let observer = NotificationCenter.default.addObserver(forName: ThemePalette.didChangeNotification,
|
||||
object: nil, queue: .main) { _ in
|
||||
MainActor.assumeIsolated {
|
||||
changes.appearances.append(ThemePalette.appearance.name)
|
||||
}
|
||||
}
|
||||
defer { NotificationCenter.default.removeObserver(observer) }
|
||||
let before = try renderedBackground(over: .black)
|
||||
try Data(swampLightTheme.utf8).write(to: theme)
|
||||
#expect(ThemePalette.reload(from: directory))
|
||||
#expect(changes.appearances == [.aqua])
|
||||
#expect(!ThemePalette.reload(from: directory))
|
||||
#expect(changes.appearances.count == 1)
|
||||
let after = try renderedBackground(over: .black)
|
||||
let oldSurface = try #require(before.colorAt(x: 375, y: 240))
|
||||
let newSurface = try #require(after.colorAt(x: 375, y: 240))
|
||||
#expect(testLuminance(newSurface) > testLuminance(oldSurface))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func lightSecondaryTextRemainsReadableAcrossRenderedGlowsWithoutChangingSourceColors() throws {
|
||||
let original = ThemePalette.loaded
|
||||
let directory = try lightConfigurationDirectory()
|
||||
defer {
|
||||
ThemePalette.loaded = original
|
||||
try? FileManager.default.removeItem(at: directory)
|
||||
}
|
||||
try Data("theme = 'tint'\n".utf8).write(to: directory.appendingPathComponent("config.toml"))
|
||||
try Data(swampLightTheme.utf8).write(to: directory.appendingPathComponent("theme.json"))
|
||||
let source = try ColorScheme.load(from: directory)
|
||||
ThemePalette.reload(from: directory)
|
||||
#expect(ThemePalette.text == source.text)
|
||||
#expect(ThemePalette.loaded.scheme.secondaryText == source.secondaryText)
|
||||
#expect(testLuminance(ThemePalette.secondaryText) < testLuminance(source.secondaryText))
|
||||
for backing in [NSColor.black, .white] {
|
||||
let bitmap = try renderedBackground(over: backing)
|
||||
for y in stride(from: 0, to: bitmap.pixelsHigh, by: 12) {
|
||||
for x in stride(from: 0, to: bitmap.pixelsWide, by: 12) {
|
||||
let surface = try #require(bitmap.colorAt(x: x, y: y))
|
||||
let foreground = testLuminance(ThemePalette.secondaryText)
|
||||
#expect((testLuminance(surface) + 0.05) / (foreground + 0.05) >= 4.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
// An already-readable manual secondary color is not needlessly restyled.
|
||||
try Data("theme = 'tint'\ncolors.secondaryText = '#302820'\n".utf8)
|
||||
.write(to: directory.appendingPathComponent("config.toml"))
|
||||
ThemePalette.reload(from: directory)
|
||||
#expect(ThemePalette.secondaryText == ThemePalette.loaded.scheme.secondaryText)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func darkBackgroundRenderingAndSemanticColorsRemainUnchanged() throws {
|
||||
let original = ThemePalette.loaded
|
||||
defer { ThemePalette.loaded = original }
|
||||
for scheme in [ColorScheme.sunset, customDarkScheme()] {
|
||||
ThemePalette.loaded = ThemePalette.State(scheme: scheme)
|
||||
#expect(ThemePalette.secondaryText == scheme.secondaryText)
|
||||
#expect(ThemePalette.text == scheme.text)
|
||||
#expect(ThemePalette.appearance.name == .darkAqua)
|
||||
for backing in [NSColor.black, .white] {
|
||||
let actual = try renderedBackground(over: backing)
|
||||
let legacy = try renderedBackground(over: backing, legacy: scheme)
|
||||
for y in stride(from: 0, to: actual.pixelsHigh, by: 12) {
|
||||
for x in stride(from: 0, to: actual.pixelsWide, by: 12) {
|
||||
#expect(actual.colorAt(x: x, y: y) == legacy.colorAt(x: x, y: y))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor private final class LightPaletteChanges {
|
||||
var appearances: [NSAppearance.Name] = []
|
||||
}
|
||||
|
||||
private func lightConfigurationDirectory() throws -> URL {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
return directory
|
||||
}
|
||||
|
||||
@MainActor private func customDarkScheme() -> ColorScheme {
|
||||
var scheme = ColorScheme.sunset
|
||||
scheme.background = NSColor(srgbRed: 0.04, green: 0.08, blue: 0.12, alpha: 1)
|
||||
scheme.accent = .cyan
|
||||
scheme.glow = .magenta
|
||||
return scheme
|
||||
}
|
||||
|
||||
private func testLuminance(_ color: NSColor) -> CGFloat {
|
||||
let rgb = color.usingColorSpace(.sRGB)!
|
||||
func linear(_ value: CGFloat) -> CGFloat {
|
||||
value <= 0.04045 ? value / 12.92 : pow((value + 0.055) / 1.055, 2.4)
|
||||
}
|
||||
return 0.2126 * linear(rgb.redComponent) + 0.7152 * linear(rgb.greenComponent) + 0.0722 * linear(rgb.blueComponent)
|
||||
}
|
||||
|
||||
@MainActor private func renderedBackground(over backing: NSColor, legacy scheme: ColorScheme? = nil) throws -> NSBitmapImageRep {
|
||||
_ = NSApplication.shared
|
||||
let bounds = NSRect(x: 0, y: 0, width: 750, height: 480)
|
||||
let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 750, pixelsHigh: 480,
|
||||
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
|
||||
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0))
|
||||
let context = try #require(NSGraphicsContext(bitmapImageRep: bitmap))
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
defer { NSGraphicsContext.restoreGraphicsState() }
|
||||
NSGraphicsContext.current = context
|
||||
backing.setFill()
|
||||
bounds.fill()
|
||||
if let scheme {
|
||||
scheme.background.withAlphaComponent(0.55).setFill()
|
||||
bounds.fill()
|
||||
let amber = try #require(NSGradient(colors: [scheme.accent.withAlphaComponent(0.18),
|
||||
scheme.accent.withAlphaComponent(0.065), scheme.accent.withAlphaComponent(0)]))
|
||||
let ember = try #require(NSGradient(colors: [scheme.glow.withAlphaComponent(0.26),
|
||||
scheme.glow.withAlphaComponent(0.10), scheme.glow.withAlphaComponent(0)]))
|
||||
let upper = NSPoint(x: bounds.width * 0.58, y: bounds.height * 0.94)
|
||||
ember.draw(fromCenter: upper, radius: 0, toCenter: upper, radius: bounds.width * 0.40, options: [])
|
||||
let lower = NSPoint(x: bounds.width * 0.88, y: bounds.height * 0.10)
|
||||
amber.draw(fromCenter: lower, radius: 0, toCenter: lower, radius: bounds.width * 0.55, options: [])
|
||||
let left = NSPoint(x: bounds.width * 0.08, y: bounds.height * 0.65)
|
||||
context.cgContext.setAlpha(0.45)
|
||||
amber.draw(fromCenter: left, radius: 0, toCenter: left, radius: bounds.width * 0.32, options: [])
|
||||
} else {
|
||||
LauncherBackground(frame: bounds).draw(bounds)
|
||||
}
|
||||
return bitmap
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import AppKit
|
||||
import ConjureKit
|
||||
import Testing
|
||||
@testable import Conjure
|
||||
|
||||
@Test @MainActor
|
||||
func existingWindowsAndNotesFollowPaletteAppearanceWithoutResettingState() throws {
|
||||
_ = NSApplication.shared
|
||||
let originalPalette = ThemePalette.loaded
|
||||
let originalAppearance = NSApp.appearance
|
||||
defer {
|
||||
NSApp.appearance = originalAppearance
|
||||
ThemePalette.loaded = originalPalette
|
||||
NotificationCenter.default.post(name: ThemePalette.didChangeNotification, object: nil)
|
||||
}
|
||||
let suite = "Conjure.LightAppearanceViews.\(UUID().uuidString)"
|
||||
let defaults = try #require(UserDefaults(suiteName: suite))
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
try? FileManager.default.removeItem(at: directory)
|
||||
}
|
||||
try Data("theme = \"tint\"".utf8).write(to: directory.appendingPathComponent("config.toml"))
|
||||
func writePalette(light: Bool) throws {
|
||||
try Data("""
|
||||
{"variant":"\(light ? "light" : "dark")","colors":{
|
||||
"background":"\(light ? "#ffffff" : "#201714")",
|
||||
"text":"\(light ? "#112233" : "#ffe3b5")",
|
||||
"secondaryText":"\(light ? "#667788" : "#d9c7a6")"
|
||||
}}
|
||||
""".utf8).write(to: directory.appendingPathComponent("theme.json"))
|
||||
}
|
||||
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
|
||||
func appearanceName(_ appearance: NSAppearance) -> NSAppearance.Name? {
|
||||
appearance.bestMatch(from: [.aqua, .darkAqua])
|
||||
}
|
||||
try writePalette(light: false)
|
||||
ThemePalette.reload(from: directory)
|
||||
NSApp.appearance = NSAppearance(named: .aqua)
|
||||
let launcher = LauncherWindowController(
|
||||
history: LaunchHistory(defaults: defaults, now: { Date(timeIntervalSinceReferenceDate: 12_000) }),
|
||||
defaults: defaults, notesDirectory: directory.appendingPathComponent("notes"))
|
||||
let window = try #require(launcher.window)
|
||||
let content = try #require(window.contentView)
|
||||
defer { window.orderOut(nil) }
|
||||
#expect(appearanceName(window.effectiveAppearance) == .darkAqua)
|
||||
let search = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first)
|
||||
search.stringValue = "notes"
|
||||
launcher.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: search))
|
||||
_ = launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.insertNewline(_:)))
|
||||
let editor = try #require(descendants(content).compactMap { $0 as? NotesTextView }.first)
|
||||
let document = NSMutableAttributedString(string: "let answer = 42\nplain")
|
||||
let code = NSRange(location: 0, length: 16)
|
||||
document.addAttribute(NoteAttributes.block, value: "code", range: code)
|
||||
document.addAttribute(NoteAttributes.language, value: "swift", range: code)
|
||||
editor.setDocumentContents(document)
|
||||
editor.beginInsertionSession()
|
||||
editor.insertText("!", replacementRange: NSRange(location: document.length, length: 0))
|
||||
editor.endInsertionSession()
|
||||
let selection = NSRange(location: 1, length: 2)
|
||||
editor.setSelectedRange(selection)
|
||||
let text = editor.string
|
||||
let undoGrouping = editor.undoManager?.groupingLevel
|
||||
let frame = window.frame
|
||||
let style = window.styleMask
|
||||
let settings = SettingsWindowController(shortcuts: GlobalShortcuts(defaults: defaults) { _ in })
|
||||
let settingsWindow = try #require(settings.window)
|
||||
defer { settingsWindow.close() }
|
||||
let settingsContent = try #require(settingsWindow.contentView)
|
||||
let destination = try #require(descendants(settingsContent).compactMap { $0 as? NSPopUpButton }.first)
|
||||
destination.selectItem(at: 2)
|
||||
let credential = try #require(descendants(settingsContent).compactMap { $0 as? NSSecureTextField }.first)
|
||||
credential.stringValue = "unsaved-value"
|
||||
let anchor = try #require(descendants(content).compactMap { $0 as? NSButton }.first {
|
||||
$0.title == "Actions" && $0.target is NotesController
|
||||
})
|
||||
let notes = try #require(anchor.target as? NotesController)
|
||||
let popup = ApplicationActionsController()
|
||||
popup.showLanguages([("en", "English"), ("ja", "Japanese")], selected: "en", source: true, relativeTo: anchor)
|
||||
defer { popup.close(animated: false) }
|
||||
let popupSearch = try #require(descendants(popup.view).compactMap { $0 as? NSSearchField }.first)
|
||||
popupSearch.stringValue = "Eng"
|
||||
popup.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: popupSearch))
|
||||
let table = try #require(descendants(popup.view).compactMap { $0 as? NSTableView }.first)
|
||||
let rows = table.numberOfRows
|
||||
let selectedRows = table.selectedRowIndexes
|
||||
let material = try #require(popup.view.subviews.compactMap { $0 as? NSVisualEffectView }.first)
|
||||
#expect(appearanceName(editor.effectiveAppearance) == .darkAqua)
|
||||
#expect(appearanceName(settingsWindow.effectiveAppearance) == .darkAqua)
|
||||
#expect(appearanceName(material.effectiveAppearance) == .darkAqua)
|
||||
|
||||
// Deliberately oppose the light palette: app/system inheritance is not the contract.
|
||||
NSApp.appearance = NSAppearance(named: .darkAqua)
|
||||
for light in [true, false] {
|
||||
try writePalette(light: light)
|
||||
#expect(ThemePalette.reload(from: directory))
|
||||
let expected: NSAppearance.Name = light ? .aqua : .darkAqua
|
||||
for appearance in [window.effectiveAppearance, content.effectiveAppearance, editor.effectiveAppearance,
|
||||
settingsWindow.effectiveAppearance, settingsContent.effectiveAppearance,
|
||||
popup.view.effectiveAppearance, material.effectiveAppearance] {
|
||||
#expect(appearanceName(appearance) == expected)
|
||||
}
|
||||
#expect(launcher.window === window)
|
||||
#expect(window.frame == frame)
|
||||
#expect(window.styleMask == style)
|
||||
#expect(editor.string == text)
|
||||
#expect(editor.selectedRange() == selection)
|
||||
#expect(editor.undoManager?.groupingLevel == undoGrouping)
|
||||
#expect(editor.undoManager?.canUndo == true)
|
||||
#expect(editor.textStorage?.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) as? String == "code")
|
||||
#expect(editor.textStorage?.attribute(NoteAttributes.language, at: 0, effectiveRange: nil) as? String == "swift")
|
||||
let keyword = try #require((editor.textStorage?.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor)?.usingColorSpace(.sRGB))
|
||||
let rgb: (CGFloat, CGFloat, CGFloat) = light ? (166, 38, 164) : (198, 120, 221)
|
||||
#expect(abs(keyword.redComponent * 255 - rgb.0) < 1)
|
||||
#expect(abs(keyword.greenComponent * 255 - rgb.1) < 1)
|
||||
#expect(abs(keyword.blueComponent * 255 - rgb.2) < 1)
|
||||
#expect(destination.indexOfSelectedItem == 2)
|
||||
#expect(credential.stringValue == "unsaved-value")
|
||||
#expect(popup.isShown)
|
||||
#expect(popupSearch.stringValue == "Eng")
|
||||
#expect(table.numberOfRows == rows)
|
||||
#expect(table.selectedRowIndexes == selectedRows)
|
||||
#expect(material.layer?.borderColor == ThemePalette.secondaryText.withAlphaComponent(0.22).cgColor)
|
||||
#expect(material.layer?.borderColor?.alpha == 0.22)
|
||||
}
|
||||
popup.close(animated: false)
|
||||
editor.undoManager?.undo()
|
||||
#expect(editor.string == document.string)
|
||||
try writePalette(light: true)
|
||||
#expect(ThemePalette.reload(from: directory))
|
||||
notes.goBack()
|
||||
#expect(window.styleMask == [.borderless])
|
||||
#expect(appearanceName(window.effectiveAppearance) == .aqua)
|
||||
#expect(appearanceName(content.effectiveAppearance) == .aqua)
|
||||
#expect(search.stringValue == "notes")
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import AppKit
|
||||
import Testing
|
||||
@testable import Conjure
|
||||
|
||||
@Test @MainActor
|
||||
func livePaletteReloadsGeneratedColorsKeepsOverridesAndRetainsLastValidPalette() throws {
|
||||
let original = ThemePalette.loaded
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
defer {
|
||||
ThemePalette.loaded = original
|
||||
try? FileManager.default.removeItem(at: directory)
|
||||
}
|
||||
let config = directory.appendingPathComponent("config.toml")
|
||||
let theme = directory.appendingPathComponent("theme.json")
|
||||
ThemePalette.reload(from: directory)
|
||||
let white = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
|
||||
let black = NSColor(srgbRed: 0, green: 0, blue: 0, alpha: 1)
|
||||
let changes = PaletteChanges()
|
||||
let observer = NotificationCenter.default.addObserver(
|
||||
forName: ThemePalette.didChangeNotification, object: nil, queue: .main
|
||||
) { _ in
|
||||
MainActor.assumeIsolated {
|
||||
changes.count += 1
|
||||
changes.background = ThemePalette.background
|
||||
changes.accent = ThemePalette.accent
|
||||
}
|
||||
}
|
||||
defer { NotificationCenter.default.removeObserver(observer) }
|
||||
try Data("theme = 'tint'\ncolors.accent = '#ffffff'\n".utf8).write(to: config)
|
||||
try Data(##"{"variant":"dark","colors":{"background":"#123456","accent":"#ff0000","text":"#000000"}}"##.utf8)
|
||||
.write(to: theme)
|
||||
#expect(ThemePalette.reload(from: directory))
|
||||
let firstBackground = ThemePalette.background
|
||||
#expect(ThemePalette.accent == white)
|
||||
#expect(ThemePalette.text == black)
|
||||
#expect(changes.count == 1)
|
||||
#expect(changes.background == ThemePalette.background)
|
||||
#expect(changes.accent == ThemePalette.accent)
|
||||
#expect(ThemePalette.background.alphaComponent == 0.55)
|
||||
#expect(ThemePalette.selectionFill.alphaComponent == 0.12)
|
||||
#expect(ThemePalette.selectionBorder.alphaComponent == 0.30)
|
||||
#expect(ThemePalette.keyBackground.alphaComponent == 0.5)
|
||||
#expect(!ThemePalette.reload(from: directory))
|
||||
#expect(changes.count == 1)
|
||||
|
||||
try Data(##"{"colors":{"background":"#abcdef","accent":"#00ff00","text":"#ffffff"}}"##.utf8)
|
||||
.write(to: theme, options: .atomic)
|
||||
#expect(ThemePalette.reload(from: directory))
|
||||
#expect(ThemePalette.background != firstBackground)
|
||||
#expect(ThemePalette.accent == white)
|
||||
#expect(ThemePalette.text == white)
|
||||
#expect(changes.count == 2)
|
||||
let lastBackground = ThemePalette.background
|
||||
|
||||
try Data("partial JSON".utf8).write(to: theme)
|
||||
#expect(!ThemePalette.reload(from: directory))
|
||||
#expect(ThemePalette.background == lastBackground)
|
||||
#expect(ThemePalette.text == white)
|
||||
#expect(changes.count == 2)
|
||||
#expect(ThemePalette.configurationError?.contains(theme.path) == true)
|
||||
|
||||
try Data(##"{"colors":{"background":"#abcdef","text":"#ffffff"}}"##.utf8).write(to: theme)
|
||||
#expect(!ThemePalette.reload(from: directory))
|
||||
#expect(ThemePalette.configurationError == nil)
|
||||
#expect(changes.count == 2)
|
||||
|
||||
try Data("colors.accent = '#invalid'\n".utf8).write(to: config)
|
||||
#expect(!ThemePalette.reload(from: directory))
|
||||
#expect(ThemePalette.background == lastBackground)
|
||||
#expect(ThemePalette.configurationError?.contains(config.path) == true)
|
||||
#expect(changes.count == 2)
|
||||
|
||||
try Data("theme = 'sunset'\n".utf8).write(to: config)
|
||||
#expect(ThemePalette.reload(from: directory))
|
||||
#expect(ThemePalette.background == ColorScheme.sunset.background.withAlphaComponent(0.55))
|
||||
#expect(ThemePalette.accent == ColorScheme.sunset.accent)
|
||||
#expect(changes.count == 3)
|
||||
#expect(ThemePalette.configurationError == nil)
|
||||
}
|
||||
|
||||
@MainActor private final class PaletteChanges {
|
||||
var count = 0
|
||||
var background: NSColor?
|
||||
var accent: NSColor?
|
||||
}
|
||||
|
||||
@Test(arguments: ["config.toml", "theme.json"]) @MainActor
|
||||
func liveObservationDetectsInPlaceWritesAndRearmsAfterAtomicReplacement(_ filename: String) async throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
let directory = root.appendingPathComponent("conjure")
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let file = directory.appendingPathComponent(filename)
|
||||
try Data("initial".utf8).write(to: file)
|
||||
let events = AsyncStream<String>.makeStream()
|
||||
let observation = ColorSchemeObservation(directory: directory) {
|
||||
events.continuation.yield((try? String(contentsOf: file, encoding: .utf8)) ?? "<missing>")
|
||||
}
|
||||
defer {
|
||||
events.continuation.finish()
|
||||
withExtendedLifetime(observation) {}
|
||||
}
|
||||
|
||||
try overwriteInPlace("in-place", at: file)
|
||||
try await expectObserved("in-place", from: events.stream)
|
||||
try Data("atomic".utf8).write(to: file, options: .atomic)
|
||||
try await expectObserved("atomic", from: events.stream)
|
||||
try overwriteInPlace("after-replacement", at: file)
|
||||
try await expectObserved("after-replacement", from: events.stream)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func liveObservationFindsInitiallyMissingDirectoriesAndRecoversAfterRemoval() async throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let missingAncestor = root.appendingPathComponent("missing")
|
||||
let directory = missingAncestor.appendingPathComponent("conjure")
|
||||
let file = directory.appendingPathComponent("theme.json")
|
||||
let events = AsyncStream<String>.makeStream()
|
||||
let observation = ColorSchemeObservation(directory: directory) {
|
||||
events.continuation.yield((try? String(contentsOf: file, encoding: .utf8)) ?? "<missing>")
|
||||
}
|
||||
defer {
|
||||
events.continuation.finish()
|
||||
withExtendedLifetime(observation) {}
|
||||
}
|
||||
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try Data("created".utf8).write(to: file)
|
||||
try await expectObserved("created", from: events.stream)
|
||||
try overwriteInPlace("created-then-edited", at: file)
|
||||
try await expectObserved("created-then-edited", from: events.stream)
|
||||
|
||||
try FileManager.default.removeItem(at: missingAncestor)
|
||||
try await expectObserved("<missing>", from: events.stream)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try Data("recreated".utf8).write(to: file, options: .atomic)
|
||||
try await expectObserved("recreated", from: events.stream)
|
||||
try overwriteInPlace("recreated-then-edited", at: file)
|
||||
try await expectObserved("recreated-then-edited", from: events.stream)
|
||||
}
|
||||
|
||||
private func overwriteInPlace(_ value: String, at url: URL) throws {
|
||||
let handle = try FileHandle(forWritingTo: url)
|
||||
defer { try? handle.close() }
|
||||
try handle.truncate(atOffset: 0)
|
||||
try handle.write(contentsOf: Data(value.utf8))
|
||||
}
|
||||
|
||||
private struct ObservationTimeout: Error {}
|
||||
|
||||
private func expectObserved(_ expected: String, from events: AsyncStream<String>) async throws {
|
||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||
group.addTask {
|
||||
for await value in events {
|
||||
if value == expected { return }
|
||||
}
|
||||
throw CancellationError()
|
||||
}
|
||||
group.addTask {
|
||||
// Only a failure deadline: event delivery, not a sleep, advances the test.
|
||||
try await Task.sleep(for: .seconds(5))
|
||||
throw ObservationTimeout()
|
||||
}
|
||||
defer { group.cancelAll() }
|
||||
_ = try await group.next()
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func liveBackgroundRebuildsCachedGradientsWithoutReplacingTheView() throws {
|
||||
_ = NSApplication.shared
|
||||
let original = ThemePalette.loaded
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 480),
|
||||
styleMask: .borderless, backing: .buffered, defer: false)
|
||||
window.isReleasedWhenClosed = false
|
||||
defer {
|
||||
ThemePalette.loaded = original
|
||||
window.orderOut(nil)
|
||||
try? FileManager.default.removeItem(at: directory)
|
||||
}
|
||||
let config = directory.appendingPathComponent("config.toml")
|
||||
try Data("colors.accent = '#ff0000'\ncolors.glow = '#ff0000'\n".utf8).write(to: config)
|
||||
ThemePalette.reload(from: directory)
|
||||
let background = LauncherBackground(frame: NSRect(x: 0, y: 0, width: 750, height: 480))
|
||||
window.contentView?.addSubview(background)
|
||||
func glowColor() throws -> NSColor {
|
||||
let bitmap = try #require(background.bitmapImageRepForCachingDisplay(in: background.bounds))
|
||||
background.cacheDisplay(in: background.bounds, to: bitmap)
|
||||
return try #require(bitmap.colorAt(x: Int(CGFloat(bitmap.pixelsWide) * 0.58),
|
||||
y: Int(CGFloat(bitmap.pixelsHigh) * 0.06))?.usingColorSpace(.deviceRGB))
|
||||
}
|
||||
let before = try glowColor()
|
||||
let backgroundColor = ThemePalette.background
|
||||
background.needsDisplay = false
|
||||
try Data("colors.accent = '#00ff00'\ncolors.glow = '#00ff00'\n".utf8).write(to: config)
|
||||
#expect(ThemePalette.reload(from: directory))
|
||||
#expect(ThemePalette.background == backgroundColor)
|
||||
#expect(background.needsDisplay)
|
||||
let after = try glowColor()
|
||||
#expect(before.redComponent > after.redComponent)
|
||||
#expect(after.greenComponent > before.greenComponent)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import AppKit
|
||||
import ConjureKit
|
||||
import Testing
|
||||
@testable import Conjure
|
||||
|
||||
@Test @MainActor
|
||||
func livePaletteRepaintsNotesWithoutChangingContentSelectionOrUndo() throws {
|
||||
_ = NSApplication.shared
|
||||
let originalPalette = ThemePalette.loaded
|
||||
defer {
|
||||
ThemePalette.loaded = originalPalette
|
||||
NotificationCenter.default.post(name: ThemePalette.didChangeNotification, object: nil)
|
||||
}
|
||||
let directory = try liveViewThemeDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
ThemePalette.reload(from: directory)
|
||||
|
||||
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 500, height: 300))
|
||||
editor.isRichText = true
|
||||
let delegate = LiveThemeTextDelegate()
|
||||
editor.delegate = delegate
|
||||
let document = NSMutableAttributedString(string: "plain code\nlet answer = 42\n")
|
||||
let inline = NSRange(location: 6, length: 4)
|
||||
let code = (document.string as NSString).paragraphRange(for: NSRange(location: 11, length: 0))
|
||||
document.addAttribute(NoteAttributes.inlineCode, value: true, range: inline)
|
||||
document.addAttribute(NoteAttributes.bold, value: true, range: NSRange(location: 0, length: 5))
|
||||
document.addAttribute(NoteAttributes.block, value: "code", range: code)
|
||||
document.addAttribute(NoteAttributes.language, value: "swift", range: code)
|
||||
editor.setDocumentContents(document)
|
||||
editor.beginInsertionSession()
|
||||
editor.insertText("!", replacementRange: NSRange(location: 0, length: 0))
|
||||
let selection = NSRange(location: 2, length: 3)
|
||||
editor.setSelectedRange(selection)
|
||||
let contents = editor.string
|
||||
let grouping = editor.undoManager?.groupingLevel
|
||||
let oldAccent = editor.insertionPointColor
|
||||
let codeColor = editor.textStorage?.attribute(.foregroundColor, at: code.location + 1, effectiveRange: nil) as? NSColor
|
||||
let boldFont = editor.textStorage?.attribute(.font, at: 1, effectiveRange: nil) as? NSFont
|
||||
let changeCount = delegate.changeCount
|
||||
|
||||
try writeLiveViewTheme(in: directory)
|
||||
#expect(ThemePalette.reload(from: directory))
|
||||
|
||||
#expect(editor.insertionPointColor == ThemePalette.accent)
|
||||
#expect(editor.insertionPointColor != oldAccent)
|
||||
#expect(editor.selectedTextAttributes[.backgroundColor] as? NSColor == ThemePalette.selectionFill)
|
||||
#expect(editor.textStorage?.attribute(.foregroundColor, at: 1, effectiveRange: nil) as? NSColor == ThemePalette.text)
|
||||
#expect(editor.textStorage?.attribute(.foregroundColor, at: inline.location + 1, effectiveRange: nil) as? NSColor == ThemePalette.accent)
|
||||
#expect(editor.textStorage?.attribute(.backgroundColor, at: inline.location + 1, effectiveRange: nil) as? NSColor == ThemePalette.selectionFill)
|
||||
#expect(editor.textStorage?.attribute(.backgroundColor, at: code.location + 1, effectiveRange: nil) as? NSColor == ThemePalette.selectionFill)
|
||||
#expect(editor.textStorage?.attribute(.foregroundColor, at: code.location + 1, effectiveRange: nil) as? NSColor != codeColor)
|
||||
#expect(editor.textStorage?.attribute(.font, at: 1, effectiveRange: nil) as? NSFont == boldFont)
|
||||
#expect(editor.textStorage?.attribute(NoteAttributes.inlineCode, at: inline.location + 1, effectiveRange: nil) as? Bool == true)
|
||||
#expect(editor.textStorage?.attribute(NoteAttributes.language, at: code.location + 1, effectiveRange: nil) as? String == "swift")
|
||||
#expect(editor.string == contents)
|
||||
#expect(editor.selectedRange() == selection)
|
||||
#expect(editor.undoManager?.groupingLevel == grouping)
|
||||
#expect(delegate.changeCount == changeCount)
|
||||
editor.endInsertionSession()
|
||||
#expect(editor.undoManager?.canUndo == true)
|
||||
editor.undoManager?.undo()
|
||||
#expect(editor.string == document.string)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func livePaletteRepaintsLauncherAndFavoriteBadgesWithoutResettingSearch() throws {
|
||||
_ = NSApplication.shared
|
||||
let originalPalette = ThemePalette.loaded
|
||||
defer {
|
||||
ThemePalette.loaded = originalPalette
|
||||
NotificationCenter.default.post(name: ThemePalette.didChangeNotification, object: nil)
|
||||
}
|
||||
let directory = try liveViewThemeDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
ThemePalette.reload(from: directory)
|
||||
let suite = "Conjure.LiveThemeViews.\(UUID().uuidString)"
|
||||
let defaults = try #require(UserDefaults(suiteName: suite))
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let launcher = LauncherWindowController(
|
||||
history: LaunchHistory(defaults: defaults, now: { Date(timeIntervalSinceReferenceDate: 12_000) }),
|
||||
defaults: defaults, notesDirectory: directory.appendingPathComponent("notes"))
|
||||
let window = try #require(launcher.window)
|
||||
let content = try #require(window.contentView)
|
||||
let views = liveThemedDescendants(content)
|
||||
let search = try #require(views.compactMap { $0 as? NSSearchField }.first)
|
||||
let table = try #require(views.compactMap { $0 as? NSTableView }.first)
|
||||
search.stringValue = "notes"
|
||||
launcher.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: search))
|
||||
let rows = table.numberOfRows
|
||||
let selected = table.selectedRowIndexes
|
||||
let placeholder = search.placeholderAttributedString?.string
|
||||
window.makeFirstResponder(search)
|
||||
let fieldEditor = try #require(search.currentEditor() as? NSTextView)
|
||||
fieldEditor.setSelectedRange(NSRange(location: 1, length: 2))
|
||||
let selection = fieldEditor.selectedRange()
|
||||
let pill = try #require(views.compactMap { $0 as? ActionPill }.first)
|
||||
let oldBorder = pill.layer?.borderColor
|
||||
let cell = ApplicationCell(frame: .zero)
|
||||
cell.kindLabel.attributedStringValue = ApplicationCell.favoriteCommandKind
|
||||
cell.setNumberHint("3", animated: false)
|
||||
let numberHint = try #require(liveThemedDescendants(cell.numberHint).compactMap { $0 as? NSTextField }.first)
|
||||
|
||||
try writeLiveViewTheme(in: directory)
|
||||
#expect(ThemePalette.reload(from: directory))
|
||||
|
||||
#expect(search.stringValue == "notes")
|
||||
#expect(search.placeholderAttributedString?.string == placeholder)
|
||||
#expect(table.numberOfRows == rows)
|
||||
#expect(table.selectedRowIndexes == selected)
|
||||
#expect(search.currentEditor() === fieldEditor)
|
||||
#expect(fieldEditor.selectedRange() == selection)
|
||||
#expect(search.textColor == ThemePalette.text)
|
||||
#expect(fieldEditor.insertionPointColor == ThemePalette.accent)
|
||||
#expect(search.placeholderAttributedString?.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor == ThemePalette.secondaryText)
|
||||
#expect(pill.layer?.borderColor == ThemePalette.accent.withAlphaComponent(0.35).cgColor)
|
||||
#expect(pill.layer?.borderColor != oldBorder)
|
||||
#expect(cell.kindLabel.attributedStringValue.attribute(.foregroundColor, at: cell.kindLabel.attributedStringValue.length - 1, effectiveRange: nil) as? NSColor == ThemePalette.secondaryText)
|
||||
#expect(!cell.numberHint.isHidden)
|
||||
#expect(numberHint.stringValue == "3")
|
||||
#expect(numberHint.layer?.backgroundColor == ThemePalette.keyBackground.cgColor)
|
||||
}
|
||||
|
||||
private func liveViewThemeDirectory() throws -> URL {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
return directory
|
||||
}
|
||||
|
||||
private func writeLiveViewTheme(in directory: URL) throws {
|
||||
try Data("""
|
||||
[colors]
|
||||
background = "#ffffff"
|
||||
text = "#112233"
|
||||
secondaryText = "#445566"
|
||||
accent = "#1234ab"
|
||||
glow = "#abcdef"
|
||||
keyBackground = "#778899"
|
||||
error = "#aa0011"
|
||||
""".utf8).write(to: directory.appendingPathComponent("config.toml"))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func liveThemedDescendants(_ view: NSView) -> [NSView] {
|
||||
[view] + view.subviews.flatMap { liveThemedDescendants($0) }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class LiveThemeTextDelegate: NSObject, NSTextViewDelegate {
|
||||
var changeCount = 0
|
||||
func textDidChange(_ notification: Notification) { changeCount += 1 }
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import AppKit
|
||||
import ConjureKit
|
||||
import Testing
|
||||
|
||||
@Test @MainActor
|
||||
func markdownImportProducesVisibleTextAndSemanticFormatting() throws {
|
||||
let markdown = "# Title 🦊\n**bold** *italic* ***both*** <u>under</u> ~~gone~~ `let x = 1` [site](https://example.com)\n\n> quote\n\n- [ ] todo\n- [x] done\n - nested\n\n3. third\n4. fourth\n\n```swift\n# literal **code**\n```\n"
|
||||
let document = NoteDocument.importMarkdown(NSAttributedString(string: markdown))
|
||||
#expect(document.string == "Title 🦊\nbold italic both under gone let x = 1 site\n\nquote\n\ntodo\ndone\nnested\n\nthird\nfourth\n\n# literal **code**\n")
|
||||
func attribute(_ key: NSAttributedString.Key, _ text: String) throws -> Any? {
|
||||
let range = (document.string as NSString).range(of: text)
|
||||
#expect(range.location != NSNotFound)
|
||||
return document.attribute(key, at: range.location, effectiveRange: nil)
|
||||
}
|
||||
#expect(try attribute(NoteAttributes.block, "Title") as? String == "heading")
|
||||
#expect(try attribute(NoteAttributes.headingLevel, "Title") as? Int == 1)
|
||||
#expect(try attribute(NoteAttributes.bold, "bold") as? Bool == true)
|
||||
#expect(try attribute(NoteAttributes.italic, "italic") as? Bool == true)
|
||||
#expect(try attribute(NoteAttributes.bold, "both") as? Bool == true)
|
||||
#expect(try attribute(NoteAttributes.italic, "both") as? Bool == true)
|
||||
#expect(try attribute(.underlineStyle, "under") as? Int == NSUnderlineStyle.single.rawValue)
|
||||
#expect(try attribute(.strikethroughStyle, "gone") as? Int == NSUnderlineStyle.single.rawValue)
|
||||
#expect(try attribute(NoteAttributes.inlineCode, "let x") as? Bool == true)
|
||||
#expect(try attribute(.link, "site") as? String == "https://example.com")
|
||||
#expect(try attribute(NoteAttributes.block, "quote") as? String == "quote")
|
||||
#expect(try attribute(NoteAttributes.checked, "todo") as? Bool == false)
|
||||
#expect(try attribute(NoteAttributes.checked, "done") as? Bool == true)
|
||||
#expect(try attribute(NoteAttributes.depth, "nested") as? Int == 1)
|
||||
#expect(try attribute(NoteAttributes.number, "third") as? Int == 3)
|
||||
#expect(try attribute(NoteAttributes.number, "fourth") as? Int == 4)
|
||||
#expect(try attribute(NoteAttributes.block, "# literal") as? String == "code")
|
||||
#expect(try attribute(NoteAttributes.language, "# literal") as? String == "swift")
|
||||
#expect(try attribute(NoteAttributes.bold, "**code**") == nil)
|
||||
let restored = NoteDocument.importMarkdown(NoteDocument.markdown(document))
|
||||
#expect(restored.string == document.string)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func markdownImportHandlesEveryHeadingEscapesAndUnsupportedLiteralContent() {
|
||||
for level in 1...6 {
|
||||
let document = NoteDocument.importMarkdown(NSAttributedString(string: String(repeating: "#", count: level) + " Heading"))
|
||||
#expect(document.string == "Heading")
|
||||
#expect(document.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) as? Int == level)
|
||||
}
|
||||
let escaped = NoteDocument.importMarkdown(NSAttributedString(string: "\\*literal\\* & café 🦊"))
|
||||
#expect(escaped.string == "*literal* & café 🦊")
|
||||
#expect(escaped.attribute(NoteAttributes.italic, at: 0, effectiveRange: nil) == nil)
|
||||
for literal in ["", "<div>literal **content**</div>", "| A | B |\n| - | - |\n| x | y |", "---"] {
|
||||
#expect(NoteDocument.importMarkdown(NSAttributedString(string: literal)).string == literal)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func markdownConversionPreservesNativeAttachmentsAndLiteralCode() throws {
|
||||
let attachment = NSTextAttachment()
|
||||
let source = NSMutableAttributedString(string: "**fox ")
|
||||
source.append(NSAttributedString(attachment: attachment))
|
||||
source.append(NSAttributedString(string: "**\n```text\n`literal` **not bold**\n```"))
|
||||
let document = NoteDocument.importMarkdown(source)
|
||||
#expect(document.string == "fox \u{fffc}\n`literal` **not bold**")
|
||||
#expect(document.attribute(.attachment, at: 4, effectiveRange: nil) as? NSTextAttachment === attachment)
|
||||
let markdown = NoteDocument.markdown(document)
|
||||
#expect(markdown.string.contains("```text\n`literal` **not bold**\n```"))
|
||||
let location = (markdown.string as NSString).range(of: "\u{fffc}").location
|
||||
#expect(markdown.attribute(.attachment, at: location, effectiveRange: nil) as? NSTextAttachment === attachment)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func markdownExportRecognizesNativeMarksAndEscapesLiteralSyntax() {
|
||||
let native = NSMutableAttributedString(string: "bold italic under strike link literal *stars* #tag")
|
||||
let text = native.string as NSString
|
||||
native.addAttribute(.font, value: NSFont.boldSystemFont(ofSize: 14), range: text.range(of: "bold"))
|
||||
native.addAttribute(.font, value: NSFontManager.shared.convert(NSFont.systemFont(ofSize: 14), toHaveTrait: .italicFontMask), range: text.range(of: "italic"))
|
||||
native.addAttribute(.underlineStyle, value: 1, range: text.range(of: "under"))
|
||||
native.addAttribute(.strikethroughStyle, value: 1, range: text.range(of: "strike"))
|
||||
native.addAttribute(.link, value: URL(string: "https://example.com")!, range: text.range(of: "link"))
|
||||
let output = NoteDocument.markdown(native)
|
||||
#expect(output.string == "**bold** *italic* <u>under</u> ~~strike~~ [link](<https://example.com>) literal \\*stars\\* \\#tag")
|
||||
#expect(NoteDocument.importMarkdown(output).string == native.string)
|
||||
native.addAttribute(NoteAttributes.bold, value: false, range: text.range(of: "bold"))
|
||||
#expect(NoteDocument.markdown(native).string.hasPrefix("bold "))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func structuredPackageRoundTripsAllSemanticAttributesIncludingFalseAndZero() throws {
|
||||
let document = NSMutableAttributedString(attributedString: NoteDocument.importMarkdown(NSAttributedString(string: "###### Heading\n- [ ] todo\n 0. nested\n```swift\nlet x = 1\n```\n**bold** *italic* <u>under</u> ~~strike~~ `code` [link](https://example.com)")))
|
||||
document.addAttributes([NoteAttributes.bold: false, NoteAttributes.italic: false, NoteAttributes.inlineCode: false, NoteAttributes.depth: 0], range: NSRange(location: 0, length: 1))
|
||||
let wrapper = try NoteDocument.fileWrapper(document)
|
||||
let serialized = try #require(wrapper.serializedRepresentation)
|
||||
let received = try #require(FileWrapper(serializedRepresentation: serialized))
|
||||
let restored = try NoteDocument.contents(from: received)
|
||||
#expect(restored.string == document.string)
|
||||
let keys: [NSAttributedString.Key] = [NoteAttributes.block, NoteAttributes.headingLevel, NoteAttributes.depth, NoteAttributes.number, NoteAttributes.checked, NoteAttributes.language, NoteAttributes.inlineCode, NoteAttributes.bold, NoteAttributes.italic, .underlineStyle, .strikethroughStyle, .link]
|
||||
for index in 0..<document.length {
|
||||
for key in keys {
|
||||
let expected = document.attribute(key, at: index, effectiveRange: nil)
|
||||
let actual = restored.attribute(key, at: index, effectiveRange: nil)
|
||||
if let expected { #expect((expected as? NSObject)?.isEqual(actual) == true) }
|
||||
else { #expect(actual == nil || (key == .underlineStyle || key == .strikethroughStyle) && actual as? Int == 0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func structuredPackagesRejectMissingCorruptMismatchedAndUnsafeMetadata() throws {
|
||||
let document = NSAttributedString(string: "safe text")
|
||||
for mutation in ["version", "length", "digest", "runs"] {
|
||||
let wrapper = try NoteDocument.fileWrapper(document)
|
||||
let original = try #require(wrapper.fileWrappers?["ConjureNote.json"])
|
||||
let data = try #require(original.regularFileContents)
|
||||
var metadata = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
switch mutation {
|
||||
case "version": metadata[mutation] = 999
|
||||
case "length": metadata[mutation] = 999
|
||||
case "digest": metadata[mutation] = "not the text digest"
|
||||
default: metadata[mutation] = [["location": 1, "length": 9, "values": [:]]]
|
||||
}
|
||||
wrapper.removeFileWrapper(original)
|
||||
let replacement = FileWrapper(regularFileWithContents: try JSONSerialization.data(withJSONObject: metadata))
|
||||
replacement.preferredFilename = "ConjureNote.json"
|
||||
wrapper.addFileWrapper(replacement)
|
||||
#expect(throws: NoteDocument.DocumentError.self) { try NoteDocument.contents(from: wrapper) }
|
||||
}
|
||||
let missing = try document.fileWrapper(from: NSRange(location: 0, length: document.length), documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd])
|
||||
#expect(throws: NoteDocument.DocumentError.self) { try NoteDocument.contents(from: missing) }
|
||||
let unsafe = try NoteDocument.fileWrapper(document)
|
||||
let link = FileWrapper(symbolicLinkWithDestinationURL: URL(fileURLWithPath: "/etc/passwd"))
|
||||
link.preferredFilename = "outside"
|
||||
unsafe.addFileWrapper(link)
|
||||
#expect(throws: NoteDocument.DocumentError.self) { try NoteDocument.contents(from: unsafe) }
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func structuredPackagesRejectNonPackageRootsAndReservedAttachmentFilenames() throws {
|
||||
#expect(throws: NoteDocument.DocumentError.self) {
|
||||
try NoteDocument.contents(from: FileWrapper(regularFileWithContents: Data()))
|
||||
}
|
||||
let attachment = FileWrapper(regularFileWithContents: Data("attachment".utf8))
|
||||
attachment.preferredFilename = "ConjureNote.json"
|
||||
#expect(throws: NoteDocument.DocumentError.self) {
|
||||
try NoteDocument.fileWrapper(NSAttributedString(attachment: NSTextAttachment(fileWrapper: attachment)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func markdownLazyContinuationUsesASTTextWithoutDuplicatingSourceOrLosingLinebreaks() {
|
||||
for (source, expected) in [
|
||||
("- [ ] task\nnext", "task\nnext"),
|
||||
("- [ ] task\n**bold** and `code`\nlast", "task\nbold and code\nlast"),
|
||||
("> quote\n[link](https://example.com)\nnext", "quote\nlink\nnext"),
|
||||
("- item\n***both*** <u>under</u>\nlast", "item\nboth under\nlast"),
|
||||
] {
|
||||
let contents = NoteDocument.importMarkdown(NSAttributedString(string: source))
|
||||
#expect(contents.string == expected)
|
||||
}
|
||||
let attachment = NSTextAttachment()
|
||||
let source = NSMutableAttributedString(string: "# Heading\n- [ ] task\n**bold** and `code`\n")
|
||||
source.append(NSAttributedString(attachment: attachment))
|
||||
let contents = NoteDocument.importMarkdown(source)
|
||||
#expect(contents.string == "Heading\ntask\nbold and code\n\u{fffc}")
|
||||
#expect(contents.attribute(.attachment, at: contents.length - 1, effectiveRange: nil) as? NSTextAttachment === attachment)
|
||||
let bold = (contents.string as NSString).range(of: "bold")
|
||||
#expect(contents.attribute(NoteAttributes.bold, at: bold.location, effectiveRange: nil) as? Bool == true)
|
||||
let code = (contents.string as NSString).range(of: "code")
|
||||
#expect(contents.attribute(NoteAttributes.inlineCode, at: code.location, effectiveRange: nil) as? Bool == true)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func markdownExportGroupsSemanticMarksAcrossDisplayRunsAndKeepsWhitespace() {
|
||||
let contents = NSMutableAttributedString(string: "fox 🦊 \n")
|
||||
let marked = NSRange(location: 0, length: (contents.string as NSString).range(of: " \n").location)
|
||||
contents.addAttributes([.underlineStyle: 1, NoteAttributes.bold: false, NoteAttributes.italic: false], range: NSRange(location: 0, length: contents.length))
|
||||
contents.addAttribute(.font, value: NSFont.systemFont(ofSize: 14), range: NSRange(location: 0, length: 4))
|
||||
contents.addAttributes([.font: NSFont.systemFont(ofSize: 18), .foregroundColor: NSColor.red], range: NSRange(location: 4, length: marked.length - 4))
|
||||
#expect(NoteDocument.markdown(contents).string == "<u>fox 🦊 </u>\n")
|
||||
contents.addAttribute(NoteAttributes.bold, value: true, range: marked)
|
||||
#expect(NoteDocument.markdown(contents).string == "**<u>fox 🦊</u>**<u> </u>\n")
|
||||
|
||||
for source in ["- [ ] todo \n", "first \nsecond \t\n", "trailing "] {
|
||||
let imported = NoteDocument.importMarkdown(NSAttributedString(string: source))
|
||||
let expected = source.hasPrefix("- [ ] ") ? String(source.dropFirst(6)) : source
|
||||
#expect(imported.string == expected)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ func markdownExportPreservesWholeTextAndSnapshotsBeforeWriting() throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false)
|
||||
let text = "\n # 日本語 🦊 \r\n**bold** and `code`\n- [ ] task\n\u{fffc}\n"
|
||||
let text = "\n日本語 🦊 \r\nplain and code\nordinary task\n\u{fffc}\n"
|
||||
let source = NSMutableAttributedString(string: text)
|
||||
source.addAttribute(.attachment, value: NSTextAttachment(), range: NSRange(location: 0, length: 1))
|
||||
let export = try NoteMarkdownExport(contents: source)
|
||||
@@ -32,9 +32,9 @@ func markdownExportWritesOriginalImageBytesAndEscapedLinksWithoutChangingSource(
|
||||
wrapper.preferredFilename = name
|
||||
let originalFilename = wrapper.filename
|
||||
let attachment = NSTextAttachment(fileWrapper: wrapper)
|
||||
let source = NSMutableAttributedString(string: "\n # 🦊\n")
|
||||
let source = NSMutableAttributedString(attributedString: NoteDocument.importMarkdown(NSAttributedString(string: "\n# 🦊\n")))
|
||||
source.append(NSAttributedString(attachment: attachment))
|
||||
source.append(NSAttributedString(string: "\n- [ ] todo \n"))
|
||||
source.append(NoteDocument.importMarkdown(NSAttributedString(string: "\n- [ ] todo \n")))
|
||||
let before = NSAttributedString(attributedString: source)
|
||||
let export = try NoteMarkdownExport(contents: source)
|
||||
#expect(export.hasImages)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import AppKit
|
||||
import Testing
|
||||
@testable import Conjure
|
||||
|
||||
@Test @MainActor
|
||||
func vimNormalCursorIsWideButInsertAndDisabledCursorsStayThin() throws {
|
||||
_ = NSApplication.shared
|
||||
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 100, height: 50))
|
||||
editor.font = NotesAppearance.font
|
||||
editor.drawsBackground = false
|
||||
editor.setDocumentContents(NSAttributedString(string: "text"))
|
||||
func paintedWidth() throws -> Int {
|
||||
let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 100, pixelsHigh: 50,
|
||||
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
|
||||
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0))
|
||||
let context = try #require(NSGraphicsContext(bitmapImageRep: bitmap))
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
NSGraphicsContext.current = context
|
||||
NSColor.clear.setFill()
|
||||
NSRect(x: 0, y: 0, width: 100, height: 50).fill(using: .copy)
|
||||
editor.drawInsertionPoint(in: NSRect(x: 10, y: 10, width: 1, height: 20), color: .orange, turnedOn: true)
|
||||
context.flushGraphics()
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
return (0..<100).filter { x in
|
||||
(0..<50).contains { y in (bitmap.colorAt(x: x, y: y)?.alphaComponent ?? 0) > 0.1 }
|
||||
}.count
|
||||
}
|
||||
let native = try paintedWidth()
|
||||
editor.vimEnabled = true
|
||||
let normal = try paintedWidth()
|
||||
#expect(normal >= 8)
|
||||
#expect(normal > native)
|
||||
editor.vimState.mode = .insert
|
||||
#expect(try paintedWidth() == native)
|
||||
editor.vimEnabled = false
|
||||
#expect(try paintedWidth() == native)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ import Testing
|
||||
@testable import Conjure
|
||||
|
||||
@Test @MainActor
|
||||
func headingsFollowVisibleParagraphEditingAndRoundTrip() throws {
|
||||
func headingShortcutsConsumeSyntaxAndPersistAllSixLevels() throws {
|
||||
_ = NSApplication.shared
|
||||
let suite = "NotesHeadings.\(UUID().uuidString)"
|
||||
let defaults = try #require(UserDefaults(suiteName: suite))
|
||||
@@ -21,56 +21,84 @@ func headingsFollowVisibleParagraphEditingAndRoundTrip() throws {
|
||||
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
|
||||
let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first)
|
||||
window.makeFirstResponder(editor)
|
||||
func reset(_ source: String) {
|
||||
editor.setMarkdownContents(NSAttributedString(string: source))
|
||||
MarkdownHighlighting.apply(to: editor)
|
||||
editor.refreshTasks()
|
||||
editor.breakUndoCoalescing()
|
||||
editor.undoManager?.removeAllActions()
|
||||
let title = (editor.string as NSString).range(of: "Title")
|
||||
editor.setSelectedRange(NSRange(location: title.location, length: 0))
|
||||
}
|
||||
for (before, after) in [
|
||||
("## Title", "## Title"),
|
||||
("\n## Title", "## Title"),
|
||||
("Text\n## Title", "TextTitle"),
|
||||
("# Text\n## Title", "# TextTitle"),
|
||||
("Text\n\n## Title", "Text\n## Title")
|
||||
] {
|
||||
reset(before)
|
||||
editor.undoManager?.beginUndoGrouping()
|
||||
editor.deleteBackward(nil)
|
||||
editor.undoManager?.endUndoGrouping()
|
||||
#expect(editor.markdownString == after)
|
||||
if before != after {
|
||||
editor.undoManager?.undo()
|
||||
#expect(editor.markdownString == before)
|
||||
editor.undoManager?.redo()
|
||||
#expect(editor.markdownString == after)
|
||||
}
|
||||
}
|
||||
for level in 1...6 {
|
||||
reset("### Title")
|
||||
editor.undoManager?.beginUndoGrouping()
|
||||
editor.setDocumentContents(NSAttributedString(string: "Title"))
|
||||
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
||||
editor.beginInsertionSession()
|
||||
for character in String(repeating: "#", count: level) + " " {
|
||||
editor.insertText(String(character), replacementRange: editor.selectedRange())
|
||||
}
|
||||
editor.undoManager?.endUndoGrouping()
|
||||
let expected = String(repeating: "#", count: level) + " Title"
|
||||
#expect(editor.markdownString == expected)
|
||||
let title = (editor.string as NSString).range(of: "Title")
|
||||
#expect(editor.selectedRange().location == title.location)
|
||||
let sizes: [CGFloat] = [26, 23, 21, 19, 17, 15]
|
||||
#expect((editor.textStorage?.attribute(.font, at: title.location, effectiveRange: nil) as? NSFont)?.pointSize == sizes[level - 1])
|
||||
editor.endInsertionSession()
|
||||
#expect(editor.string == "Title")
|
||||
#expect(editor.selectedRange() == NSRange(location: 0, length: 0))
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) as? String == "heading")
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) as? Int == level)
|
||||
editor.undoManager?.undo()
|
||||
#expect(editor.markdownString == "### Title")
|
||||
#expect(editor.string == "Title")
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) == nil)
|
||||
editor.undoManager?.redo()
|
||||
#expect(editor.markdownString == expected)
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) as? Int == level)
|
||||
#expect(controller.save())
|
||||
let reloaded = try #require(NotesStore(directory: directory).notes.first)
|
||||
#expect(reloaded.contents.string == expected)
|
||||
#expect(reloaded.contents.string == "Title")
|
||||
#expect(reloaded.contents.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) as? Int == level)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func headingReturnSplitsVisibleParagraphAndUndoRestoresItsSemantics() throws {
|
||||
_ = NSApplication.shared
|
||||
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 500))
|
||||
editor.allowsUndo = true
|
||||
let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false)
|
||||
window.contentView = editor
|
||||
window.makeFirstResponder(editor)
|
||||
defer { window.orderOut(nil) }
|
||||
for position in [2, 5] {
|
||||
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: "## Title")))
|
||||
editor.setSelectedRange(NSRange(location: position, length: 0))
|
||||
editor.undoManager?.removeAllActions()
|
||||
editor.insertNewline(nil)
|
||||
#expect(editor.string == (position == 2 ? "Ti\ntle" : "Title\n"))
|
||||
#expect(editor.selectedRange() == NSRange(location: position + 1, length: 0))
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) as? Int == 2)
|
||||
editor.insertText("body", replacementRange: editor.selectedRange())
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.block, at: position + 1, effectiveRange: nil) == nil)
|
||||
editor.undoManager?.undo()
|
||||
editor.undoManager?.undo()
|
||||
#expect(editor.string == "Title")
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) as? Int == 2)
|
||||
}
|
||||
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: "```text\n## Title\n```")))
|
||||
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
||||
for character in "# " { editor.insertText(String(character), replacementRange: editor.selectedRange()) }
|
||||
#expect(editor.string == "# ## Title")
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) as? String == "code")
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func headingCommandsKeepCaretOnTheVisibleBodyLine() throws {
|
||||
_ = NSApplication.shared
|
||||
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 500))
|
||||
let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false)
|
||||
window.contentView = editor
|
||||
window.makeFirstResponder(editor)
|
||||
defer { window.orderOut(nil) }
|
||||
for source in ["\n# Title", "Text\n\n## Title", "Text\n\n\n###### Title"] {
|
||||
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: source)))
|
||||
let before = editor.string
|
||||
editor.setSelectedRange(NSRange(location: (editor.string as NSString).range(of: "Title").location, length: 0))
|
||||
for command in [#selector(NSResponder.deleteBackward(_:)), #selector(NSResponder.insertNewline(_:))] {
|
||||
editor.doCommand(by: command)
|
||||
let title = (editor.string as NSString).range(of: "Title")
|
||||
#expect(editor.selectedRange() == NSRange(location: title.location, length: 0))
|
||||
let caretRect = editor.firstRect(forCharacterRange: editor.selectedRange(), actualRange: nil)
|
||||
let titleRect = editor.firstRect(forCharacterRange: NSRange(location: title.location, length: 1), actualRange: nil)
|
||||
try #require(titleRect.height > 0)
|
||||
#expect(caretRect.origin == titleRect.origin)
|
||||
#expect(caretRect.height == titleRect.height)
|
||||
}
|
||||
#expect(editor.string == before)
|
||||
}
|
||||
reset("```text\n## Title\n```")
|
||||
editor.insertText("# ", replacementRange: editor.selectedRange())
|
||||
#expect(editor.markdownString == "```text\n## # Title\n```")
|
||||
}
|
||||
|
||||
@@ -4,92 +4,98 @@ import Testing
|
||||
@testable import Conjure
|
||||
|
||||
@Test @MainActor
|
||||
func noteFormattingHidesSyntaxButPreservesMarkdownAndLiteralCode() throws {
|
||||
func importedFormattingBecomesVisibleTextAndNativeSemanticMarks() throws {
|
||||
_ = NSApplication.shared
|
||||
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 800))
|
||||
let source = "# Heading\n## Subheading\n*italic* **bold** _emphasis_ __strong__ ~~gone~~ `*literal*`\n> Quote\n[Label](https://example.com)\n```text\n# Code *literal*\n```\nUnmatched * and \\*escaped*"
|
||||
editor.setMarkdownContents(NSAttributedString(string: source))
|
||||
MarkdownHighlighting.apply(to: editor)
|
||||
editor.refreshTasks()
|
||||
let layout = try #require(editor.layoutManager)
|
||||
let container = try #require(editor.textContainer)
|
||||
layout.ensureLayout(for: container)
|
||||
func visibleText() -> String {
|
||||
var result = ""
|
||||
let text = editor.string as NSString
|
||||
for index in 0..<text.length {
|
||||
let glyph = layout.glyphIndexForCharacter(at: index)
|
||||
if !layout.propertyForGlyph(at: glyph).contains(.null) {
|
||||
result += text.substring(with: NSRange(location: index, length: 1))
|
||||
let source = "# Heading\n## Subheading\n*italic* **bold** _emphasis_ __strong__ ~~gone~~ <u>underlined</u> `*literal*`\n> Quote\n[Label](https://example.com)\n```text\n# Code *literal*\n```"
|
||||
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: source)))
|
||||
#expect(editor.string == "Heading\nSubheading\nitalic bold emphasis strong gone underlined *literal*\nQuote\nLabel\n# Code *literal*")
|
||||
func attribute(_ key: NSAttributedString.Key, in token: String) -> Any? {
|
||||
editor.documentContents.attribute(key, at: (editor.string as NSString).range(of: token).location, effectiveRange: nil)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
#expect(visibleText() == "Heading\nSubheading\nitalic bold emphasis strong gone *literal*\nQuote\nLabel\n\n# Code *literal*\n\nUnmatched * and \\*escaped*")
|
||||
#expect(editor.markdownString == source)
|
||||
editor.setSelectedRange(NSRange(location: 0, length: editor.string.utf16.count))
|
||||
let pasteboard = NSPasteboard.withUniqueName()
|
||||
defer { pasteboard.releaseGlobally() }
|
||||
#expect(editor.writeSelection(to: pasteboard, types: editor.writablePasteboardTypes))
|
||||
#expect(pasteboard.string(forType: .string) == source)
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let store = try NotesStore(directory: directory)
|
||||
let id = try store.create()
|
||||
try store.update(id: id, contents: editor.markdownContents)
|
||||
let reloaded = try #require(NotesStore(directory: directory).notes.first)
|
||||
#expect(reloaded.contents.string == source)
|
||||
#expect(attribute(NoteAttributes.headingLevel, in: "Heading") as? Int == 1)
|
||||
#expect(attribute(NoteAttributes.headingLevel, in: "Subheading") as? Int == 2)
|
||||
for token in ["italic", "emphasis"] { #expect(attribute(NoteAttributes.italic, in: token) as? Bool == true) }
|
||||
for token in ["bold", "strong"] { #expect(attribute(NoteAttributes.bold, in: token) as? Bool == true) }
|
||||
#expect(attribute(.strikethroughStyle, in: "gone") as? Int == NSUnderlineStyle.single.rawValue)
|
||||
#expect(attribute(.underlineStyle, in: "underlined") as? Int == NSUnderlineStyle.single.rawValue)
|
||||
#expect(attribute(NoteAttributes.inlineCode, in: "*literal*") as? Bool == true)
|
||||
#expect(attribute(NoteAttributes.block, in: "Quote") as? String == "quote")
|
||||
#expect(String(describing: try #require(attribute(.link, in: "Label"))) == "https://example.com")
|
||||
#expect(attribute(NoteAttributes.block, in: "# Code") as? String == "code")
|
||||
#expect(attribute(NoteAttributes.bold, in: "# Code") as? Bool != true)
|
||||
#expect(attribute(NoteAttributes.headingLevel, in: "# Code") == nil)
|
||||
}
|
||||
|
||||
private final class NotesLayoutProbe: NSLayoutManager {
|
||||
var observeFormatting: ((NSTextStorage) -> Void)?
|
||||
|
||||
override func processEditing(for textStorage: NSTextStorage, edited editMask: NSTextStorageEditActions,
|
||||
range newCharRange: NSRange, changeInLength delta: Int,
|
||||
invalidatedRange invalidatedCharRange: NSRange) {
|
||||
observeFormatting?(textStorage)
|
||||
super.processEditing(for: textStorage, edited: editMask, range: newCharRange,
|
||||
changeInLength: delta, invalidatedRange: invalidatedCharRange)
|
||||
@Test @MainActor
|
||||
func completedInlineShortcutsConsumeDelimitersAndKeepUndoableNativeMarks() throws {
|
||||
_ = NSApplication.shared
|
||||
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
|
||||
editor.allowsUndo = true
|
||||
let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false)
|
||||
window.contentView = editor
|
||||
window.makeFirstResponder(editor)
|
||||
defer { window.orderOut(nil) }
|
||||
for (source, key) in [("**fox 🦊**", NoteAttributes.bold), ("*fox 🦊*", NoteAttributes.italic),
|
||||
("`fox 🦊`", NoteAttributes.inlineCode), ("~~fox 🦊~~", .strikethroughStyle),
|
||||
("<u>fox 🦊</u>", .underlineStyle), ("[fox 🦊](https://example.com)", .link)] {
|
||||
editor.setDocumentContents(NSAttributedString(string: ""))
|
||||
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
||||
editor.beginInsertionSession()
|
||||
for character in source { editor.insertText(String(character), replacementRange: editor.selectedRange()) }
|
||||
editor.endInsertionSession()
|
||||
#expect(editor.string == "fox 🦊")
|
||||
#expect(editor.selectedRange() == NSRange(location: "fox 🦊".utf16.count, length: 0))
|
||||
let mark = try #require(editor.documentContents.attribute(key, at: 0, effectiveRange: nil))
|
||||
if key == .link {
|
||||
#expect(String(describing: mark) == "https://example.com")
|
||||
} else if key == .underlineStyle || key == .strikethroughStyle {
|
||||
#expect(mark as? Int == NSUnderlineStyle.single.rawValue)
|
||||
} else {
|
||||
#expect(mark as? Bool == true)
|
||||
}
|
||||
editor.undoManager?.undo()
|
||||
#expect(editor.string.isEmpty)
|
||||
editor.undoManager?.redo()
|
||||
#expect(editor.string == "fox 🦊")
|
||||
#expect(editor.documentContents.attribute(key, at: 0, effectiveRange: nil) != nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func typingDoesNotExposeUnformattedExistingTextToLayout() throws {
|
||||
func richCopyPasteKeepsNativeMarksUnicodeAndImageBytesWithoutMarkdownParsing() throws {
|
||||
_ = NSApplication.shared
|
||||
let suite = "NotesFlashing.\(UUID().uuidString)"
|
||||
let defaults = try #require(UserDefaults(suiteName: suite))
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite)
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
try? FileManager.default.removeItem(at: directory)
|
||||
}
|
||||
let store = try NotesStore(directory: directory)
|
||||
let id = try store.create()
|
||||
try store.update(id: id, contents: NSAttributedString(string: "# Heading\n- [ ] Task\nPlain"))
|
||||
defaults.set(id.uuidString, forKey: "selectedNote")
|
||||
let controller = NotesController(defaults: defaults, directory: directory)
|
||||
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 570, height: 404),
|
||||
styleMask: .borderless, backing: .buffered, defer: false)
|
||||
window.contentViewController = controller
|
||||
defer { _ = controller.close(); window.orderOut(nil) }
|
||||
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
|
||||
let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first)
|
||||
let layout = NotesLayoutProbe()
|
||||
try #require(editor.textContainer).replaceLayoutManager(layout)
|
||||
editor.refreshTasks()
|
||||
window.makeFirstResponder(editor)
|
||||
layout.observeFormatting = { storage in
|
||||
let text = storage.string as NSString
|
||||
let heading = text.range(of: "Heading")
|
||||
let task = text.range(of: "Task")
|
||||
#expect((storage.attribute(.font, at: heading.location, effectiveRange: nil) as? NSFont)?.pointSize == 26)
|
||||
#expect((storage.attribute(.paragraphStyle, at: task.location, effectiveRange: nil) as? NSParagraphStyle)?.headIndent == 22)
|
||||
#expect(storage.attribute(MarkdownHighlighting.hiddenMarkup, at: 0, effectiveRange: nil) as? Bool == true)
|
||||
}
|
||||
defer { layout.observeFormatting = nil }
|
||||
editor.insertText("!", replacementRange: NSRange(location: editor.string.utf16.count, length: 0))
|
||||
#expect(editor.markdownString == "# Heading\n- [ ] Task\nPlain!")
|
||||
#expect(try NotesStore(directory: directory).notes.first?.contents.string == editor.markdownString)
|
||||
let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 1, pixelsHigh: 1,
|
||||
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
|
||||
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0))
|
||||
bitmap.setColor(.red, atX: 0, y: 0)
|
||||
let png = try #require(bitmap.representation(using: .png, properties: [:]))
|
||||
let file = FileWrapper(regularFileWithContents: png)
|
||||
file.preferredFilename = "pixel.png"
|
||||
let contents = NSMutableAttributedString(attributedString:
|
||||
NoteDocument.importMarkdown(NSAttributedString(string: "- [x] **🦊 café** ")))
|
||||
contents.append(NSAttributedString(attachment: NSTextAttachment(fileWrapper: file)))
|
||||
let source = NotesTextView(frame: NSRect(x: 0, y: 0, width: 400, height: 200))
|
||||
source.setDocumentContents(contents)
|
||||
source.setSelectedRange(NSRange(location: 0, length: source.string.utf16.count))
|
||||
let board = NSPasteboard.withUniqueName()
|
||||
defer { board.releaseGlobally() }
|
||||
#expect(source.writeSelection(to: board, types: source.writablePasteboardTypes))
|
||||
#expect(board.string(forType: .string) == source.string)
|
||||
let destination = NotesTextView(frame: source.frame)
|
||||
destination.allowsUndo = true
|
||||
#expect(destination.readSelection(from: board))
|
||||
#expect(destination.string == source.string)
|
||||
#expect(destination.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool == true)
|
||||
#expect(destination.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) as? String == "task")
|
||||
#expect(destination.documentContents.attribute(NoteAttributes.checked, at: 0, effectiveRange: nil) as? Bool == true)
|
||||
let image = try #require(destination.documentContents.attribute(.attachment,
|
||||
at: destination.string.utf16.count - 1, effectiveRange: nil) as? NSTextAttachment)
|
||||
#expect(image.fileWrapper?.regularFileContents == png)
|
||||
destination.undoManager?.undo()
|
||||
#expect(destination.string.isEmpty)
|
||||
destination.undoManager?.redo()
|
||||
#expect(destination.string == source.string)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
@@ -112,7 +118,7 @@ func noteImagesRenderRoundedWithoutChangingSavedImageData() throws {
|
||||
#expect(editor.readSelection(from: pasteboard))
|
||||
|
||||
func checkRoundedDrawing() throws {
|
||||
MarkdownHighlighting.apply(to: editor)
|
||||
editor.applyDocumentStyle()
|
||||
let layout = try #require(editor.layoutManager)
|
||||
layout.ensureLayout(for: try #require(editor.textContainer))
|
||||
let output = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 200, pixelsHigh: 120,
|
||||
@@ -146,60 +152,40 @@ func noteImagesRenderRoundedWithoutChangingSavedImageData() throws {
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let store = try NotesStore(directory: directory)
|
||||
let id = try store.create()
|
||||
try store.update(id: id, contents: editor.markdownContents)
|
||||
try store.update(id: id, contents: editor.documentContents)
|
||||
let reloaded = try #require(NotesStore(directory: directory).notes.first)
|
||||
let attachment = try #require(reloaded.contents.attribute(.attachment, at: 0, effectiveRange: nil) as? NSTextAttachment)
|
||||
#expect(attachment.fileWrapper?.regularFileContents == png)
|
||||
editor.setMarkdownContents(reloaded.contents)
|
||||
editor.setDocumentContents(reloaded.contents)
|
||||
try checkRoundedDrawing()
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func hiddenNoteMarkupKeepsTypingDeletionFormattingAndUndoAtVisibleBoundaries() throws {
|
||||
func nativeFormattingSurvivesVisibleBoundaryEditingAndUndo() throws {
|
||||
_ = NSApplication.shared
|
||||
let suite = "NotesMarkup.\(UUID().uuidString)"
|
||||
let defaults = try #require(UserDefaults(suiteName: suite))
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite)
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
try? FileManager.default.removeItem(at: directory)
|
||||
}
|
||||
let controller = NotesController(defaults: defaults, directory: directory)
|
||||
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 570, height: 404),
|
||||
styleMask: .borderless, backing: .buffered, defer: false)
|
||||
window.contentViewController = controller
|
||||
defer { _ = controller.close(); window.orderOut(nil) }
|
||||
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
|
||||
let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first)
|
||||
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
|
||||
editor.allowsUndo = true
|
||||
let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false)
|
||||
window.contentView = editor
|
||||
window.makeFirstResponder(editor)
|
||||
editor.insertText("# Title\n*word*", replacementRange: NSRange(location: 0, length: 0))
|
||||
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
||||
editor.insertText("A", replacementRange: editor.selectedRange())
|
||||
#expect(editor.markdownString == "# ATitle\n*word*")
|
||||
editor.breakUndoCoalescing()
|
||||
editor.undoManager?.removeAllActions()
|
||||
editor.undoManager?.beginUndoGrouping()
|
||||
defer { window.orderOut(nil) }
|
||||
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: "# Title\n*word*")))
|
||||
editor.insertText("A", replacementRange: NSRange(location: 0, length: 0))
|
||||
#expect(editor.string == "ATitle\nword")
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.headingLevel, at: 0, effectiveRange: nil) as? Int == 1)
|
||||
editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0))
|
||||
editor.undoManager?.removeAllActions()
|
||||
editor.deleteBackward(nil)
|
||||
editor.undoManager?.endUndoGrouping()
|
||||
#expect(editor.markdownString == "# ATitle\n*wor*")
|
||||
#expect(editor.string == "ATitle\nwor")
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.italic, at: "ATitle\n".utf16.count, effectiveRange: nil) as? Bool == true)
|
||||
editor.undoManager?.undo()
|
||||
#expect(editor.markdownString == "# ATitle\n*word*")
|
||||
#expect(editor.string == "ATitle\nword")
|
||||
let word = (editor.string as NSString).range(of: "word")
|
||||
editor.setSelectedRange(NSRange(location: word.location, length: 0))
|
||||
editor.moveRight(nil)
|
||||
#expect(editor.selectedRange().location == word.location + 1)
|
||||
editor.setSelectedRange(NSRange(location: NSMaxRange(word), length: 0))
|
||||
editor.moveRight(nil)
|
||||
#expect(editor.selectedRange().location == editor.string.utf16.count)
|
||||
editor.setSelectedRange(NSRange(location: word.location, length: 0))
|
||||
let italic = try #require(descendants(controller.view).compactMap { $0 as? NSButton }.first { $0.accessibilityLabel() == "Italic" })
|
||||
// Selecting the body and toggling formatting must remove its hidden delimiters.
|
||||
editor.setSelectedRange(word)
|
||||
italic.performClick(nil)
|
||||
#expect(editor.markdownString == "# ATitle\nword")
|
||||
#expect(controller.save())
|
||||
#expect(try NotesStore(directory: directory).notes.first?.contents.string == "# ATitle\nword")
|
||||
editor.format(2)
|
||||
#expect(editor.string == "ATitle\nword")
|
||||
#expect(editor.selectedRange() == word)
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.italic, at: word.location, effectiveRange: nil) as? Bool != true)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
@@ -234,41 +220,80 @@ func notesFormattingShortcutsToggleCombinedStylesAndRespectPopupFocus() throws {
|
||||
}
|
||||
editor.insertText("fox 🦊", replacementRange: editor.selectedRange())
|
||||
editor.setSelectedRange(NSRange(location: 0, length: editor.string.utf16.count))
|
||||
let selection = editor.selectedRange()
|
||||
#expect(try shortcut("b"))
|
||||
#expect(editor.markdownString == "**fox 🦊**")
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool == true)
|
||||
#expect(try shortcut("i"))
|
||||
#expect(editor.markdownString == "***fox 🦊***")
|
||||
let font = try #require(editor.textStorage?.attribute(.font, at: editor.selectedRange().location,
|
||||
effectiveRange: nil) as? NSFont)
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.italic, at: 0, effectiveRange: nil) as? Bool == true)
|
||||
let font = try #require(editor.textStorage?.attribute(.font, at: 0, effectiveRange: nil) as? NSFont)
|
||||
let traits = NSFontManager.shared.traits(of: font)
|
||||
#expect(traits.contains(.boldFontMask) && traits.contains(.italicFontMask))
|
||||
#expect(try shortcut("i"))
|
||||
#expect(editor.markdownString == "**fox 🦊**")
|
||||
#expect(try shortcut("b"))
|
||||
#expect(editor.markdownString == "fox 🦊")
|
||||
|
||||
#expect(try shortcut("x", shift: true))
|
||||
#expect(editor.markdownString == "~~fox 🦊~~")
|
||||
#expect(try shortcut("x", shift: true))
|
||||
#expect(editor.markdownString == "fox 🦊")
|
||||
#expect(!(try shortcut("b", option: true)))
|
||||
#expect(editor.markdownString == "fox 🦊")
|
||||
|
||||
editor.breakUndoCoalescing()
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool != true)
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.italic, at: 0, effectiveRange: nil) as? Bool != true)
|
||||
#expect(try shortcut("s", shift: true))
|
||||
#expect(editor.documentContents.attribute(.strikethroughStyle, at: 0, effectiveRange: nil) as? Int == NSUnderlineStyle.single.rawValue)
|
||||
#expect(try shortcut("s", shift: true))
|
||||
#expect(editor.documentContents.attribute(.strikethroughStyle, at: 0, effectiveRange: nil) == nil)
|
||||
editor.undoManager?.removeAllActions()
|
||||
editor.undoManager?.beginUndoGrouping()
|
||||
#expect(try shortcut("e"))
|
||||
editor.undoManager?.endUndoGrouping()
|
||||
#expect(editor.markdownString == "`fox 🦊`")
|
||||
#expect(try shortcut("u", shift: true))
|
||||
#expect(editor.documentContents.attribute(.underlineStyle, at: 0, effectiveRange: nil) as? Int == NSUnderlineStyle.single.rawValue)
|
||||
#expect(editor.string == "fox 🦊")
|
||||
#expect(editor.selectedRange() == selection)
|
||||
#expect(controller.save())
|
||||
let saved = try #require(NotesStore(directory: directory).notes.first)
|
||||
#expect(saved.contents.string == "fox 🦊")
|
||||
#expect(saved.contents.attribute(.underlineStyle, at: 0, effectiveRange: nil) as? Int == NSUnderlineStyle.single.rawValue)
|
||||
#expect(try NoteMarkdownExport(contents: editor.documentContents).clipboardText == "<u>fox 🦊</u>")
|
||||
editor.undoManager?.undo()
|
||||
#expect(editor.markdownString == "fox 🦊")
|
||||
#expect(editor.documentContents.attribute(.underlineStyle, at: 0, effectiveRange: nil) == nil)
|
||||
#expect(try shortcut("l", shift: true))
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) as? String == "bullet")
|
||||
#expect(try shortcut("l", shift: true))
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) == nil)
|
||||
#expect(!(try shortcut("b", option: true)))
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool != true)
|
||||
editor.undoManager?.removeAllActions()
|
||||
#expect(try shortcut("e"))
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.inlineCode, at: 0, effectiveRange: nil) as? Bool == true)
|
||||
editor.undoManager?.undo()
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.inlineCode, at: 0, effectiveRange: nil) as? Bool != true)
|
||||
#expect(try shortcut("k"))
|
||||
let search = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first { $0.accessibilityLabel() == "Search notes actions" })
|
||||
#expect(window.firstResponder !== editor)
|
||||
_ = try shortcut("b")
|
||||
#expect(editor.markdownString == "fox 🦊")
|
||||
#expect(editor.string == "fox 🦊")
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool != true)
|
||||
#expect(search.stringValue.isEmpty)
|
||||
#expect(controller.handle(#selector(NSResponder.cancelOperation(_:))))
|
||||
#expect(controller.save())
|
||||
#expect(try NotesStore(directory: directory).notes.first?.contents.string == "fox 🦊")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func inlineCodeContainerIsLimitedToItsMarkAndDisappearsWhenToggledOff() throws {
|
||||
_ = NSApplication.shared
|
||||
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 200, height: 300))
|
||||
let source = "Before `some *literal* code 🦊` after\n```text\n`not inline` <u>literal</u>\n```"
|
||||
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: source)))
|
||||
let storage = try #require(editor.textStorage)
|
||||
let body = (editor.string as NSString).range(of: "some *literal* code 🦊")
|
||||
for index in body.location..<NSMaxRange(body) {
|
||||
#expect(storage.attribute(.foregroundColor, at: index, effectiveRange: nil) as? NSColor == ThemePalette.accent)
|
||||
#expect(storage.attribute(.backgroundColor, at: index, effectiveRange: nil) as? NSColor == ThemePalette.selectionFill)
|
||||
}
|
||||
for token in ["Before", "after", "not inline"] {
|
||||
let index = (editor.string as NSString).range(of: token).location
|
||||
#expect(storage.attribute(NoteAttributes.inlineCode, at: index, effectiveRange: nil) as? Bool != true)
|
||||
}
|
||||
let literalUnderline = (editor.string as NSString).range(of: "<u>literal</u>")
|
||||
#expect(storage.attribute(.underlineStyle, at: literalUnderline.location, effectiveRange: nil) == nil)
|
||||
let before = editor.string
|
||||
editor.setSelectedRange(body)
|
||||
editor.format(4)
|
||||
#expect(editor.string == before)
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.inlineCode, at: body.location, effectiveRange: nil) as? Bool != true)
|
||||
#expect(storage.attribute(.backgroundColor, at: body.location, effectiveRange: nil) == nil)
|
||||
#expect(storage.attribute(.foregroundColor, at: body.location, effectiveRange: nil) as? NSColor == ThemePalette.text)
|
||||
}
|
||||
|
||||
@@ -22,30 +22,30 @@ func notesRoundTripIndependentMarkdownUnicodeAndImageContents() throws {
|
||||
let imageFile = FileWrapper(regularFileWithContents: png)
|
||||
imageFile.preferredFilename = "pixel.png"
|
||||
let attachment = NSTextAttachment(fileWrapper: imageFile)
|
||||
let first = NSMutableAttributedString(string: "\n # 日本語 🦊 \n**bold** and `code`\n")
|
||||
let first = NSMutableAttributedString(attributedString: NoteDocument.importMarkdown(NSAttributedString(string: "\n # 日本語 🦊 \n**bold** and `code`\n")))
|
||||
first.append(NSAttributedString(attachment: attachment))
|
||||
let expectedFirstText = first.string
|
||||
try store.update(id: firstID, contents: first)
|
||||
first.mutableString.setString("Mutated caller buffer")
|
||||
#expect(store.notes.first { $0.id == firstID }?.contents.string == expectedFirstText)
|
||||
#expect(store.notes.first { $0.id == firstID }?.title == "# 日本語 🦊")
|
||||
#expect(store.notes.first { $0.id == firstID }?.title == "日本語 🦊")
|
||||
|
||||
try store.update(id: secondID, contents: NSAttributedString(string: "Second note"))
|
||||
try store.update(id: secondID, contents: NSAttributedString(string: "## Edited\n- café\n- Ελληνικά"))
|
||||
try store.update(id: secondID, contents: NoteDocument.importMarkdown(NSAttributedString(string: "## Edited\n- café\n- Ελληνικά")))
|
||||
#expect(store.notes.first { $0.id == firstID }?.contents.string == expectedFirstText)
|
||||
|
||||
let reloaded = try NotesStore(directory: directory)
|
||||
#expect(Set(reloaded.notes.map(\.id)) == Set([firstID, secondID]))
|
||||
let restoredFirst = try #require(reloaded.notes.first { $0.id == firstID })
|
||||
#expect(restoredFirst.contents.string == expectedFirstText)
|
||||
#expect(restoredFirst.title == "# 日本語 🦊")
|
||||
#expect(restoredFirst.title == "日本語 🦊")
|
||||
let restoredAttachment = try #require(restoredFirst.contents.attribute(
|
||||
.attachment, at: restoredFirst.contents.length - 1, effectiveRange: nil
|
||||
) as? NSTextAttachment)
|
||||
let restoredPNG = try #require(restoredAttachment.fileWrapper?.regularFileContents)
|
||||
#expect(restoredPNG == png)
|
||||
#expect(NSImage(data: restoredPNG) != nil)
|
||||
#expect(reloaded.notes.first { $0.id == secondID }?.contents.string == "## Edited\n- café\n- Ελληνικά")
|
||||
#expect(reloaded.notes.first { $0.id == secondID }?.contents.string == "Edited\ncafé\nΕλληνικά")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
@@ -131,3 +131,85 @@ func deletingNotesPersistsWithoutChangingOtherNotesAndPreservesMemoryOnFailure()
|
||||
#expect(store.notes.isEmpty)
|
||||
#expect(try NotesStore(directory: directory).notes.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func legacyNotesConvertWithoutWritingAndBackUpOriginalPackageOnlyOnFirstSave() throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let id = UUID()
|
||||
let url = directory.appendingPathComponent(id.uuidString + ".rtfd")
|
||||
let legacy = NSMutableAttributedString(string: "# Heading\n- [ ] task\n**bold** and `code`\n")
|
||||
let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 1, pixelsHigh: 1, bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0))
|
||||
bitmap.setColor(.blue, atX: 0, y: 0)
|
||||
let png = try #require(bitmap.representation(using: .png, properties: [:]))
|
||||
let image = FileWrapper(regularFileWithContents: png)
|
||||
image.preferredFilename = "legacy.png"
|
||||
legacy.append(NSAttributedString(attachment: NSTextAttachment(fileWrapper: image)))
|
||||
let wrapper = try legacy.fileWrapper(from: NSRange(location: 0, length: legacy.length), documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd])
|
||||
try wrapper.write(to: url, options: .atomic, originalContentsURL: nil)
|
||||
let originalRTF = try Data(contentsOf: url.appendingPathComponent("TXT.rtf"))
|
||||
let store = try NotesStore(directory: directory)
|
||||
let note = try #require(store.notes.first)
|
||||
#expect(note.contents.string == "Heading\ntask\nbold and code\n\u{fffc}")
|
||||
#expect(note.title == "Heading")
|
||||
#expect(try Data(contentsOf: url.appendingPathComponent("TXT.rtf")) == originalRTF)
|
||||
#expect(!FileManager.default.fileExists(atPath: url.appendingPathComponent("ConjureNote.json").path))
|
||||
#expect(!FileManager.default.fileExists(atPath: directory.appendingPathComponent("LegacyBackups").path))
|
||||
|
||||
try store.update(id: id, contents: note.contents)
|
||||
let backup = directory.appendingPathComponent("LegacyBackups").appendingPathComponent(id.uuidString + ".rtfd")
|
||||
#expect(try Data(contentsOf: backup.appendingPathComponent("TXT.rtf")) == originalRTF)
|
||||
#expect(try Data(contentsOf: backup.appendingPathComponent("legacy.png")) == png)
|
||||
#expect(!FileManager.default.fileExists(atPath: backup.appendingPathComponent("ConjureNote.json").path))
|
||||
#expect(FileManager.default.fileExists(atPath: url.appendingPathComponent("ConjureNote.json").path))
|
||||
let reloaded = try NotesStore(directory: directory)
|
||||
#expect(reloaded.notes.first?.contents.string == note.contents.string)
|
||||
let restored = try #require(reloaded.notes.first?.contents)
|
||||
let attachment = try #require(restored.attribute(.attachment, at: restored.length - 1, effectiveRange: nil) as? NSTextAttachment)
|
||||
#expect(attachment.fileWrapper?.regularFileContents == png)
|
||||
try reloaded.update(id: id, contents: NSAttributedString(string: "Changed"))
|
||||
#expect(try Data(contentsOf: backup.appendingPathComponent("TXT.rtf")) == originalRTF)
|
||||
#expect(try FileManager.default.contentsOfDirectory(atPath: directory.appendingPathComponent("LegacyBackups").path) == [id.uuidString + ".rtfd"])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func failedLegacyBackupBlocksMigrationAndPreservesMemoryAndOriginalSource() throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let id = UUID()
|
||||
let url = directory.appendingPathComponent(id.uuidString + ".rtfd")
|
||||
let source = NSAttributedString(string: "## Original")
|
||||
let wrapper = try source.fileWrapper(from: NSRange(location: 0, length: source.length), documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd])
|
||||
try wrapper.write(to: url, options: .atomic, originalContentsURL: nil)
|
||||
let original = try Data(contentsOf: url.appendingPathComponent("TXT.rtf"))
|
||||
let store = try NotesStore(directory: directory)
|
||||
let blocker = directory.appendingPathComponent("LegacyBackups")
|
||||
try Data("not a directory".utf8).write(to: blocker)
|
||||
#expect(throws: (any Error).self) { try store.update(id: id, contents: NSAttributedString(string: "Unsaved")) }
|
||||
#expect(store.notes.first?.contents.string == "Original")
|
||||
#expect(try Data(contentsOf: url.appendingPathComponent("TXT.rtf")) == original)
|
||||
#expect(!FileManager.default.fileExists(atPath: url.appendingPathComponent("ConjureNote.json").path))
|
||||
try FileManager.default.removeItem(at: blocker)
|
||||
try store.update(id: id, contents: NSAttributedString(string: "Saved"))
|
||||
#expect(try NotesStore(directory: directory).notes.first?.contents.string == "Saved")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func corruptStructuredMetadataIsNeverReinterpretedAsLegacyMarkdown() throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let store = try NotesStore(directory: directory)
|
||||
let id = try store.create()
|
||||
try store.update(id: id, contents: NSAttributedString(string: "# literal"))
|
||||
let package = directory.appendingPathComponent(id.uuidString + ".rtfd")
|
||||
let metadata = package.appendingPathComponent("ConjureNote.json")
|
||||
let corrupt = Data("invalid json".utf8)
|
||||
try corrupt.write(to: metadata)
|
||||
let originalRTF = try Data(contentsOf: package.appendingPathComponent("TXT.rtf"))
|
||||
#expect(throws: NoteDocument.DocumentError.self) { try NotesStore(directory: directory) }
|
||||
#expect(try Data(contentsOf: metadata) == corrupt)
|
||||
#expect(try Data(contentsOf: package.appendingPathComponent("TXT.rtf")) == originalRTF)
|
||||
#expect(!FileManager.default.fileExists(atPath: directory.appendingPathComponent("LegacyBackups").path))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
import AppKit
|
||||
import ConjureKit
|
||||
import Testing
|
||||
@testable import Conjure
|
||||
|
||||
@MainActor
|
||||
private func vimKey(_ text: String, editor: NotesTextView, flags: NSEvent.ModifierFlags = [], code: UInt16 = 0) throws {
|
||||
let event = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: flags,
|
||||
timestamp: 1, windowNumber: editor.window?.windowNumber ?? 0, context: nil,
|
||||
characters: text, charactersIgnoringModifiers: text, isARepeat: false, keyCode: code))
|
||||
editor.keyDown(with: event)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func vimKeys(_ keys: String, editor: NotesTextView) throws {
|
||||
for character in keys { try vimKey(String(character), editor: editor) }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func vimEventTurn() {
|
||||
// Let AppKit close its ordinary event-scoped undo groups between key events.
|
||||
CFRunLoopRunInMode(.defaultMode, 0.001, false)
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
("one two three", "dw", "two three"),
|
||||
("one,two three", "dw", ",two three"),
|
||||
("one,two three", "dW", "three"),
|
||||
("one two three four five", "2d2w", "five"),
|
||||
("one two", "cw", " two"),
|
||||
("a two", "cw", " two"),
|
||||
("one two", "ce", " two"),
|
||||
("one two", "d$", ""),
|
||||
("one two", "ldh", "ne two"),
|
||||
("one\ntwo\nthree", "dd", "two\nthree"),
|
||||
("one\ntwo\nthree", "jdd", "one\nthree"),
|
||||
("one\ntwo\nthree", "Gdd", "one\ntwo"),
|
||||
("one\ntwo\nthree", "dj", "three"),
|
||||
("one\ntwo\nthree", "dG", ""),
|
||||
("one\ntwo\nthree", "Gdgg", ""),
|
||||
("one two", "vld", "e two"),
|
||||
("one two", "llvhd", "o two"),
|
||||
("one\ntwo\nthree", "Vjd", "three"),
|
||||
("## Title\nBody", "dd", "Body"),
|
||||
("## Title\nBody", "Vd", "Body"),
|
||||
("## Title\nBody", "Vjd", ""),
|
||||
("a two three", "c2w", " three"),
|
||||
("one\ntwo", "dw", "\ntwo"),
|
||||
("one", "$dl", "on"),
|
||||
("**bold** next", "dw", "next"),
|
||||
("<u>word</u> next", "cw", " next"),
|
||||
("[label](https://example.com) next", "de", " next"),
|
||||
("- [ ] task\nnext", "dd", "next"),
|
||||
("🧑🏽💻é!", "x", "é!"),
|
||||
("one two", "dh", "one two"),
|
||||
("one two", "dq", "one two")
|
||||
])
|
||||
@MainActor func vimOperatorsRespectVisibleTextAndBoundaries(_ scenario: (String, String, String)) throws {
|
||||
_ = NSApplication.shared
|
||||
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
|
||||
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: scenario.0)))
|
||||
editor.vimEnabled = true
|
||||
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
||||
try vimKeys(scenario.1, editor: editor)
|
||||
#expect(editor.documentContents.string == scenario.2)
|
||||
#expect(editor.vimMode == (scenario.1.contains("c") ? .insert : .normal))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func vimMotionsCountCharactersWordsAndLogicalLines() throws {
|
||||
_ = NSApplication.shared
|
||||
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 90, height: 400))
|
||||
let contents = NSMutableAttributedString(string: "🧑🏽💻é abc,def\nx\n long final line")
|
||||
contents.addAttributes([NoteAttributes.block: "heading", NoteAttributes.headingLevel: 2],
|
||||
range: (contents.string as NSString).paragraphRange(for: NSRange(location: 0, length: 0)))
|
||||
editor.setDocumentContents(contents)
|
||||
editor.vimEnabled = true
|
||||
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
||||
try vimKeys("l", editor: editor)
|
||||
#expect(editor.selectedRange().location == 7) // One composed emoji, not seven UTF-16 units.
|
||||
try vimKeys("l", editor: editor)
|
||||
#expect(editor.selectedRange().location == 9) // Combining accent remains a single character.
|
||||
try vimKeys("w", editor: editor)
|
||||
#expect(editor.selectedRange().location == 10)
|
||||
try vimKeys("e", editor: editor)
|
||||
#expect(editor.selectedRange().location == 12)
|
||||
try vimKeys("w", editor: editor)
|
||||
#expect(editor.selectedRange().location == 13)
|
||||
try vimKeys("b", editor: editor)
|
||||
#expect(editor.selectedRange().location == 10)
|
||||
try vimKeys("0", editor: editor)
|
||||
#expect(editor.selectedRange().location == 0)
|
||||
try vimKeys("4ljj", editor: editor)
|
||||
let finalRange = try #require(editor.string.range(of: " long final line"))
|
||||
let final = NSRange(finalRange, in: editor.string).location
|
||||
#expect(editor.selectedRange().location == final + 4) // Column survives a shorter line.
|
||||
try vimKeys("^", editor: editor)
|
||||
#expect(editor.selectedRange().location == final + 2)
|
||||
try vimKeys("2gg", editor: editor)
|
||||
#expect(editor.selectedRange().location == (editor.string as NSString).range(of: "\nx\n").location + 1)
|
||||
try vimKeys("G$", editor: editor)
|
||||
#expect(editor.selectedRange().location == editor.string.utf16.count - 1)
|
||||
try vimKeys("gg0", editor: editor)
|
||||
#expect(editor.selectedRange().location == 0)
|
||||
editor.setDocumentContents(NSAttributedString(string: "one two"))
|
||||
editor.setSelectedRange(NSRange(location: 0, length: 3))
|
||||
try vimKeys("d", editor: editor)
|
||||
#expect(editor.documentContents.string == " two") // Native mouse selections also accept Visual operators.
|
||||
try vimKey("ø", editor: editor, flags: .option)
|
||||
#expect(editor.documentContents.string == " two") // Modified text cannot leak into Normal mode.
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func vimYankPasteAndUndoPreserveRichAttachments() throws {
|
||||
_ = NSApplication.shared
|
||||
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
|
||||
let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false)
|
||||
window.contentView = editor
|
||||
window.makeFirstResponder(editor)
|
||||
defer { window.orderOut(nil) }
|
||||
editor.isRichText = true
|
||||
editor.allowsUndo = true
|
||||
let attachment = NSTextAttachment()
|
||||
let source = NSMutableAttributedString(string: "Image ")
|
||||
source.append(NSAttributedString(attachment: attachment))
|
||||
source.append(NSAttributedString(string: "\nTail"))
|
||||
source.addAttributes([NoteAttributes.block: "heading", NoteAttributes.headingLevel: 2], range: NSRange(location: 0, length: 8))
|
||||
source.addAttributes([NoteAttributes.bold: true, .underlineStyle: NSUnderlineStyle.single.rawValue], range: NSRange(location: 0, length: 5))
|
||||
editor.setDocumentContents(source)
|
||||
editor.vimEnabled = true
|
||||
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
||||
try vimKeys("yyp", editor: editor)
|
||||
#expect(editor.documentContents.string == "Image \u{fffc}\nImage \u{fffc}\nTail")
|
||||
let pasted = editor.documentContents
|
||||
#expect(pasted.attribute(.attachment, at: 14, effectiveRange: nil) as? NSTextAttachment === attachment)
|
||||
#expect(pasted.attribute(NoteAttributes.headingLevel, at: 8, effectiveRange: nil) as? Int == 2)
|
||||
#expect(pasted.attribute(NoteAttributes.bold, at: 8, effectiveRange: nil) as? Bool == true)
|
||||
#expect(pasted.attribute(.underlineStyle, at: 8, effectiveRange: nil) as? Int == NSUnderlineStyle.single.rawValue)
|
||||
try vimKeys("u", editor: editor)
|
||||
#expect(editor.documentContents.string == source.string)
|
||||
try vimKey("r", editor: editor, flags: .control)
|
||||
#expect(editor.documentContents.string == pasted.string)
|
||||
editor.setDocumentContents(NSAttributedString(string: "one two"))
|
||||
editor.undoManager?.removeAllActions()
|
||||
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
||||
try vimKeys("ywP", editor: editor)
|
||||
#expect(editor.documentContents.string == "one one two")
|
||||
try vimKeys("u", editor: editor)
|
||||
#expect(editor.documentContents.string == "one two")
|
||||
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
||||
try vimKeys("cw", editor: editor)
|
||||
try vimKeys("new", editor: editor)
|
||||
try vimKey("\u{1b}", editor: editor, code: 53)
|
||||
#expect(editor.documentContents.string == "new two")
|
||||
#expect(editor.vimMode == .normal)
|
||||
#expect(editor.selectedRange().location == 2)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func vimInsertEntryAndLinewisePasteKeepParagraphsSeparate() throws {
|
||||
_ = NSApplication.shared
|
||||
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
|
||||
for (before, keys, after) in [
|
||||
("one\ntwo", "cc", "\ntwo"),
|
||||
("one\ntwo", "Gcc", "one\n"),
|
||||
("one\ntwo", "yyGp", "one\ntwo\none"),
|
||||
("one\ntwo", "yyP", "one\none\ntwo"),
|
||||
("one\n", "yyp", "one\none\n"),
|
||||
("one", "o", "one\n"),
|
||||
("one", "O", "\none")
|
||||
] {
|
||||
editor.setDocumentContents(NSAttributedString(string: before))
|
||||
editor.vimEnabled = true
|
||||
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
||||
try vimKeys(keys, editor: editor)
|
||||
#expect(editor.documentContents.string == after)
|
||||
if keys == "O" { #expect(editor.selectedRange().location == 0) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func vimDeletingFinalBoldCharacterOnlyDeletesThatCharacter() throws {
|
||||
_ = NSApplication.shared
|
||||
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
|
||||
let contents = NSMutableAttributedString(string: "ab tail")
|
||||
contents.addAttribute(NoteAttributes.bold, value: true, range: NSRange(location: 0, length: 2))
|
||||
editor.setDocumentContents(contents)
|
||||
editor.vimEnabled = true
|
||||
editor.setSelectedRange(NSRange(location: 1, length: 0))
|
||||
try vimKeys("x", editor: editor)
|
||||
#expect(editor.documentContents.string == "a tail")
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool == true)
|
||||
#expect(editor.vimState.register?.string == "b")
|
||||
#expect(editor.vimState.register?.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool == true)
|
||||
try vimKeys("P", editor: editor)
|
||||
#expect(editor.documentContents.string == "ab tail")
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.bold, at: 1, effectiveRange: nil) as? Bool == true)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func vimCharacterRegisterPreservesNativeImageAttributes() throws {
|
||||
_ = NSApplication.shared
|
||||
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
|
||||
let attachment = NSTextAttachment()
|
||||
let imageBytes = Data([0, 1, 2, 3])
|
||||
attachment.fileWrapper = FileWrapper(regularFileWithContents: imageBytes)
|
||||
let contents = NSMutableAttributedString(attributedString: NSAttributedString(attachment: attachment))
|
||||
contents.append(NSAttributedString(string: " tail"))
|
||||
editor.setDocumentContents(contents)
|
||||
editor.vimEnabled = true
|
||||
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
||||
try vimKeys("ylp", editor: editor)
|
||||
#expect(editor.documentContents.string == "\u{fffc}\u{fffc} tail")
|
||||
let pasted = try #require(editor.documentContents.attribute(.attachment, at: 1, effectiveRange: nil) as? NSTextAttachment)
|
||||
#expect(pasted.fileWrapper?.regularFileContents == imageBytes)
|
||||
}
|
||||
|
||||
@Test(arguments: ["escape", "focus", "toggle", "reset"])
|
||||
@MainActor func vimChangeAndTypingUndoTogetherAcrossEvents(_ ending: String) throws {
|
||||
_ = NSApplication.shared
|
||||
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
|
||||
let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false)
|
||||
window.contentView = editor
|
||||
window.makeFirstResponder(editor)
|
||||
defer { window.orderOut(nil) }
|
||||
editor.allowsUndo = true
|
||||
let original = NSMutableAttributedString(string: "one two")
|
||||
original.addAttribute(NoteAttributes.bold, value: true, range: NSRange(location: 0, length: 3))
|
||||
editor.setDocumentContents(original)
|
||||
editor.vimEnabled = true
|
||||
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
||||
let undo = try #require(editor.undoManager)
|
||||
undo.removeAllActions()
|
||||
try vimKeys("cw", editor: editor)
|
||||
vimEventTurn()
|
||||
for character in "new" {
|
||||
try vimKey(String(character), editor: editor)
|
||||
vimEventTurn()
|
||||
}
|
||||
#expect(editor.documentContents.string == "new two")
|
||||
switch ending {
|
||||
case "escape": try vimKey("\u{1b}", editor: editor, code: 53)
|
||||
case "focus": #expect(window.makeFirstResponder(nil))
|
||||
case "toggle": editor.vimEnabled = false
|
||||
default: editor.resetVim()
|
||||
}
|
||||
vimEventTurn()
|
||||
#expect(undo.canUndo)
|
||||
undo.undo()
|
||||
#expect(editor.documentContents.string == original.string)
|
||||
#expect(editor.documentContents.attribute(NoteAttributes.bold, at: 0, effectiveRange: nil) as? Bool == true)
|
||||
#expect(!undo.canUndo)
|
||||
undo.redo()
|
||||
#expect(editor.documentContents.string == "new two")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func vimSeparateInsertionSessionsHaveSeparateUndoOperations() throws {
|
||||
_ = NSApplication.shared
|
||||
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
|
||||
let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false)
|
||||
window.contentView = editor
|
||||
window.makeFirstResponder(editor)
|
||||
defer { window.orderOut(nil) }
|
||||
editor.allowsUndo = true
|
||||
editor.setDocumentContents(NSAttributedString(string: "tail"))
|
||||
editor.vimEnabled = true
|
||||
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
||||
let undo = try #require(editor.undoManager)
|
||||
undo.removeAllActions()
|
||||
try vimKeys("ione ", editor: editor)
|
||||
vimEventTurn()
|
||||
#expect(editor.documentContents.string == "one tail")
|
||||
#expect(editor.vimMode == .insert)
|
||||
try vimKey("\u{1b}", editor: editor, code: 53)
|
||||
try vimKeys("0itwo ", editor: editor)
|
||||
vimEventTurn()
|
||||
try vimKey("\u{1b}", editor: editor, code: 53)
|
||||
#expect(editor.documentContents.string == "two one tail")
|
||||
try vimKeys("u", editor: editor)
|
||||
#expect(editor.documentContents.string == "one tail")
|
||||
try vimKeys("u", editor: editor)
|
||||
#expect(editor.documentContents.string == "tail")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func vimPreferenceEscapePickersAndNativeShortcutsCoexist() throws {
|
||||
_ = NSApplication.shared
|
||||
let suite = "NotesVim.\(UUID().uuidString)"
|
||||
let defaults = try #require(UserDefaults(suiteName: suite))
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite)
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
try? FileManager.default.removeItem(at: directory)
|
||||
}
|
||||
let launcher = LauncherWindowController(
|
||||
history: LaunchHistory(defaults: defaults, now: { Date(timeIntervalSinceReferenceDate: 12_000) }),
|
||||
defaults: defaults, notesDirectory: directory)
|
||||
launcher.present(command: .notes)
|
||||
defer { launcher.dismiss(restoringFocus: false) }
|
||||
let window = try #require(launcher.window)
|
||||
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
|
||||
let content = try #require(window.contentView)
|
||||
let editor = try #require(descendants(content).compactMap { $0 as? NotesTextView }.first)
|
||||
let toggle = try #require(descendants(content).compactMap { $0 as? NSButton }.first { $0.accessibilityLabel() == "Toggle Vim editing" })
|
||||
#expect(!editor.vimEnabled)
|
||||
try vimKeys("hello", editor: editor)
|
||||
#expect(editor.documentContents.string == "hello")
|
||||
toggle.performClick(nil)
|
||||
#expect(defaults.bool(forKey: "notesVimEnabled"))
|
||||
#expect(editor.vimMode == .normal)
|
||||
try vimKeys("i!", editor: editor)
|
||||
let escape = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [],
|
||||
timestamp: 1, windowNumber: window.windowNumber, context: nil, characters: "\u{1b}",
|
||||
charactersIgnoringModifiers: "\u{1b}", isARepeat: false, keyCode: 53))
|
||||
window.sendEvent(escape)
|
||||
#expect(editor.vimMode == .normal)
|
||||
#expect(window.isVisible)
|
||||
let browse = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: .command,
|
||||
timestamp: 1, windowNumber: window.windowNumber, context: nil, characters: "p",
|
||||
charactersIgnoringModifiers: "p", isARepeat: false, keyCode: 35))
|
||||
try vimKeys("d", editor: editor)
|
||||
#expect(window.performKeyEquivalent(with: browse))
|
||||
window.sendEvent(escape)
|
||||
#expect(window.firstResponder === editor)
|
||||
#expect(window.isVisible)
|
||||
let beforeMotion = editor.documentContents.string
|
||||
try vimKeys("w", editor: editor)
|
||||
#expect(editor.documentContents.string == beforeMotion)
|
||||
try vimKeys("d", editor: editor)
|
||||
window.sendEvent(escape)
|
||||
#expect(!window.isVisible)
|
||||
launcher.present(command: .notes)
|
||||
let reopenedContent = try #require(window.contentView)
|
||||
let reopened = try #require(descendants(reopenedContent).compactMap { $0 as? NotesTextView }.first)
|
||||
#expect(reopened.vimEnabled)
|
||||
#expect(reopened.vimMode == .normal)
|
||||
let beforeReopenedMotion = reopened.documentContents.string
|
||||
try vimKeys("w", editor: reopened)
|
||||
#expect(reopened.documentContents.string == beforeReopenedMotion)
|
||||
let newToggle = try #require(descendants(reopenedContent).compactMap { $0 as? NSButton }.first { $0.accessibilityLabel() == "Toggle Vim editing" })
|
||||
newToggle.performClick(nil)
|
||||
#expect(!defaults.bool(forKey: "notesVimEnabled"))
|
||||
window.sendEvent(escape)
|
||||
#expect(!window.isVisible)
|
||||
}
|
||||
@@ -1,41 +1,44 @@
|
||||
import AppKit
|
||||
import ConjureKit
|
||||
import Testing
|
||||
@testable import Conjure
|
||||
|
||||
@Test @MainActor func numberedMarkersShareTheBodyTextBaseline() throws {
|
||||
_ = NSApplication.shared
|
||||
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 400, height: 200))
|
||||
for source in ["1. 1", "123) 123", " 12. 12"] {
|
||||
editor.setMarkdownContents(NSAttributedString(string: source))
|
||||
MarkdownHighlighting.apply(to: editor)
|
||||
editor.refreshTasks()
|
||||
editor.drawsBackground = false
|
||||
let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false)
|
||||
window.contentView = editor
|
||||
defer { window.orderOut(nil) }
|
||||
for source in ["1. 1", "123. 123", " 12. 12"] {
|
||||
editor.setDocumentContents(NoteDocument.importMarkdown(NSAttributedString(string: source)))
|
||||
let layout = try #require(editor.layoutManager)
|
||||
let container = try #require(editor.textContainer)
|
||||
layout.ensureLayout(for: container)
|
||||
let text = editor.string as NSString
|
||||
let marker = text.range(of: "\u{fffc}")
|
||||
let body = NSRange(location: NSMaxRange(marker) + 1, length: text.length - NSMaxRange(marker) - 1)
|
||||
var bottoms: [Int] = []
|
||||
for range in [marker, body] {
|
||||
let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 400, pixelsHigh: 80,
|
||||
let glyph = layout.glyphIndexForCharacter(at: 0)
|
||||
let fragment = layout.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil)
|
||||
let bodyX = editor.textContainerOrigin.x + fragment.minX + layout.location(forGlyphAt: glyph).x
|
||||
let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 400, pixelsHigh: 200,
|
||||
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
|
||||
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0))
|
||||
let bitmapContext = try #require(NSGraphicsContext(bitmapImageRep: bitmap))
|
||||
bitmapContext.cgContext.translateBy(x: 0, y: 80)
|
||||
bitmapContext.cgContext.translateBy(x: 0, y: 200)
|
||||
bitmapContext.cgContext.scaleBy(x: 1, y: -1)
|
||||
let context = NSGraphicsContext(cgContext: bitmapContext.cgContext, flipped: true)
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
NSGraphicsContext.current = context
|
||||
context.cgContext.clear(CGRect(x: 0, y: 0, width: 400, height: 80))
|
||||
layout.drawGlyphs(forGlyphRange: layout.glyphRange(forCharacterRange: range, actualCharacterRange: nil), at: .zero)
|
||||
context.cgContext.clear(CGRect(x: 0, y: 0, width: 400, height: 200))
|
||||
editor.draw(editor.bounds)
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
var bottom = -1
|
||||
for y in 0..<80 {
|
||||
for x in 0..<400 where (bitmap.colorAt(x: x, y: y)?.alphaComponent ?? 0) > 0.2 { bottom = y }
|
||||
}
|
||||
try #require(bottom >= 0)
|
||||
bottoms.append(bottom)
|
||||
}
|
||||
#expect(abs(bottoms[0] - bottoms[1]) <= 1, "Marker/body ink baseline for \(source)")
|
||||
var markerBottom = -1, bodyBottom = -1
|
||||
for y in 0..<200 {
|
||||
for x in 0..<400 where (bitmap.colorAt(x: x, y: y)?.alphaComponent ?? 0) > 0.2 {
|
||||
if CGFloat(x) < bodyX - 1 { markerBottom = y }
|
||||
else { bodyBottom = y }
|
||||
}
|
||||
}
|
||||
try #require(markerBottom >= 0 && bodyBottom >= 0)
|
||||
#expect(abs(markerBottom - bodyBottom) <= 1, "Marker/body ink baseline for \(source)")
|
||||
#expect(!editor.string.contains("\u{fffc}"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ func uninstallFilteringPreservesDiscoveryStateAndCheckedFiles() throws {
|
||||
controller.applyDiscoveryResult(.failure(ScanFailure()))
|
||||
let failure = status.stringValue
|
||||
#expect(failure.contains(ScanFailure().localizedDescription))
|
||||
#expect(status.textColor == .systemRed)
|
||||
#expect(status.textColor == ThemePalette.error)
|
||||
filterBy("different")
|
||||
#expect(status.stringValue == failure)
|
||||
#expect(!remove.isEnabled)
|
||||
@@ -46,7 +46,7 @@ func uninstallFilteringPreservesDiscoveryStateAndCheckedFiles() throws {
|
||||
controller.applyDiscoveryResult(.success([candidate]))
|
||||
filterBy("")
|
||||
let counts = status.stringValue
|
||||
#expect(status.textColor == SunsetPalette.secondaryText)
|
||||
#expect(status.textColor == ThemePalette.secondaryText)
|
||||
#expect(remove.isEnabled)
|
||||
#expect(table.numberOfRows == 1)
|
||||
filterBy("no-match")
|
||||
@@ -72,7 +72,7 @@ func shortcutModifierGuidanceDoesNotAppearAsAnError() throws {
|
||||
$0.action == NSSelectorFromString("beginRecording")
|
||||
})
|
||||
let error = try #require(views.compactMap { $0 as? NSTextField }.first {
|
||||
$0.accessibilityLabel() == "Shortcut feedback"
|
||||
$0.accessibilityLabel() == "Settings feedback"
|
||||
})
|
||||
let status = try #require(views.compactMap { $0 as? NSTextField }.first {
|
||||
$0.accessibilityLabel() == "Global shortcut registration status"
|
||||
@@ -85,5 +85,5 @@ func shortcutModifierGuidanceDoesNotAppearAsAnError() throws {
|
||||
context: nil, characters: "", charactersIgnoringModifiers: "", isARepeat: false, keyCode: 55))
|
||||
recorder.flagsChanged(with: modifier)
|
||||
#expect(error.stringValue.isEmpty)
|
||||
#expect(status.textColor == SunsetPalette.secondaryText)
|
||||
#expect(status.textColor == ThemePalette.secondaryText)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import AppKit
|
||||
import ConjureKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Conjure
|
||||
@@ -146,11 +147,14 @@ func primaryActionCopiesCompletedTranslationAndResetsAfterEditing() async throws
|
||||
#expect(!dismissed)
|
||||
requests.pending[0].resume(returning: "こんにちは")
|
||||
try await waitForTranslation { button.isEnabled }
|
||||
#expect(tool.draft?.input == "Hello")
|
||||
tool.performPrimaryAction()
|
||||
#expect(pasteboard.string(forType: .string) == "こんにちは")
|
||||
#expect(dismissed)
|
||||
#expect(tool.draft == nil)
|
||||
dismissed = false
|
||||
tool.inputChanged()
|
||||
#expect(tool.draft?.input == "Hello")
|
||||
button.performClick(nil)
|
||||
try await waitForTranslation { requests.pending.count == 2 }
|
||||
#expect(!dismissed)
|
||||
@@ -269,7 +273,7 @@ func translationExecutionRecoversFromCredentialAndRequestFailures() async throws
|
||||
tool.translate() // No stored key.
|
||||
#expect(requests.inputs.isEmpty)
|
||||
#expect(button.isEnabled)
|
||||
#expect(status.textColor == .systemRed)
|
||||
#expect(status.textColor == ThemePalette.error)
|
||||
#expect(!openSettings.isHidden)
|
||||
field.stringValue = "typed-key"
|
||||
tool.translate() // Typing in Settings must not implicitly save on Translate.
|
||||
@@ -296,7 +300,7 @@ func translationExecutionRecoversFromCredentialAndRequestFailures() async throws
|
||||
#expect(requests.inputs[0].key == "typed-key")
|
||||
requests.pending[0].resume(throwing: CredentialFailure())
|
||||
try await waitForTranslation { button.isEnabled }
|
||||
#expect(status.textColor == .systemRed)
|
||||
#expect(status.textColor == ThemePalette.error)
|
||||
keys.denied = true
|
||||
forget.performClick(nil)
|
||||
#expect(keys.value == "typed-key")
|
||||
@@ -315,7 +319,7 @@ func translationExecutionRecoversFromCredentialAndRequestFailures() async throws
|
||||
try await waitForTranslation { requests.pending.count == 3 }
|
||||
requests.pending[2].resume(returning: "成功")
|
||||
try await waitForTranslation { output.string == "成功" }
|
||||
#expect(status.textColor != .systemRed)
|
||||
#expect(status.textColor != ThemePalette.error)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
@@ -435,4 +439,73 @@ func blankContextTranslatesWithoutSwappingAndProviderChangesRestoreOriginalText(
|
||||
requests.pending[2].resume(returning: "やあ")
|
||||
try await waitForTranslation { output.string == "やあ" }
|
||||
}
|
||||
|
||||
@Test(arguments: ["escape", "window-close", "deactivate", "switch"])
|
||||
@MainActor func uncommittedInputSurvivesLeavingTranslate(_ exit: String) throws {
|
||||
_ = NSApplication.shared
|
||||
let suite = "TranslateDraft.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defaults.set("openai", forKey: "translationProvider")
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let launcher = LauncherWindowController(
|
||||
history: LaunchHistory(defaults: defaults, now: { Date(timeIntervalSinceReferenceDate: 12_000) }),
|
||||
defaults: defaults)
|
||||
let window = try #require(launcher.window)
|
||||
defer { window.orderOut(nil) }
|
||||
let content = try #require(window.contentView)
|
||||
let search = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first)
|
||||
func tool() throws -> TranslationController {
|
||||
try #require(descendants(content).compactMap { $0 as? NSButton }
|
||||
.compactMap { $0.target as? TranslationController }.first)
|
||||
}
|
||||
func enter(_ text: String) {
|
||||
search.stringValue = text
|
||||
launcher.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: search))
|
||||
}
|
||||
func leave() {
|
||||
switch exit {
|
||||
case "escape":
|
||||
_ = launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.cancelOperation(_:)))
|
||||
case "window-close":
|
||||
window.performClose(nil)
|
||||
case "deactivate":
|
||||
launcher.windowDidResignKey(Notification(name: NSWindow.didResignKeyNotification, object: window))
|
||||
default:
|
||||
launcher.present(command: .emoji)
|
||||
}
|
||||
}
|
||||
launcher.present(command: .translate)
|
||||
enter(" An unfinished sentence 🌏\n")
|
||||
leave()
|
||||
launcher.present(command: .translate)
|
||||
#expect(search.stringValue == " An unfinished sentence 🌏\n")
|
||||
#expect(search.accessibilityLabel() == "English text to translate into Japanese")
|
||||
|
||||
try tool().performPrimaryAction() // Advance to context without sending a request.
|
||||
leave()
|
||||
launcher.present(command: .translate)
|
||||
#expect(search.stringValue.isEmpty)
|
||||
#expect(search.accessibilityLabel() == "Context or situation for the translation (optional)")
|
||||
enter("Said by a friend")
|
||||
leave()
|
||||
launcher.present(command: .translate)
|
||||
#expect(search.stringValue == "Said by a friend")
|
||||
#expect(search.accessibilityLabel() == "Context or situation for the translation (optional)")
|
||||
let preview = try #require(descendants(content).compactMap { $0 as? NSTextField }
|
||||
.first { $0.accessibilityLabel() == "Original text to translate" })
|
||||
#expect(preview.stringValue == "Text to translate: An unfinished sentence 🌏\n")
|
||||
try tool().goBack()
|
||||
#expect(search.stringValue == " An unfinished sentence 🌏\n")
|
||||
try tool().goBack() // Returning to Applications must also retain the draft.
|
||||
search.stringValue = "translate"
|
||||
launcher.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: search))
|
||||
_ = launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.insertNewline(_:)))
|
||||
#expect(search.stringValue == " An unfinished sentence 🌏\n")
|
||||
|
||||
enter("")
|
||||
leave()
|
||||
launcher.present(command: .translate)
|
||||
#expect(search.stringValue.isEmpty) // Clearing a draft must not resurrect older text.
|
||||
launcher.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user