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() + } + } + } + } + } +}