From a5403f74537b836d26a8965153fbe9857267f10f Mon Sep 17 00:00:00 2001 From: 18631665217 Date: Mon, 6 Jul 2026 23:15:23 +0800 Subject: [PATCH] iPad port with Apple Pencil support Port of MaximeRivest/riddle for iPad + Apple Pencil. - Swift/SwiftUI implementation (original is Rust) - Apple Pencil input via UITouch (replaces evdev) - Surface pixel buffer (replaces reMarkable framebuffer) - Zhang-Suen thinning + stroke tracing (ported from script.rs) - Ink dissolve with px_hash (ported from ink.rs) - OpenAI-compatible API with in-app Settings screen - Supports any vision-capable LLM (OpenAI, OpenRouter, Volcano Ark, Groq) --- .gitignore | 35 +- Info.plist | 42 ++ README.md | 190 +++------ Riddle.xcodeproj/project.pbxproj | 395 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + project.yml | 45 ++ riddle/Models/DiaryState.swift | 63 +++ riddle/Resources/DancingScript.ttf | Bin 0 -> 133636 bytes riddle/RiddleApp.swift | 12 + riddle/Services/HandwritingSynthesis.swift | 274 ++++++++++++ riddle/Services/Oracle.swift | 208 +++++++++ riddle/Services/Surface.swift | 273 ++++++++++++ riddle/Utils/QuestionMarkDetector.swift | 91 ++++ riddle/Utils/SentenceSplitter.swift | 67 +++ riddle/Views/ContentView.swift | 159 +++++++ riddle/Views/DiaryCanvasView.swift | 192 +++++++++ riddle/Views/DiaryViewModel.swift | 337 +++++++++++++++ riddle/Views/InkDissolver.swift | 2 + riddle/Views/SettingsView.swift | 64 +++ 19 files changed, 2312 insertions(+), 144 deletions(-) create mode 100644 Info.plist create mode 100644 Riddle.xcodeproj/project.pbxproj create mode 100644 Riddle.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 project.yml create mode 100644 riddle/Models/DiaryState.swift create mode 100644 riddle/Resources/DancingScript.ttf create mode 100644 riddle/RiddleApp.swift create mode 100644 riddle/Services/HandwritingSynthesis.swift create mode 100644 riddle/Services/Oracle.swift create mode 100644 riddle/Services/Surface.swift create mode 100644 riddle/Utils/QuestionMarkDetector.swift create mode 100644 riddle/Utils/SentenceSplitter.swift create mode 100644 riddle/Views/ContentView.swift create mode 100644 riddle/Views/DiaryCanvasView.swift create mode 100644 riddle/Views/DiaryViewModel.swift create mode 100644 riddle/Views/InkDissolver.swift create mode 100644 riddle/Views/SettingsView.swift 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 0000000000000000000000000000000000000000..9f521e7fb357ec9ea9820ebd36a9d7b1f72f2935 GIT binary patch literal 133636 zcmdSC2b^R@wJ=_FZ|9tI&N-*ase8iC%+AbC4m&%`CN{Adc9&&=B_mlRC_X`gf&tNo z2~{`j z`P%7H?-%Xed~`qaKJqO7Jk)oc zwR!Kh-lHn$+mmo#@VEW@4jq2vROfFI`l$>dtz!SdZTrs@Ty+UN58sgxC<#rY-pQv< zwQf?j{}r+93jje+sxi5bem-;OLsj+Z-|#g&LU#ew<^I8Q>@BCEF7K~Zb(PQ8(3Wa9 z$%VOyK;ZA)@RkSRN%E%Ee?0ik!+*xtAwJZ)k^zj-Fn7hvD1*MaS{0$&5jwvc?$=@K z$JSTSBM800)NmNY8KA&>$$dP+JPsEZV)4h}8xL9^ufEKzAT47+2Igv1!mn2UjDLlC znFxH}iHi8U)o1Vrs?Ra&k%MWiewKLy>R*5o%s!-Nlt{xoh`dY>QZc6LKk*+>h4~sX zGe?n`y%MfBL3uwiGUrr(hyT6$JifB}I_|5!fuF9vf=8>b;!^ctd`a~s{4=1ypQ~@6 zXR6)!%fQJmA(pwc`g8mQyn8P^b6NGn_8qpm}K>j_%ssWUsV5&e_efxNuxB-pbc=gGX*q*dDR!OsQLhY5bePqs{Q~y zT74b;7>UqNkOV!2Fr$FaKSBZg8{|Xhp^MO8pwXHA1IVZ z2%j{UU8qR-hTRThM|kJ|wA6TweF}Ju@LN-1b@08d`dOX<_>b^lz1)b#*>$z=gfIU; zF02zI@SDg<=K+}u%?0KKnHy`$W|(giD287|ZDh_saX01;nLqzSh0MX`LgylxkN;JH zc}nIgov+_OA@=_2t;{a~pZ`vIH=O^A=cp_o^5FkbVVu?HNujca$eZRuWDhCNR9|5Y zH916N(f>rb8-=JGA~LPH{2b&Ek#Vnrj3aW6TwkeO$!8A!WaA!u$KP+jx%)q@b|WZ6 zKH{sY&kz~OmFj6626_G%eE%a{iOj5mOd?lOzEypO`FQO!k)>(}XrrcJ2 zm5Ej#X8fS{o(3KF=jv-~%B%4GpVc>60qC{Ykc52<`gA_piH&GC=#*Z(1r6dLn!s{Y z#+vHo%okCXIfdfPC(tCm3>{`t=q~gs3Ng1KFsRY#P)-Jz8jhGZ;==OJMiH* zQ5M_bnu06ne^MADGJtN(ft_JszJMkfEsV`G)!#DrL*K5bs)4uEOcd^ofer8;&}-X~ zfcY7;dm&2j?gSg)6Hw-=KVr^9KA3gx`@fyTqhN?e-ey(SK3T+Pn%`S##Zbw39 z9F5TuM^|GpTEZ+m&x3Lv8o+m=0Q)3Lpzji!r}`!MBZBKQaJ9qrc>;wi*u~YK)XMMR zdN&keSF&)u7YcnEu5Utl5Xw3z&{p*dC}2-kr=jeDG71H_t9lyB{qVdNuJDfP*PuKK zrT&f^;Pczs{}*kK!2G%ywS&zuhlk-xFnSv4==Ejz{4;tF;TF_R>s8@;FFFP^g@29r zlJ^r_n#&_ZzOZLfnvlxGa)o|F+bklss1EV?pN8w@=mL;iKs_W!Nz{V6(E?gQ$I(fA z5?_mNz<1zJ;qT$!;TIVz6KAqak(p;6X1>XMoB27*V`Z$8)v#99$A;Jhn`ZNlOC0~^ z_=ij9vbY>Bk1OCxxmsOi*J0NkuDe_xaoy|sgzM9;2VGxueaUTbdpvxP#-sO`J%gSV z&$c(1Y8CJxn7B|1wW1!hh&H2h(d`tI+wuMQQT#mq1O6uyVNx|r9s^8%!Xj1xn5Y30 zFB_;~asgm+yG!dbxoj>M#iXT%$(^oy0F#fo9so?f;ClESm~4Fm1T1q3{}pf%0A0Td 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 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() + } + } + } + } + } +}