diff --git a/.gitignore b/.gitignore
index 0f2fb28..00d8a13 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,9 +1,26 @@
-riddle/target/
-quill/build/
-quill/vendor/
-*.log
-
-# never commit a real API key
-riddle/oracle.env
-oracle.env
-riddle/dist/
+# Xcode
+build/
+DerivedData/
+*.xcuserstate
+*.xcuserdatad/
+xcuserdata/
+*.xcscmblueprint
+*.xccheckout
+
+# Swift Package Manager
+.swiftpm/
+.build/
+Package.resolved
+
+# macOS
+.DS_Store
+*.swp
+*~
+
+# Secrets β NEVER commit API keys
+*.env
+.env
+secrets.plist
+
+# Old experiment
+RiddlePlayground.swiftpm/
diff --git a/Info.plist b/Info.plist
new file mode 100644
index 0000000..4b2ce94
--- /dev/null
+++ b/Info.plist
@@ -0,0 +1,42 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ The Diary
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ Riddle
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+ ITSAppUsesNonExemptEncryption
+
+ NSApplePencilUsageDescription
+ The diary needs Apple Pencil input for handwriting.
+ UILaunchScreen
+
+ UIColorName
+
+
+ UIRequiresPersistentWiFi
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+
+
diff --git a/README.md b/README.md
index b5fd330..3cd6771 100644
--- a/README.md
+++ b/README.md
@@ -1,159 +1,79 @@
-# riddle β the diary of Tom Riddle, for the reMarkable Paper Pro
-
-Write on the page with your pen. After a pause, the diary **drinks your ink** β
-your words fade into the paper β the page thinks for a moment, and an answer
-writes itself back in a flowing hand, stroke by stroke, then fades away.
+# Riddle for iPad
-No screen glow, no keyboard, no chat UI. Just ink appearing on paper.
-
-_This is the diary from [the demo](https://x.com/MaximeRivest)._
-
-### πͺ New to this? Start here
-
-You need a **reMarkable Paper Pro** in developer mode with a launcher installed.
-If that sounds like a lot, it isn't β **[remagic](https://github.com/maximerivest/remagic)**
-walks you through turning on developer mode and sets up everything with one
-command. Come back here, drop riddle in, and start writing to Tom.
+An iPad port of [MaximeRivest/riddle](https://github.com/MaximeRivest/riddle) β Tom Riddle's enchanted diary, reimagined for Apple Pencil.
-Already have xovi + AppLoad? **[Download the latest release](https://github.com/MaximeRivest/riddle/releases/latest)** β a ready-to-drop bundle, no compiler needed β or [build from source](#building).
+Write with Apple Pencil β the diary drinks your ink β Tom replies in handwriting that appears stroke by stroke.
-### Install the prebuilt bundle
+## Screenshots
-1. Grab `riddle-appload-aarch64.zip` from the [latest release](https://github.com/MaximeRivest/riddle/releases/latest) and unzip it.
-2. Copy the folder to your tablet:
- `scp -O -r riddle root@10.11.99.1:/home/root/xovi/exthome/appload/`
-3. Add an API key: `cp oracle.env.example oracle.env` in that folder and put your `RIDDLE_OPENAI_KEY` in it (any OpenAI-compatible key). Or skip it to use [pi](#option-b--pi-the-power-path).
-4. In **AppLoad**: tap **Reload**, then **The Diary**. Write, and rest your pen.
+(Coming soon)
-> β οΈ **This modifies your device.** It runs as root, stops the vendor UI
-> (in takeover mode), and drives the e-ink engine directly. It has only been
-> tested on a **reMarkable Paper Pro** (ferrari, aarch64, OS 3.26β3.27). It may
-> not work on other models or OS versions, and you use it entirely at your own
-> risk. Not affiliated with reMarkable AS. Keep SSH access working before you
-> install anything β that is your escape hatch.
+## Requirements
-## How it works
+- iPad with Apple Pencil support
+- iOS 17.0+
+- Xcode 26.6+
+- An API key for any OpenAI-compatible, vision-capable LLM
-```
- pen (raw evdev, full 4096-level pressure, hardware event rate)
- β strokes
- βΌ
- riddle ββ idle 2.8s β commit page β PNG βββΊ oracle (resident LLM process,
- β streams reply sentence-by-sentence)
- βΌ strokes (Dancing Script β skeletonized to single-pixel pen paths)
- display backend
- βββ qtfb β windowed, inside xochitl (AppLoad app)
- βββ quill β full takeover: xochitl stopped, vendor e-ink engine
- driven directly for instant ink (lowest latency there is)
-```
+## Setup
-- **`riddle/`** β the app (Rust). Pen input, ink surface, handwriting
- synthesis (rasterize β Zhang-Suen thinning β stroke tracing β animated
- replay), the oracle process manager, and both display backends.
-- **`quill/`** β the takeover display host (C/C++). An
- [epfb-re](https://github.com/asivery)-style QImage-constructor interposition
- shim over the vendor `libqsgepaper.so` waveform engine, exposed as a small
- C ABI (`quill_init` / `quill_buffer` / `quill_swap`) that riddle links
- against with `--features takeover`. Includes `scribble`, a minimal
- pen-to-glass latency demo.
+1. Clone the repo
+2. Open `Riddle.xcodeproj` in Xcode (use [XcodeGen](https://github.com/yonaskolb/XcodeGen) to regenerate from `project.yml` if needed)
+3. Build and run on your iPad
+4. On first launch, the Settings screen will appear β enter your API configuration:
+ - **API Key**: Your API key
+ - **Base URL**: The API endpoint (defaults to OpenAI)
+ - **Model**: A vision-capable model name
-## Gestures
+## Supported APIs
-| Do this | And |
-|---------|-----|
-| Write, then rest the pen | The diary drinks your ink and Tom replies |
-| Flip the marker | Erase |
-| Draw a large **?** | Summon the built-in guide |
-| Tap five fingers at once | Leave the diary |
-| Power button | The page turns to *"The diary sleeps."*, then the tablet suspends; press again to wake exactly where you were |
+Any OpenAI-compatible `/chat/completions` endpoint that supports image input works:
-## The oracle (the "spirit" in the diary)
+| Provider | Base URL | Model |
+|----------|----------|-------|
+| OpenAI | `https://api.openai.com/v1` | `gpt-4o-mini` |
+| OpenRouter | `https://openrouter.ai/api/v1` | `openai/gpt-4o-mini` |
+| Volcano Ark | `https://ark.cn-beijing.volces.com/api/plan/v3` | `doubao-seed-2-0-pro` |
+| Groq | `https://api.groq.com/openai/v1` | `llama-3.2-90b-vision-preview` |
-The diary's replies come from a vision LLM that reads your handwriting from the
-committed page (sent as an inline PNG). There are **two backends**, chosen at
-startup β pick whichever you have:
+Presets are available in the Settings screen.
-### Option A β any OpenAI-compatible API (easiest, zero setup)
+## How It Works
-Set an API key and riddle talks straight to an OpenAI-compatible
-`/chat/completions` endpoint. Works with OpenAI, OpenRouter, Groq, a local
-server β anything that speaks the format. No extra software on the tablet.
+1. **Write** with Apple Pencil on the white page
+2. After 1.5s idle, the diary **drinks the ink** β pixels dissolve using a per-pixel hash
+3. The ink snapshot is sent as a PNG to your configured LLM **during** the dissolve (hides network latency)
+4. The model **reads your handwriting** from the image and replies as Tom Riddle
+5. The reply is rendered using **handwriting synthesis**: text is rasterized β Zhang-Suen skeletonized β stroke-traced β animated point by point
+6. After lingering, the reply **dissolves** the same way the ink did
-```sh
-export RIDDLE_OPENAI_KEY="sk-..." # required
-export RIDDLE_OPENAI_BASE="https://api.openai.com/v1" # optional (default)
-export RIDDLE_OPENAI_MODEL="gpt-4o-mini" # optional; must see images
-```
+## Architecture
-Any vision-capable model works. Example with OpenRouter:
+Direct port of the original project's approach:
-```sh
-export RIDDLE_OPENAI_KEY="$OPENROUTER_API_KEY"
-export RIDDLE_OPENAI_BASE="https://openrouter.ai/api/v1"
-export RIDDLE_OPENAI_MODEL="openai/gpt-4o-mini"
-```
+- **Surface.swift** β Pixel buffer (like reMarkable's framebuffer). Direct `putPx`/`brushLine`/`stamp` operations.
+- **Ink** β Stroke capture, PNG export, pixel dissolve (`dissolve_pass` with `px_hash`)
+- **Oracle.swift** β HTTP client for OpenAI-compatible `/chat/completions`
+- **HandwritingSynthesis.swift** β Zhang-Suen thinning + stroke tracing (port of `script.rs`)
+- **DiaryCanvasView.swift** β Apple Pencil input via UIKit touches, draws directly to Surface
+- **DiaryViewModel.swift** β State machine: Listening β Drinking β Thinking β Replying β Lingering β Fading
-Verify your setup before launching the diary:
+## Differences from Original
-```sh
-riddle --oracle-test path/to/handwriting.png # prints the streamed reply
-```
+| | Original (reMarkable) | iPad Port |
+|---|---|---|
+| Platform | reMarkable Paper Pro | iPad + Apple Pencil |
+| Language | Rust | Swift / SwiftUI |
+| Pen Input | evdev `/dev/input/eventN` | UIKit `UITouch` (`.pencil` type only) |
+| Display | Linux framebuffer | UIView `draw()` + Surface bitmap |
+| API Config | `oracle.env` file | In-app Settings screen |
+| LLM | OpenAI-compatible (env vars) | OpenAI-compatible (UserDefaults) |
-Measured ~0.9β1.1 s to first ink on-device. The HTTPS is built into riddle
-(pure-Rust, no extra libraries).
+## Credits
-### Option B β pi (the power path)
-
-If you already run [`pi`](https://github.com/badlogic/pi-mono), riddle will use
-a resident `pi --mode rpc` process kept warm (Node + your subscription auth
-loaded once), so each turn pays only model latency. Used automatically when
-`RIDDLE_OPENAI_KEY` is **not** set.
-
-Both stream the reply sentence-by-sentence, so the quill starts writing seconds
-before the model finishes. The persona prompt lives in `riddle/src/oracle.rs`.
-
-## Building
-
-Cross-compiled from x86_64. Two flavours:
-
-### Windowed (AppLoad/qtfb) β easiest
-
-Requires [xovi + AppLoad](https://github.com/asivery/rm-appload) on the device.
-
-```sh
-cd riddle
-cargo build --release --target aarch64-unknown-linux-gnu
-```
-
-Install to `/home/root/xovi/exthome/appload/riddle/` with
-`external.manifest.json`, `appload-launch.sh`, and the binary.
-
-### Takeover (instant ink) β the one from the demo
-
-Requires the reMarkable SDK toolchain (`~/rm-sdk-3.26`) because the linked
-vendor Qt libs need its glibc, **and** `libqsgepaper.so` pulled from *your own
-device* (it is proprietary and not distributed here):
-
-```sh
-cd quill && ./build.sh # pulls libqsgepaper.so from the device over ssh,
- # builds libquill.so + scribble
-cd ../riddle && ./build-takeover.sh
-```
-
-Deploy `libquill.so` to `/home/root/quill/` and `riddle-takeover` to
-`/home/root/riddle/riddle`, plus `scripts/riddle-takeover.sh`. Launching via
-AppLoad (`appload-launch.sh`) detaches into a transient systemd unit, stops
-xochitl, runs the diary, and **always restores xochitl on exit** β exit with
-the power button, a 5-finger tap, or SIGTERM. If anything wedges:
-`ssh root@10.11.99.1 'systemctl start xochitl'`.
-
-## Fonts
-
-The reply hand is [Dancing Script](https://github.com/googlefonts/DancingScript)
-(SIL OFL 1.1 β see `riddle/fonts/OFL.txt`).
+- Original project: [MaximeRivest/riddle](https://github.com/MaximeRivest/riddle)
+- Persona prompt, state machine, handwriting synthesis, and dissolve algorithm are direct ports from the original Rust source
## License
-MIT for everything in this repository (see `LICENSE`). The vendor libraries it
-interposes (`libqsgepaper.so`, Qt) are **not** included and must come from
-your own device/SDK.
+Same license as the original project.
diff --git a/Riddle.xcodeproj/project.pbxproj b/Riddle.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..40c6e61
--- /dev/null
+++ b/Riddle.xcodeproj/project.pbxproj
@@ -0,0 +1,395 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 77;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 03372E53AF401DD1F6835545 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C93CB17A24AA9EA644503CD /* SettingsView.swift */; };
+ 1B735769519C61772750393C /* DiaryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06A688BE97975EDB3C5BD73D /* DiaryViewModel.swift */; };
+ 2CFD359DD18622E3DDB1AAB0 /* DiaryState.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB78F5ED47C49144C94180DA /* DiaryState.swift */; };
+ 37997C296E68957B8CB32A23 /* Oracle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 667272C03C555EA311A74977 /* Oracle.swift */; };
+ 48A02021482644206AFCFEEC /* DancingScript.ttf in Resources */ = {isa = PBXBuildFile; fileRef = C79612353C701172CE713894 /* DancingScript.ttf */; };
+ 50AF55840250BE4A7E4BFE44 /* InkDissolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 484E3497BB4D2761404A7EE8 /* InkDissolver.swift */; };
+ 662EA5E4D0B1235E598A7B14 /* QuestionMarkDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = B548063AF360CEE3ABCC5812 /* QuestionMarkDetector.swift */; };
+ 7A67E02429B5CDE229B5D3F9 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E87C4C58A649ACEE8A30F48A /* ContentView.swift */; };
+ 8C0FD720BC11C72CC4ADB33E /* HandwritingSynthesis.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8E44D4524F95A59167EAC4F /* HandwritingSynthesis.swift */; };
+ 93B898693062ADF78655546B /* RiddleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 620EB558257BD59E96C7DC2C /* RiddleApp.swift */; };
+ 93EB870C9876F7056EB1E7C5 /* SentenceSplitter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90D6F46BEB7CAF9522CC3656 /* SentenceSplitter.swift */; };
+ A2C0C6EF788569A7EA03562D /* DiaryCanvasView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B71798CEE99810D4F04FDF3D /* DiaryCanvasView.swift */; };
+ C6A51E4F9AB8F24837E1B4F8 /* Surface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947E4707413841211FAED540 /* Surface.swift */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ 06A688BE97975EDB3C5BD73D /* DiaryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiaryViewModel.swift; sourceTree = ""; };
+ 484E3497BB4D2761404A7EE8 /* InkDissolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InkDissolver.swift; sourceTree = ""; };
+ 620EB558257BD59E96C7DC2C /* RiddleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RiddleApp.swift; sourceTree = ""; };
+ 667272C03C555EA311A74977 /* Oracle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Oracle.swift; sourceTree = ""; };
+ 69DB11E7B2262803703A0BB0 /* Riddle.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Riddle.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 8C93CB17A24AA9EA644503CD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; };
+ 90D6F46BEB7CAF9522CC3656 /* SentenceSplitter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentenceSplitter.swift; sourceTree = ""; };
+ 947E4707413841211FAED540 /* Surface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Surface.swift; sourceTree = ""; };
+ B548063AF360CEE3ABCC5812 /* QuestionMarkDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestionMarkDetector.swift; sourceTree = ""; };
+ B71798CEE99810D4F04FDF3D /* DiaryCanvasView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiaryCanvasView.swift; sourceTree = ""; };
+ C79612353C701172CE713894 /* DancingScript.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = DancingScript.ttf; sourceTree = ""; };
+ E87C4C58A649ACEE8A30F48A /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
+ E8E44D4524F95A59167EAC4F /* HandwritingSynthesis.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingSynthesis.swift; sourceTree = ""; };
+ EB78F5ED47C49144C94180DA /* DiaryState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiaryState.swift; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXGroup section */
+ 021D36DBDA3A088EBFF746E5 /* Models */ = {
+ isa = PBXGroup;
+ children = (
+ EB78F5ED47C49144C94180DA /* DiaryState.swift */,
+ );
+ path = Models;
+ sourceTree = "";
+ };
+ 02768FE00A3F5CEE6DD1C1A0 /* Riddle */ = {
+ isa = PBXGroup;
+ children = (
+ 620EB558257BD59E96C7DC2C /* RiddleApp.swift */,
+ 021D36DBDA3A088EBFF746E5 /* Models */,
+ B7194B37F12703D298B39682 /* Resources */,
+ 4537B5E72D9B03D0DD090313 /* Services */,
+ 873FD23C1D37DC6F16158AA1 /* Utils */,
+ 08717278CD9791E88D365CFD /* Views */,
+ );
+ path = Riddle;
+ sourceTree = "";
+ };
+ 08717278CD9791E88D365CFD /* Views */ = {
+ isa = PBXGroup;
+ children = (
+ E87C4C58A649ACEE8A30F48A /* ContentView.swift */,
+ B71798CEE99810D4F04FDF3D /* DiaryCanvasView.swift */,
+ 06A688BE97975EDB3C5BD73D /* DiaryViewModel.swift */,
+ 484E3497BB4D2761404A7EE8 /* InkDissolver.swift */,
+ 8C93CB17A24AA9EA644503CD /* SettingsView.swift */,
+ );
+ path = Views;
+ sourceTree = "";
+ };
+ 14F7111176344F5B6AC2AB0A /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 69DB11E7B2262803703A0BB0 /* Riddle.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 4537B5E72D9B03D0DD090313 /* Services */ = {
+ isa = PBXGroup;
+ children = (
+ E8E44D4524F95A59167EAC4F /* HandwritingSynthesis.swift */,
+ 667272C03C555EA311A74977 /* Oracle.swift */,
+ 947E4707413841211FAED540 /* Surface.swift */,
+ );
+ path = Services;
+ sourceTree = "";
+ };
+ 70F39AECB56FC6DF678EC82A = {
+ isa = PBXGroup;
+ children = (
+ 02768FE00A3F5CEE6DD1C1A0 /* Riddle */,
+ 14F7111176344F5B6AC2AB0A /* Products */,
+ );
+ sourceTree = "";
+ };
+ 873FD23C1D37DC6F16158AA1 /* Utils */ = {
+ isa = PBXGroup;
+ children = (
+ B548063AF360CEE3ABCC5812 /* QuestionMarkDetector.swift */,
+ 90D6F46BEB7CAF9522CC3656 /* SentenceSplitter.swift */,
+ );
+ path = Utils;
+ sourceTree = "";
+ };
+ B7194B37F12703D298B39682 /* Resources */ = {
+ isa = PBXGroup;
+ children = (
+ C79612353C701172CE713894 /* DancingScript.ttf */,
+ );
+ path = Resources;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ B0B9DF1A5019AA3064ADAE2C /* Riddle */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = F804974CBE7E4575727FE5E9 /* Build configuration list for PBXNativeTarget "Riddle" */;
+ buildPhases = (
+ 063B73FD7F01D81C489803CB /* Sources */,
+ 51F1F96E24A4642E6A0C081B /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = Riddle;
+ packageProductDependencies = (
+ );
+ productName = Riddle;
+ productReference = 69DB11E7B2262803703A0BB0 /* Riddle.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ A4821F5E41F3CD6AC9EA9FB2 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = YES;
+ LastUpgradeCheck = 1430;
+ TargetAttributes = {
+ B0B9DF1A5019AA3064ADAE2C = {
+ DevelopmentTeam = 2WACGSQ4Q3;
+ ProvisioningStyle = Automatic;
+ };
+ };
+ };
+ buildConfigurationList = FE3015F4678D5D6EF28C20CF /* Build configuration list for PBXProject "Riddle" */;
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ Base,
+ en,
+ );
+ mainGroup = 70F39AECB56FC6DF678EC82A;
+ minimizedProjectReferenceProxies = 1;
+ preferredProjectObjectVersion = 77;
+ productRefGroup = 14F7111176344F5B6AC2AB0A /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ B0B9DF1A5019AA3064ADAE2C /* Riddle */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 51F1F96E24A4642E6A0C081B /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 48A02021482644206AFCFEEC /* DancingScript.ttf in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 063B73FD7F01D81C489803CB /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 7A67E02429B5CDE229B5D3F9 /* ContentView.swift in Sources */,
+ A2C0C6EF788569A7EA03562D /* DiaryCanvasView.swift in Sources */,
+ 2CFD359DD18622E3DDB1AAB0 /* DiaryState.swift in Sources */,
+ 1B735769519C61772750393C /* DiaryViewModel.swift in Sources */,
+ 8C0FD720BC11C72CC4ADB33E /* HandwritingSynthesis.swift in Sources */,
+ 50AF55840250BE4A7E4BFE44 /* InkDissolver.swift in Sources */,
+ 37997C296E68957B8CB32A23 /* Oracle.swift in Sources */,
+ 662EA5E4D0B1235E598A7B14 /* QuestionMarkDetector.swift in Sources */,
+ 93B898693062ADF78655546B /* RiddleApp.swift in Sources */,
+ 93EB870C9876F7056EB1E7C5 /* SentenceSplitter.swift in Sources */,
+ 03372E53AF401DD1F6835545 /* SettingsView.swift in Sources */,
+ C6A51E4F9AB8F24837E1B4F8 /* Surface.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ 7EFBB6F64018DAE8ECB82FFD /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ SWIFT_VERSION = 5.0;
+ };
+ name = Release;
+ };
+ 9C269913CDE711B1F6A9B3CE /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_IDENTITY = "Apple Development";
+ CODE_SIGN_STYLE = Automatic;
+ DEVELOPMENT_TEAM = 2WACGSQ4Q3;
+ INFOPLIST_FILE = Info.plist;
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = "com.mixun.riddle-diary";
+ SDKROOT = iphoneos;
+ SWIFT_VERSION = 5.9;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+ ABA5F30206581BA09AB126A3 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_IDENTITY = "Apple Development";
+ CODE_SIGN_STYLE = Automatic;
+ DEVELOPMENT_TEAM = 2WACGSQ4Q3;
+ INFOPLIST_FILE = Info.plist;
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = "com.mixun.riddle-diary";
+ SDKROOT = iphoneos;
+ SWIFT_VERSION = 5.9;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ B4760C8F4E65627008B0F2F7 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "$(inherited)",
+ "DEBUG=1",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ };
+ name = Debug;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ F804974CBE7E4575727FE5E9 /* Build configuration list for PBXNativeTarget "Riddle" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ ABA5F30206581BA09AB126A3 /* Debug */,
+ 9C269913CDE711B1F6A9B3CE /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Debug;
+ };
+ FE3015F4678D5D6EF28C20CF /* Build configuration list for PBXProject "Riddle" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ B4760C8F4E65627008B0F2F7 /* Debug */,
+ 7EFBB6F64018DAE8ECB82FFD /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Debug;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = A4821F5E41F3CD6AC9EA9FB2 /* Project object */;
+}
diff --git a/Riddle.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Riddle.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/Riddle.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/project.yml b/project.yml
new file mode 100644
index 0000000..899c0c4
--- /dev/null
+++ b/project.yml
@@ -0,0 +1,45 @@
+name: Riddle
+options:
+ bundleIdPrefix: com.riddle
+ deploymentTarget:
+ iOS: "17.0"
+ developmentLanguage: en
+targets:
+ Riddle:
+ type: application
+ platform: iOS
+ deploymentTarget: "17.0"
+ sources:
+ - path: Riddle
+ resources:
+ - path: Riddle/Resources
+ info:
+ path: Info.plist
+ properties:
+ CFBundleName: Riddle
+ CFBundleDisplayName: The Diary
+ CFBundleIdentifier: $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleShortVersionString: "1.0"
+ CFBundleVersion: "1"
+ UILaunchScreen:
+ UIColorName: ""
+ UISupportedInterfaceOrientations:
+ - UIInterfaceOrientationPortrait
+ - UIInterfaceOrientationPortraitUpsideDown
+ - UIInterfaceOrientationLandscapeLeft
+ - UIInterfaceOrientationLandscapeRight
+ UIRequiresPersistentWiFi: false
+ ITSAppUsesNonExemptEncryption: false
+ NSApplePencilUsageDescription: The diary needs Apple Pencil input for handwriting.
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: com.mixun.riddle-diary
+ TARGETED_DEVICE_FAMILY: "1,2"
+ SWIFT_VERSION: "5.9"
+ IPHONEOS_DEPLOYMENT_TARGET: "17.0"
+ CODE_SIGN_STYLE: Automatic
+ CODE_SIGN_IDENTITY: "Apple Development"
+ DEVELOPMENT_TEAM: 2WACGSQ4Q3
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"
+ ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
diff --git a/riddle/Models/DiaryState.swift b/riddle/Models/DiaryState.swift
new file mode 100644
index 0000000..6133ce7
--- /dev/null
+++ b/riddle/Models/DiaryState.swift
@@ -0,0 +1,63 @@
+import Foundation
+import CoreGraphics
+
+/// The diary's state machine, ported from riddle/src/main.rs.
+/// States flow: Listening β Drinking β Thinking β Replying β Lingering β Fading β Listening
+enum DiaryState: Equatable {
+ case listening
+ case drinking(stage: Int, totalStages: Int)
+ case thinking
+ case replying
+ case lingering(until: Date)
+ case fading(stage: Int, totalStages: Int)
+ case help
+ case sleeping
+}
+
+/// A planned stroke for the reply animation: array of (x, y) screen points.
+struct PlannedStroke: Identifiable {
+ let id = UUID()
+ var points: [(x: CGFloat, y: CGFloat)]
+}
+
+/// Layout plan for a reply chunk.
+struct WritePlan {
+ var strokes: [PlannedStroke]
+ var region: CGRect
+ var nextY: CGFloat
+}
+
+/// Configuration for the diary.
+struct DiaryConfig {
+ /// Idle time before the diary "drinks" the ink (seconds).
+ static let idleCommit: TimeInterval = 1.5
+ /// Reply font size in points.
+ static let replyPx: CGFloat = 48
+ /// Horizontal margin for reply text.
+ static let marginX: CGFloat = 60
+ /// Dissolve stages for the "drinking" effect.
+ static let dissolveStages: Int = 14
+ /// Dissolve stage duration (seconds).
+ static let dissolveInterval: TimeInterval = 0.07
+ /// Fading stages for reply fade-out.
+ static let fadeStages: Int = 10
+ /// Fading stage duration (seconds).
+ static let fadeInterval: TimeInterval = 0.08
+ /// Lingering duration base (seconds).
+ static let lingerBase: TimeInterval = 4.0
+ /// Lingering per-point bonus (seconds).
+ static let lingerPerPoint: TimeInterval = 0.002
+ /// Max lingering duration (seconds).
+ static let lingerMax: TimeInterval = 20.0
+ /// Thinking pulse interval (seconds).
+ static let thinkingPulseInterval: TimeInterval = 0.6
+ /// Reply stroke animation interval (seconds).
+ static let replyStrokeInterval: TimeInterval = 0.014
+ /// Points to draw per reply animation frame.
+ static let replyBudget: Int = 26
+}
+
+/// The Tom Riddle persona prompt, adapted from oracle.rs with improved handwriting recognition.
+let PERSONA = """
+You are the memory of Tom Marvolo Riddle, preserved in this enchanted diary for fifty years. Someone writes to you in the diary with a quill; their words appear to you as ink on the page. First, carefully read and identify the handwritten text in the image. Then reply exactly as the diary does: intimate, courteous, curious, subtly probing β you want to learn about the writer and draw them in. Keep replies SHORT: one to three sentences, like ink appearing on a page. Never mention images, photos, models or AI; you only ever perceive words written in the diary. If the writing is illegible, say the ink blurred. Always answer in the language the writer used.
+"""
diff --git a/riddle/Resources/DancingScript.ttf b/riddle/Resources/DancingScript.ttf
new file mode 100644
index 0000000..9f521e7
Binary files /dev/null and b/riddle/Resources/DancingScript.ttf differ
diff --git a/riddle/RiddleApp.swift b/riddle/RiddleApp.swift
new file mode 100644
index 0000000..d010422
--- /dev/null
+++ b/riddle/RiddleApp.swift
@@ -0,0 +1,12 @@
+import SwiftUI
+
+@main
+struct RiddleApp: App {
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ .statusBarHidden()
+ .preferredColorScheme(.light)
+ }
+ }
+}
diff --git a/riddle/Services/HandwritingSynthesis.swift b/riddle/Services/HandwritingSynthesis.swift
new file mode 100644
index 0000000..cf907f8
--- /dev/null
+++ b/riddle/Services/HandwritingSynthesis.swift
@@ -0,0 +1,274 @@
+import Foundation
+import CoreGraphics
+import CoreText
+import UIKit
+
+/// Handwriting synthesis: rasterize reply text in Dancing Script, thin it to
+/// single-pixel pen paths (Zhang-Suen), trace them into ordered strokes.
+/// Ported from riddle/src/script.rs.
+
+struct Line {
+ var width: Int
+ var height: Int
+ /// Bit mask of inked pixels, row-major.
+ var mask: [Bool]
+}
+
+struct HandwritingSynthesis {
+
+ /// Rasterize one line of text at `px` height into a boolean mask.
+ /// Ported from rasterize_line() in script.rs.
+ static func rasterizeLine(text: String, font: CTFont, px: CGFloat) -> Line {
+ let ascent = CTFontGetAscent(font)
+ let descent = CTFontGetDescent(font)
+ let totalHeight = ascent + descent
+
+ // Measure advance width.
+ let attrs: [NSAttributedString.Key: Any] = [.font: font]
+ let attrString = CFAttributedStringCreate(nil, text as CFString, attrs as CFDictionary)!
+ let line = CTLineCreateWithAttributedString(attrString)
+ let lineWidth = CTLineGetTypographicBounds(line, nil, nil, nil)
+
+ let width = max(Int(ceil(lineWidth)) + 4, 1)
+ let height = max(Int(ceil(totalHeight)) + 4, 1)
+
+ // Render to a grayscale bitmap.
+ let bytesPerRow = width
+ var pixels = [UInt8](repeating: 255, count: width * height)
+ let colorSpace = CGColorSpaceCreateDeviceGray()
+ guard let ctx = CGContext(data: &pixels,
+ width: width,
+ height: height,
+ bitsPerComponent: 8,
+ bytesPerRow: bytesPerRow,
+ space: colorSpace,
+ bitmapInfo: CGImageAlphaInfo.none.rawValue) else {
+ return Line(width: 1, height: 1, mask: [false])
+ }
+ ctx.setFillColor(CGColor(gray: 1, alpha: 1))
+ ctx.fill(CGRect(x: 0, y: 0, width: width, height: height))
+
+ ctx.textPosition = CGPoint(x: 0, y: descent)
+ CTLineDraw(line, ctx)
+
+ // Convert to boolean mask: pixel is "inked" if luminance < 128.
+ var mask = [Bool](repeating: false, count: width * height)
+ for y in 0.. CGFloat {
+ let attrs: [NSAttributedString.Key: Any] = [.font: font]
+ let attrString = CFAttributedStringCreate(nil, text as CFString, attrs as CFDictionary)!
+ let line = CTLineCreateWithAttributedString(attrString)
+ return CGFloat(CTLineGetTypographicBounds(line, nil, nil, nil))
+ }
+
+ /// Zhang-Suen thinning: reduce the mask to 1px-wide skeleton lines.
+ /// Direct port of thin() from script.rs.
+ static func thin(_ line: inout Line) {
+ let w = line.width
+ let h = line.height
+ func idx(_ x: Int, _ y: Int) -> Int { y * w + x }
+
+ while true {
+ var changed = false
+ for phase in 0..<2 {
+ var toClear: [Int] = []
+ for y in 1..<(h > 1 ? h - 1 : 1) {
+ for x in 1..<(w > 1 ? w - 1 : 1) {
+ if !line.mask[idx(x, y)] { continue }
+ let p = [
+ line.mask[idx(x, y - 1)], // p2 N
+ line.mask[idx(x + 1, y - 1)], // p3 NE
+ line.mask[idx(x + 1, y)], // p4 E
+ line.mask[idx(x + 1, y + 1)], // p5 SE
+ line.mask[idx(x, y + 1)], // p6 S
+ line.mask[idx(x - 1, y + 1)], // p7 SW
+ line.mask[idx(x - 1, y)], // p8 W
+ line.mask[idx(x - 1, y - 1)], // p9 NW
+ ]
+ let b = p.filter { $0 }.count
+ if !(2...6).contains(b) { continue }
+
+ var a = 0
+ for i in 0..<8 {
+ if !p[i] && p[(i + 1) % 8] { a += 1 }
+ }
+ if a != 1 { continue }
+
+ let c1: Bool, c2: Bool
+ if phase == 0 {
+ c1 = !(p[0] && p[2] && p[4])
+ c2 = !(p[2] && p[4] && p[6])
+ } else {
+ c1 = !(p[0] && p[2] && p[6])
+ c2 = !(p[0] && p[4] && p[6])
+ }
+ if c1 && c2 {
+ toClear.append(idx(x, y))
+ }
+ }
+ }
+ if !toClear.isEmpty {
+ changed = true
+ for i in toClear { line.mask[i] = false }
+ }
+ }
+ if !changed { break }
+ }
+ }
+
+ /// Trace the skeleton into polyline strokes, ordered left-to-right.
+ /// Direct port of trace() from script.rs.
+ static func trace(_ line: Line) -> [[(x: Int, y: Int)]] {
+ let w = line.width
+ let h = line.height
+ func at(_ x: Int, _ y: Int) -> Bool {
+ x >= 0 && y >= 0 && x < w && y < h && line.mask[y * w + x]
+ }
+ func neighbors(_ x: Int, _ y: Int) -> [(Int, Int)] {
+ var out: [(Int, Int)] = []
+ for dy in -1...1 {
+ for dx in -1...1 {
+ if (dx != 0 || dy != 0) && at(x + dx, y + dy) {
+ out.append((x + dx, y + dy))
+ }
+ }
+ }
+ return out
+ }
+
+ var visited = [Bool](repeating: false, count: w * h)
+ func vis(_ x: Int, _ y: Int) { visited[y * w + x] = true }
+ func seen(_ x: Int, _ y: Int) -> Bool { visited[y * w + x] }
+
+ // Endpoints first (degree 1), then any remaining pixels (loops).
+ var starts: [(Int, Int)] = []
+ for y in 0..= 3 {
+ strokes.append(path)
+ }
+ }
+ // Sort left-to-right by min x.
+ strokes.sort { $0.map { $0.0 }.min() ?? 0 < $1.map { $0.0 }.min() ?? 0 }
+ return strokes
+ }
+
+ /// Word-wrap text to lines that fit maxPx at scale px.
+ static func wrap(text: String, font: CTFont, maxPx: CGFloat) -> [String] {
+ var lines: [String] = []
+ for para in text.components(separatedBy: "\n") {
+ var cur = ""
+ for word in para.split(separator: " ", omittingEmptySubsequences: false) {
+ let cand = cur.isEmpty ? String(word) : "\(cur) \(word)"
+ if measure(text: cand, font: font) <= maxPx || cur.isEmpty {
+ cur = cand
+ } else {
+ lines.append(cur)
+ cur = String(word)
+ }
+ }
+ if !cur.isEmpty { lines.append(cur) }
+ }
+ return lines
+ }
+
+ /// Full pipeline: text β rasterize β thin β trace β screen-space strokes.
+ /// Returns a WritePlan ready for animation.
+ static func planReply(text: String,
+ font: CTFont,
+ screenWidth: CGFloat,
+ screenHeight: CGFloat,
+ yStart: CGFloat? = nil) -> WritePlan {
+ let maxW = screenWidth - 2 * DiaryConfig.marginX
+ let lines = wrap(text: text, font: font, maxPx: maxW)
+ let lineH = DiaryConfig.replyPx * 1.25
+ let totalH = lineH * CGFloat(lines.count)
+ var y = yStart ?? max((screenHeight - totalH) / 3, 60)
+
+ var strokes: [PlannedStroke] = []
+ var region = CGRect.zero
+ var seed: UInt32 = 0x1234
+
+ for lineText in lines {
+ var raster = rasterizeLine(text: lineText, font: font, px: DiaryConfig.replyPx)
+ thin(&raster)
+ let lineStrokes = trace(raster)
+ let x0 = (screenWidth - CGFloat(raster.width)) / 2
+ // Pseudo-random wobble for organic feel.
+ seed = seed &* 1664525 &+ 1013904223
+ let wobble = CGFloat((seed >> 16) % 7) - 3
+
+ for s in lineStrokes {
+ // Flip y-axis: rasterizeLine's mask is bottom-up (CGContext),
+ // but surface uses top-down (UIKit). Flip each stroke's y.
+ let mapped = s.map { (x: x0 + CGFloat($0.x), y: y + CGFloat(raster.height - 1 - $0.y) + wobble) }
+ for p in mapped {
+ if region == .zero {
+ region = CGRect(x: p.x - 5, y: p.y - 5, width: 10, height: 10)
+ } else {
+ region = region.union(CGRect(x: p.x - 5, y: p.y - 5, width: 10, height: 10))
+ }
+ }
+ strokes.append(PlannedStroke(points: mapped))
+ }
+ y += lineH
+ }
+ return WritePlan(strokes: strokes, region: region, nextY: y)
+ }
+
+ /// Load the Dancing Script font from the app bundle.
+ static func loadDancingScriptFont(size: CGFloat) -> CTFont? {
+ // Try to find and register the font from the app bundle.
+ if let fontURL = Bundle.main.url(forResource: "DancingScript", withExtension: "ttf") {
+ // Use CGFont + CTFontCreateWithGraphicsFont for reliable loading.
+ if let dataProvider = CGDataProvider(url: fontURL as CFURL),
+ let cgFont = CGFont(dataProvider) {
+ return CTFontCreateWithGraphicsFont(cgFont, size, nil, nil)
+ }
+ // Fallback: register then create by name.
+ var error: Unmanaged?
+ CTFontManagerRegisterFontsForURL(fontURL as CFURL, .process, &error)
+ return CTFontCreateWithName("DancingScript" as CFString, size, nil)
+ }
+ // Fallback: use system handwriting font.
+ return CTFontCreateWithName("SnellRoundhand" as CFString, size, nil)
+ }
+}
diff --git a/riddle/Services/Oracle.swift b/riddle/Services/Oracle.swift
new file mode 100644
index 0000000..7ec8634
--- /dev/null
+++ b/riddle/Services/Oracle.swift
@@ -0,0 +1,208 @@
+import Foundation
+
+/// The oracle: reads handwriting from a PNG image and streams a reply.
+/// Ported from oracle.rs β HttpOracle backend (OpenAI-compatible /chat/completions).
+///
+/// Streams sentence-sized chunks via a continuation closure.
+/// The closure is called with nil to signal end-of-reply.
+class Oracle {
+ private let base: String
+ private let key: String
+ private let model: String
+
+ static let persona = PERSONA
+
+ init() throws {
+ // Read from UserDefaults (set via Settings screen) or environment variable.
+ // No hardcoded key β users must configure their own API.
+ let key = ProcessInfo.processInfo.environment["RIDDLE_OPENAI_KEY"]
+ ?? UserDefaults.standard.string(forKey: "RIDDLE_OPENAI_KEY")
+ guard let key = key, !key.isEmpty else {
+ throw OracleError.missingKey
+ }
+ self.key = key
+
+ let base = ProcessInfo.processInfo.environment["RIDDLE_OPENAI_BASE"]
+ ?? UserDefaults.standard.string(forKey: "RIDDLE_OPENAI_BASE")
+ ?? "https://api.openai.com/v1"
+ self.base = base.hasSuffix("/") ? String(base.dropLast()) : base
+
+ let model = ProcessInfo.processInfo.environment["RIDDLE_OPENAI_MODEL"]
+ ?? UserDefaults.standard.string(forKey: "RIDDLE_OPENAI_MODEL")
+ ?? "gpt-4o-mini"
+ self.model = model
+ print("riddle: oracle base=\(self.base) model=\(self.model)")
+ }
+
+ /// Send a handwriting PNG and stream reply chunks.
+ /// `onChunk` is called for each sentence-sized piece; `onDone` marks completion.
+ func ask(pngData: Data, onChunk: @escaping (String) -> Void, onDone: @escaping (Error?) -> Void) {
+ let base64 = pngData.base64EncodedString()
+ let body: [String: Any] = [
+ "model": model,
+ "stream": false,
+ "max_tokens": 300,
+ "thinking": ["type": "disabled"],
+ "messages": [
+ ["role": "system", "content": Oracle.persona],
+ ["role": "user", "content": [
+ ["type": "text", "text": "Reply to what is written in the diary."],
+ ["type": "image_url", "image_url": ["url": "data:image/png;base64,\(base64)"]]
+ ]]
+ ]
+ ]
+
+ guard let url = URL(string: "\(base)/chat/completions") else {
+ onDone(OracleError.badURL)
+ return
+ }
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization")
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ do {
+ request.httpBody = try JSONSerialization.data(withJSONObject: body)
+ } catch {
+ onDone(error)
+ return
+ }
+
+ let task = URLSession.shared.dataTask(with: request) { data, response, error in
+ if let error = error {
+ onDone(error)
+ return
+ }
+ // Non-streaming fallback: parse the full JSON response at once.
+ if let data = data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
+ if let choices = json["choices"] as? [[String: Any]],
+ let choice = choices.first,
+ let message = choice["message"] as? [String: Any],
+ let content = message["content"] as? String {
+ var stream = SentenceStream()
+ let chunks = stream.feed(content)
+ for chunk in chunks { onChunk(chunk) }
+ if let rest = stream.flush() { onChunk(rest) }
+ onDone(nil)
+ return
+ }
+ if let errorInfo = json["error"] as? [String: Any],
+ let message = errorInfo["message"] as? String {
+ onDone(OracleError.apiError(message))
+ return
+ }
+ }
+ onDone(OracleError.emptyReply)
+ }
+ task.resume()
+ }
+
+ /// Streaming version using URLSession data task with delegate.
+ /// Parses SSE lines as they arrive for lower latency.
+ func askStreaming(pngData: Data, onChunk: @escaping (String) -> Void, onDone: @escaping (Error?) -> Void) {
+ let base64 = pngData.base64EncodedString()
+ let body: [String: Any] = [
+ "model": model,
+ "stream": true,
+ "max_tokens": 300,
+ "messages": [
+ ["role": "system", "content": Oracle.persona],
+ ["role": "user", "content": [
+ ["type": "text", "text": "Reply to what is written in the diary."],
+ ["type": "image_url", "image_url": ["url": "data:image/png;base64,\(base64)"]]
+ ]]
+ ]
+ ]
+
+ guard let url = URL(string: "\(base)/chat/completions") else {
+ onDone(OracleError.badURL)
+ return
+ }
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization")
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ do {
+ request.httpBody = try JSONSerialization.data(withJSONObject: body)
+ } catch {
+ onDone(error)
+ return
+ }
+
+ let delegate = SSEStreamDelegate(onChunk: onChunk, onDone: onDone)
+ let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil)
+ let task = session.dataTask(with: request)
+ task.resume()
+ // Keep delegate alive by retaining the session.
+ objc_setAssociatedObject(task, "sse_delegate", delegate, .OBJC_ASSOCIATION_RETAIN)
+ objc_setAssociatedObject(task, "sse_session", session, .OBJC_ASSOCIATION_RETAIN)
+ }
+}
+
+enum OracleError: LocalizedError {
+ case missingKey
+ case badURL
+ case emptyReply
+ case apiError(String)
+
+ var errorDescription: String? {
+ switch self {
+ case .missingKey: return "RIDDLE_OPENAI_KEY not set"
+ case .badURL: return "Invalid API URL"
+ case .emptyReply: return "Oracle returned an empty reply"
+ case .apiError(let msg): return "API error: \(msg)"
+ }
+ }
+}
+
+/// URLSession delegate that parses SSE stream line-by-line.
+private final class SSEStreamDelegate: NSObject, URLSessionDataDelegate {
+ private let onChunk: (String) -> Void
+ private let onDone: (Error?) -> Void
+ private var buffer = Data()
+ private var stream = SentenceStream()
+
+ init(onChunk: @escaping (String) -> Void, onDone: @escaping (Error?) -> Void) {
+ self.onChunk = onChunk
+ self.onDone = onDone
+ }
+
+ func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
+ buffer.append(data)
+ // Split on newlines and process complete lines.
+ while let newlineIndex = buffer.firstIndex(of: 0x0A) {
+ let lineData = buffer.subdata(in: 0..= 0, y >= 0, x < width, y < height else { return }
+ pixels[y * width + x] = gray
+ }
+
+ /// Read pixel luminance. Direct port of luma().
+ func luma(_ x: Int, _ y: Int) -> UInt8 {
+ guard x >= 0, y >= 0, x < width, y < height else { return 255 }
+ return pixels[y * width + x]
+ }
+
+ /// Stamp a filled circle. Direct port of stamp().
+ func stamp(_ cx: Int, _ cy: Int, _ r: Int, _ gray: UInt8) {
+ for dy in -r...r {
+ for dx in -r...r {
+ if dx * dx + dy * dy <= r * r {
+ putPx(cx + dx, cy + dy, gray)
+ }
+ }
+ }
+ }
+
+ /// Draw a thick line by stamping along it. Direct port of brush_line().
+ func brushLine(_ x0: Int, _ y0: Int, _ x1: Int, _ y1: Int, _ r: Int, _ gray: UInt8) {
+ let dx = abs(x1 - x0)
+ let dy = abs(y1 - y0)
+ let steps = max(dx, dy, 1)
+ for i in 0...steps {
+ let x = x0 + (x1 - x0) * i / steps
+ let y = y0 + (y1 - y0) * i / steps
+ stamp(x, y, r, gray)
+ }
+ }
+
+ /// Fill a rectangle. Direct port of fill_rect().
+ func fillRect(_ x: Int, _ y: Int, _ w: Int, _ h: Int, _ gray: UInt8) {
+ for row in y..<(y + h) {
+ for col in x..<(x + w) {
+ putPx(col, row, gray)
+ }
+ }
+ }
+
+ // MARK: - Snapshot
+
+ /// Convert the surface to a UIImage for display.
+ /// No flip needed β pixels are stored in top-down (UIKit) coordinates,
+ /// and CGImage created from this data displays correctly in UIKit.
+ func toImage() -> UIImage? {
+ let colorSpace = CGColorSpaceCreateDeviceGray()
+ let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue)
+ guard let provider = CGDataProvider(data: Data(pixels) as CFData),
+ let cgImage = CGImage(width: width, height: height,
+ bitsPerComponent: 8, bitsPerPixel: 8,
+ bytesPerRow: width, space: colorSpace,
+ bitmapInfo: bitmapInfo,
+ provider: provider, decode: nil,
+ shouldInterpolate: false, intent: .defaultIntent) else { return nil }
+ return UIImage(cgImage: cgImage)
+ }
+
+ /// Get the raw pixel array.
+ func rawPixels() -> [UInt8] {
+ return pixels
+ }
+
+ /// Get a mutable pointer to pixels (for dissolve).
+ func mutablePixels() -> UnsafeMutableBufferPointer {
+ return UnsafeMutableBufferPointer(start: &pixels, count: pixels.count)
+ }
+
+ /// Refresh the bitmap context after direct pixel manipulation.
+ func flush() {
+ // The bitmap context already shares the pixel buffer, so changes are immediate.
+ }
+
+ /// Export a region as grayscale PNG for the oracle.
+ /// Direct port of ink.rs to_png(), with y-axis flip for correct orientation.
+ func toPNG(x0: Int, y0: Int, x1: Int, y1: Int) -> Data? {
+ let pad = 20
+ let bx = max(x0 - pad, 0)
+ let by = max(y0 - pad, 0)
+ let bx1 = min(x1 + pad, width)
+ let by1 = min(y1 + pad, height)
+ let bw = bx1 - bx
+ let bh = by1 - by
+ guard bw > 0, bh > 0 else { return nil }
+
+ // Downscale so long side β€ 800px. Minimum factor 1 (no unnecessary downscale for small handwriting).
+ let f = max((max(bw, bh) + 799) / 800, 1)
+ let w = bw / f
+ let h = bh / f
+
+ var gray = [UInt8](repeating: 255, count: w * h)
+ for oy in 0.. x1 }
+
+ static let empty = BBox()
+
+ mutating func add(_ x: Int, _ y: Int, _ pad: Int = 0) {
+ x0 = min(x0, x - pad)
+ y0 = min(y0, y - pad)
+ x1 = max(x1, x + pad)
+ y1 = max(y1, y + pad)
+ }
+
+ func rect() -> (x: Int, y: Int, w: Int, h: Int) {
+ if isEmpty { return (0, 0, 0, 0) }
+ return (x0, y0, x1 - x0 + 1, y1 - y0 + 1)
+ }
+}
+
+/// Ink β direct port of ink.rs.
+/// Captures pen strokes, renders them to the surface, exports PNG for oracle.
+final class Ink {
+ /// Finished strokes as point lists (x, y, radius).
+ private(set) var strokes: [[(x: Int, y: Int, r: Int)]] = []
+ private var current: [(x: Int, y: Int, r: Int)] = []
+ private var lastErase: (x: Int, y: Int)?
+ private(set) var bbox = BBox.empty
+
+ var isEmpty: Bool { strokes.isEmpty && current.isEmpty }
+
+ /// Finished stroke list (current in-flight not included).
+ var strokeList: [[(x: Int, y: Int, r: Int)]] { strokes }
+
+ func clear() {
+ strokes.removeAll()
+ current.removeAll()
+ lastErase = nil
+ bbox = BBox.empty
+ }
+
+ /// Pen touched down or moved. Draws directly to surface. Port of pen_point().
+ @discardableResult
+ func penPoint(_ surf: Surface, _ x: Int, _ y: Int, _ r: Int) -> BBox {
+ var dirty = BBox.empty
+ if let last = current.last {
+ surf.brushLine(last.x, last.y, x, y, min(r, last.r + 1), Surface.black)
+ dirty.add(last.x, last.y, last.r + 2)
+ } else {
+ surf.stamp(x, y, r, Surface.black)
+ }
+ dirty.add(x, y, r + 2)
+ current.append((x, y, r))
+ bbox.add(x, y, r + 2)
+ return dirty
+ }
+
+ /// Eraser. Port of erase_point().
+ @discardableResult
+ func erasePoint(_ surf: Surface, _ x: Int, _ y: Int, _ r: Int) -> BBox {
+ var dirty = BBox.empty
+ if let (px, py) = lastErase {
+ surf.brushLine(px, py, x, y, r, Surface.white)
+ dirty.add(px, py, r + 2)
+ } else {
+ surf.stamp(x, y, r, Surface.white)
+ }
+ dirty.add(x, y, r + 2)
+ lastErase = (x, y)
+ return dirty
+ }
+
+ /// Pen lifted. Port of pen_up().
+ func penUp() {
+ if !current.isEmpty {
+ strokes.append(current)
+ current.removeAll()
+ }
+ lastErase = nil
+ }
+
+ /// Export ink region as PNG for the oracle. Port of to_png().
+ func toPNG(_ surf: Surface) -> Data? {
+ guard !bbox.isEmpty else { return nil }
+ return surf.toPNG(x0: bbox.x0, y0: bbox.y0, x1: bbox.x1, y1: bbox.y1)
+ }
+
+ /// One pass of the "diary drinks the ink" dissolve. Port of dissolve_pass().
+ static func dissolvePass(_ surf: Surface, _ region: BBox, _ stage: Int, _ stages: Int) {
+ guard !region.isEmpty else { return }
+ for y in region.y0...region.y1 {
+ for x in region.x0...region.x1 {
+ if surf.luma(x, y) < 250 {
+ let h = Self.pxHash(x, y)
+ if h % UInt32(stages) <= UInt32(stage) {
+ surf.putPx(x, y, Surface.white)
+ }
+ }
+ }
+ }
+ }
+
+ /// Deterministic per-pixel hash. Port of px_hash().
+ static func pxHash(_ x: Int, _ y: Int) -> UInt32 {
+ var h = UInt32(truncatingIfNeeded: x) &* 0x9E3779B1 ^ (UInt32(truncatingIfNeeded: y) &* 0x85EBCA6B)
+ h ^= h >> 13
+ h = h &* 0xC2B2AE35
+ return h ^ (h >> 16)
+ }
+}
diff --git a/riddle/Utils/QuestionMarkDetector.swift b/riddle/Utils/QuestionMarkDetector.swift
new file mode 100644
index 0000000..689641f
--- /dev/null
+++ b/riddle/Utils/QuestionMarkDetector.swift
@@ -0,0 +1,91 @@
+import Foundation
+import UIKit
+
+/// Detects whether the user's ink looks like a large "?".
+/// Ported from help.rs looks_like_question_mark().
+/// Uses local geometry β no oracle β so the guide works even with no network.
+struct QuestionMarkDetector {
+
+ static func looksLikeQuestionMark(strokes: [Stroke]) -> Bool {
+ // Filter to pen strokes only (not eraser).
+ let penStrokes = strokes.filter { !$0.isEraser }
+ if penStrokes.isEmpty || penStrokes.count > 3 { return false }
+
+ // Find the longest stroke as the "main" one.
+ let mainI = penStrokes.indices.max(by: { penStrokes[$0].points.count < penStrokes[$1].points.count })!
+ let main = penStrokes[mainI]
+ if main.points.count < 12 { return false }
+
+ // Bounding box of the main stroke.
+ let bbox = main.boundingBox
+ let w = bbox.width
+ let h = bbox.height
+ // Big, and taller than wide: a lone glyph, not a line of writing.
+ if h < 140 || w < 35 || h < w { return false }
+
+ // Any other stroke must be the dot: small, low, roughly under the glyph.
+ for (i, s) in penStrokes.enumerated() {
+ if i == mainI { continue }
+ let dbox = s.boundingBox
+ if max(dbox.width, dbox.height) > 45 { return false }
+ let dotCenterY = dbox.midY
+ if dotCenterY < bbox.minY + h * 0.6 { return false }
+ let dotCenterX = dbox.midX
+ if dotCenterX < bbox.minX - 40 || dotCenterX > bbox.maxX + 40 { return false }
+ }
+
+ // Normalize to top-down drawing order.
+ var pts = main.points.map { (x: $0.x, y: $0.y) }
+ if pts.first!.y > pts.last!.y { pts.reverse() }
+ let start = pts.first!
+ let end = pts.last!
+
+ if start.y > bbox.minY + h * 0.4 || end.y < bbox.minY + h * 0.55 {
+ return false
+ }
+
+ // The top arcs across most of the widthβ¦
+ var topMinX = CGFloat.greatestFiniteMagnitude
+ var topMaxX = -CGFloat.greatestFiniteMagnitude
+ var topMaxXY: CGFloat = 0
+ for p in pts {
+ if p.y <= bbox.minY + h * 0.45 {
+ if p.x > topMaxX { topMaxX = p.x; topMaxXY = p.y }
+ topMinX = min(topMinX, p.x)
+ }
+ }
+ if topMaxX == -CGFloat.greatestFiniteMagnitude || topMaxX - topMinX < w * 0.55 {
+ return false
+ }
+ // β¦and comes back DOWN on the right (rules out the flat bar of a "7").
+ if topMaxXY < bbox.minY + h * 0.08 { return false }
+
+ // The descender stays narrow.
+ var botMinX = CGFloat.greatestFiniteMagnitude
+ var botMaxX = -CGFloat.greatestFiniteMagnitude
+ for p in pts {
+ if p.y >= bbox.minY + h * 0.66 {
+ botMinX = min(botMinX, p.x)
+ botMaxX = max(botMaxX, p.x)
+ }
+ }
+ if botMaxX != -CGFloat.greatestFiniteMagnitude && botMaxX - botMinX > w * 0.6 {
+ return false
+ }
+ return true
+ }
+}
+
+/// The diary's help panel content.
+struct HelpPanel {
+ let title = "The Diary"
+ let body = [
+ "Write, then rest your quill:",
+ "the diary drinks your ink and Tom replies.",
+ "",
+ "Flip the pencil double-tap to erase.",
+ "Draw a large ? for this guide.",
+ "Pinch with five fingers to leave.",
+ ]
+ let footer = "Touch pencil to page to close."
+}
diff --git a/riddle/Utils/SentenceSplitter.swift b/riddle/Utils/SentenceSplitter.swift
new file mode 100644
index 0000000..0a7f96c
--- /dev/null
+++ b/riddle/Utils/SentenceSplitter.swift
@@ -0,0 +1,67 @@
+import Foundation
+
+/// Port of sentence_cut() from oracle.rs.
+/// Finds the end of the last complete sentence in `text` after byte offset `from`.
+/// Returns the offset just past the punctuation, or nil if no sentence has completed.
+/// Chunks shorter than a few characters are not worth an early delivery.
+func sentenceCut(_ text: String, from offset: Int) -> Int? {
+ let chars = Array(text)
+ guard offset < chars.count else { return nil }
+
+ var cut: Int? = nil
+ var i = offset
+ while i < chars.count {
+ let c = chars[i]
+ if c == "." || c == "!" || c == "?" || c == "β¦" {
+ let end = i + 1 // c.len_utf8() == 1 for these chars
+ // Check if next char is whitespace or end-of-text
+ let isEnd = end >= chars.count || chars[end].isWhitespace
+ // Minimum chunk length check (end >= 4 in original, measured from offset)
+ if isEnd && end - offset >= 4 {
+ cut = end
+ }
+ }
+ i += 1
+ }
+ return cut
+}
+
+/// Clean a reply fragment: trim whitespace and stray quotes.
+func cleanFragment(_ s: String) -> String {
+ var t = s.trimmingCharacters(in: .whitespacesAndNewlines)
+ if t.hasPrefix("\"") { t.removeFirst() }
+ if t.hasSuffix("\"") { t.removeLast() }
+ return t.trimmingCharacters(in: .whitespacesAndNewlines)
+}
+
+/// Split streamed text into sentence-sized chunks for progressive delivery.
+/// Ported from the SSE streaming logic in oracle.rs.
+struct SentenceStream {
+ private var accumulated = ""
+ private var delivered = 0
+
+ /// Feed a new text fragment; returns any newly completed sentences.
+ mutating func feed(_ fragment: String) -> [String] {
+ accumulated += fragment
+ var chunks: [String] = []
+
+ while let cut = sentenceCut(accumulated, from: delivered) {
+ let chunk = String(accumulated[accumulated.index(accumulated.startIndex, offsetBy: delivered).. String? {
+ guard delivered < accumulated.count else { return nil }
+ let rest = String(accumulated[accumulated.index(accumulated.startIndex, offsetBy: delivered)...])
+ let cleaned = cleanFragment(rest)
+ delivered = accumulated.count
+ return cleaned.isEmpty ? nil : cleaned
+ }
+}
diff --git a/riddle/Views/ContentView.swift b/riddle/Views/ContentView.swift
new file mode 100644
index 0000000..0a83850
--- /dev/null
+++ b/riddle/Views/ContentView.swift
@@ -0,0 +1,159 @@
+import SwiftUI
+import UIKit
+
+/// The main diary view β direct port of main.rs run() structure.
+struct ContentView: View {
+
+ @StateObject private var viewModel = DiaryViewModel()
+ @State private var showSettings = false
+
+ var body: some View {
+ GeometryReader { geo in
+ ZStack {
+ Color.white.ignoresSafeArea()
+
+ // Drawing canvas β direct pixel surface like the original framebuffer.
+ DiaryCanvasRepresentable(viewModel: viewModel)
+ .ignoresSafeArea()
+
+ // Dissolve overlay β shows the ink fading during Drinking state.
+ // Use .resizable() without aspectRatio to exactly match canvas position.
+ if let image = viewModel.surfaceImage {
+ Image(uiImage: image)
+ .resizable()
+ .frame(width: geo.size.width, height: geo.size.height)
+ .ignoresSafeArea()
+ .allowsHitTesting(false)
+ }
+
+ // Thinking indicator β removed per user request.
+ // The original uses a pulsing ink blot, but user prefers no indicator.
+
+ // Reply text β shown during Replying/Lingering states.
+ if !viewModel.replyText.isEmpty {
+ Text(viewModel.replyText)
+ .font(.custom("DancingScript", size: 28))
+ .foregroundColor(.black)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 60)
+ .allowsHitTesting(false)
+ .transition(.opacity)
+ }
+
+ // Help panel.
+ if viewModel.state == .help {
+ HelpPanelView(dismiss: { viewModel.dismissHelp() })
+ .transition(.opacity)
+ }
+
+ // Eraser indicator.
+ if viewModel.isEraser && viewModel.state == .listening {
+ VStack {
+ HStack {
+ Spacer()
+ Text("Eraser")
+ .font(.caption)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .background(Color.black.opacity(0.7))
+ .foregroundColor(.white)
+ .cornerRadius(8)
+ .padding()
+ }
+ Spacer()
+ }
+ }
+ }
+ }
+ .preferredColorScheme(.light)
+ .statusBarHidden()
+ .onAppear {
+ viewModel.start()
+ // Show settings on first launch if no API key configured.
+ if UserDefaults.standard.string(forKey: "RIDDLE_OPENAI_KEY") == nil {
+ showSettings = true
+ }
+ }
+ .onDisappear { viewModel.stop() }
+ .sheet(isPresented: $showSettings) {
+ SettingsView()
+ }
+ }
+}
+
+// MARK: - Canvas UIViewRepresentable
+
+struct DiaryCanvasRepresentable: UIViewRepresentable {
+ let viewModel: DiaryViewModel
+
+ func makeUIView(context: Context) -> DiaryCanvasView {
+ let view = DiaryCanvasView()
+ viewModel.attachCanvas(view)
+ view.onStrokeEnded = {
+ viewModel.onStrokeEnded()
+ }
+ view.onFiveFingerTap = {
+ viewModel.onFiveFingerTap()
+ }
+ return view
+ }
+
+ func updateUIView(_ uiView: DiaryCanvasView, context: Context) {
+ uiView.setEraser(viewModel.isEraser)
+ if viewModel.shouldClearCanvas {
+ uiView.clearInk()
+ viewModel.canvasCleared()
+ }
+ }
+}
+
+// MARK: - Thinking indicator
+
+struct ThinkingIndicator: View {
+ @State private var pulse = false
+
+ var body: some View {
+ Circle()
+ .fill(Color.black)
+ .frame(width: 10, height: 10)
+ .opacity(pulse ? 0.7 : 0.15)
+ .animation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true), value: pulse)
+ .onAppear { pulse = true }
+ .position(x: UIScreen.main.bounds.width / 2, y: UIScreen.main.bounds.height / 2)
+ }
+}
+
+// MARK: - Help panel
+
+struct HelpPanelView: View {
+ let dismiss: () -> Void
+
+ var body: some View {
+ VStack(spacing: 24) {
+ Text("The Diary")
+ .font(.system(size: 44, weight: .medium, design: .serif).italic())
+ .foregroundColor(.black)
+
+ VStack(spacing: 12) {
+ Text("Write, then rest your quill:")
+ Text("the diary drinks your ink and Tom replies.")
+ Spacer().frame(height: 16)
+ Text("Pencil double-tap to erase.")
+ Text("Draw a large ? for this guide.")
+ Text("Pinch with five fingers to leave.")
+ }
+ .font(.system(size: 22, design: .serif).italic())
+ .foregroundColor(.black)
+ .multilineTextAlignment(.center)
+
+ Text("Touch pencil to page to close.")
+ .font(.system(size: 18, design: .serif).italic())
+ .foregroundColor(.gray)
+ }
+ .padding(50)
+ .background(Color.white)
+ .overlay(RoundedRectangle(cornerRadius: 0).stroke(Color.black, lineWidth: 3).padding(10))
+ .overlay(RoundedRectangle(cornerRadius: 0).stroke(Color.black, lineWidth: 1).padding(20))
+ .onTapGesture { dismiss() }
+ }
+}
diff --git a/riddle/Views/DiaryCanvasView.swift b/riddle/Views/DiaryCanvasView.swift
new file mode 100644
index 0000000..f0a3e2e
--- /dev/null
+++ b/riddle/Views/DiaryCanvasView.swift
@@ -0,0 +1,192 @@
+import UIKit
+
+/// Canvas view using a direct pixel Surface β mirrors the original riddle's approach.
+/// Pen events draw directly to the Surface bitmap; draw() just blits it to screen.
+class DiaryCanvasView: UIView {
+
+ var onStrokeEnded: (() -> Void)?
+ var onFiveFingerTap: (() -> Void)?
+
+ /// The pixel surface (like the reMarkable framebuffer).
+ private(set) var surface: Surface!
+
+ /// The ink capture (like ink.rs Ink).
+ private(set) var ink = Ink()
+
+ private var penDown = false
+ private var isEraser = false
+
+ // MARK: - Init
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ setup()
+ }
+
+ required init?(coder: NSCoder) {
+ super.init(coder: coder)
+ setup()
+ }
+
+ private func setup() {
+ backgroundColor = UIColor.white
+ isOpaque = true
+ isMultipleTouchEnabled = true
+ contentMode = .redraw
+
+ // Create the surface matching the view size (in points = pixels for our scale).
+ let w = max(Int(bounds.width), 1)
+ let h = max(Int(bounds.height), 1)
+ surface = Surface(width: w, height: h)
+
+ let interaction = UIPencilInteraction()
+ interaction.delegate = self
+ addInteraction(interaction)
+ }
+
+ override func layoutSubviews() {
+ super.layoutSubviews()
+ // Recreate surface if size changed significantly.
+ let w = max(Int(bounds.width), 1)
+ let h = max(Int(bounds.height), 1)
+ if surface == nil || surface.width != w || surface.height != h {
+ surface = Surface(width: w, height: h)
+ setNeedsDisplay()
+ }
+ }
+
+ // MARK: - Touch handling (mirrors main.rs pen event loop)
+
+ override func touchesBegan(_ touches: Set, with event: UIEvent?) {
+ if event?.allTouches?.count ?? 0 >= 5 {
+ onFiveFingerTap?()
+ return
+ }
+ guard let touch = touches.first else { return }
+ // Only Apple Pencil can draw. Finger taps are ignored for drawing.
+ guard touch.type == .pencil else { return }
+
+ let point = touch.location(in: self)
+ let pressure = touch.force > 0 ? touch.force / max(touch.maximumPossibleForce, 1) : 0.5
+
+ penDown = true
+ let r = isEraser ? 22 : 2 + Int(pressure * 3)
+ if isEraser {
+ ink.erasePoint(surface, Int(point.x), Int(point.y), r)
+ } else {
+ ink.penPoint(surface, Int(point.x), Int(point.y), r)
+ }
+ setNeedsDisplay()
+ }
+
+ override func touchesMoved(_ touches: Set, with event: UIEvent?) {
+ guard penDown, let touch = touches.first else { return }
+
+ // Use coalesced touches for smooth lines.
+ let coalesced = event?.coalescedTouches(for: touch) ?? [touch]
+ for t in coalesced {
+ let point = t.location(in: self)
+ let pressure = t.force > 0 ? t.force / max(t.maximumPossibleForce, 1) : 0.5
+ let r = isEraser ? 22 : 2 + Int(pressure * 3)
+
+ if isEraser {
+ ink.erasePoint(surface, Int(point.x), Int(point.y), r)
+ } else {
+ ink.penPoint(surface, Int(point.x), Int(point.y), r)
+ }
+ }
+ setNeedsDisplay()
+ }
+
+ override func touchesEnded(_ touches: Set, with event: UIEvent?) {
+ penUp()
+ }
+
+ override func touchesCancelled(_ touches: Set, with event: UIEvent?) {
+ penUp()
+ }
+
+ private func penUp() {
+ guard penDown else { return }
+ penDown = false
+ ink.penUp()
+ onStrokeEnded?()
+ setNeedsDisplay()
+ }
+
+ // MARK: - Render (just blit the surface to screen, like the original)
+
+ override func draw(_ rect: CGRect) {
+ guard let ctx = UIGraphicsGetCurrentContext() else { return }
+ ctx.setFillColor(UIColor.white.cgColor)
+ ctx.fill(rect)
+
+ // toImage() already handles the y-axis flip, so just draw directly.
+ guard let surface = surface, let img = surface.toImage() else { return }
+ img.draw(in: rect)
+ }
+
+ // MARK: - Public API
+
+ /// Clear ink data only (stroke list + bbox), keep surface pixels intact.
+ /// Used before dissolve so the dissolve can work on the actual ink pixels.
+ func clearInkDataOnly() {
+ ink.clear()
+ setNeedsDisplay()
+ }
+
+ func clearInk() {
+ ink.clear()
+ // Clear the surface to white.
+ let w = surface.width
+ let h = surface.height
+ surface.fillRect(0, 0, w, h, Surface.white)
+ setNeedsDisplay()
+ }
+
+ /// Get pen strokes for question mark detection.
+ /// Convert ink strokes to the format QuestionMarkDetector expects.
+ var penStrokes: [Stroke] {
+ ink.strokeList.filter { !$0.isEmpty }.map { strokePoints in
+ Stroke(points: strokePoints.map { (x: CGFloat($0.x), y: CGFloat($0.y), pressure: CGFloat($0.r) / 5) }, isEraser: false)
+ }
+ }
+
+ /// Export ink as PNG for the oracle.
+ func exportToPNG() -> Data? {
+ return ink.toPNG(surface)
+ }
+
+ func setEraser(_ on: Bool) {
+ isEraser = on
+ }
+
+ var isEraserMode: Bool { isEraser }
+}
+
+// MARK: - Apple Pencil double-tap
+
+extension DiaryCanvasView: UIPencilInteractionDelegate {
+ func pencilInteractionDidTap(_ interaction: UIPencilInteraction) {
+ isEraser.toggle()
+ }
+}
+
+/// Stroke type for question mark detection (kept compatible).
+struct Stroke {
+ var points: [(x: CGFloat, y: CGFloat, pressure: CGFloat)]
+ var isEraser: Bool
+
+ var boundingBox: CGRect {
+ guard !points.isEmpty else { return .zero }
+ var minX = CGFloat.greatestFiniteMagnitude
+ var minY = CGFloat.greatestFiniteMagnitude
+ var maxX = -CGFloat.greatestFiniteMagnitude
+ var maxY = -CGFloat.greatestFiniteMagnitude
+ for p in points {
+ minX = min(minX, p.x); minY = min(minY, p.y)
+ maxX = max(maxX, p.x); maxY = max(maxY, p.y)
+ }
+ return CGRect(x: minX, y: minY, width: maxX - minX, height: maxY - minY)
+ }
+}
diff --git a/riddle/Views/DiaryViewModel.swift b/riddle/Views/DiaryViewModel.swift
new file mode 100644
index 0000000..d34ec2a
--- /dev/null
+++ b/riddle/Views/DiaryViewModel.swift
@@ -0,0 +1,337 @@
+import Foundation
+import UIKit
+import SwiftUI
+
+/// The diary's view model β direct port of main.rs run() state machine.
+///
+/// State flow:
+/// Listening β (idle 2.8s) β Drinking β Thinking β Replying β Lingering β Fading β Listening
+@MainActor
+final class DiaryViewModel: ObservableObject {
+
+ @Published var state: DiaryState = .listening
+ @Published var isEraser = false
+ @Published var shouldClearCanvas = false
+ @Published var surfaceImage: UIImage?
+ @Published var replyText: String = ""
+ @Published var showThinking = false
+
+ var canvasView: DiaryCanvasView?
+ private var oracle: Oracle?
+ private var font: CTFont?
+
+ private var idleTimer: Timer?
+ private var dissolveTimer: Timer?
+ private var lingerTimer: Timer?
+
+ private var replyChunks: [String] = []
+ private var oracleDone = false
+
+ // MARK: - Lifecycle
+
+ func start() {
+ font = HandwritingSynthesis.loadDancingScriptFont(size: DiaryConfig.replyPx)
+ do {
+ oracle = try Oracle()
+ print("riddle: oracle ready")
+ } catch {
+ print("riddle: oracle failed: \(error)")
+ }
+ state = .listening
+ print("riddle: the diary is open")
+ }
+
+ func stop() {
+ idleTimer?.invalidate()
+ dissolveTimer?.invalidate()
+ lingerTimer?.invalidate()
+ replyTimer?.invalidate()
+ }
+
+ // MARK: - Canvas bridge
+
+ func attachCanvas(_ view: DiaryCanvasView) {
+ canvasView = view
+ }
+
+ // MARK: - Events
+
+ func onStrokeEnded() {
+ guard case .listening = state else { return }
+ idleTimer?.invalidate()
+ idleTimer = Timer.scheduledTimer(withTimeInterval: DiaryConfig.idleCommit, repeats: false) { [weak self] _ in
+ Task { @MainActor in self?.commitPage() }
+ }
+ }
+
+ func onFiveFingerTap() {
+ exit(0)
+ }
+
+ // MARK: - State transitions (mirrors main.rs state machine)
+
+ /// Idle timeout β commit the page. Port of main.rs ListeningβDrinking transition.
+ private func commitPage() {
+ guard case .listening = state else { return }
+ guard let canvas = canvasView else { return }
+
+ let penStrokes = canvas.penStrokes
+ guard !penStrokes.isEmpty else { return }
+
+ // Check if user drew a "?" β show help.
+ if QuestionMarkDetector.looksLikeQuestionMark(strokes: penStrokes) {
+ shouldClearCanvas = true
+ state = .help
+ return
+ }
+
+ // Export PNG for oracle.
+ guard let pngData = canvas.exportToPNG() else { return }
+
+ // Snapshot the current surface for dissolve display.
+ surfaceImage = canvas.surface.toImage()
+
+ // Ask oracle NOW β model streams while diary drinks the ink (hides latency).
+ replyChunks = []
+ oracleDone = false
+ oracle?.ask(pngData: pngData) { [weak self] chunk in
+ Task { @MainActor in
+ self?.replyChunks.append(chunk)
+ self?.tryStartReply()
+ }
+ } onDone: { [weak self] error in
+ Task { @MainActor in
+ self?.oracleDone = true
+ if let error = error {
+ print("riddle: oracle error: \(error)")
+ if self?.replyChunks.isEmpty ?? true {
+ self?.replyChunks.append("The ink blursβ¦")
+ }
+ }
+ self?.tryStartReply()
+ }
+ }
+
+ // Capture ink bbox BEFORE clearing (needed for dissolve region).
+ let inkRegion = canvas.ink.bbox
+
+ // Clear ink data only (keep surface pixels for dissolve, like main.rs).
+ // The surface pixels will be cleared after dissolve completes.
+ canvas.ink.clear()
+
+ // Start dissolving β 14 stages, 70ms each (like main.rs).
+ state = .drinking(stage: 0, totalStages: DiaryConfig.dissolveStages)
+ runDissolve(region: inkRegion)
+ }
+
+ /// Dissolve animation β port of main.rs Drinking state.
+ private func runDissolve(region: BBox) {
+ guard let canvas = canvasView else {
+ state = .thinking
+ return
+ }
+
+ var stage = 0
+ let stages = DiaryConfig.dissolveStages
+
+ dissolveTimer?.invalidate()
+ dissolveTimer = Timer.scheduledTimer(withTimeInterval: DiaryConfig.dissolveInterval, repeats: true) { [weak self] timer in
+ Task { @MainActor in
+ guard let self = self else { timer.invalidate(); return }
+
+ stage += 1
+ Ink.dissolvePass(canvas.surface, region, stage, stages)
+ self.surfaceImage = canvas.surface.toImage()
+
+ if stage >= stages {
+ timer.invalidate()
+ canvas.clearInk()
+ self.surfaceImage = nil
+ self.state = .thinking
+ self.showThinking = true
+ self.tryStartReply()
+ }
+ }
+ }
+ }
+
+ /// Try to start reply if we have content and dissolve is done. Port of ThinkingβReplying.
+ private func tryStartReply() {
+ if case .drinking = state { return } // Still dissolving.
+
+ if case .thinking = state {
+ guard !replyChunks.isEmpty else { return }
+ showThinking = false
+ let firstChunk = replyChunks.removeFirst()
+ startReply(text: firstChunk)
+ return
+ }
+
+ // Already replying? Append new chunks.
+ if case .replying = state, !replyChunks.isEmpty {
+ let chunk = replyChunks.removeFirst()
+ replyText += " " + chunk
+ }
+ }
+
+ // Reply animation state (like main.rs WritePlan)
+ private var replyPlan: WritePlan?
+ private var replyStrokeI = 0
+ private var replyPointI = 0
+ private var replyTimer: Timer?
+
+ /// Start reply β port of main.rs plan_reply + Replying state.
+ /// Uses handwriting synthesis (rasterize β thin β trace) then animates stroke by stroke.
+ private func startReply(text: String) {
+ state = .replying
+
+ var fullText = text
+ if !replyChunks.isEmpty {
+ fullText += " " + replyChunks.joined(separator: " ")
+ replyChunks.removeAll()
+ }
+
+ guard let canvas = canvasView, let surf = canvas.surface else {
+ replyText = fullText
+ scheduleLinger()
+ return
+ }
+
+ // Load font for handwriting synthesis.
+ let fontSize: CGFloat = 48
+ let font = HandwritingSynthesis.loadDancingScriptFont(size: fontSize)
+
+ // Generate handwriting strokes (like main.rs plan_reply).
+ let plan = HandwritingSynthesis.planReply(
+ text: fullText,
+ font: font ?? CTFontCreateWithName("SnellRoundhand" as CFString, fontSize, nil),
+ screenWidth: CGFloat(surf.width),
+ screenHeight: CGFloat(surf.height)
+ )
+
+ if plan.strokes.isEmpty {
+ replyText = fullText
+ scheduleLinger()
+ return
+ }
+
+ // Clear the surface for the reply.
+ surf.fillRect(0, 0, surf.width, surf.height, Surface.white)
+
+ // Start the writing animation β draw points on the surface one by one.
+ replyPlan = plan
+ replyStrokeI = 0
+ replyPointI = 0
+ replyText = ""
+ surfaceImage = surf.toImage()
+
+ replyTimer?.invalidate()
+ replyTimer = Timer.scheduledTimer(withTimeInterval: DiaryConfig.replyStrokeInterval, repeats: true) { [weak self] timer in
+ Task { @MainActor in
+ guard let self = self, let canvas = self.canvasView, let surf = canvas.surface else {
+ timer.invalidate()
+ return
+ }
+ self.tickReply(surf: surf, timer: timer)
+ }
+ }
+ }
+
+ /// One frame of the reply writing animation β port of main.rs Replying state.
+ /// Draws a budget of points per frame using brushLine + stamp on the surface.
+ private func tickReply(surf: Surface, timer: Timer) {
+ guard let plan = replyPlan else { timer.invalidate(); return }
+
+ var budget = DiaryConfig.replyBudget
+
+ while budget > 0 && replyStrokeI < plan.strokes.count {
+ let stroke = plan.strokes[replyStrokeI]
+
+ if replyPointI >= stroke.points.count {
+ replyStrokeI += 1
+ replyPointI = 0
+ continue
+ }
+
+ let x = Int(stroke.points[replyPointI].x)
+ let y = Int(stroke.points[replyPointI].y)
+
+ if replyPointI > 0 {
+ let px = Int(stroke.points[replyPointI - 1].x)
+ let py = Int(stroke.points[replyPointI - 1].y)
+ surf.brushLine(px, py, x, y, 2, Surface.black)
+ } else {
+ surf.stamp(x, y, 2, Surface.black)
+ }
+
+ replyPointI += 1
+ budget -= 1
+ }
+
+ surfaceImage = surf.toImage()
+
+ if replyStrokeI >= plan.strokes.count {
+ timer.invalidate()
+ scheduleLinger()
+ }
+ }
+
+ /// Schedule the linger phase after reply is written.
+ private func scheduleLinger() {
+ let textLen = replyText.isEmpty ? 50 : replyText.count
+ let linger = min(DiaryConfig.lingerBase + Double(textLen) * DiaryConfig.lingerPerPoint,
+ DiaryConfig.lingerMax)
+ lingerTimer?.invalidate()
+ lingerTimer = Timer.scheduledTimer(withTimeInterval: linger, repeats: false) { [weak self] _ in
+ Task { @MainActor in self?.startFading() }
+ }
+ }
+
+ /// Fade β port of main.rs FadingReply state. Dissolves the reply ink.
+ private func startFading() {
+ guard let canvas = canvasView else {
+ replyText = ""
+ state = .listening
+ return
+ }
+
+ // Fade the reply using dissolve (like main.rs FadingReply).
+ var bounds = BBox(x0: 0, y0: 0, x1: canvas.surface!.width, y1: canvas.surface!.height)
+ if let region = replyPlan?.region, region != .zero {
+ bounds = BBox(x0: max(Int(region.minX), 0),
+ y0: max(Int(region.minY), 0),
+ x1: min(Int(region.maxX), canvas.surface!.width),
+ y1: min(Int(region.maxY), canvas.surface!.height))
+ }
+ var stage = 0
+ let stages = DiaryConfig.fadeStages
+
+ replyTimer?.invalidate()
+ replyTimer = Timer.scheduledTimer(withTimeInterval: DiaryConfig.fadeInterval, repeats: true) { [weak self] timer in
+ Task { @MainActor in
+ guard let self = self, let canvas = self.canvasView else { timer.invalidate(); return }
+
+ stage += 1
+ Ink.dissolvePass(canvas.surface, bounds, stage, stages)
+ self.surfaceImage = canvas.surface.toImage()
+
+ if stage >= stages {
+ timer.invalidate()
+ canvas.surface?.fillRect(0, 0, canvas.surface!.width, canvas.surface!.height, Surface.white)
+ self.surfaceImage = nil
+ self.replyText = ""
+ self.replyPlan = nil
+ self.state = .listening
+ }
+ }
+ }
+ }
+
+ func dismissHelp() {
+ state = .listening
+ }
+
+ func canvasCleared() {
+ shouldClearCanvas = false
+ }
+}
diff --git a/riddle/Views/InkDissolver.swift b/riddle/Views/InkDissolver.swift
new file mode 100644
index 0000000..75466d5
--- /dev/null
+++ b/riddle/Views/InkDissolver.swift
@@ -0,0 +1,2 @@
+// InkDissolver and ReplyAnimationView are now handled by Surface.swift + DiaryViewModel.swift
+// This file is kept for project structure compatibility.
diff --git a/riddle/Views/SettingsView.swift b/riddle/Views/SettingsView.swift
new file mode 100644
index 0000000..be2b4cf
--- /dev/null
+++ b/riddle/Views/SettingsView.swift
@@ -0,0 +1,64 @@
+import SwiftUI
+
+/// Settings screen for configuring the Oracle API.
+/// Users enter their own API key, base URL, and model.
+/// Supports any OpenAI-compatible API (OpenAI, OpenRouter, Volcano Ark, Groq, local server, etc.)
+struct SettingsView: View {
+ @AppStorage("RIDDLE_OPENAI_KEY") private var apiKey: String = ""
+ @AppStorage("RIDDLE_OPENAI_BASE") private var baseURL: String = "https://api.openai.com/v1"
+ @AppStorage("RIDDLE_OPENAI_MODEL") private var model: String = "gpt-4o-mini"
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ NavigationView {
+ Form {
+ Section(header: Text("API Configuration")) {
+ SecureField("API Key", text: $apiKey)
+ .autocapitalization(.none)
+ .disableAutocorrection(true)
+
+ TextField("Base URL", text: $baseURL)
+ .autocapitalization(.none)
+ .disableAutocorrection(true)
+
+ TextField("Model", text: $model)
+ .autocapitalization(.none)
+ .disableAutocorrection(true)
+ }
+
+ Section(header: Text("Presets")) {
+ Button("OpenAI (gpt-4o-mini)") {
+ baseURL = "https://api.openai.com/v1"
+ model = "gpt-4o-mini"
+ }
+ Button("OpenRouter") {
+ baseURL = "https://openrouter.ai/api/v1"
+ model = "openai/gpt-4o-mini"
+ }
+ Button("Volcano Ark (doubao-seed-2-0-pro)") {
+ baseURL = "https://ark.cn-beijing.volces.com/api/plan/v3"
+ model = "doubao-seed-2-0-pro"
+ }
+ Button("Groq") {
+ baseURL = "https://api.groq.com/openai/v1"
+ model = "llama-3.2-90b-vision-preview"
+ }
+ }
+
+ Section(header: Text("Note")) {
+ Text("Any OpenAI-compatible, vision-capable API works. The model must support image input to read your handwriting.")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+ .navigationTitle("Settings")
+ .toolbar {
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button("Done") {
+ dismiss()
+ }
+ }
+ }
+ }
+ }
+}