diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index d416eb1b6..f668298b9 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -72,7 +72,7 @@ jobs: timeout_minutes: 30 max_attempts: 3 shell: pwsh - command: ./gradlew :desktop:packageReleaseMsi -Pchannel=github + command: ./gradlew :desktop:packageReleaseMsi -Pchannel=github -Pformat=msi - name: Rename MSI Artifact shell: cmd @@ -104,7 +104,7 @@ jobs: timeout_minutes: 30 max_attempts: 3 shell: pwsh - command: ./gradlew :desktop:packageReleaseExe -Pchannel=github + command: ./gradlew :desktop:packageReleaseExe -Pchannel=github -Pformat=exe - name: Rename EXE Artifact shell: cmd @@ -130,6 +130,17 @@ jobs: echo Successfully renamed file to "hammer.exe" + - name: Write update digests + shell: pwsh + run: ./gradlew writeAssetDigests "-PassetFiles=desktop/build/installers/main-release/msi/hammer.msi,desktop/build/installers/main-release/exe/hammer.exe" + + - name: Upload update digests + uses: actions/upload-artifact@v7 + with: + name: update-digests-windows + path: desktop/build/installers/main-release/*/hammer.*.sha512 + if-no-files-found: error + - name: Create Release uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0 env: @@ -192,7 +203,7 @@ jobs: with: timeout_minutes: 30 max_attempts: 3 - command: ./gradlew :desktop:packageReleaseDeb -Pchannel=github + command: ./gradlew :desktop:packageReleaseDeb -Pchannel=github -Pformat=deb - name: Rename DEB Artifact run: | @@ -216,7 +227,7 @@ jobs: with: timeout_minutes: 30 max_attempts: 3 - command: ./gradlew :desktop:packageReleaseRpm -Pchannel=github + command: ./gradlew :desktop:packageReleaseRpm -Pchannel=github -Pformat=rpm - name: Rename RPM Artifact run: | @@ -240,7 +251,17 @@ jobs: with: timeout_minutes: 30 max_attempts: 3 - command: ./gradlew buildDistAppImage -Pchannel=appimage + command: ./gradlew buildDistAppImage -Pchannel=appimage -Pformat=appimage + + - name: Write update digests + run: ./gradlew writeAssetDigests "-PassetFiles=desktop/build/installers/main-release/deb/hammer.deb,desktop/build/installers/main-release/rpm/hammer.rpm,desktop/build/installers/main-release/appimage/hammer.AppImage" + + - name: Upload update digests + uses: actions/upload-artifact@v7 + with: + name: update-digests-linux + path: desktop/build/installers/main-release/*/hammer.*.sha512 + if-no-files-found: error - name: Create Release uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0 @@ -283,7 +304,7 @@ jobs: with: timeout_minutes: 30 max_attempts: 3 - command: ./gradlew :desktop:packageReleaseDmg -Pchannel=github + command: ./gradlew :desktop:packageReleaseDmg -Pchannel=github -Pformat=dmg - name: Rename DMG Artifact run: | @@ -307,7 +328,7 @@ jobs: with: timeout_minutes: 30 max_attempts: 3 - command: ./gradlew :desktop:packageReleasePkg -Pchannel=github + command: ./gradlew :desktop:packageReleasePkg -Pchannel=github -Pformat=pkg - name: Rename PKG Artifact run: | @@ -328,6 +349,16 @@ jobs: echo "Contents of pkg directory:" ls -la + - name: Write update digests + run: ./gradlew writeAssetDigests "-PassetFiles=desktop/build/installers/main-release/dmg/hammer.dmg,desktop/build/installers/main-release/pkg/hammer.pkg" + + - name: Upload update digests + uses: actions/upload-artifact@v7 + with: + name: update-digests-macos + path: desktop/build/installers/main-release/*/hammer.*.sha512 + if-no-files-found: error + - name: Create Release uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0 env: @@ -465,6 +496,9 @@ jobs: echo "File renamed successfully." + - name: Rename R8 Mapping + run: cp "$GITHUB_WORKSPACE/android/build/outputs/mapping/release/mapping.txt" "$GITHUB_WORKSPACE/android/build/outputs/apk/release/hammer-apk-mapping.txt" + - name: Create Release uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0 env: @@ -474,7 +508,7 @@ jobs: prerelease: true artifactErrorsFailBuild: true allowUpdates: true - artifacts: "${{ github.workspace }}/android/build/outputs/apk/release/hammer.apk" + artifacts: "${{ github.workspace }}/android/build/outputs/apk/release/hammer.apk,${{ github.workspace }}/android/build/outputs/apk/release/hammer-apk-mapping.txt" snap: needs: tests @@ -591,6 +625,47 @@ jobs: - name: Fetch tags run: git fetch --depth=1 origin +refs/tags/*:refs/tags/* + - name: Set up JDK + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '21' + + - name: Gradle caching + uses: actions/cache@v6 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + # The desktop updater reads latest*.yml from the release. Each platform job hashed + # the installer it attached and handed the digest over as a workflow artifact, so + # nothing is downloaded back and no release lookup is needed. Attached before the + # draft flips, so a published release never exists without them. + - name: Collect update digests + uses: actions/download-artifact@v7 + with: + pattern: update-digests-* + merge-multiple: true + path: ${{ runner.temp }}/update-digests + + - name: Write update metadata + run: ./gradlew writeUpdateMetadata "-PreleaseDigestsDir=${{ runner.temp }}/update-digests" --no-daemon + + - name: Attach update metadata + uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + draft: true + prerelease: true + artifactErrorsFailBuild: true + allowUpdates: true + artifacts: "${{ github.workspace }}/build/update-metadata/latest.yml,${{ github.workspace }}/build/update-metadata/latest-mac.yml,${{ github.workspace }}/build/update-metadata/latest-linux.yml" + - name: Get last tag message run: | TAG_NAME=${GITHUB_REF#refs/tags/} diff --git a/.github/workflows/publish-google-play.yml b/.github/workflows/publish-google-play.yml index 6d01db373..ac51a276a 100644 --- a/.github/workflows/publish-google-play.yml +++ b/.github/workflows/publish-google-play.yml @@ -89,3 +89,15 @@ jobs: esac echo "Running fastlane $LANE for track=$TRACK" bundle exec fastlane "$LANE" + + # Keeps the mappings for the exact Play builds next to the release, so prod + # crash traces can be retraced without the Play Console. + - name: Attach R8 mappings to the GitHub release + if: github.event_name == 'release' + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event.release.tag_name }} + run: | + cp android/build/outputs/mapping/release/mapping.txt hammer-google-play-mapping.txt + cp wear/build/outputs/mapping/release/mapping.txt hammer-wear-mapping.txt + gh release upload "$TAG" hammer-google-play-mapping.txt hammer-wear-mapping.txt --clobber diff --git a/.github/workflows/publish-microsoft-store.yml b/.github/workflows/publish-microsoft-store.yml index f7ab6c96b..2f8d84899 100644 --- a/.github/workflows/publish-microsoft-store.yml +++ b/.github/workflows/publish-microsoft-store.yml @@ -27,7 +27,7 @@ jobs: cache: gradle - name: Build MSIX package - run: ./gradlew :desktop:packageMsix -Pchannel=ms-store + run: ./gradlew :desktop:packageMsix -Pchannel=ms-store -Pformat=msix - name: Upload Gradle problems report on failure if: failure() diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index b98ca1ec5..75b102dab 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -11,6 +11,8 @@ jobs: publish-google-play: if: ${{ !contains(github.event.release.tag_name, '+') || contains(github.event.release.tag_name, '+google-play') }} uses: ./.github/workflows/publish-google-play.yml + permissions: + contents: write secrets: inherit with: track: production diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c5b7b67c..285a08301 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ # Changelog +## [3.12.0] - 2026-10-5 + +New features +- Reader Mode: read your story as prose, one chapter at a time, instead of through the scene editor. Open it from the Read button under the scene list, the book icon in the scene editor, or the editor menu. The scene list becomes a table of contents, and the reader has its own font size +- Export: three new options in the Export Story dialog. Number chapter titles, pick a font (Word, RTF and EPUB), and choose whether to keep blank lines between paragraphs +- Android and iOS: an OLED theme, the dark theme on a pure black background +- Desktop (Windows and Linux installs from GitHub): Hammer tells you when a newer release is out and can update itself from the project list or the About screen. macOS DMG installs are told and sent to the release page. Automatic checks can be turned off in Settings +- Text editor 3.0: Huge improvements in input and performance +- Text editor: change the text size with Ctrl/Cmd and Plus, Minus or 0, or from the format bar's overflow menu +- Text editor: Delete in the right-click menu +- Turkish translation +- Web: a fifth reader reaction on public stories, "Surprised me" + +Improvements +- Syncing with unsaved work now asks Save, Discard or Cancel first, where before it silently saved every unsaved scene +- Open notes, timeline events, encyclopedia entries and the scene title, outline and notes refresh when a sync or conflict resolution changes them. An edit in progress keeps its draft +- The Backup Manager lists backups of projects that are no longer on disk, so a deleted project can be restored +- Exports no longer number chapter titles unless you turn the new option on. Word, RTF, PDF and Markdown used to number them always +- Text editor: the heading button writes real Markdown headings, and Hammer's shortcuts land on the same keys on non-QWERTY layouts +- Android: a launch splash screen on every supported version +- Translations and dependencies updated + +Fixes +- Android: unsaved scene edits could be lost when the app was closed in the background. They now come back as unsaved scenes the next time the project opens [#983] +- Export: unsaved scene edits were left out of the exported file [#983] +- Restoring a backup from the Backup Manager made the project disappear from the project list +- Choosing Discard on close with automatic sync on still saved and uploaded the discarded scenes +- PDF export: long paragraphs ran off the bottom of the page or left large blank gaps +- Android: a crash on launch when the saved login could not be decrypted, for example after restoring the device from a backup. You are asked to sign in again +- iOS: a crash when tapping into a text editor +- Server URL, number, tag and word-list fields no longer autocorrect or capitalize, which put a space after each "." in a server URL +- Encyclopedia references no longer match a name inside a longer word with accented or non-Latin letters ("Ana" in "Anaïs") + + ## [3.11.0] - 2026-9-24 New features diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index f7573b67c..4f140dc73 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -174,6 +174,19 @@ enum on purpose: every channel-conditional branch is a greppable `when` on the t Capability flags (`canSelfUpdate`, `isSandboxed`, …) can be introduced later, from real duplication rather than a guess at the axes. +The build side reads the same token through `Project.distributionChannel()` in buildSrc: +`desktop/build.gradle.kts` derives its Mac App Store packaging mode (`isAppStoreRelease`: +sandbox entitlements, provisioning profiles, signing) from `-Pchannel=mac-app-store`. Together +with `-PbuildNumber=N`, that is everything `build-appstore.sh` passes. + +Packaging runs carry a second stamp, `-Pformat=` (`msi`, `exe`, `deb`, `rpm`, `dmg`, +`pkg`, `appimage`, `msix`, `snap`, `flatpak`), read the same way into +`BuildMetadata.PACKAGE_FORMAT` and `PackageFormat.current`. The channel says which vehicle a +build is for; the format says which installer it came out of, which is what the desktop +updater needs to pick its download. Omitting it gives `none`, an unknown token fails the +build, and each release step passes its own. Two packaging tasks in one Gradle invocation +would both carry the first task's format, so the release workflow runs one per step. + Note that every branch compiles into every build, so a store-forbidden code path is physically present in a store binary even when unreachable. If a store ever objects to presence rather than behaviour, promote that one case to a real source set rather than diff --git a/android/build.gradle.kts b/android/build.gradle.kts index a83771780..78478cd8d 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -40,6 +40,7 @@ dependencies { implementation(libs.work.runtime.ktx) implementation(libs.material) implementation(libs.appcompat) + implementation(libs.core.splashscreen) implementation(libs.multiplatform.settings) androidTestImplementation(libs.androidx.junit) diff --git a/android/src/gms/kotlin/com/darkrockstudios/apps/hammer/android/wear/WearPairingActivity.kt b/android/src/gms/kotlin/com/darkrockstudios/apps/hammer/android/wear/WearPairingActivity.kt index 3bb013fac..68fd1f7f4 100644 --- a/android/src/gms/kotlin/com/darkrockstudios/apps/hammer/android/wear/WearPairingActivity.kt +++ b/android/src/gms/kotlin/com/darkrockstudios/apps/hammer/android/wear/WearPairingActivity.kt @@ -69,7 +69,7 @@ class WearPairingActivity : ComponentActivity(), KoinComponent { setContent { val isDark = when (settings.uiTheme) { UiTheme.Light -> false - UiTheme.Dark -> true + UiTheme.Dark, UiTheme.OledBlack -> true UiTheme.FollowSystem -> isSystemInDarkTheme() } diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 692f75d6e..0fe741212 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -23,6 +23,7 @@ diff --git a/android/src/main/kotlin/com/darkrockstudios/apps/hammer/android/ProjectRootActivity.kt b/android/src/main/kotlin/com/darkrockstudios/apps/hammer/android/ProjectRootActivity.kt index 69f25ba73..dc3a8f639 100644 --- a/android/src/main/kotlin/com/darkrockstudios/apps/hammer/android/ProjectRootActivity.kt +++ b/android/src/main/kotlin/com/darkrockstudios/apps/hammer/android/ProjectRootActivity.kt @@ -109,7 +109,7 @@ class ProjectRootActivity : AppCompatActivity() { val settingsState by globalSettings.subscribeAsState() val isDark = when (settingsState.uiTheme) { UiTheme.Light -> false - UiTheme.Dark -> true + UiTheme.Dark, UiTheme.OledBlack -> true UiTheme.FollowSystem -> isSystemInDarkTheme() } @@ -220,7 +220,8 @@ class ProjectRootActivity : AppCompatActivity() { // Only intercept back when at Home AND there's potential unsaved work to confirm // Otherwise, let Android's default back behavior close the activity - BackHandler(enabled = backEnabled && (component.hasUnsavedBuffers() || shouldConfirmClose.isNotEmpty())) { + val unsavedBuffers by component.unsavedBuffers.subscribeAsState() + BackHandler(enabled = backEnabled && (unsavedBuffers || shouldConfirmClose.isNotEmpty())) { component.requestClose() } diff --git a/android/src/main/kotlin/com/darkrockstudios/apps/hammer/android/ProjectSelectActivity.kt b/android/src/main/kotlin/com/darkrockstudios/apps/hammer/android/ProjectSelectActivity.kt index 7b3b2d475..ecea069c1 100644 --- a/android/src/main/kotlin/com/darkrockstudios/apps/hammer/android/ProjectSelectActivity.kt +++ b/android/src/main/kotlin/com/darkrockstudios/apps/hammer/android/ProjectSelectActivity.kt @@ -14,6 +14,7 @@ import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.runtime.ExperimentalComposeApi import androidx.compose.runtime.getValue import androidx.compose.ui.platform.LocalContext +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.lifecycleScope import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.retainedComponent @@ -43,6 +44,7 @@ class ProjectSelectActivity : AppCompatActivity() { private var settingsUpdateJob: Job? = null override fun onCreate(savedInstanceState: Bundle?) { + installSplashScreen() super.onCreate(savedInstanceState) enableEdgeToEdge() @@ -61,7 +63,7 @@ class ProjectSelectActivity : AppCompatActivity() { val settingsState by globalSettings.subscribeAsState() val isDark = when (settingsState.uiTheme) { UiTheme.Light -> false - UiTheme.Dark -> true + UiTheme.Dark, UiTheme.OledBlack -> true UiTheme.FollowSystem -> isSystemInDarkTheme() } diff --git a/android/src/main/kotlin/com/darkrockstudios/apps/hammer/android/preview/ProjectSelectActivity.Preview.kt b/android/src/main/kotlin/com/darkrockstudios/apps/hammer/android/preview/ProjectSelectActivity.Preview.kt index c094e6f5a..09c155676 100644 --- a/android/src/main/kotlin/com/darkrockstudios/apps/hammer/android/preview/ProjectSelectActivity.Preview.kt +++ b/android/src/main/kotlin/com/darkrockstudios/apps/hammer/android/preview/ProjectSelectActivity.Preview.kt @@ -18,6 +18,7 @@ import com.darkrockstudios.apps.hammer.common.components.projectselection.projec import com.darkrockstudios.apps.hammer.common.components.storyeditor.metadata.ProjectMetadata import com.darkrockstudios.apps.hammer.base.http.storyideas.StoryIdea import com.darkrockstudios.apps.hammer.common.data.ImportOptions +import com.darkrockstudios.apps.hammer.common.data.appupdate.AppUpdateState import com.darkrockstudios.apps.hammer.common.data.Msg import com.darkrockstudios.apps.hammer.common.data.ProjectDef import com.darkrockstudios.apps.hammer.common.fileio.HPath @@ -95,6 +96,8 @@ val component = object : ProjectSelection { override val changelog: Value = MutableValue(ProjectSelection.ChangelogState()) + override val appUpdate: Value = MutableValue(AppUpdateState.Unsupported) + override fun isAtRoot() = false override fun onBack() {} override fun showLocation(location: ProjectSelection.Locations) {} @@ -102,6 +105,8 @@ val component = object : ProjectSelection { override fun openLatestRelease() {} override fun dismissChangelog() {} override fun showChangelog() {} + override fun updateApp() {} + override fun dismissUpdate() {} override val backHandler = dummyBackHandler } diff --git a/android/src/main/kotlin/com/darkrockstudios/apps/hammer/android/widgets/AddNoteActivity.kt b/android/src/main/kotlin/com/darkrockstudios/apps/hammer/android/widgets/AddNoteActivity.kt index 6f4012e9a..3a54625ac 100644 --- a/android/src/main/kotlin/com/darkrockstudios/apps/hammer/android/widgets/AddNoteActivity.kt +++ b/android/src/main/kotlin/com/darkrockstudios/apps/hammer/android/widgets/AddNoteActivity.kt @@ -97,7 +97,7 @@ class AddNoteActivity : ComponentActivity(), KoinComponent { val settingsState by globalSettings.subscribeAsState() val isDark = when (settingsState.uiTheme) { UiTheme.Light -> false - UiTheme.Dark -> true + UiTheme.Dark, UiTheme.OledBlack -> true UiTheme.FollowSystem -> isSystemInDarkTheme() } diff --git a/android/src/main/kotlin/com/darkrockstudios/apps/hammer/android/widgets/AddNoteWidgetConfigActivity.kt b/android/src/main/kotlin/com/darkrockstudios/apps/hammer/android/widgets/AddNoteWidgetConfigActivity.kt index aa5ecef3d..38f3fceee 100644 --- a/android/src/main/kotlin/com/darkrockstudios/apps/hammer/android/widgets/AddNoteWidgetConfigActivity.kt +++ b/android/src/main/kotlin/com/darkrockstudios/apps/hammer/android/widgets/AddNoteWidgetConfigActivity.kt @@ -90,7 +90,7 @@ class AddNoteWidgetConfigActivity : ComponentActivity() { val settingsState by globalSettings.subscribeAsState() val isDark = when (settingsState.uiTheme) { UiTheme.Light -> false - UiTheme.Dark -> true + UiTheme.Dark, UiTheme.OledBlack -> true UiTheme.FollowSystem -> isSystemInDarkTheme() } diff --git a/android/src/main/kotlin/com/darkrockstudios/apps/hammer/android/widgets/StoryInfoWidgetConfigActivity.kt b/android/src/main/kotlin/com/darkrockstudios/apps/hammer/android/widgets/StoryInfoWidgetConfigActivity.kt index 02fdc9cab..a41f53df1 100644 --- a/android/src/main/kotlin/com/darkrockstudios/apps/hammer/android/widgets/StoryInfoWidgetConfigActivity.kt +++ b/android/src/main/kotlin/com/darkrockstudios/apps/hammer/android/widgets/StoryInfoWidgetConfigActivity.kt @@ -88,7 +88,7 @@ class StoryInfoWidgetConfigActivity : ComponentActivity() { val settingsState by globalSettings.subscribeAsState() val isDark = when (settingsState.uiTheme) { UiTheme.Light -> false - UiTheme.Dark -> true + UiTheme.Dark, UiTheme.OledBlack -> true UiTheme.FollowSystem -> isSystemInDarkTheme() } diff --git a/android/src/main/res/values-de/strings_wear_pairing.xml b/android/src/main/res/values-de/strings_wear_pairing_android.xml similarity index 100% rename from android/src/main/res/values-de/strings_wear_pairing.xml rename to android/src/main/res/values-de/strings_wear_pairing_android.xml diff --git a/android/src/main/res/values-es/strings_wear_pairing.xml b/android/src/main/res/values-es/strings_wear_pairing_android.xml similarity index 100% rename from android/src/main/res/values-es/strings_wear_pairing.xml rename to android/src/main/res/values-es/strings_wear_pairing_android.xml diff --git a/android/src/main/res/values-fr/strings_wear_pairing_android.xml b/android/src/main/res/values-fr/strings_wear_pairing_android.xml new file mode 100644 index 000000000..8fbaab47f --- /dev/null +++ b/android/src/main/res/values-fr/strings_wear_pairing_android.xml @@ -0,0 +1,15 @@ + + + APPAIRER LA MONTRE + Se connecter à %1$s sur Hammer ? + La montre sera synchronisée en tant que %1$s avec sa propre session. Vous pouvez la déconnecter depuis la montre à tout moment. + Connectez-vous au serveur sur ce smartphone en premier, puis appairez la montre de nouveau. + Appairer + Refuser + Fermer + Montre appairée + La montre n\'a pas pu être appairée + Appairage de la montre + Appairez votre montre + Tapoter pour que votre montre se synchronise avec votre compte Hammer + diff --git a/android/src/main/res/values-it/strings_android.xml b/android/src/main/res/values-it/strings_android.xml index 9e612f14f..9afc7c03d 100644 --- a/android/src/main/res/values-it/strings_android.xml +++ b/android/src/main/res/values-it/strings_android.xml @@ -46,5 +46,3 @@ P CONTINUA · - - diff --git a/android/src/main/res/values-it/strings_wear_pairing.xml b/android/src/main/res/values-it/strings_wear_pairing.xml deleted file mode 100644 index 69076f70c..000000000 --- a/android/src/main/res/values-it/strings_wear_pairing.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - PAIR WATCH - Sign in %1$s to Hammer? - The watch will sync as %1$s with a session of its own. You can sign it out from the watch at any time. - Sign in to a sync server on this phone first, then pair the watch again. - Pair - Rifiuta - Chiudi - Watch paired - The watch could not be paired - Watch pairing - Pair your watch - Tap to let your watch sync with your Hammer account - diff --git a/android/src/main/res/values-it/strings_wear_pairing_android.xml b/android/src/main/res/values-it/strings_wear_pairing_android.xml new file mode 100644 index 000000000..cac4461ed --- /dev/null +++ b/android/src/main/res/values-it/strings_wear_pairing_android.xml @@ -0,0 +1,15 @@ + + + ASSOCIA OROLOGIO + Accedere come %1$s su Hammer? + L’orologio si sincronizzerà come %1$s con una propria sessione. Puoi disconnetterlo dall’orologio in qualsiasi momento. + Accedi prima a un server di sincronizzazione su questo telefono, poi associa nuovamente l’orologio. + Associa + Rifiuta + Chiudi + Orologio associato + Impossibile associare l’orologio + Associazione orologio + Associa il tuo orologio + Tocca per consentire all’orologio di sincronizzarsi con il tuo account Hammer + diff --git a/android/src/main/res/values-pt-rBR/strings_wear_pairing.xml b/android/src/main/res/values-pt-rBR/strings_wear_pairing_android.xml similarity index 100% rename from android/src/main/res/values-pt-rBR/strings_wear_pairing.xml rename to android/src/main/res/values-pt-rBR/strings_wear_pairing_android.xml diff --git a/android/src/main/res/values-tr/strings_android.xml b/android/src/main/res/values-tr/strings_android.xml new file mode 100644 index 000000000..cb2213594 --- /dev/null +++ b/android/src/main/res/values-tr/strings_android.xml @@ -0,0 +1,48 @@ + + + Hammer + Not Ekle + Yükleniyor… + Widget\'ı Yapılandır + Diğer Menüler + Gezinti Çekmecesi + Notu Şuraya Ekle: + Kaydet + İptal + Projeler + Proje bulunamadı: %1$s + Nottan Vazgeçilsin mi? + Vazgeç + İptal + Not Ekle + Proje yok + Not Eklendi + Not Ekle + Hikâye Notu Ekle + Not ekleme şu anda devre dışı + Widget\'ı Yapılandır + Not Widget\'ını yapılandır: + Notları belirtilen projeye ekle + Eğer seçili bir proje yoksa her seferinde bir proje seçmen istenir. + Hammer · Kütüphane — bir hikâyeyi aç + Hammer · Hikâye — bir proje için özet istatistikler + Hikâye Bilgisi Widget\'ı: + Hikâye Bilgisi Widget\'ını Yapılandır + Yapılandırmak için dokun + § %1$s · HİKÂYE + - %1$s + WORDS + %1$d%% OF %2$s %3$s + TODAY + THIS WEEK + +%1$d TODAY + NO SESSION TODAY + %1$dD STREAK + SESSIONS · 7D + %1$s W + OPEN + NOTE + § %1$s + W + CONTINUE · + diff --git a/android/src/main/res/values/strings_wear_pairing.xml b/android/src/main/res/values-tr/strings_wear_pairing_android.xml similarity index 100% rename from android/src/main/res/values/strings_wear_pairing.xml rename to android/src/main/res/values-tr/strings_wear_pairing_android.xml diff --git a/android/src/main/res/values-uk/strings_wear_pairing.xml b/android/src/main/res/values-uk/strings_wear_pairing_android.xml similarity index 100% rename from android/src/main/res/values-uk/strings_wear_pairing.xml rename to android/src/main/res/values-uk/strings_wear_pairing_android.xml diff --git a/android/src/main/res/values-fr/strings_wear_pairing.xml b/android/src/main/res/values/strings_wear_pairing_android.xml similarity index 89% rename from android/src/main/res/values-fr/strings_wear_pairing.xml rename to android/src/main/res/values/strings_wear_pairing_android.xml index 62be20bcf..fdda9aaf4 100644 --- a/android/src/main/res/values-fr/strings_wear_pairing.xml +++ b/android/src/main/res/values/strings_wear_pairing_android.xml @@ -5,8 +5,8 @@ The watch will sync as %1$s with a session of its own. You can sign it out from the watch at any time. Sign in to a sync server on this phone first, then pair the watch again. Pair - Refuser - Fermer + Decline + Close Watch paired The watch could not be paired Watch pairing diff --git a/android/src/main/res/values/theme.xml b/android/src/main/res/values/theme.xml index 1df75deb6..fa9ef32bc 100644 --- a/android/src/main/res/values/theme.xml +++ b/android/src/main/res/values/theme.xml @@ -2,4 +2,10 @@ - \ No newline at end of file + + + diff --git a/android/src/main/res/xml/backup_rules.xml b/android/src/main/res/xml/backup_rules.xml index 3541c377c..8df45f7c2 100644 --- a/android/src/main/res/xml/backup_rules.xml +++ b/android/src/main/res/xml/backup_rules.xml @@ -3,4 +3,5 @@ + diff --git a/android/src/main/res/xml/data_extraction_rules.xml b/android/src/main/res/xml/data_extraction_rules.xml index 5835bbc34..26160975f 100644 --- a/android/src/main/res/xml/data_extraction_rules.xml +++ b/android/src/main/res/xml/data_extraction_rules.xml @@ -4,10 +4,12 @@ + + diff --git a/base/build.gradle.kts b/base/build.gradle.kts index d629e394a..d0b8fd23d 100644 --- a/base/build.gradle.kts +++ b/base/build.gradle.kts @@ -1,4 +1,5 @@ -import com.darkrockstudios.build.resolveDistributionChannel +import com.darkrockstudios.build.distributionChannel +import com.darkrockstudios.build.packageFormat import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { @@ -78,12 +79,8 @@ buildConfig { buildConfigField("String", "APP_VERSION", "\"${libs.versions.app.get()}\"") buildConfigField("String", "DATA_VERSION", "\"${libs.versions.data.version.get()}\"") - // Mirrors the F-Droid detection in settings.gradle.kts so a plain F-Droid build reports its - // channel without every call site having to pass -Pchannel too. - val isFDroid = project.findProperty("fdroid")?.toString()?.isNotEmpty() == true || - System.getenv("FDROID_BUILD") != null - val channel = resolveDistributionChannel(project.findProperty("channel")?.toString(), isFDroid) - buildConfigField("String", "CHANNEL", "\"${channel.token}\"") + buildConfigField("String", "CHANNEL", "\"${project.distributionChannel().token}\"") + buildConfigField("String", "PACKAGE_FORMAT", "\"${project.packageFormat().token}\"") } val GIT_TASK_NAME = "install-git-hooks" diff --git a/base/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/base/DistributionChannel.kt b/base/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/base/DistributionChannel.kt index 0d321e109..9227312cd 100644 --- a/base/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/base/DistributionChannel.kt +++ b/base/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/base/DistributionChannel.kt @@ -37,5 +37,9 @@ enum class DistributionChannel(val token: String, val displayName: String) { */ val current: DistributionChannel = entries.firstOrNull { it.token == BuildMetadata.CHANNEL } ?: DEV + + /** The sandboxed, App Review facing desktop build. */ + val isMacAppStore: Boolean + get() = current == MAC_APP_STORE } } diff --git a/base/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/base/PackageFormat.kt b/base/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/base/PackageFormat.kt new file mode 100644 index 000000000..8a5a6a863 --- /dev/null +++ b/base/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/base/PackageFormat.kt @@ -0,0 +1,30 @@ +package com.darkrockstudios.apps.hammer.base + +/** + * The installer format this build was packaged as, baked in at build time from `-Pformat=`. + * The second axis next to [DistributionChannel]: the channel says which vehicle the build is for + * and whether it may update itself, the format says which installer artifact it came from. Dev + * runs and anything built without a packaging task report [NONE]. + * + * Kept in step with the build-side enum in `buildSrc/src/main/java/packageFormat.kt`. + */ +enum class PackageFormat(val token: String) { + NONE("none"), + MSI("msi"), + EXE("exe"), + DEB("deb"), + RPM("rpm"), + DMG("dmg"), + PKG("pkg"), + APPIMAGE("appimage"), + MSIX("msix"), + SNAP("snap"), + FLATPAK("flatpak"), + ; + + companion object { + /** An unrecognised token falls back to [NONE] for the same reason [DistributionChannel.current] falls back to DEV. */ + val current: PackageFormat = + entries.firstOrNull { it.token == BuildMetadata.PACKAGE_FORMAT } ?: NONE + } +} diff --git a/base/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/base/markdown/ProseHtml.kt b/base/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/base/markdown/ProseHtml.kt index 0966e772c..616597ae8 100644 --- a/base/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/base/markdown/ProseHtml.kt +++ b/base/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/base/markdown/ProseHtml.kt @@ -32,16 +32,18 @@ object ProseHtml { /** * The generating providers [flavour] would use, with prose layout applied. Pass the result to - * [HtmlGenerator]. + * [HtmlGenerator]. With [keepBlankLines] off, a lone blank line between paragraphs produces + * nothing and a run of two or more produces a single break. */ fun providers( flavour: MarkdownFlavourDescriptor, linkMap: LinkMap, + keepBlankLines: Boolean = true, ): Map = flavour.createHtmlGeneratingProviders(linkMap, null) + mapOf( MarkdownElementTypes.PARAGRAPH to ProseParagraphProvider, - MarkdownElementTypes.MARKDOWN_FILE to ProseContainerProvider("body"), - MarkdownElementTypes.BLOCK_QUOTE to ProseContainerProvider("blockquote"), + MarkdownElementTypes.MARKDOWN_FILE to ProseContainerProvider("body", keepBlankLines), + MarkdownElementTypes.BLOCK_QUOTE to ProseContainerProvider("blockquote", keepBlankLines), ) /** @@ -100,7 +102,10 @@ private object ProseParagraphProvider : TrimmingInlineHolderProvider() { } /** Emits a break for each blank line the author left between two paragraphs. */ -private class ProseContainerProvider(tag: String) : SimpleTagProvider(tag) { +private class ProseContainerProvider( + tag: String, + private val keepBlankLines: Boolean, +) : SimpleTagProvider(tag) { override fun processNode( visitor: HtmlGenerator.HtmlGeneratingVisitor, text: String, @@ -119,7 +124,12 @@ private class ProseContainerProvider(tag: String) : SimpleTagProvider(tag) { else -> { val isParagraph = child.type == MarkdownElementTypes.PARAGRAPH if (afterParagraph && isParagraph) { - val breaks = (newlines - 1).coerceIn(0, ProseHtml.MAX_CONSECUTIVE_BREAKS) + val blanks = (newlines - 1).coerceIn(0, ProseHtml.MAX_CONSECUTIVE_BREAKS) + val breaks = when { + keepBlankLines -> blanks + blanks >= 2 -> 1 + else -> 0 + } repeat(breaks) { visitor.consumeHtml(BREAK) } } child.accept(visitor) diff --git a/base/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/base/PackageFormatTest.kt b/base/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/base/PackageFormatTest.kt new file mode 100644 index 000000000..8233e2d28 --- /dev/null +++ b/base/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/base/PackageFormatTest.kt @@ -0,0 +1,34 @@ +package com.darkrockstudios.apps.hammer.base + +import kotlin.test.Test +import kotlin.test.assertEquals + +class PackageFormatTest { + + /** + * Pins the tokens against the build-side enum in `buildSrc/src/main/java/packageFormat.kt`, + * the same way [DistributionChannelTest] pins the channel tokens. + */ + @Test + fun `tokens match the build side enum`() { + val expected = listOf( + "none", + "msi", + "exe", + "deb", + "rpm", + "dmg", + "pkg", + "appimage", + "msix", + "snap", + "flatpak", + ) + assertEquals(expected, PackageFormat.entries.map { it.token }) + } + + @Test + fun `current resolves the baked in token`() { + assertEquals(BuildMetadata.PACKAGE_FORMAT, PackageFormat.current.token) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 47f5934d8..9887bd8c5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,6 +7,7 @@ import com.darkrockstudios.build.formatStoreNotes import com.darkrockstudios.build.isPlatformReleaseTag import com.darkrockstudios.build.registerLinuxDistributionTasks import com.darkrockstudios.build.registerPublishTasks +import com.darkrockstudios.build.registerUpdateMetadataTasks import com.darkrockstudios.build.releaseNotesUrl import com.darkrockstudios.build.storeNotesLength import com.darkrockstudios.build.updateFlatpakFiles @@ -128,6 +129,7 @@ kover { registerPublishTasks() registerLinuxDistributionTasks(libs.versions.app.get()) +registerUpdateMetadataTasks(libs.versions.app.get()) // The pre-commit hook runs Gradle, so git's hook env leaks into the daemon and Gradle // blanks rather than unsets it later; a blank GIT_INDEX_FILE reads as an empty index. diff --git a/buildSrc/src/main/java/distributionChannel.kt b/buildSrc/src/main/java/distributionChannel.kt index 9cf03e802..11d6dab85 100644 --- a/buildSrc/src/main/java/distributionChannel.kt +++ b/buildSrc/src/main/java/distributionChannel.kt @@ -1,5 +1,7 @@ package com.darkrockstudios.build +import org.gradle.api.Project + /** * Build-side mirror of the runtime `DistributionChannel` enum in `:base`. The two lists have to * stay in step; `DistributionChannelTest` in `:base` pins the token strings so a change to one @@ -43,3 +45,13 @@ fun resolveDistributionChannel( DistributionChannel.entries.joinToString(", ") { it.token } ) } + +/** + * The channel this build is for. The F-Droid detection mirrors settings.gradle.kts, which cannot + * see buildSrc, so a plain F-Droid build reports its channel without passing -Pchannel too. + */ +fun Project.distributionChannel(): DistributionChannel { + val isFDroid = findProperty("fdroid")?.toString()?.isNotEmpty() == true || + System.getenv("FDROID_BUILD") != null + return resolveDistributionChannel(findProperty("channel")?.toString(), isFDroid) +} diff --git a/buildSrc/src/main/java/packageFormat.kt b/buildSrc/src/main/java/packageFormat.kt new file mode 100644 index 000000000..1fdc3c719 --- /dev/null +++ b/buildSrc/src/main/java/packageFormat.kt @@ -0,0 +1,45 @@ +package com.darkrockstudios.build + +import org.gradle.api.Project + +/** + * Build-side mirror of the runtime `PackageFormat` enum in `:base`. The two lists have to stay + * in step; `PackageFormatTest` in `:base` pins the token strings so a change to one without the + * other fails a test rather than shipping a build that reports the wrong format. + */ +enum class PackageFormat(val token: String) { + NONE("none"), + MSI("msi"), + EXE("exe"), + DEB("deb"), + RPM("rpm"), + DMG("dmg"), + PKG("pkg"), + APPIMAGE("appimage"), + MSIX("msix"), + SNAP("snap"), + FLATPAK("flatpak"), + ; + + companion object { + fun fromToken(token: String): PackageFormat? = entries.firstOrNull { it.token == token } + } +} + +/** + * Resolves the installer format for this build from `-Pformat=`, defaulting to [NONE] + * for anything that is not a packaging run. Throws on an unknown token, so a typo in a release + * workflow fails the build instead of quietly shipping an installer that cannot name itself. + */ +fun resolvePackageFormat(formatProperty: String?): PackageFormat { + val requested = formatProperty?.trim()?.takeIf { it.isNotEmpty() } ?: return PackageFormat.NONE + + return PackageFormat.fromToken(requested) + ?: error( + "Unknown -Pformat=$requested. Valid formats: " + + PackageFormat.entries.joinToString(", ") { it.token } + ) +} + +/** The installer format this build is packaged as, from `-Pformat`. */ +fun Project.packageFormat(): PackageFormat = resolvePackageFormat(findProperty("format")?.toString()) diff --git a/buildSrc/src/main/java/updateMetadata.kt b/buildSrc/src/main/java/updateMetadata.kt new file mode 100644 index 000000000..effbe4224 --- /dev/null +++ b/buildSrc/src/main/java/updateMetadata.kt @@ -0,0 +1,145 @@ +package com.darkrockstudios.build + +import org.gradle.api.Project +import java.io.File +import java.security.MessageDigest +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.Base64 + +/** One attached installer as the update metadata describes it. */ +data class UpdateAsset(val name: String, val size: Long, val sha512: String) + +/** + * The metadata files the desktop updater reads, each listing its platform's installers in + * preference order. The format is electron-builder's `latest.yml`, which Nucleus parses; the + * `url` is the bare asset name and the client prefixes the release URL. + */ +private val UPDATE_METADATA_FILES: Map> = mapOf( + "latest.yml" to listOf("hammer.msi", "hammer.exe"), + "latest-mac.yml" to listOf("hammer.zip", "hammer.dmg", "hammer.pkg"), + "latest-linux.yml" to listOf("hammer.deb", "hammer.rpm", "hammer.AppImage"), +) + +/** Assets a release may legitimately lack; every other listed name must have a digest. */ +private val OPTIONAL_ASSETS: Set = setOf("hammer.zip") + +/** Suffix of the sidecar each platform job writes next to its installer: ` `. */ +const val DIGEST_SUFFIX = ".sha512" + +private const val SHA512_BUFFER_SIZE = 1 shl 16 + +fun renderUpdateMetadata(version: String, releaseDate: String, assets: List): String = + buildString { + appendLine("version: $version") + appendLine("releaseDate: $releaseDate") + appendLine("files:") + for (asset in assets) { + appendLine(" - url: ${asset.name}") + appendLine(" sha512: ${asset.sha512}") + appendLine(" size: ${asset.size}") + } + } + +/** Base64 of the SHA-512 digest, which is how Nucleus's checksum verifier compares it. */ +fun sha512Base64(file: File): String { + val digest = MessageDigest.getInstance("SHA-512") + file.inputStream().use { input -> + val buffer = ByteArray(SHA512_BUFFER_SIZE) + while (true) { + val read = input.read(buffer) + if (read < 0) break + digest.update(buffer, 0, read) + } + } + return Base64.getEncoder().encodeToString(digest.digest()) +} + +/** The one-line sidecar content for an installer. */ +fun assetDigestLine(file: File): String = "${sha512Base64(file)} ${file.length()}" + +fun parseAssetDigest(name: String, line: String): UpdateAsset { + val parts = line.trim().split(' ') + require(parts.size == 2) { "Digest for $name should be ' ', got '$line'" } + val size = parts[1].toLongOrNull() ?: error("Digest for $name has a non-numeric size: '$line'") + return UpdateAsset(name = name, size = size, sha512 = parts[0]) +} + +/** + * Builds every metadata file from the digest sidecars anywhere under [digestsDir], keyed by file + * name: upload-artifact keeps each installer's format subdirectory. A required installer with no + * sidecar fails loudly: a yml that omits it would silently leave that format with no update path. + */ +fun updateMetadataFiles(digestsDir: File, version: String, releaseDate: String): Map { + val sidecars = digestsDir.walk() + .filter { it.isFile && it.name.endsWith(DIGEST_SUFFIX) } + .groupBy { it.name } + .mapValues { (name, found) -> + found.singleOrNull() ?: error("$digestsDir has more than one $name: $found") + } + return UPDATE_METADATA_FILES.mapValues { (metadataFile, names) -> + val assets = names.mapNotNull { name -> + val sidecar = sidecars[name + DIGEST_SUFFIX] + when { + sidecar != null -> parseAssetDigest(name, sidecar.readText()) + name in OPTIONAL_ASSETS -> null + else -> error("$metadataFile needs $name$DIGEST_SUFFIX, but $digestsDir has no such file") + } + } + renderUpdateMetadata(version, releaseDate, assets) + } +} + +/** + * Registers the two halves of the update metadata: + * + * - `writeAssetDigests -PassetFiles=` runs in each platform job right after it + * packages and renames its installers, writing `.sha512` beside each one. The + * sidecars travel to the final job as a workflow artifact, so no installer is downloaded + * twice and no release lookup is needed. + * - `writeUpdateMetadata -PreleaseDigestsDir=` runs once in the final job over the + * collected sidecars and writes the yml files to `build/update-metadata/`. + */ +fun Project.registerUpdateMetadataTasks(appVersion: String) { + tasks.register("writeAssetDigests") { + group = "distribution" + description = "Writes a .sha512 sidecar beside each installer in -PassetFiles (comma separated)." + + doLast { + val files = findProperty("assetFiles")?.toString() + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + ?.takeIf { it.isNotEmpty() } + ?: error("Pass -PassetFiles=[,...]") + for (path in files) { + val file = File(path) + check(file.isFile) { "No installer at $path" } + val sidecar = File(file.path + DIGEST_SUFFIX) + sidecar.writeText(assetDigestLine(file)) + println("Wrote $sidecar") + } + } + } + + tasks.register("writeUpdateMetadata") { + group = "distribution" + description = "Writes the latest*.yml files the desktop updater reads, from the digest sidecars in -PreleaseDigestsDir." + + doLast { + val digestsDir = File( + findProperty("releaseDigestsDir")?.toString() + ?: error("Pass -PreleaseDigestsDir=") + ) + val outputDir = layout.buildDirectory.dir("update-metadata").get().asFile + outputDir.mkdirs() + + val releaseDate = Instant.now().truncatedTo(ChronoUnit.SECONDS).toString() + for ((name, content) in updateMetadataFiles(digestsDir, appVersion, releaseDate)) { + val target = outputDir.resolve(name) + target.writeText(content) + println("Wrote $target") + } + } + } +} diff --git a/buildSrc/src/test/java/PackageFormatTest.kt b/buildSrc/src/test/java/PackageFormatTest.kt new file mode 100644 index 000000000..e542031e9 --- /dev/null +++ b/buildSrc/src/test/java/PackageFormatTest.kt @@ -0,0 +1,34 @@ +import com.darkrockstudios.build.PackageFormat +import com.darkrockstudios.build.resolvePackageFormat +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class PackageFormatTest { + + @Test + fun `no format property means not packaged`() { + assertEquals(PackageFormat.NONE, resolvePackageFormat(null)) + assertEquals(PackageFormat.NONE, resolvePackageFormat("")) + assertEquals(PackageFormat.NONE, resolvePackageFormat(" ")) + } + + @Test + fun `every token resolves to its own format`() { + for (format in PackageFormat.entries) { + assertEquals(format, resolvePackageFormat(format.token)) + } + } + + @Test + fun `an unknown token fails the build`() { + val error = assertFailsWith { resolvePackageFormat("windows") } + assertEquals(true, error.message?.contains("Unknown -Pformat=windows")) + } + + @Test + fun `tokens are unique`() { + val tokens = PackageFormat.entries.map { it.token } + assertEquals(tokens.toSet().size, tokens.size) + } +} diff --git a/buildSrc/src/test/java/UpdateMetadataTest.kt b/buildSrc/src/test/java/UpdateMetadataTest.kt new file mode 100644 index 000000000..7847fa53a --- /dev/null +++ b/buildSrc/src/test/java/UpdateMetadataTest.kt @@ -0,0 +1,140 @@ +import com.darkrockstudios.build.DIGEST_SUFFIX +import com.darkrockstudios.build.UpdateAsset +import com.darkrockstudios.build.assetDigestLine +import com.darkrockstudios.build.parseAssetDigest +import com.darkrockstudios.build.renderUpdateMetadata +import com.darkrockstudios.build.sha512Base64 +import com.darkrockstudios.build.updateMetadataFiles +import java.io.File +import java.nio.file.Files +import java.security.MessageDigest +import java.util.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +class UpdateMetadataTest { + + private val installers = listOf( + "hammer.msi", "hammer.exe", "hammer.dmg", "hammer.pkg", "hammer.deb", "hammer.rpm", "hammer.AppImage", + ) + + @Test + fun `renders the electron-builder layout Nucleus parses`() { + val yml = renderUpdateMetadata( + version = "3.12.0", + releaseDate = "2026-10-20T18:04:11Z", + assets = listOf( + UpdateAsset("hammer.msi", 190034708, "abc="), + UpdateAsset("hammer.exe", 190100000, "def="), + ), + ) + + val expected = """ + |version: 3.12.0 + |releaseDate: 2026-10-20T18:04:11Z + |files: + | - url: hammer.msi + | sha512: abc= + | size: 190034708 + | - url: hammer.exe + | sha512: def= + | size: 190100000 + |""".trimMargin() + assertEquals(expected, yml) + } + + @Test + fun `sha512 is base64 of the digest`() { + val file = tempDir().resolve("asset.bin").apply { writeBytes(ByteArray(100_000) { it.toByte() }) } + + val expected = Base64.getEncoder().encodeToString( + MessageDigest.getInstance("SHA-512").digest(file.readBytes()) + ) + assertEquals(expected, sha512Base64(file)) + } + + @Test + fun `a digest line round-trips through the sidecar`() { + val file = tempDir().resolve("hammer.msi").apply { writeBytes(ByteArray(12_345) { (it * 3).toByte() }) } + + val asset = parseAssetDigest("hammer.msi", assetDigestLine(file) + "\n") + + assertEquals(UpdateAsset("hammer.msi", 12_345, sha512Base64(file)), asset) + } + + @Test + fun `a malformed sidecar is rejected`() { + assertFailsWith { parseAssetDigest("hammer.msi", "abc=") } + assertFailsWith { parseAssetDigest("hammer.msi", "abc= many") } + } + + @Test + fun `every platform file lists its installers from the sidecars`() { + val dir = tempDir() + installers.forEachIndexed { i, name -> dir.sidecar(name, "digest$i= ${100 + i}") } + + val files = updateMetadataFiles(dir, "3.12.0", "2026-10-20T18:04:11Z") + + assertEquals(setOf("latest.yml", "latest-mac.yml", "latest-linux.yml"), files.keys) + assertEquals(listOf("hammer.msi", "hammer.exe"), urlsIn(files.getValue("latest.yml"))) + assertEquals(listOf("hammer.dmg", "hammer.pkg"), urlsIn(files.getValue("latest-mac.yml"))) + assertEquals( + listOf("hammer.deb", "hammer.rpm", "hammer.AppImage"), + urlsIn(files.getValue("latest-linux.yml")), + ) + assertEquals(" sha512: digest0=", files.getValue("latest.yml").lines()[4]) + assertEquals(" size: 100", files.getValue("latest.yml").lines()[5]) + } + + @Test + fun `sidecars are found inside the per-format subdirectories the artifacts keep`() { + val dir = tempDir() + installers.forEach { dir.resolve(it.substringAfter('.').lowercase()).apply { mkdirs() }.sidecar(it, "x= 1") } + + val files = updateMetadataFiles(dir, "1", "d") + + assertEquals(listOf("hammer.msi", "hammer.exe"), urlsIn(files.getValue("latest.yml"))) + assertEquals(listOf("hammer.deb", "hammer.rpm", "hammer.AppImage"), urlsIn(files.getValue("latest-linux.yml"))) + } + + @Test + fun `a duplicated sidecar fails instead of picking one`() { + val dir = tempDir() + installers.forEach { dir.sidecar(it, "x= 1") } + dir.resolve("msi").apply { mkdirs() }.sidecar("hammer.msi", "y= 2") + + assertFailsWith { updateMetadataFiles(dir, "1", "d") } + } + + @Test + fun `the mac zip is listed first when present and skipped when absent`() { + val dir = tempDir() + installers.forEach { dir.sidecar(it, "x= 1") } + + assertNull(urlsIn(updateMetadataFiles(dir, "1", "d").getValue("latest-mac.yml")).firstOrNull { it == "hammer.zip" }) + + dir.sidecar("hammer.zip", "z= 2") + assertEquals( + listOf("hammer.zip", "hammer.dmg", "hammer.pkg"), + urlsIn(updateMetadataFiles(dir, "1", "d").getValue("latest-mac.yml")), + ) + } + + @Test + fun `a missing required installer fails instead of publishing a gap`() { + val dir = tempDir() + installers.filter { it != "hammer.exe" }.forEach { dir.sidecar(it, "x= 1") } + + val error = assertFailsWith { updateMetadataFiles(dir, "1", "d") } + assertEquals(true, error.message?.startsWith("latest.yml needs hammer.exe$DIGEST_SUFFIX")) + } + + private fun File.sidecar(name: String, line: String) = resolve(name + DIGEST_SUFFIX).writeText(line) + + private fun urlsIn(yml: String): List = + yml.lines().filter { it.trim().startsWith("- url:") }.map { it.substringAfter("- url:").trim() } + + private fun tempDir(): File = Files.createTempDirectory("update-metadata-test").toFile().apply { deleteOnExit() } +} diff --git a/common/src/androidHostTest/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsettings/datasource/EncryptedSharedPrefsAuthTokenStoreTest.kt b/common/src/androidHostTest/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsettings/datasource/EncryptedSharedPrefsAuthTokenStoreTest.kt new file mode 100644 index 000000000..58c96f1a7 --- /dev/null +++ b/common/src/androidHostTest/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsettings/datasource/EncryptedSharedPrefsAuthTokenStoreTest.kt @@ -0,0 +1,150 @@ +package com.darkrockstudios.apps.hammer.common.data.globalsettings.datasource + +import android.content.SharedPreferences +import kotlinx.serialization.json.Json +import javax.crypto.AEADBadTagException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class EncryptedSharedPrefsAuthTokenStoreTest { + + private val disk = FakeEncryptedPrefsDisk() + + private fun createStore() = EncryptedSharedPrefsAuthTokenStore( + json = Json, + openPrefs = disk::open, + resetPrefs = disk::reset, + ) + + @Test + fun `tokens round trip`() { + val store = createStore() + + store.put(URL, USER_ID, TOKENS) + + assertEquals(TOKENS, createStore().get(URL, USER_ID)) + } + + @Test + fun `keyset the master key cannot decrypt is treated as no tokens`() { + disk.keysetDecryptable = false + val store = createStore() + + val tokens = store.get(URL, USER_ID) + + assertNull(tokens) + } + + @Test + fun `store is usable again after an undecryptable keyset`() { + disk.keysetDecryptable = false + createStore().get(URL, USER_ID) + + createStore().put(URL, USER_ID, TOKENS) + + assertEquals(TOKENS, createStore().get(URL, USER_ID)) + } + + @Test + fun `value that cannot be decrypted is treated as no tokens`() { + createStore().put(URL, USER_ID, TOKENS) + disk.valuesDecryptable = false + + val tokens = createStore().get(URL, USER_ID) + + assertNull(tokens) + } + + @Test + fun `put replaces a value that cannot be decrypted`() { + createStore().put(URL, USER_ID, TOKENS) + disk.valuesDecryptable = false + val replacement = AuthTokens(bearerToken = "new-bearer", refreshToken = "new-refresh") + + createStore().put(URL, USER_ID, replacement) + + assertEquals(replacement, createStore().get(URL, USER_ID)) + } + + companion object { + private const val URL = "https://hammer.example" + private const val USER_ID = 7L + private val TOKENS = AuthTokens(bearerToken = "bearer", refreshToken = "refresh") + } +} + +/** + * Models the encrypted prefs file: [open] fails like EncryptedSharedPreferences.create + * when the Keystore master key cannot decrypt the stored keyset, and [reset] removes + * the file (keyset included) so the next open generates a fresh one. + */ +private class FakeEncryptedPrefsDisk { + val values = mutableMapOf() + var keysetDecryptable = true + var valuesDecryptable = true + + fun open(): SharedPreferences { + if (!keysetDecryptable) throw AEADBadTagException() + return FakePrefs() + } + + fun reset() { + values.clear() + keysetDecryptable = true + valuesDecryptable = true + } + + private inner class FakePrefs : SharedPreferences { + override fun getString(key: String, defValue: String?): String? { + val stored = values[key] ?: return defValue + if (!valuesDecryptable) throw SecurityException("Could not decrypt value") + return stored + } + + override fun contains(key: String): Boolean = key in values + override fun getAll(): MutableMap = values.toMutableMap() + override fun edit(): SharedPreferences.Editor = FakeEditor() + + override fun getStringSet(key: String, defValues: MutableSet?) = unsupported() + override fun getInt(key: String, defValue: Int): Int = unsupported() + override fun getLong(key: String, defValue: Long): Long = unsupported() + override fun getFloat(key: String, defValue: Float): Float = unsupported() + override fun getBoolean(key: String, defValue: Boolean): Boolean = unsupported() + override fun registerOnSharedPreferenceChangeListener( + listener: SharedPreferences.OnSharedPreferenceChangeListener + ) = unsupported() + + override fun unregisterOnSharedPreferenceChangeListener( + listener: SharedPreferences.OnSharedPreferenceChangeListener + ) = unsupported() + } + + private inner class FakeEditor : SharedPreferences.Editor { + private val pending = mutableMapOf() + private var clearAll = false + + override fun putString(key: String, value: String?) = apply { pending[key] = value } + override fun remove(key: String) = apply { pending[key] = null } + override fun clear() = apply { clearAll = true } + + override fun commit(): Boolean { + if (clearAll) values.clear() + pending.forEach { (key, value) -> if (value == null) values.remove(key) else values[key] = value } + valuesDecryptable = true + return true + } + + override fun apply() { + commit() + } + + override fun putStringSet(key: String, values: MutableSet?) = unsupported() + override fun putInt(key: String, value: Int) = unsupported() + override fun putLong(key: String, value: Long) = unsupported() + override fun putFloat(key: String, value: Float) = unsupported() + override fun putBoolean(key: String, value: Boolean) = unsupported() + } +} + +private fun unsupported(): Nothing = throw UnsupportedOperationException() diff --git a/common/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsettings/datasource/EncryptedSharedPrefsAuthTokenStore.kt b/common/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsettings/datasource/EncryptedSharedPrefsAuthTokenStore.kt index bdfb5e2d9..18407af83 100644 --- a/common/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsettings/datasource/EncryptedSharedPrefsAuthTokenStore.kt +++ b/common/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsettings/datasource/EncryptedSharedPrefsAuthTokenStore.kt @@ -9,31 +9,46 @@ import io.github.aakira.napier.Napier import kotlinx.atomicfu.locks.reentrantLock import kotlinx.atomicfu.locks.withLock import kotlinx.serialization.json.Json +import java.io.IOException +import java.security.GeneralSecurityException +import java.security.KeyStore /** * [AuthTokenStore] backed by [EncryptedSharedPreferences] with an Android * Keystore-backed master key. The account-keyed token map is stored as JSON in a * single encrypted preferences entry. */ -class EncryptedSharedPrefsAuthTokenStore( - context: Context, +class EncryptedSharedPrefsAuthTokenStore internal constructor( private val json: Json, + private val openPrefs: () -> SharedPreferences, + private val resetPrefs: () -> Unit, ) : AuthTokenStore { + constructor(context: Context, json: Json) : this( + json = json, + openPrefs = { openEncryptedPrefs(context) }, + resetPrefs = { resetEncryptedPrefs(context) }, + ) + private val lock = reentrantLock() private val prefs: SharedPreferences by lazy { - val masterKey = MasterKey.Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() + try { + openPrefs() + } catch (e: GeneralSecurityException) { + recreatePrefs(e) + } catch (e: IOException) { + recreatePrefs(e) + } + } - EncryptedSharedPreferences.create( - context, - PREFS_NAME, - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, - ) + // The keyset lives in the prefs file but its master key lives in the Keystore, which is + // never backed up, so a restored or orphaned file is undecryptable, and some keystores + // leave the master key itself unusable. Start over with both (forces re-login). + private fun recreatePrefs(cause: Exception): SharedPreferences { + Napier.w("Auth token keyset is unreadable; recreating the store", cause) + resetPrefs() + return openPrefs() } override fun get(url: String, userId: Long): AuthTokens? = lock.withLock { @@ -54,11 +69,11 @@ class EncryptedSharedPrefsAuthTokenStore( } private fun loadMap(): Map { - val stored = prefs.getString(TOKENS_KEY, null) ?: return emptyMap() return try { + val stored = prefs.getString(TOKENS_KEY, null) ?: return emptyMap() json.decodeFromString>(stored) } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { - Napier.w("Failed to parse encrypted auth tokens; treating as empty", e) + Napier.w("Failed to read encrypted auth tokens; treating as empty", e) emptyMap() } } @@ -70,5 +85,26 @@ class EncryptedSharedPrefsAuthTokenStore( companion object { private const val PREFS_NAME = "hammer_auth_tokens" private const val TOKENS_KEY = "tokens" + private const val ANDROID_KEYSTORE = "AndroidKeyStore" + + private fun openEncryptedPrefs(context: Context): SharedPreferences { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + return EncryptedSharedPreferences.create( + context, + PREFS_NAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } + + private fun resetEncryptedPrefs(context: Context) { + context.deleteSharedPreferences(PREFS_NAME) + KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + .deleteEntry(MasterKey.DEFAULT_MASTER_KEY_ALIAS) + } } } diff --git a/common/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/dependencyinjection/platformModule.kt b/common/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/dependencyinjection/platformModule.kt index 7c1db01fd..ac7891da1 100644 --- a/common/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/dependencyinjection/platformModule.kt +++ b/common/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/dependencyinjection/platformModule.kt @@ -1,5 +1,8 @@ package com.darkrockstudios.apps.hammer.common.dependencyinjection +import com.darkrockstudios.apps.hammer.common.data.appupdate.AppUpdater +import com.darkrockstudios.apps.hammer.common.data.appupdate.NoOpAppUpdater + import com.darkrockstudios.apps.hammer.common.components.projectselection.accountsettings.AndroidPlatformSettingsComponent import com.darkrockstudios.apps.hammer.common.components.projectselection.accountsettings.PlatformSettings import com.darkrockstudios.apps.hammer.common.components.storyeditor.focusmode.FocusModeService @@ -36,4 +39,5 @@ actual val platformModule = module { single() single() single { get() } + single { NoOpAppUpdater } } \ No newline at end of file diff --git a/common/src/commonMain/composeResources/files/changelog.md b/common/src/commonMain/composeResources/files/changelog.md index 2ea83aea5..416bb9a40 100644 --- a/common/src/commonMain/composeResources/files/changelog.md +++ b/common/src/commonMain/composeResources/files/changelog.md @@ -1,17 +1,32 @@ -## [3.11.0] - 2026-9-24 +## [3.12.0] - 2026-10-5 New features -- Web: signed-in readers can leave kudos at the end of a publicly published story, picking up to four craft chips and one reaction. Kudos are anonymous: authors see per-chip totals on their story page, and a chip appears on the public page once three readers have picked it. Authors can turn kudos off for any story +- Reader Mode: read your story as prose, one chapter at a time, instead of through the scene editor. Open it from the Read button under the scene list, the book icon in the scene editor, or the editor menu. The scene list becomes a table of contents, and the reader has its own font size +- Export: three new options in the Export Story dialog. Number chapter titles, pick a font (Word, RTF and EPUB), and choose whether to keep blank lines between paragraphs +- Android and iOS: an OLED theme, the dark theme on a pure black background +- Desktop (Windows and Linux installs from GitHub): Hammer tells you when a newer release is out and can update itself from the project list or the About screen. macOS DMG installs are told and sent to the release page. Automatic checks can be turned off in Settings +- Text editor 3.0: Huge improvements in input and performance +- Text editor: change the text size with Ctrl/Cmd and Plus, Minus or 0, or from the format bar's overflow menu +- Text editor: Delete in the right-click menu +- Turkish translation +- Web: a fifth reader reaction on public stories, "Surprised me" Improvements -- Word counts no longer count standalone Markdown markers such as horizontal rules, heading marks, bullets and blockquote markers as words. The server now counts the same way, so both give the same number -- Android and Wear OS builds are now shrunk and obfuscated with R8 +- Syncing with unsaved work now asks Save, Discard or Cancel first, where before it silently saved every unsaved scene +- Open notes, timeline events, encyclopedia entries and the scene title, outline and notes refresh when a sync or conflict resolution changes them. An edit in progress keeps its draft +- The Backup Manager lists backups of projects that are no longer on disk, so a deleted project can be restored +- Exports no longer number chapter titles unless you turn the new option on. Word, RTF, PDF and Markdown used to number them always +- Text editor: the heading button writes real Markdown headings, and Hammer's shortcuts land on the same keys on non-QWERTY layouts +- Android: a launch splash screen on every supported version - Translations and dependencies updated Fixes -- Text editor: numpad navigation keys and numpad Enter now work -- Text editor: keyboard (IME) edits are applied right away, and the keyboard is told about each one only once -- Text editor: spelling underlines stay on the right words while you keep typing -- Text editor: the formatting you picked for new text is kept after unrelated updates -- Text editor: fixed crashes and misplaced carets when drawing the cursor or moving it during a layout change -- Wear OS: pairing with the phone app works in release builds +- Android: unsaved scene edits could be lost when the app was closed in the background. They now come back as unsaved scenes the next time the project opens [#983] +- Export: unsaved scene edits were left out of the exported file [#983] +- Restoring a backup from the Backup Manager made the project disappear from the project list +- Choosing Discard on close with automatic sync on still saved and uploaded the discarded scenes +- PDF export: long paragraphs ran off the bottom of the page or left large blank gaps +- Android: a crash on launch when the saved login could not be decrypted, for example after restoring the device from a backup. You are asked to sign in again +- iOS: a crash when tapping into a text editor +- Server URL, number, tag and word-list fields no longer autocorrect or capitalize, which put a space after each "." in a server URL +- Encyclopedia references no longer match a name inside a longer word with accented or non-Latin letters ("Ana" in "Anaïs") diff --git a/common/src/commonMain/composeResources/values-de/strings.xml b/common/src/commonMain/composeResources/values-de/strings.xml index 70265b756..9660024ab 100644 --- a/common/src/commonMain/composeResources/values-de/strings.xml +++ b/common/src/commonMain/composeResources/values-de/strings.xml @@ -22,6 +22,7 @@ Nein Erweitern Einklappen + Text size Textgröße verringern Textgröße vergrößern Textgröße zurücksetzen diff --git a/common/src/commonMain/composeResources/values-de/strings_about_app.xml b/common/src/commonMain/composeResources/values-de/strings_about_app.xml index 322cd32ef..9fcafdc58 100644 --- a/common/src/commonMain/composeResources/values-de/strings_about_app.xml +++ b/common/src/commonMain/composeResources/values-de/strings_about_app.xml @@ -12,6 +12,17 @@ Dark Rock Studios Changes GitHub Release + Check for Updates + Checking for updates… + Hammer is up to date. + Hammer %1$s is available. + Update to %1$s + Downloading Hammer %1$s (%2$s) + Installing Hammer %1$s. Hammer will restart. + Could not update to %1$s: %2$s + Retry + Later + Hammer will quit to install the update, then reopen. What's New Versionshinweise Keine Versionshinweise vorhanden. diff --git a/common/src/commonMain/composeResources/values-de/strings_account_settings.xml b/common/src/commonMain/composeResources/values-de/strings_account_settings.xml index 8aa22b40b..2afd0484b 100644 --- a/common/src/commonMain/composeResources/values-de/strings_account_settings.xml +++ b/common/src/commonMain/composeResources/values-de/strings_account_settings.xml @@ -9,6 +9,7 @@ Farbschema HELL DUNKEL + OLED SYSTEM Anfangsbildschirm des Projekts Startseite @@ -55,6 +56,8 @@ Delete all local projects? This permanently deletes every project on this device and replaces them with the projects from the server. This cannot be undone. Delete and Replace + Check for updates automatically + Looks for a new release on GitHub shortly after launch and every few hours. Nothing is downloaded or installed until you choose to update. Plattform-Einstellungen Keine plattformspezifischen Einstellungen. Bildschirm nicht ausschalten @@ -83,6 +86,7 @@ Fehler beim Laden der Backups: %1$s Fehler beim Laden der Backups für das Projekt: %1$s Fehler beim Löschen der Backups: %1$s + Failed to restore backup: %1$s Backup wiederherstellen Warnung: Das Wiederherstellen dieses Backups ersetzt den aktuellen Inhalt des Projekts vollständig. Alle vorhandenen Dateien werden gelöscht und mit den Backup-Daten ersetzt. Diese Aktion kann nicht rückgängig gemacht werden. Wiederherstellen diff --git a/common/src/commonMain/composeResources/values-de/strings_project_home.xml b/common/src/commonMain/composeResources/values-de/strings_project_home.xml index 972c346ce..76f715526 100644 --- a/common/src/commonMain/composeResources/values-de/strings_project_home.xml +++ b/common/src/commonMain/composeResources/values-de/strings_project_home.xml @@ -78,6 +78,10 @@ RTF (.rtf) Abbrechen Exportieren + Number chapter titles + Font + Keep blank lines between paragraphs + Turn off if you separate paragraphs with a blank line and do not want empty paragraphs in the export. Scene breaks and runs of two or more blank lines are kept. Limit to specific scenes Only the selected scenes will be included in the export Szenen diff --git a/common/src/commonMain/composeResources/values-de/strings_projects_list.xml b/common/src/commonMain/composeResources/values-de/strings_projects_list.xml index 93ceda51c..f28d26f01 100644 --- a/common/src/commonMain/composeResources/values-de/strings_projects_list.xml +++ b/common/src/commonMain/composeResources/values-de/strings_projects_list.xml @@ -7,6 +7,7 @@ Projekt erstellen Projekte synchronisiert Fehler beim Synchronisieren der Projekte + Could not delete the project. Check that its folder is accessible and try again. Mehr %1$s Wörter von %1$s diff --git a/common/src/commonMain/composeResources/values-de/strings_reader_mode.xml b/common/src/commonMain/composeResources/values-de/strings_reader_mode.xml new file mode 100644 index 000000000..c4c0588e8 --- /dev/null +++ b/common/src/commonMain/composeResources/values-de/strings_reader_mode.xml @@ -0,0 +1,16 @@ + + + Reader + Chapter %1$d of %2$d + Edit ↗ + × Close + Textgröße verringern + Textgröße vergrößern + Textgröße zurücksetzen + Show scene headings + ← Previous chapter + Next chapter → + This chapter has no scenes yet + There is nothing to read yet + End of story + diff --git a/common/src/commonMain/composeResources/values-de/strings_scene_editor.xml b/common/src/commonMain/composeResources/values-de/strings_scene_editor.xml index 0e75ed6a5..6df2a0b94 100644 --- a/common/src/commonMain/composeResources/values-de/strings_scene_editor.xml +++ b/common/src/commonMain/composeResources/values-de/strings_scene_editor.xml @@ -24,6 +24,7 @@ Archivieren Speichern Schließen + Read Add to dictionary Szene archivieren Möchten Sie '%1$s' wirklich archivieren? Die Szene wird aus der Szenenliste ausgeblendet, kann aber später wiederhergestellt werden. diff --git a/common/src/commonMain/composeResources/values-de/strings_scene_list.xml b/common/src/commonMain/composeResources/values-de/strings_scene_list.xml index cc6eee6cc..c640c8f1d 100644 --- a/common/src/commonMain/composeResources/values-de/strings_scene_list.xml +++ b/common/src/commonMain/composeResources/values-de/strings_scene_list.xml @@ -16,7 +16,8 @@ Erstellen Abbrechen Gliederung - Gliederungsübersicht + Gliederung + Read <keine Gliederung> Schließen Manuskript diff --git a/common/src/commonMain/composeResources/values-es/strings.xml b/common/src/commonMain/composeResources/values-es/strings.xml index fe6cbaf0f..95f8b1a33 100644 --- a/common/src/commonMain/composeResources/values-es/strings.xml +++ b/common/src/commonMain/composeResources/values-es/strings.xml @@ -22,6 +22,7 @@ No Expandir Contraer + Text size Reducir el tamaño del texto Aumentar el tamaño del texto Restablecer el tamaño del texto diff --git a/common/src/commonMain/composeResources/values-es/strings_about_app.xml b/common/src/commonMain/composeResources/values-es/strings_about_app.xml index e12c256e2..7ad3757c2 100644 --- a/common/src/commonMain/composeResources/values-es/strings_about_app.xml +++ b/common/src/commonMain/composeResources/values-es/strings_about_app.xml @@ -12,6 +12,17 @@ Dark Rock Studios Changes GitHub Release + Check for Updates + Checking for updates… + Hammer is up to date. + Hammer %1$s is available. + Update to %1$s + Downloading Hammer %1$s (%2$s) + Installing Hammer %1$s. Hammer will restart. + Could not update to %1$s: %2$s + Retry + Later + Hammer will quit to install the update, then reopen. What's New Notas de la versión No se proporcionaron notas de la versión. diff --git a/common/src/commonMain/composeResources/values-es/strings_account_settings.xml b/common/src/commonMain/composeResources/values-es/strings_account_settings.xml index aea35e638..ada703829 100644 --- a/common/src/commonMain/composeResources/values-es/strings_account_settings.xml +++ b/common/src/commonMain/composeResources/values-es/strings_account_settings.xml @@ -9,6 +9,7 @@ Tema de la interfaz CLARO OSCURO + OLED SISTEMA Pantalla inicial del proyecto Inicio @@ -55,6 +56,8 @@ Delete all local projects? This permanently deletes every project on this device and replaces them with the projects from the server. This cannot be undone. Delete and Replace + Check for updates automatically + Looks for a new release on GitHub shortly after launch and every few hours. Nothing is downloaded or installed until you choose to update. Ajustes de la plataforma No hay ajustes específicos de la plataforma. Mantener la pantalla encendida @@ -83,6 +86,7 @@ No se pudieron cargar las copias de seguridad: %1$s No se pudieron cargar las copias de seguridad del proyecto: %1$s No se pudo eliminar la copia de seguridad: %1$s + Failed to restore backup: %1$s Restaurar copia de seguridad Advertencia: restaurar esta copia de seguridad reemplazará por completo el contenido actual del proyecto. Todos los archivos existentes se eliminarán y se reemplazarán con los datos de la copia de seguridad. Esta acción no se puede deshacer. Restaurar diff --git a/common/src/commonMain/composeResources/values-es/strings_project_home.xml b/common/src/commonMain/composeResources/values-es/strings_project_home.xml index 6aa9bf0e2..23a789b00 100644 --- a/common/src/commonMain/composeResources/values-es/strings_project_home.xml +++ b/common/src/commonMain/composeResources/values-es/strings_project_home.xml @@ -78,6 +78,10 @@ RTF (.rtf) Cancelar Exportar + Number chapter titles + Font + Keep blank lines between paragraphs + Turn off if you separate paragraphs with a blank line and do not want empty paragraphs in the export. Scene breaks and runs of two or more blank lines are kept. Limit to specific scenes Only the selected scenes will be included in the export Escenas diff --git a/common/src/commonMain/composeResources/values-es/strings_projects_list.xml b/common/src/commonMain/composeResources/values-es/strings_projects_list.xml index 1d44f6a2a..47e053825 100644 --- a/common/src/commonMain/composeResources/values-es/strings_projects_list.xml +++ b/common/src/commonMain/composeResources/values-es/strings_projects_list.xml @@ -7,6 +7,7 @@ Crear proyecto Proyectos sincronizados No se pudieron sincronizar los proyectos + Could not delete the project. Check that its folder is accessible and try again. Más %1$s palabras por %1$s diff --git a/common/src/commonMain/composeResources/values-es/strings_reader_mode.xml b/common/src/commonMain/composeResources/values-es/strings_reader_mode.xml new file mode 100644 index 000000000..9e2de7738 --- /dev/null +++ b/common/src/commonMain/composeResources/values-es/strings_reader_mode.xml @@ -0,0 +1,16 @@ + + + Reader + Chapter %1$d of %2$d + Edit ↗ + × Close + Reducir el tamaño del texto + Aumentar el tamaño del texto + Restablecer el tamaño del texto + Show scene headings + ← Previous chapter + Next chapter → + This chapter has no scenes yet + There is nothing to read yet + End of story + diff --git a/common/src/commonMain/composeResources/values-es/strings_scene_editor.xml b/common/src/commonMain/composeResources/values-es/strings_scene_editor.xml index 533fcc03a..0412e7a76 100644 --- a/common/src/commonMain/composeResources/values-es/strings_scene_editor.xml +++ b/common/src/commonMain/composeResources/values-es/strings_scene_editor.xml @@ -24,6 +24,7 @@ Archivar Guardar Cerrar + Read Add to dictionary Archivar escena ¿Seguro que quieres archivar «%1$s»? La escena se ocultará de la lista de escenas, pero podrás restaurarla más tarde. diff --git a/common/src/commonMain/composeResources/values-es/strings_scene_list.xml b/common/src/commonMain/composeResources/values-es/strings_scene_list.xml index bc1d6f41f..0b13c235c 100644 --- a/common/src/commonMain/composeResources/values-es/strings_scene_list.xml +++ b/common/src/commonMain/composeResources/values-es/strings_scene_list.xml @@ -16,7 +16,8 @@ Crear Cancelar Esquema de la historia - Vista del esquema + Esquema + Read <sin esquema> Descartar Manuscrito diff --git a/common/src/commonMain/composeResources/values-fr/strings.xml b/common/src/commonMain/composeResources/values-fr/strings.xml index 8ebd794e0..abb9b31e7 100644 --- a/common/src/commonMain/composeResources/values-fr/strings.xml +++ b/common/src/commonMain/composeResources/values-fr/strings.xml @@ -22,6 +22,7 @@ Non Développer Réduire + Taille du texte Réduire la taille du texte Augmenter la taille du texte Réinitialiser la taille du texte diff --git a/common/src/commonMain/composeResources/values-fr/strings_about_app.xml b/common/src/commonMain/composeResources/values-fr/strings_about_app.xml index cf9a1c869..474b77d32 100644 --- a/common/src/commonMain/composeResources/values-fr/strings_about_app.xml +++ b/common/src/commonMain/composeResources/values-fr/strings_about_app.xml @@ -12,6 +12,17 @@ Dark Rock Studios Changements Version GitHub + Vérifier les mises à jour + Recherche de mises à jour… + Hammer est à jour. + Hammer %1$s est disponible. + Mettre à jour ver %1$s + Télécharger Hammer %1$s (%2$s) + Installation de Hammer %1$s. Hammer va redémarrer. + Impossible de mettre à jour vers la %1$s: %2$s + Réessayer + Plus tard + Hammer va se fermer pour installer la mise à jour, puis se rouvrira. Nouveautés Notes de version Aucune note de version publiée. @@ -21,9 +32,9 @@ Exporter les journaux Aucun fichier journal trouvé Ouvrir le dossier des journaux - Logs exported. - Could not export the logs. See the log directory. - Copy Diagnostics - Diagnostics copied to the clipboard. - Could not copy the diagnostics. + Journeaux exportés. + Impossible d'exporter les journeaux. Voir le répertoire des journeaux. + Copier les diagnostics + Les diagnostics ont été copier dans le presse-papiers. + Impossible de copier les diagnostics. diff --git a/common/src/commonMain/composeResources/values-fr/strings_account_settings.xml b/common/src/commonMain/composeResources/values-fr/strings_account_settings.xml index 60fc8ad29..075385b0a 100644 --- a/common/src/commonMain/composeResources/values-fr/strings_account_settings.xml +++ b/common/src/commonMain/composeResources/values-fr/strings_account_settings.xml @@ -9,6 +9,7 @@ Thème de l'interface CLAIR SOMBRE + OLED SYSTÈME Écran initial du projet Accueil @@ -36,10 +37,10 @@ Voulez-vous vraiment supprimer le serveur ? Configurer le serveur URL du serveur : hammer.ink - Plain HTTP: your email, password and writing travel unencrypted. Only use this on a network you trust. + HTTP en clair : votre email, mot de passe et vos écrits non chiffrés. Utilisez cela uniquement sur un réseau de confiance. E-mail : utilisateur@exemple.com Mot de passe - %1$d-%2$d characters + %1$d-%2$d personnages Afficher le mot de passe Masquer le mot de passe Se connecter @@ -52,9 +53,11 @@ Hammer fera correspondre vos projets locaux aux projets déjà présents sur le serveur. Les projets qui n'existent que sur le serveur seront téléchargés, et les projets présents en local seront téléversés sur le serveur. Tous vos projets locaux seront supprimés et remplacés par ceux présents sur le serveur. Continuer - Delete all local projects? - This permanently deletes every project on this device and replaces them with the projects from the server. This cannot be undone. - Delete and Replace + Supprimer tous les projets locaux ? + Cela supprime de façon permanente tous les projets de cet appareil et les remplacera avec les projets présents sur le serveur. Cette action ne peut pas être annulé. + Supprimer et remplacer + Vérifier automatiquement les mises à jour + Recherche les nouvelles versions sur GitHub peu après le lancement et toutes les heures. Rien n'est téléchargé ou installé sans votre intervention. Paramètres de la plateforme Aucun paramètre spécifique à la plateforme. Garder l'écran allumé @@ -71,8 +74,8 @@ Si cela pose problème, désactivez-la simplement. Activer la correction orthographique Activer en mode concentration - Learn encyclopedia names - Entry names and aliases won’t be marked as misspelled while their project is open + Apprendre les noms de l'encyclopédie + Les noms d'entrés et les alias ne seront pas marqués comme mal orthographiés pendant que leur projet est ouvert Dictionnaire Langue de correction orthographique Sur Android, la langue de correction orthographique est choisie par votre appareil, pas par l'application. Pour la modifier, changez les langues de votre système et de votre clavier dans les paramètres Android. @@ -83,6 +86,7 @@ Échec du chargement des sauvegardes : %1$s Échec du chargement des sauvegardes du projet : %1$s Échec de la suppression de la sauvegarde : %1$s + Échec de la suppression de la sauvegarde : %1$s Restaurer la sauvegarde Avertissement : la restauration de cette sauvegarde remplacera entièrement le contenu actuel du projet. Tous les fichiers existants seront supprimés et remplacés par les données de la sauvegarde. Cette action est irréversible. Restaurer @@ -123,5 +127,5 @@ Serveur introuvable. Vérifiez l'adresse du serveur et votre connexion Internet. Erreur réseau lors de la connexion à %1$s. Vérifiez votre connexion et réessayez. Impossible d'établir une connection sécurisée à %1$s. Hammer se connecte uniquement via HTTPS, le serveur a besoin de son propre certificat TLS ou d'un proxy inverse qui lui en fournit un - Hammer could not open a network connection on this device, so the request never reached the server. Security software, a VPN, or a firewall may be blocking it. + Hammer ne peut pas établir une connexion réseau sur cet appareil, la requête ne peut donc pas atteindre le serveur. Un système de sécurité, un VPN ou un pare-feu peuvent être la source du problème. diff --git a/common/src/commonMain/composeResources/values-fr/strings_encyclopedia.xml b/common/src/commonMain/composeResources/values-fr/strings_encyclopedia.xml index 6ecd78d80..feec43cb9 100644 --- a/common/src/commonMain/composeResources/values-fr/strings_encyclopedia.xml +++ b/common/src/commonMain/composeResources/values-fr/strings_encyclopedia.xml @@ -30,7 +30,7 @@ Annuler Entrée créée Le nom de l'entrée est trop longue. Max %1$d - The entry name contains invalid characters + Le nom de l'entrée contient des caractères invalides L'Étiquette est trop longue. Max %1$d L'alias est trop long. Max %1$d Alias diff --git a/common/src/commonMain/composeResources/values-fr/strings_project_home.xml b/common/src/commonMain/composeResources/values-fr/strings_project_home.xml index eef371655..5a50aadc0 100644 --- a/common/src/commonMain/composeResources/values-fr/strings_project_home.xml +++ b/common/src/commonMain/composeResources/values-fr/strings_project_home.xml @@ -78,10 +78,14 @@ RTF (.rtf) Annuler Exporter - Limit to specific scenes - Only the selected scenes will be included in the export + Nombre de titres de chapitre + Police + Conserver les lignes vides entre les paragraphes + Désactivez si vous séparez les paragraphes avec une ligne vide et ne voulez pas de paragraphes vides dans l'export. Les pauses de scène et les exécutions de deux ou plusieurs lignes vierges sont conservées. + Limiter aux scènes spécifiques + Seules les scènes sélectionnées seront incluse dans l'export Scènes - %1$d of %2$d selected + %1$d sur %2$d sélectionnés Tout sélectionner Tout effacer En savoir plus sur les formats d'exportation diff --git a/common/src/commonMain/composeResources/values-fr/strings_projects_list.xml b/common/src/commonMain/composeResources/values-fr/strings_projects_list.xml index f3f4e81f8..cd74dd754 100644 --- a/common/src/commonMain/composeResources/values-fr/strings_projects_list.xml +++ b/common/src/commonMain/composeResources/values-fr/strings_projects_list.xml @@ -7,6 +7,7 @@ Créer un projet Projets synchronisés Échec de la synchronisation des projets + Could not delete the project. Check that its folder is accessible and try again. Plus %1$s mots par %1$s diff --git a/common/src/commonMain/composeResources/values-fr/strings_reader_mode.xml b/common/src/commonMain/composeResources/values-fr/strings_reader_mode.xml new file mode 100644 index 000000000..a0eb81b31 --- /dev/null +++ b/common/src/commonMain/composeResources/values-fr/strings_reader_mode.xml @@ -0,0 +1,16 @@ + + + Lecteur + Chapitre %1$d sur %2$d + Modifier + × Fermer + Réduire la taille du texte + Augmenter la taille du texte + Réinitialiser la taille du texte + Afficher les en-têtes de scène + ← Chapitre précédent + Chapitre suivant → + Ce chapitre n'a pas encore de scène + Il n'y a rien à lire pour le moment + Fin de l'histoire + diff --git a/common/src/commonMain/composeResources/values-fr/strings_scene_editor.xml b/common/src/commonMain/composeResources/values-fr/strings_scene_editor.xml index 4318ea8a1..da2c69aea 100644 --- a/common/src/commonMain/composeResources/values-fr/strings_scene_editor.xml +++ b/common/src/commonMain/composeResources/values-fr/strings_scene_editor.xml @@ -24,7 +24,8 @@ Archiver Enregistrer Fermer - Add to dictionary + Lire + Ajouter au dictionnaire Archiver la scène Voulez-vous vraiment archiver « %1$s » ? La scène sera masquée de la liste des scènes mais pourra être restaurée plus tard. Archiver diff --git a/common/src/commonMain/composeResources/values-fr/strings_scene_list.xml b/common/src/commonMain/composeResources/values-fr/strings_scene_list.xml index f167dd53a..03641839f 100644 --- a/common/src/commonMain/composeResources/values-fr/strings_scene_list.xml +++ b/common/src/commonMain/composeResources/values-fr/strings_scene_list.xml @@ -16,7 +16,8 @@ Créer Annuler Plan de l'histoire - Aperçu du plan + Plan + Lire <aucun plan> Ignorer Manuscrit diff --git a/common/src/commonMain/composeResources/values-fr/strings_sync.xml b/common/src/commonMain/composeResources/values-fr/strings_sync.xml index 9720e5aa1..12191b9ff 100644 --- a/common/src/commonMain/composeResources/values-fr/strings_sync.xml +++ b/common/src/commonMain/composeResources/values-fr/strings_sync.xml @@ -52,11 +52,11 @@ par (aucun auteur défini) Correction orthographique La vérification orthographique est désactivée : la langue du projet (%1$s) ne correspond pas à votre dictionnaire (%2$s). Changez là pour la réactiver. - Learn encyclopedia names in this project - Turn off to keep this project’s entry names out of the spell check dictionary - Project dictionary + Apprendre les noms du dictionnaire dans ce projet + Désactivez pour garder les noms d'entrés en dehors du dictionnaire de vérification orthographique + Dictionnaire du projet ↵ ajouter - Words the spell checker should accept in this project + Les mots que le correcteur orthographique devrait accepter dans ce projet Paramètres du projet · %1$s §§ %1$d Conflit de paramètres du projet @@ -66,13 +66,13 @@ Objectif de nombre de mots Étiquettes Langue - Learn Encyclopedia Names + Apprendre les noms de l'encyclopédie (non défini) - On - Off + Activer + Désactiver jour semaine - Project dictionary words from both sides are kept. + Les mots du dictionnaire des deux parties sont conservés. Résoudre Nom : Type : @@ -116,16 +116,16 @@ Échec du renommage du projet depuis le serveur : %1$s Projet créé sur le serveur : %1$s Échec de la création du projet sur le serveur : %1$s - Project created on server, but failed to save its ID locally: %1$s + Projet a été créé sur le serveur, mais n'a pas pu sauvegarder son ID localement : %1$s Projet local créé : %1$s Définition de l'ID serveur du projet local : %1$s - Failed to create local project from server: %1$s - Skipped server project %1$s: a local project of that name already syncs with a different server project + Impossible de créer un projet local depuis le serveur : %1$s + Projet du serveur %1$s ignoré : un projet local possédant ce nom est déjà synchronisé depuis un autre serveur Synchronisation du compte… Synchronisation des projets… Synchronisation des projets terminée Synchronisation du projet : %1$s - No changes, skipped + Pas de modification, ignorée Échec de la synchronisation : %1$s Il y a un conflit dans le projet : %1$s. Ouvrez ce projet et synchronisez pour le résoudre. Données d'entité client calculées @@ -193,8 +193,7 @@ Patreon Hammer.Ink Inscription - Servers have closed registration, meaning the server admin needs to add you to the server's allowed users list before you can create an account. - + Les serveurs ont fermé leur inscription, ce qui signifie que l'administrateur du serveur doit vous ajouter à la liste des utilisateurs autorisés avant que vous ne puissiez créer un compte. Configuration Saisissez l'URL du serveur, par exemple « hammer.ink » pour le serveur officiel. Saisissez votre e-mail et votre mot de passe, puis appuyez sur « Créer » pour créer votre compte. diff --git a/common/src/commonMain/composeResources/values-it/strings.xml b/common/src/commonMain/composeResources/values-it/strings.xml index 41774cb33..ae4b4e979 100644 --- a/common/src/commonMain/composeResources/values-it/strings.xml +++ b/common/src/commonMain/composeResources/values-it/strings.xml @@ -22,6 +22,7 @@ No Espandi Comprimi + Text size Riduci la dimensione del testo Aumenta la dimensione del testo Reimposta la dimensione del testo diff --git a/common/src/commonMain/composeResources/values-it/strings_about_app.xml b/common/src/commonMain/composeResources/values-it/strings_about_app.xml index 550893db7..b817f8f75 100644 --- a/common/src/commonMain/composeResources/values-it/strings_about_app.xml +++ b/common/src/commonMain/composeResources/values-it/strings_about_app.xml @@ -2,7 +2,7 @@ Uno strumento semplice per costruire storie. Studio - Hammer is made by Dark Rock Studios, alongside a family of other privacy-respecting, open source apps. + Hammer è realizzato da Dark Rock Studios, insieme a una famiglia di altre app open source che rispettano la privacy. Community Discord Reddit @@ -10,9 +10,20 @@ Attribuzione Librerie Dark Rock Studios - Changes - GitHub Release - What's New + Modifiche + Rilascio su GitHub + Check for Updates + Checking for updates… + Hammer is up to date. + Hammer %1$s is available. + Update to %1$s + Downloading Hammer %1$s (%2$s) + Installing Hammer %1$s. Hammer will restart. + Could not update to %1$s: %2$s + Retry + Later + Hammer will quit to install the update, then reopen. + Novità Note di rilascio Nessuna nota di rilascio fornita. Apri la versione @@ -21,9 +32,9 @@ Esporta i log Nessun file di log trovato Apri la cartella dei log - Logs exported. - Could not export the logs. See the log directory. - Copy Diagnostics - Diagnostics copied to the clipboard. - Could not copy the diagnostics. + Log esportati. + Impossibile esportare i log. Controlla la cartella dei log. + Copia diagnostica + Diagnostica copiata negli appunti. + Impossibile copiare la diagnostica. diff --git a/common/src/commonMain/composeResources/values-it/strings_account_settings.xml b/common/src/commonMain/composeResources/values-it/strings_account_settings.xml index adc2c4baa..857898b61 100644 --- a/common/src/commonMain/composeResources/values-it/strings_account_settings.xml +++ b/common/src/commonMain/composeResources/values-it/strings_account_settings.xml @@ -9,6 +9,7 @@ Tema dell’interfaccia CHIARO SCURO + OLED SISTEMA Schermata iniziale del progetto Home @@ -55,6 +56,8 @@ Delete all local projects? This permanently deletes every project on this device and replaces them with the projects from the server. This cannot be undone. Delete and Replace + Check for updates automatically + Looks for a new release on GitHub shortly after launch and every few hours. Nothing is downloaded or installed until you choose to update. Impostazioni della piattaforma Nessuna impostazione specifica della piattaforma. Tieni lo schermo acceso @@ -83,6 +86,7 @@ Impossibile caricare i backup: %1$s Impossibile caricare i backup del progetto: %1$s Impossibile eliminare il backup: %1$s + Failed to restore backup: %1$s Ripristina backup Attenzione: ripristinare questo backup sostituirà completamente il contenuto attuale del progetto. Tutti i file esistenti verranno eliminati e sostituiti con i dati del backup. Questa azione non può essere annullata. Ripristina diff --git a/common/src/commonMain/composeResources/values-it/strings_project_home.xml b/common/src/commonMain/composeResources/values-it/strings_project_home.xml index 40e3338d4..0185926d0 100644 --- a/common/src/commonMain/composeResources/values-it/strings_project_home.xml +++ b/common/src/commonMain/composeResources/values-it/strings_project_home.xml @@ -78,6 +78,10 @@ RTF (.rtf) Annulla Esporta + Number chapter titles + Font + Keep blank lines between paragraphs + Turn off if you separate paragraphs with a blank line and do not want empty paragraphs in the export. Scene breaks and runs of two or more blank lines are kept. Limit to specific scenes Only the selected scenes will be included in the export Scene @@ -115,7 +119,7 @@ Large scene One scene holds %1$s words, so chapter detection probably missed your chapter titles. Try another detection setting. %1$d scenes hold %2$s words or more, so chapter detection probably missed some chapter titles. Try another detection setting. - %1$s W + %1$s P Annulla Importa Impostazioni diff --git a/common/src/commonMain/composeResources/values-it/strings_project_navigation.xml b/common/src/commonMain/composeResources/values-it/strings_project_navigation.xml index a18770c78..e48407eaf 100644 --- a/common/src/commonMain/composeResources/values-it/strings_project_navigation.xml +++ b/common/src/commonMain/composeResources/values-it/strings_project_navigation.xml @@ -7,7 +7,7 @@ Cronologia HOME STOR - NOTE + NOTA ENCI CRON Espandi la navigazione diff --git a/common/src/commonMain/composeResources/values-it/strings_projects_list.xml b/common/src/commonMain/composeResources/values-it/strings_projects_list.xml index 38e4961c4..a3428d82c 100644 --- a/common/src/commonMain/composeResources/values-it/strings_projects_list.xml +++ b/common/src/commonMain/composeResources/values-it/strings_projects_list.xml @@ -7,6 +7,7 @@ Crea progetto Progetti sincronizzati Impossibile sincronizzare i progetti + Could not delete the project. Check that its folder is accessible and try again. Altro %1$s parole di %1$s diff --git a/common/src/commonMain/composeResources/values-it/strings_reader_mode.xml b/common/src/commonMain/composeResources/values-it/strings_reader_mode.xml new file mode 100644 index 000000000..eab2e51c4 --- /dev/null +++ b/common/src/commonMain/composeResources/values-it/strings_reader_mode.xml @@ -0,0 +1,16 @@ + + + Reader + Chapter %1$d of %2$d + Edit ↗ + × Close + Riduci la dimensione del testo + Aumenta la dimensione del testo + Reimposta la dimensione del testo + Show scene headings + ← Previous chapter + Next chapter → + This chapter has no scenes yet + There is nothing to read yet + End of story + diff --git a/common/src/commonMain/composeResources/values-it/strings_scene_editor.xml b/common/src/commonMain/composeResources/values-it/strings_scene_editor.xml index 33bfd0ecf..4869b3f68 100644 --- a/common/src/commonMain/composeResources/values-it/strings_scene_editor.xml +++ b/common/src/commonMain/composeResources/values-it/strings_scene_editor.xml @@ -24,6 +24,7 @@ Archivia Salva Chiudi + Read Add to dictionary Archivia scena Vuoi davvero archiviare «%1$s»? La scena verrà nascosta dall’elenco delle scene ma potrà essere ripristinata in seguito. diff --git a/common/src/commonMain/composeResources/values-it/strings_scene_list.xml b/common/src/commonMain/composeResources/values-it/strings_scene_list.xml index 0e10c2a96..372b60de3 100644 --- a/common/src/commonMain/composeResources/values-it/strings_scene_list.xml +++ b/common/src/commonMain/composeResources/values-it/strings_scene_list.xml @@ -16,7 +16,8 @@ Crea Annulla Scaletta della storia - Panoramica della scaletta + Scaletta + Read <nessuna scaletta> Ignora Manoscritto diff --git a/common/src/commonMain/composeResources/values-pt-rBR/strings.xml b/common/src/commonMain/composeResources/values-pt-rBR/strings.xml index 88bf41b76..ac321ad32 100644 --- a/common/src/commonMain/composeResources/values-pt-rBR/strings.xml +++ b/common/src/commonMain/composeResources/values-pt-rBR/strings.xml @@ -22,6 +22,7 @@ Não Expandir Encolher + Text size Diminuir o tamanho do texto Aumentar o tamanho do texto Redefinir o tamanho do texto diff --git a/common/src/commonMain/composeResources/values-pt-rBR/strings_about_app.xml b/common/src/commonMain/composeResources/values-pt-rBR/strings_about_app.xml index b689dc1b8..94e5cd11b 100644 --- a/common/src/commonMain/composeResources/values-pt-rBR/strings_about_app.xml +++ b/common/src/commonMain/composeResources/values-pt-rBR/strings_about_app.xml @@ -12,6 +12,17 @@ Dark Rock Studios Changes GitHub Release + Check for Updates + Checking for updates… + Hammer is up to date. + Hammer %1$s is available. + Update to %1$s + Downloading Hammer %1$s (%2$s) + Installing Hammer %1$s. Hammer will restart. + Could not update to %1$s: %2$s + Retry + Later + Hammer will quit to install the update, then reopen. What's New Notas de versão Nenhuma nota de versão fornecida. diff --git a/common/src/commonMain/composeResources/values-pt-rBR/strings_account_settings.xml b/common/src/commonMain/composeResources/values-pt-rBR/strings_account_settings.xml index f3ce35259..79b9e03a2 100644 --- a/common/src/commonMain/composeResources/values-pt-rBR/strings_account_settings.xml +++ b/common/src/commonMain/composeResources/values-pt-rBR/strings_account_settings.xml @@ -9,6 +9,7 @@ Tema de 'interface' do usuário CLARO ESCURO + OLED SISTEMA Tela Inicial do Projeto Início @@ -55,6 +56,8 @@ Delete all local projects? This permanently deletes every project on this device and replaces them with the projects from the server. This cannot be undone. Delete and Replace + Check for updates automatically + Looks for a new release on GitHub shortly after launch and every few hours. Nothing is downloaded or installed until you choose to update. Configurações da Plataforma Nenhuma configuração específica da plataforma. Manter a tela ligada @@ -83,6 +86,7 @@ Falha ao carregar 'backups': %1$s Falha ao carregar 'backups' para o projeto: %1$s Falha ao excluir 'backup': %1$s + Failed to restore backup: %1$s Restaurar 'Backup' Aviso: Restaurar este 'backup' substituirá completamente o conteúdo atual do projeto. Todos os arquivos existentes serão apagados e substituídos pelos dados de 'backup'. Esta ação não pode ser desfeita. Restaurar diff --git a/common/src/commonMain/composeResources/values-pt-rBR/strings_project_home.xml b/common/src/commonMain/composeResources/values-pt-rBR/strings_project_home.xml index 13ba84f5b..313882252 100644 --- a/common/src/commonMain/composeResources/values-pt-rBR/strings_project_home.xml +++ b/common/src/commonMain/composeResources/values-pt-rBR/strings_project_home.xml @@ -78,6 +78,10 @@ RTF (.rtf) Cancelar Exportar + Number chapter titles + Font + Keep blank lines between paragraphs + Turn off if you separate paragraphs with a blank line and do not want empty paragraphs in the export. Scene breaks and runs of two or more blank lines are kept. Limit to specific scenes Only the selected scenes will be included in the export Cenas diff --git a/common/src/commonMain/composeResources/values-pt-rBR/strings_projects_list.xml b/common/src/commonMain/composeResources/values-pt-rBR/strings_projects_list.xml index c1677bd6f..30a1ec38d 100644 --- a/common/src/commonMain/composeResources/values-pt-rBR/strings_projects_list.xml +++ b/common/src/commonMain/composeResources/values-pt-rBR/strings_projects_list.xml @@ -7,6 +7,7 @@ Criar Projeto Projetos sincronizados Falha ao sincronizar projetos + Could not delete the project. Check that its folder is accessible and try again. Mais %1$s palavras por %1$s diff --git a/common/src/commonMain/composeResources/values-pt-rBR/strings_reader_mode.xml b/common/src/commonMain/composeResources/values-pt-rBR/strings_reader_mode.xml new file mode 100644 index 000000000..1e3ab7bc4 --- /dev/null +++ b/common/src/commonMain/composeResources/values-pt-rBR/strings_reader_mode.xml @@ -0,0 +1,16 @@ + + + Reader + Chapter %1$d of %2$d + Edit ↗ + × Close + Diminuir o tamanho do texto + Aumentar o tamanho do texto + Redefinir o tamanho do texto + Show scene headings + ← Previous chapter + Next chapter → + This chapter has no scenes yet + There is nothing to read yet + End of story + diff --git a/common/src/commonMain/composeResources/values-pt-rBR/strings_scene_editor.xml b/common/src/commonMain/composeResources/values-pt-rBR/strings_scene_editor.xml index 2cce627ca..4d91325f9 100644 --- a/common/src/commonMain/composeResources/values-pt-rBR/strings_scene_editor.xml +++ b/common/src/commonMain/composeResources/values-pt-rBR/strings_scene_editor.xml @@ -24,6 +24,7 @@ Arquivar Salvar Fechar + Read Add to dictionary Arquivar Cena Tem certeza que deseja arquivar '%1$s'? A cena será ocultada da lista de cenas, mas pode ser restaurada mais tarde. diff --git a/common/src/commonMain/composeResources/values-pt-rBR/strings_scene_list.xml b/common/src/commonMain/composeResources/values-pt-rBR/strings_scene_list.xml index 8f9b9bbec..f1b10ea03 100644 --- a/common/src/commonMain/composeResources/values-pt-rBR/strings_scene_list.xml +++ b/common/src/commonMain/composeResources/values-pt-rBR/strings_scene_list.xml @@ -16,7 +16,8 @@ Criar Cancelar Esboço da história - Visão geral + Esboço + Read <sem esboço> Descartar Manuscrito diff --git a/common/src/commonMain/composeResources/values-tr/strings.xml b/common/src/commonMain/composeResources/values-tr/strings.xml new file mode 100644 index 000000000..c43d6cc10 --- /dev/null +++ b/common/src/commonMain/composeResources/values-tr/strings.xml @@ -0,0 +1,41 @@ + + + Hammer + Saved all Scenes + Unsaved Scenes + What should be done with unsaved scenes? + Unsaved Encyclopedia Entry + Discard entry? + Unsaved Note + Discard note? + Unsaved Timeline Event + Discard event? + Cancel + Discard and close + Discard + Cancel + Save + Close + More + Failed to parse response body: %1$s + Yes + No + Expand + Collapse + Text size + Decrease text size + Increase text size + Reset text size + Find and replace + Bold + Italic + Strikethrough + Heading + Quote + Bulleted list + Numbered list + Horizontal rule + Undo + Redo + ESC cancel + diff --git a/common/src/commonMain/composeResources/values-tr/strings_about_app.xml b/common/src/commonMain/composeResources/values-tr/strings_about_app.xml new file mode 100644 index 000000000..878ddefc1 --- /dev/null +++ b/common/src/commonMain/composeResources/values-tr/strings_about_app.xml @@ -0,0 +1,40 @@ + + + A simple tool for building stories. + Studio + Hammer is made by Dark Rock Studios, alongside a family of other privacy-respecting, open source apps. + Community + Discord + Reddit + Github + Attribution + Libraries + Dark Rock Studios + Changes + GitHub Release + Check for Updates + Checking for updates… + Hammer is up to date. + Hammer %1$s is available. + Update to %1$s + Downloading Hammer %1$s (%2$s) + Installing Hammer %1$s. Hammer will restart. + Could not update to %1$s: %2$s + Retry + Later + Hammer will quit to install the update, then reopen. + What's New + Release notes + No release notes provided. + Open Release + Application Logs + Log Directory: + Export Logs + No log files found + Open log directory + Logs exported. + Could not export the logs. See the log directory. + Copy Diagnostics + Diagnostics copied to the clipboard. + Could not copy the diagnostics. + diff --git a/common/src/commonMain/composeResources/values-tr/strings_account_settings.xml b/common/src/commonMain/composeResources/values-tr/strings_account_settings.xml new file mode 100644 index 000000000..1d5f09070 --- /dev/null +++ b/common/src/commonMain/composeResources/values-tr/strings_account_settings.xml @@ -0,0 +1,146 @@ + + + ACCOUNT + LOCAL INSTALL + ACCOUNT SETTINGS + Backups + This backup system is meant to protect against data loss and corruption. It is NOT intended as a way to keep older revisions of your stories. Please use the Drafts system for that. + + Manage Backups + UI Theme + LIGHT + DARK + OLED + SYSTEM + Initial Project Screen + Home + Scene Editor + Projects Directory + Set the directory Hammer will use to store project data in. + + Select Dir + Example Project + Install an example project to see how different features are used. + + Reinstall + Example project created. + CONNECTED + NOT CONNECTED + URL + HELP ↗ + Configure data syncing + Set Up + Auto-Sync + Backup on sync + Close Sync Dialogs on Success + Max Backups per Project + Must be between 1 and %1$d + Remove Server + Remove Server + Are you sure you want to remove the server? + Set Up Server + Server URL: hammer.ink + Plain HTTP: your email, password and writing travel unencrypted. Only use this on a network you trust. + E-Mail: user@example.com + Password + %1$d-%2$d characters + Show password + Hide password + Log In + Create + Cancel + Merge existing projects with the server? + PROJECT DATA + Merge + Replace + Hammer will match your local projects to projects already on the server by name. Projects that only exist on the server will be downloaded, and projects that only exist on this device will be uploaded. + All of your local projects will be deleted and replaced with whatever is on the server. + Continue + Delete all local projects? + This permanently deletes every project on this device and replaces them with the projects from the server. This cannot be undone. + Delete and Replace + Check for updates automatically + Looks for a new release on GitHub shortly after launch and every few hours. Nothing is downloaded or installed until you choose to update. + Platform Settings + No platform-specific settings. + Keep screen on + Enable Do Not Disturb when in Focus Mode + Grant DND Permission + Hammer needs permission to enable Do Not Disturb mode when you are in Focus Mode. + + Store projects in public storage + Keep your projects in the shared Documents folder so other apps and your file manager can reach them. + + Projects Location + Hammer needs file access permission to store projects in public storage. + + Grant File Access + Experimental + Spell Checking + If it is causing problems just turn it off. + Enable spell checking + Enable in focus mode + Learn encyclopedia names + Entry names and aliases won’t be marked as misspelled while their project is open + Dictionary + Spell check language + On Android, the spell check language is chosen by your device, not the app. To change it, update your system and keyboard languages in Android Settings. + Set up a server to sync your projects across devices and create secure backups. + + Sync & Backup Preferences + Danger Zone + + Failed to load backups: %1$s + Failed to load backups for project: %1$s + Failed to delete backup: %1$s + Failed to restore backup: %1$s + Restore Backup + Warning: Restoring this backup will completely replace the current contents of the project. All existing files will be deleted and replaced with the backup data. This action cannot be undone. + + Restore + Cancel + Manage Backups + No backups found + Select a project + No backups for this project + Restore + Delete backup + Show in Folder + Export + Cancel Sync? + Are you sure you want to cancel the sync? + Sync canceled + + An unexpected error occurred. Please try again. + Invalid server address. Enter a URL like "hammer.ink" or an IP address. + + Please enter a valid email address. + Password must be at least 8 characters. + Password must be 64 characters or less. + Password must contain at least one uppercase letter. + + Password must contain at least one lowercase letter. + + Password must contain at least one number. + Password must contain at least one special character. + + Server setup successful! + Server setup failed: %1$s + Server setup failed. Please check your connection and try again. + + + Terms of Service + Accept + Decline + You must accept the Terms of Service to create an account. + + Could not connect to server. Make sure the server is running and the address is correct. + + The server is not responding. Check your internet connection and try again. + + Server not found. Check the server address and your internet connection. + Network error connecting to %1$s. Check your connection and try again. + + Could not make a secure connection to %1$s. Hammer only connects over HTTPS, so the server needs a valid TLS certificate of its own or a reverse proxy that provides one. + Hammer could not open a network connection on this device, so the request never reached the server. Security software, a VPN, or a firewall may be blocking it. + diff --git a/common/src/commonMain/composeResources/values-tr/strings_desktop.xml b/common/src/commonMain/composeResources/values-tr/strings_desktop.xml new file mode 100644 index 000000000..269a79bd7 --- /dev/null +++ b/common/src/commonMain/composeResources/values-tr/strings_desktop.xml @@ -0,0 +1,20 @@ + + + Hammer - Project Selection + Hammer - %1$s + File + Close Project + Exit + Recent Projects + + Welcome to Hammer + Hammer stores your projects in a folder you choose, so your writing stays in + your hands.\n\nOn the next screen, pick an existing hammer projects folder or create a new one. You can change + this later in Settings. + + Choose where to store your Hammer projects + Choose Folder + Quit + Hammer needs a folder to store your projects.\nChoose a folder now, or quit. + + diff --git a/common/src/commonMain/composeResources/values-tr/strings_drafts.xml b/common/src/commonMain/composeResources/values-tr/strings_drafts.xml new file mode 100644 index 000000000..e9a75bb9b --- /dev/null +++ b/common/src/commonMain/composeResources/values-tr/strings_drafts.xml @@ -0,0 +1,14 @@ + + + Merge with Current + Copy parts of the draft into here + Take Merged + Draft: %1$s + Copy desired parts into current, or accept all of this. + Take this whole draft + Draft + Current + %1$s Drafts: + No Drafts Found + Invalid name + diff --git a/common/src/commonMain/composeResources/values-tr/strings_encyclopedia.xml b/common/src/commonMain/composeResources/values-tr/strings_encyclopedia.xml new file mode 100644 index 000000000..48730b1a3 --- /dev/null +++ b/common/src/commonMain/composeResources/values-tr/strings_encyclopedia.xml @@ -0,0 +1,85 @@ + + + Encyclopedia + Search + Clear + Person + Place + Thing + Event + Idea + No Entries Found + Create Entry + Create entry + New + Draft + Name + e.g. Cheshire Cat + Tags + ↵ to add + ritual, magical, recurring… + Description + Cover Art + Drop image · or + Browse files + 1 attached + Replace + Image is too large. Maximum size is %1$d MB. + Could not read the selected image. + Create entry + Cancel + Entry Created + Entry Name was too long. Max %1$d + The entry name contains invalid characters + Tag is too long. Max %1$d + Alias is too long. Max %1$d + Aliases + Learn in spell check + This entry’s name and aliases won’t be marked as misspelled + Add Alias + Add + Nickname or other name + Appears in scenes + Not yet referenced in any scene + Entry name cannot be empty + Discard New Entry? + Error: Failed to load entry! + Click to enter description + Cancel + Save + Entry Saved + Discard Changes? + You will lose any changes you have made. + + Delete Entry? + This cannot be undone! + Entry Deleted + Delete Image? + This cannot be undone! + Name + Close Entry + Entry + Delete Entry + Remove Image + Add Image + Add Tags + Add Tags + Encyclopedia + Edit + Particulars + Type + Tags + Aliases + Scenes + Tags + tag + alias + Fig. I · Entry + Fig. I + + Add + Fol. %1$s · Ent-%2$s + Sc.%1$s + Entry · Catalogue card + Edited + End of entry ✦ + diff --git a/common/src/commonMain/composeResources/values-tr/strings_globalsearch.xml b/common/src/commonMain/composeResources/values-tr/strings_globalsearch.xml new file mode 100644 index 000000000..bc78f6853 --- /dev/null +++ b/common/src/commonMain/composeResources/values-tr/strings_globalsearch.xml @@ -0,0 +1,19 @@ + + + Search project + Search project + Type to search, use #tag to filter… + Clear search + No results + Type at least 2 characters to search + %1$d results + Scene + Note + Encyclopedia + Timeline + All + Scenes + Notes + Encyclopedia + Timeline + diff --git a/common/src/commonMain/composeResources/values-tr/strings_ideas.xml b/common/src/commonMain/composeResources/values-tr/strings_ideas.xml new file mode 100644 index 000000000..9255d1790 --- /dev/null +++ b/common/src/commonMain/composeResources/values-tr/strings_ideas.xml @@ -0,0 +1,83 @@ + + + Story Ideas + IDEA + Story Ideas + %1$d IDEAS · %2$d TAGS + No story ideas yet. Capture your first spark. + No archived ideas. + %1$d W + %1$d TAGS + Search ideas + Search ideas + Clear search + Close search + All + Archived + FILTERED %1$d / %2$d + Clear All + SORT + Newest + Oldest + Title A–Z + ↓ DATE + ↑ DATE + ↓ A–Z + PROMOTED · %1$s + ARCHIVED · %1$s + Capture a new idea + New Idea + Story Idea + Editing + UNSAVED + STORY IDEAS + Close idea + Edit + Title + Optional title + Tags + Enter or comma adds a tag + Add tag… + %1$d / %2$d CH + Create + Save + Cancel + Delete + Archive + Unarchive + Idea captured + Idea saved + Idea deleted + Idea archived + Idea unarchived + Idea cannot be empty + Idea is too long: %1$d characters max + Tags can be at most %1$d characters + Create Project + Create Project from Idea + A new project will be created and this idea will be added to it as its first note. + Project created: %1$s + Failed to create project + Delete Idea + This idea will be permanently deleted. This cannot be undone. + Discard Changes + You have unsaved changes. Discard them? + Syncing story ideas + Story ideas unchanged — skipped + Story ideas sync complete + Failed to fetch story ideas state: %1$s + Removed deleted idea: %1$s + Propagated idea deletion to server: %1$s + Failed to delete idea on server: %1$s + Uploaded idea: %1$s + Failed to upload idea "%1$s": %2$s + Downloaded idea: %1$s + Failed to download idea %1$s: %2$s + Conflict detected for idea: %1$s + Resolved conflict for idea: %1$s + Conflict left unresolved for idea: %1$s + Story Idea Conflict + Title + Tags + Content + diff --git a/common/src/commonMain/composeResources/values-tr/strings_notes.xml b/common/src/commonMain/composeResources/values-tr/strings_notes.xml new file mode 100644 index 000000000..262a4159d --- /dev/null +++ b/common/src/commonMain/composeResources/values-tr/strings_notes.xml @@ -0,0 +1,55 @@ + + + Notes + No Notes Found + Create Note + Cancel + Discard Changes? + You will lose any changes you have made. + Create Note + III · NEW + DRAFT + Create + Cancel + Note Created + Note was too long + Note was empty + Tag was too long (max %1$d) + Tags + ↵ to add + idea, todo, follow-up… + Delete Note? + This action cannot be undone! + Note Deleted + Note + Delete + Note + Editing + Close Note + Notes + Edit + Save + UNSAVED + %1$d W · %2$d CH + ESC TO CANCEL + Search notes + Search notes + Close search + Clear search + %1$d NOTES · %2$d TAGS + %1$d W + ALL + FILTERED · %1$d OF %2$d + CLEAR ALL + SORT + Newest + Oldest + Longest + Shortest + Title A–Z + ↓ DATE + ↑ DATE + ↓ WORDS + ↑ WORDS + ↓ A–Z + diff --git a/common/src/commonMain/composeResources/values-tr/strings_project_home.xml b/common/src/commonMain/composeResources/values-tr/strings_project_home.xml new file mode 100644 index 000000000..380b14572 --- /dev/null +++ b/common/src/commonMain/composeResources/values-tr/strings_project_home.xml @@ -0,0 +1,127 @@ + + + Created: %1$s + Calculating statistics... + Refresh statistics + Statistics may be out of date + Scenes + Total Words + Encyclopedia Entries + Words in Chapters + Chapter + %1$s: %2$s words + Avg Words / Scene + Notes + Timeline Events + Longest Scene + %1$s words + No scenes yet + Last Edited + Median + ≈ %1$d min reading + %1$d pages + Daily goal + Weekly goal + This week + no change vs last week + no data last week + Today + Daily avg + Streak + %1$d days + Longest: %1$d + Days written + Best day + %1$s · %2$s + Characters by Appearances + %1$d connections + Words by Device + Activity · %1$d weeks + %1$s · %2$s words + %1$s · no writing + Avg weekday + Avg weekend + σ %1$d · min %2$d · max %3$d + entries + %1$s · %2$s entries + Themes + %1$d tags · %2$d uses + %1$d/4 + Most connective + Appears in all four types · %1$d uses + Appears in %1$d of 4 types · %2$d uses + All 4 types + %1$d of 4 types + Distribution + Showing %1$d of %2$d + Rank · Tag · Breadth · Uses + Scenes + Notes + Encyclopedia + Events + Export Story + Sync Story + Backup + Backup Created: %1$s + Backup Failed + Story Exported + Export failed + Export Story + EXPORT + × CLOSE + Treat top-level scenes as chapters + Format + Markdown (.md) + EPub (.epub) + PDF (.pdf) + Word (.docx) + RTF (.rtf) + Cancel + Export + Number chapter titles + Font + Keep blank lines between paragraphs + Turn off if you separate paragraphs with a blank line and do not want empty paragraphs in the export. Scene breaks and runs of two or more blank lines are kept. + Limit to specific scenes + Only the selected scenes will be included in the export + Scenes + %1$d of %2$d selected + Select all + Clear all + Learn about export formats + Choosing an Export Format + Each format suits a different purpose. Pick the one that matches how you plan to use the exported story. + Plain text with light formatting; ideal for version control, re-importing, or other writing tools. + The standard e-book format; best for beta readers reading on e-readers and phones with reflowable text. + A fixed page layout that looks the same everywhere; best for printing or sharing a final, read-only copy. + Opens in Word and Google Docs; best for editors and beta readers who leave comments or track changes. + A broadly compatible rich-text format; a safe choice when you are unsure which word processor the recipient uses. + Got it + Contents + by %1$s + Story Imported + Import failed + Import Story + Chapter detection + Auto + H1 (#) + H2 (##) + Split into scenes + Formatting + Pattern + Single scene + Chapter pattern (regex) + Create chapter groups + Preview + Nothing to import + Reading your manuscript + Will create %1$d scene(s) + Large scene + One scene holds %1$s words, so chapter detection probably missed your chapter titles. Try another detection setting. + %1$d scenes hold %2$s words or more, so chapter detection probably missed some chapter titles. Try another detection setting. + %1$s W + Cancel + Import + Settings + Project menu + diff --git a/common/src/commonMain/composeResources/values-tr/strings_project_navigation.xml b/common/src/commonMain/composeResources/values-tr/strings_project_navigation.xml new file mode 100644 index 000000000..ce837ac4a --- /dev/null +++ b/common/src/commonMain/composeResources/values-tr/strings_project_navigation.xml @@ -0,0 +1,15 @@ + + + Home + Story + Notes + Encyclopedia + Timeline + HOME + STRY + NOTE + ENCY + TIME + Expand navigation + Collapse navigation + diff --git a/common/src/commonMain/composeResources/values-tr/strings_project_select_navigation.xml b/common/src/commonMain/composeResources/values-tr/strings_project_select_navigation.xml new file mode 100644 index 000000000..82972b264 --- /dev/null +++ b/common/src/commonMain/composeResources/values-tr/strings_project_select_navigation.xml @@ -0,0 +1,10 @@ + + + Projects + Settings + About + PROJ + SETT + INFO + Libraries + diff --git a/common/src/commonMain/composeResources/values-tr/strings_projects_list.xml b/common/src/commonMain/composeResources/values-tr/strings_projects_list.xml new file mode 100644 index 000000000..c435282e5 --- /dev/null +++ b/common/src/commonMain/composeResources/values-tr/strings_projects_list.xml @@ -0,0 +1,81 @@ + + + No Projects Found + Delete + Rename + Sync Projects + Create Project + Projects synced + Failed to sync projects + Could not delete the project. Check that its folder is accessible and try again. + More + %1$s words + by %1$s + Created %1$s + Project %1$s + Search projects + Close search + filter by name or #tag + Clear filter + No projects match your filter + Hammer · Library + Title + Last Open + — Empty Index — + Library + Sync · On + Local + 0 entries + 1 entry + %1$d entries + SORT + Recently Opened + Least Recent + Newest + Oldest + Longest + Shortest + ↓ OPENED + ↑ OPENED + ↓ CREATED + ↑ CREATED + ↓ WORDS + ↑ WORDS + Account Sync + Sync Log + Project + All + Pending + Error + Conflict + Sync Complete + Canceled + Delete Project + This action cannot be undone. All project data will be permanently deleted. + Type "%1$s" to confirm + Delete + Cancel + Rename Project + New Project Name + Rename Project + Cancel + Create Project + New + Project + New Project Name + Create Project + Cancel + Import + Learn about importing a story + Create from an Existing Story + Already have a manuscript? Import it to create a new project. Hammer names the project after your file and automatically splits the text into scenes. + Supported Formats + Markdown (.md) + Rich Text (.rtf) + Got it + Project already exists + Missing Project Name + Project name too long + The project name contains invalid characters + The project name was left blank + diff --git a/common/src/commonMain/composeResources/values-tr/strings_reader_mode.xml b/common/src/commonMain/composeResources/values-tr/strings_reader_mode.xml new file mode 100644 index 000000000..f3edd2c29 --- /dev/null +++ b/common/src/commonMain/composeResources/values-tr/strings_reader_mode.xml @@ -0,0 +1,16 @@ + + + Reader + Chapter %1$d of %2$d + Edit ↗ + × Close + Decrease text size + Increase text size + Reset text size + Show scene headings + ← Previous chapter + Next chapter → + This chapter has no scenes yet + There is nothing to read yet + End of story + diff --git a/common/src/commonMain/composeResources/values-tr/strings_scene_editor.xml b/common/src/commonMain/composeResources/values-tr/strings_scene_editor.xml new file mode 100644 index 000000000..2511fc20d --- /dev/null +++ b/common/src/commonMain/composeResources/values-tr/strings_scene_editor.xml @@ -0,0 +1,84 @@ + + + Start New Draft: + Draft name + Save + Cancel + Draft Saved + Saving current draft as: + Current draft name is invalid! + Rename Scene + Scene Name + Rename + Cancel + Unsaved + Save + Scene Info + Saved + Scene + New Draft + View Drafts + Rename + Discard + Delete + Archive + Save + Close + Read + Add to dictionary + Archive Scene + Are you sure you want to archive '%1$s'? The scene will be hidden from the scene list but can be restored later. + Archive + Cancel + Discard Changes + Are you sure you want to discard all unsaved changes to '%1$s'? This will reload the last saved version from disk. + Discard + Cancel + Archived Scenes + No archived scenes + Restore + "%1$s" has been restored + "%1$s" has been archived + View Archived + Enter Focus Mode + Metadata + Hide Info + Words: + Advanced + Entity ID: + Filename: + Path: + Created + Edited + — + Outline + A short description of what happens in the scene. + Notes + Notes to yourself. + Draft Name + References + Dismissed + No references yet + Dismiss + Restore + (dismissed) + Characters + Locations + Other + + Add reference + Add reference + Done + Search characters… + Search locations… + Search entries… + No matching entries. + Close dialog + Tags + No tags yet + + Add tag + Add tags + tag1 tag2 … + Done + Select a Scene to edit + New Draft + diff --git a/common/src/commonMain/composeResources/values-tr/strings_scene_list.xml b/common/src/commonMain/composeResources/values-tr/strings_scene_list.xml new file mode 100644 index 000000000..c8b3c4b18 --- /dev/null +++ b/common/src/commonMain/composeResources/values-tr/strings_scene_list.xml @@ -0,0 +1,65 @@ + + + Scenes + Create Group + Group Name + Create Scene + Scene Name + Group Collapsed + Group Expanded + Delete Scene + Are you sure you want to delete this scene: "%1$s" + Delete + Dismiss + Cannot Delete Group + You cannot delete a group while it still has children: "%1$s" + + Create + Cancel + Story Outline + Outline + Read + <no outline> + Dismiss + Manuscript + An outline of + Jump to + Chapters + Scenes + Chapter %1$s · %2$d Scenes + %1$d SCN + End of outline + %1$d/%2$d + Untitled chapter + Options + Delete + Rename + Archive + Archive Scene + Are you sure you want to archive this scene: "%1$s" + Archive + Cancel + Rename Scene + Scene Name + Rename + Create Scene + Create Group + Move To + Destination + Search groups + Top Level + No matching groups + Position (1–%1$d) + Enter a position between 1 and %1$d + Move + Cancel + %1$d · %2$d GR + %1$d SCN + New Scene + New Group + Add + Move + Scene + Group + Root + diff --git a/common/src/commonMain/composeResources/values-tr/strings_sync.xml b/common/src/commonMain/composeResources/values-tr/strings_sync.xml new file mode 100644 index 000000000..a41f985cd --- /dev/null +++ b/common/src/commonMain/composeResources/values-tr/strings_sync.xml @@ -0,0 +1,227 @@ + + + Project Synchronization + Scene Conflict + Scene Draft Conflict + Note Conflict + Timeline Event Conflict + No Date + Local + Remote + %1$s Conflict + Scene + Note + Timeline Event + Encyclopedia Entry + Scene Draft + Project Settings + You can take either the local or remote copy as is, or you can manually merge the two by copying parts of the remote into your local copy, and then taking that. + + Encyclopedia Entry Conflict + Author + Custom theme + Use a custom theme for this project + Primary + Secondary + Word-count goal + Track a writing goal + Words + Per + Day + Week + Effective + W / day + Tags + Tags + ↵ to add + fantasy, draft, nanowrimo… + Language + Not set + Choose a language + Close + Search languages + Clear (not set) + No languages match + Edit + Cancel + OK + Home + Project Settings + Autosaved + § 0 · Project + by %1$s + by (no author set) + Spell checking + Spell check is off: the project language (%1$s) doesn’t match your dictionary (%2$s). Change either to re-enable it. + Learn encyclopedia names in this project + Turn off to keep this project’s entry names out of the spell check dictionary + Project dictionary + ↵ to add + Words the spell checker should accept in this project + Project Settings · %1$s + §§ %1$d + Project Settings Conflict + Pick a side for each field. Local is what you have on this device; Server is what was synced from another device. + Author Name + Project Theme + Word Count Goal + Tags + Language + Learn Encyclopedia Names + (not set) + On + Off + day + week + Project dictionary words from both sides are kept. + Resolve + Name: + Type: + Image: + Content: + Tags: + Has Image + Name + Scene Content + Outline + Notes + Content + Metadata + References + Confirmed + Dismissed + None + Aliases: + Name + Scene Content + Note Content + Date + Event Content + Sync + Start Sync + Synchronizing... + Sync Failed + Sync Complete + Project Sync Complete + Project Sync Failed + Begin Sync + Server data loaded + Account Sync complete + Failed to sync projects: %1$s + Deleting server project: + ID(%1$s) + + Failed to delete project on server: %1$s + Deleting local project: %1$s + Renaming server project: + ID(%1$s) + + Failed to rename project on + server: %1$s + + Rename project from + server: %1$s + + Failed to rename + project from server: %1$s + + Created project on server: %1$s + Failed to create project on server: %1$s + Project created on server, but failed to save its ID locally: %1$s + Created local project: %1$s + Setting local project Server ID: + %1$s + + Failed to create local project from server: %1$s + Skipped server project %1$s: a local project of that name already syncs with a different server project + Syncing Account... + Syncing Projects... + Projects Sync Complete + Syncing Project: %1$s + No changes, skipped + Sync failed: %1$s + There is a conflict in project: %1$s, open that project and sync in order to resolve it + + Client Entity data calculated + The server no longer has this project. It will be recreated the next time you sync. + Server data received + Client data loaded + Local Backup made: %1$s + Entity %1$d downloaded + Failed to download entity %1$d + Entity %1$d not found on server + Entity %1$d not modified + Rejected download for entity %1$d: server returned a mismatched id or type + Failed to upload entity %1$d: type not owned by anything, probably deleted + Failed to delete ID %1$d on server: %2$s + Deleted ID %1$d on server + Encountered conflict for new Entity, this should not be possible! ID: %1$d TYPE: %2$s + + Sync failed: %1$s + Server cached hash is stale for entity %1$d. Healing by force uploading. + Successfully healed server cache for entity %1$d by force uploading + Failed to heal server cache for entity %1$d - force upload failed + Entities transferred + Sync finalized + Sync data saved + Sync data not saved due to errors + Failed to end sync + Rejected draft %1$d from server: unsafe draft name + Skipped image for encyclopedia entry %1$d from server: unsafe file extension + Deleted Encyclopedia ID %1$d from client + Deleted note ID %1$d from client + Deleted draft %1$d from client + Deleted scene %1$d from client + Failed to delete scene %1$d from client + Failed find scene to delete: %1$d + Deleted event %1$d from client + Failed to delete event %1$d from client + Failed find event to delete: %1$d + Creating new scene %1$d + Downloaded scene content for: %1$d + Moved scene %1$d to new parent %2$d + Failed to save downloaded scene content for: %1$d + Creating new group %1$d + Downloaded scene content for: %1$d + Moved scene %1$d to new parent %2$d + Unauthorized + Unhandled server error + UPDATE REQUIRED + Your app is out of date + The sync server is out of date + This version of Hammer can no longer sync with the server. Update to the latest version to keep syncing your work. + The sync server is running an older protocol than this app. The server needs to be updated before syncing will work again. + Latest version: %s + You have: %s + Get the Update + Dismiss + Reauthorize + Your token has expired, please reauthorize with your sync server. + Token Expired + Address + E-Mail + About Sync Servers + Sync Servers for Hammer allow you to work on your projects from multiple devices. They will sync your work across whatever devices you configure with the same server. + + Official Server + You can run your own Sync server, but if that is too technical, or you just want to support the project, we run an official server at hammer.ink. + + If you subscribe to our Patreon, you get access to the official server. This is the best way to support this project. + + Patreon + Hammer.Ink + Registration + Servers have closed registration, meaning the server admin needs to add you to the server's allowed users list before you can create an account. + + SetUp + Enter the server's URL, such as "hammer.ink" for the official server. + + Enter your email and password, then hit "Create" to create your account. + + In the future, and on other devices, you will simply hit "Log In". + + Important Note + Syncing in Hammer is different from online-first applications like Google Docs. The syncing is not real-time. It usually happens when you open or close the app. Make sure you're synced with the latest versions of your projects before going offline. Hammer does automatic syncing, but it's always good to check if you're going to be offline for a while. + + Got It + diff --git a/common/src/commonMain/composeResources/values-tr/strings_timeline.xml b/common/src/commonMain/composeResources/values-tr/strings_timeline.xml new file mode 100644 index 000000000..c6900839d --- /dev/null +++ b/common/src/commonMain/composeResources/values-tr/strings_timeline.xml @@ -0,0 +1,51 @@ + + + No Events + Create Event + Event Created + Tag was too long (max %1$d) + Tags + ↵ to add + arc, foreshadow, conflict… + Event + Editing + Timeline + Save + Cancel + Edit + Close Entry + Date + optional + e.g. Spring of Y3 + UNDATED + UNSAVED + %1$d W · %2$d CH + ESC TO CANCEL + %1$d W + %1$d TAGS + Saved + Failed to save + Create Event + III · NEW + DRAFT + %1$d W · %2$d CH + Cancel + Create + optional + e.g. Spring of Y3 + Discard Changes? + You will lose any changes you have made. + Event + Delete + Delete Timeline Event? + This cannot be undone! + Timeline + %1$d ENTRIES · %2$d TAGS + Search timeline + Search timeline + Close search + Clear search + ALL + FILTERED · %1$d OF %2$d + CLEAR ALL + diff --git a/common/src/commonMain/composeResources/values-uk/strings.xml b/common/src/commonMain/composeResources/values-uk/strings.xml index 239eede8e..b3896244f 100644 --- a/common/src/commonMain/composeResources/values-uk/strings.xml +++ b/common/src/commonMain/composeResources/values-uk/strings.xml @@ -22,6 +22,7 @@ Ні Розгорнути Згорнути + Text size Зменшити розмір тексту Збільшити розмір тексту Скинути розмір тексту diff --git a/common/src/commonMain/composeResources/values-uk/strings_about_app.xml b/common/src/commonMain/composeResources/values-uk/strings_about_app.xml index 4953a770a..4b0d11587 100644 --- a/common/src/commonMain/composeResources/values-uk/strings_about_app.xml +++ b/common/src/commonMain/composeResources/values-uk/strings_about_app.xml @@ -12,6 +12,17 @@ Dark Rock Studios Changes GitHub Release + Check for Updates + Checking for updates… + Hammer is up to date. + Hammer %1$s is available. + Update to %1$s + Downloading Hammer %1$s (%2$s) + Installing Hammer %1$s. Hammer will restart. + Could not update to %1$s: %2$s + Retry + Later + Hammer will quit to install the update, then reopen. What's New Примітки до випуску Приміток до випуску не надано. diff --git a/common/src/commonMain/composeResources/values-uk/strings_account_settings.xml b/common/src/commonMain/composeResources/values-uk/strings_account_settings.xml index bd223e912..026a78cb6 100644 --- a/common/src/commonMain/composeResources/values-uk/strings_account_settings.xml +++ b/common/src/commonMain/composeResources/values-uk/strings_account_settings.xml @@ -9,6 +9,7 @@ Тема інтерфейсу СВІТЛА ТЕМНА + OLED СИСТЕМНА Початковий екран проєкту Головна @@ -55,6 +56,8 @@ Delete all local projects? This permanently deletes every project on this device and replaces them with the projects from the server. This cannot be undone. Delete and Replace + Check for updates automatically + Looks for a new release on GitHub shortly after launch and every few hours. Nothing is downloaded or installed until you choose to update. Налаштування платформи Немає налаштувань, специфічних для платформи. Не вимикати екран @@ -83,6 +86,7 @@ Не вдалося завантажити резервні копії: %1$s Не вдалося завантажити резервні копії проєкту: %1$s Не вдалося видалити резервну копію: %1$s + Failed to restore backup: %1$s Відновити з резервної копії Увага: відновлення з цієї резервної копії повністю замінить поточний вміст проєкту. Усі наявні файли буде видалено та замінено даними резервної копії. Цю дію неможливо скасувати. Відновити diff --git a/common/src/commonMain/composeResources/values-uk/strings_project_home.xml b/common/src/commonMain/composeResources/values-uk/strings_project_home.xml index a89d4db77..dea76063a 100644 --- a/common/src/commonMain/composeResources/values-uk/strings_project_home.xml +++ b/common/src/commonMain/composeResources/values-uk/strings_project_home.xml @@ -78,6 +78,10 @@ RTF (.rtf) Скасувати Експортувати + Number chapter titles + Font + Keep blank lines between paragraphs + Turn off if you separate paragraphs with a blank line and do not want empty paragraphs in the export. Scene breaks and runs of two or more blank lines are kept. Limit to specific scenes Only the selected scenes will be included in the export Сцени diff --git a/common/src/commonMain/composeResources/values-uk/strings_projects_list.xml b/common/src/commonMain/composeResources/values-uk/strings_projects_list.xml index 6a707346e..35a29fc75 100644 --- a/common/src/commonMain/composeResources/values-uk/strings_projects_list.xml +++ b/common/src/commonMain/composeResources/values-uk/strings_projects_list.xml @@ -7,6 +7,7 @@ Створити проєкт Проєкти синхронізовано Не вдалося синхронізувати проєкти + Could not delete the project. Check that its folder is accessible and try again. Більше %1$s слів автор: %1$s diff --git a/common/src/commonMain/composeResources/values-uk/strings_reader_mode.xml b/common/src/commonMain/composeResources/values-uk/strings_reader_mode.xml new file mode 100644 index 000000000..b240ac13e --- /dev/null +++ b/common/src/commonMain/composeResources/values-uk/strings_reader_mode.xml @@ -0,0 +1,16 @@ + + + Reader + Chapter %1$d of %2$d + Edit ↗ + × Close + Зменшити розмір тексту + Збільшити розмір тексту + Скинути розмір тексту + Show scene headings + ← Previous chapter + Next chapter → + This chapter has no scenes yet + There is nothing to read yet + End of story + diff --git a/common/src/commonMain/composeResources/values-uk/strings_scene_editor.xml b/common/src/commonMain/composeResources/values-uk/strings_scene_editor.xml index 506a42e0a..bf7932b3f 100644 --- a/common/src/commonMain/composeResources/values-uk/strings_scene_editor.xml +++ b/common/src/commonMain/composeResources/values-uk/strings_scene_editor.xml @@ -24,6 +24,7 @@ Архівувати Зберегти Закрити + Read Add to dictionary Архівувати сцену Ви впевнені, що хочете архівувати «%1$s»? Сцену буде приховано зі списку сцен, але її можна відновити пізніше. diff --git a/common/src/commonMain/composeResources/values-uk/strings_scene_list.xml b/common/src/commonMain/composeResources/values-uk/strings_scene_list.xml index 3ff787e23..710df424d 100644 --- a/common/src/commonMain/composeResources/values-uk/strings_scene_list.xml +++ b/common/src/commonMain/composeResources/values-uk/strings_scene_list.xml @@ -16,7 +16,8 @@ Створити Скасувати План історії - Огляд плану + План + Read <немає плану> Закрити Рукопис diff --git a/common/src/commonMain/composeResources/values/strings.xml b/common/src/commonMain/composeResources/values/strings.xml index f76a6b2a6..6d1c98fe9 100644 --- a/common/src/commonMain/composeResources/values/strings.xml +++ b/common/src/commonMain/composeResources/values/strings.xml @@ -35,6 +35,7 @@ Expand Collapse + Text size Decrease text size Increase text size Reset text size diff --git a/common/src/commonMain/composeResources/values/strings_about_app.xml b/common/src/commonMain/composeResources/values/strings_about_app.xml index 467aef1aa..3539ae4c1 100644 --- a/common/src/commonMain/composeResources/values/strings_about_app.xml +++ b/common/src/commonMain/composeResources/values/strings_about_app.xml @@ -17,6 +17,18 @@ Changes GitHub Release + Check for Updates + Checking for updates… + Hammer is up to date. + Hammer %1$s is available. + Update to %1$s + Downloading Hammer %1$s (%2$s) + Installing Hammer %1$s. Hammer will restart. + Could not update to %1$s: %2$s + Retry + Later + Hammer will quit to install the update, then reopen. + What's New Release notes No release notes provided. diff --git a/common/src/commonMain/composeResources/values/strings_account_settings.xml b/common/src/commonMain/composeResources/values/strings_account_settings.xml index cc9b4f074..fb854f5c1 100644 --- a/common/src/commonMain/composeResources/values/strings_account_settings.xml +++ b/common/src/commonMain/composeResources/values/strings_account_settings.xml @@ -11,6 +11,7 @@ UI Theme LIGHT DARK + OLED SYSTEM Initial Project Screen Home @@ -60,6 +61,8 @@ Delete all local projects? This permanently deletes every project on this device and replaces them with the projects from the server. This cannot be undone. Delete and Replace + Check for updates automatically + Looks for a new release on GitHub shortly after launch and every few hours. Nothing is downloaded or installed until you choose to update. Platform Settings No platform-specific settings. Keep screen on @@ -98,6 +101,7 @@ Failed to load backups: %1$s Failed to load backups for project: %1$s Failed to delete backup: %1$s + Failed to restore backup: %1$s Restore Backup Warning: Restoring this backup will completely replace the current contents of the project. All existing files will be deleted and replaced with the backup data. This action cannot be undone. diff --git a/common/src/commonMain/composeResources/values/strings_project_home.xml b/common/src/commonMain/composeResources/values/strings_project_home.xml index 6e5713c9c..2576942fa 100644 --- a/common/src/commonMain/composeResources/values/strings_project_home.xml +++ b/common/src/commonMain/composeResources/values/strings_project_home.xml @@ -92,6 +92,10 @@ RTF (.rtf) Cancel Export + Number chapter titles + Font + Keep blank lines between paragraphs + Turn off if you separate paragraphs with a blank line and do not want empty paragraphs in the export. Scene breaks and runs of two or more blank lines are kept. Limit to specific scenes Only the selected scenes will be included in the export Scenes diff --git a/common/src/commonMain/composeResources/values/strings_projects_list.xml b/common/src/commonMain/composeResources/values/strings_projects_list.xml index a26dc0f55..c435282e5 100644 --- a/common/src/commonMain/composeResources/values/strings_projects_list.xml +++ b/common/src/commonMain/composeResources/values/strings_projects_list.xml @@ -7,6 +7,7 @@ Create Project Projects synced Failed to sync projects + Could not delete the project. Check that its folder is accessible and try again. More %1$s words by %1$s diff --git a/common/src/commonMain/composeResources/values/strings_reader_mode.xml b/common/src/commonMain/composeResources/values/strings_reader_mode.xml new file mode 100644 index 000000000..f3edd2c29 --- /dev/null +++ b/common/src/commonMain/composeResources/values/strings_reader_mode.xml @@ -0,0 +1,16 @@ + + + Reader + Chapter %1$d of %2$d + Edit ↗ + × Close + Decrease text size + Increase text size + Reset text size + Show scene headings + ← Previous chapter + Next chapter → + This chapter has no scenes yet + There is nothing to read yet + End of story + diff --git a/common/src/commonMain/composeResources/values/strings_scene_editor.xml b/common/src/commonMain/composeResources/values/strings_scene_editor.xml index d68478fe9..05792be0c 100644 --- a/common/src/commonMain/composeResources/values/strings_scene_editor.xml +++ b/common/src/commonMain/composeResources/values/strings_scene_editor.xml @@ -24,6 +24,7 @@ Archive Save Close + Read Add to dictionary Archive Scene diff --git a/common/src/commonMain/composeResources/values/strings_scene_list.xml b/common/src/commonMain/composeResources/values/strings_scene_list.xml index 757760070..41ae93a81 100644 --- a/common/src/commonMain/composeResources/values/strings_scene_list.xml +++ b/common/src/commonMain/composeResources/values/strings_scene_list.xml @@ -18,7 +18,8 @@ Cancel Story Outline - Outline Overview + Outline + Read <no outline> Dismiss Manuscript diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/StartupBanner.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/StartupBanner.kt index 069e5ef8c..98eb13aee 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/StartupBanner.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/StartupBanner.kt @@ -2,6 +2,7 @@ package com.darkrockstudios.apps.hammer.common import com.darkrockstudios.apps.hammer.base.BuildMetadata import com.darkrockstudios.apps.hammer.base.DistributionChannel +import com.darkrockstudios.apps.hammer.base.PackageFormat import io.github.aakira.napier.Napier /** @@ -17,6 +18,7 @@ fun logStartupBanner() { fun startupBanner(): String = "Hammer v${BuildMetadata.APP_VERSION}" + " | channel: ${DistributionChannel.current.token}" + + " | format: ${PackageFormat.current.token}" + " | ${platformStartupInfo()}" /** Platform + environment details for the startup banner. */ diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/encyclopedia/EncyclopediaComponent.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/encyclopedia/EncyclopediaComponent.kt index da7056e51..a906a921b 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/encyclopedia/EncyclopediaComponent.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/encyclopedia/EncyclopediaComponent.kt @@ -65,19 +65,26 @@ class EncyclopediaComponent( } override fun shouldConfirmClose(): Set { - val unsaved = when (val destination = stack.value.active.instance) { - is Encyclopedia.Destination.CreateEntryDestination -> true - is Encyclopedia.Destination.ViewEntryDestination -> { - destination.component.state.value.editName || destination.component.state.value.editText - } + val unsaved = stack.value.items.any { isUnsaved(it.instance) } + return if (unsaved) setOf(CloseConfirm.Encyclopedia) else emptySet() + } - else -> false + private fun isUnsaved(destination: Encyclopedia.Destination) = when (destination) { + is Encyclopedia.Destination.CreateEntryDestination -> true + is Encyclopedia.Destination.ViewEntryDestination -> isEditing(destination.component) + else -> false + } + + private fun isEditing(entry: ViewEntry) = entry.state.value.let { it.editName || it.editText } + + override fun discardUnsaved(item: CloseConfirm) { + if (item != CloseConfirm.Encyclopedia) return + stack.value.items.forEach { child -> + val view = child.instance as? Encyclopedia.Destination.ViewEntryDestination ?: return@forEach + if (isEditing(view.component)) view.component.discardEdit() } - - return if (unsaved) { - setOf(CloseConfirm.Encyclopedia) - } else { - emptySet() + if (stack.value.active.instance is Encyclopedia.Destination.CreateEntryDestination) { + navigation.pop() } } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/encyclopedia/ViewEntry.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/encyclopedia/ViewEntry.kt index 5001e8774..486f6dbf5 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/encyclopedia/ViewEntry.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/encyclopedia/ViewEntry.kt @@ -59,6 +59,9 @@ interface ViewEntry : TagSuggesting { fun startTextEdit() fun finishNameEdit() fun finishTextEdit() + + /** Leaves name and text edit mode without saving; the UI reseeds its fields from [State.content]. */ + fun discardEdit() fun confirmClose() fun dismissConfirmClose() fun removeTag(tag: String) diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/encyclopedia/ViewEntryComponent.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/encyclopedia/ViewEntryComponent.kt index 419de7203..a1d06667c 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/encyclopedia/ViewEntryComponent.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/encyclopedia/ViewEntryComponent.kt @@ -13,6 +13,7 @@ import com.darkrockstudios.apps.hammer.common.data.MenuItemDescriptor import com.darkrockstudios.apps.hammer.common.data.SceneItem import com.darkrockstudios.apps.hammer.common.data.encyclopediarepository.EncyclopediaService import com.darkrockstudios.apps.hammer.common.data.encyclopediarepository.EntryError +import com.darkrockstudios.apps.hammer.common.data.encyclopediarepository.EntryLoadError import com.darkrockstudios.apps.hammer.common.data.encyclopediarepository.EntryResult import com.darkrockstudios.apps.hammer.common.data.encyclopediarepository.entry.EntryContent import com.darkrockstudios.apps.hammer.common.data.encyclopediarepository.entry.EntryDef @@ -22,8 +23,13 @@ import com.darkrockstudios.apps.hammer.common.data.references.CleanupReferencesO import com.darkrockstudios.apps.hammer.common.data.references.ReferenceIndexService import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.SceneEditorService import com.darkrockstudios.apps.hammer.common.data.tagindex.parseTagInput +import io.github.aakira.napier.Napier +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import okio.IOException class ViewEntryComponent( componentContext: ComponentContext, @@ -48,6 +54,8 @@ class ViewEntryComponent( private val backfillEntryReferences: BackfillEntryReferencesUseCase by projectInject() private val cleanupReferencesOnDelete: CleanupReferencesOnEntryDeleteUseCase by projectInject() + private val reloadMutex = Mutex() + private val backButtonHandler = BackCallback(isEnabled = false) { // Only called when editing - show confirmation before discarding confirmClose() @@ -94,24 +102,50 @@ class ViewEntryComponent( } reload() + watchEntry() + } + + // Follows writes made elsewhere (sync), including a rename that moves the entry's file. + private fun watchEntry() { + scope.launch { + val entryId = state.value.entryDef.id + encyclopediaService.entryChangedFlow.filter { it == entryId }.collect { + // The entry's files can be mid-move while a sync is writing them. + try { + val currentDef = encyclopediaService.findEntryDef(entryId) + ?: return@collect + withContext(dispatcherMain) { + _state.getAndUpdate { it.copy(entryDef = currentDef) } + } + loadEntryState() + } catch (e: EntryLoadError) { + Napier.w("Failed to refresh entry $entryId", e) + } catch (e: IOException) { + Napier.w("Failed to refresh entry $entryId", e) + } + } + } } private fun reload() { - scope.launch { - val entryDef = state.value.entryDef - val entryImagePath = getImagePath(entryDef) - val imageHash = encyclopediaService.findEntryImageExtension(entryDef) - ?.let { ext -> encyclopediaService.calculateEntryImageHash(entryDef, ext) } + scope.launch { loadEntryState() } + } - val content = loadEntryContent(state.value.entryDef) - withContext(dispatcherMain) { - _state.getAndUpdate { - it.copy( - entryImagePath = entryImagePath, - entryImageHash = imageHash, - content = content - ) - } + // Serialized so overlapping reloads can't land out of order. + private suspend fun loadEntryState() = reloadMutex.withLock { + val entryDef = state.value.entryDef + val entryImagePath = getImagePath(entryDef) + val imageHash = encyclopediaService.findEntryImageExtension(entryDef) + ?.let { ext -> encyclopediaService.calculateEntryImageHash(entryDef, ext) } + + val content = loadEntryContent(entryDef) + withContext(dispatcherMain) { + _state.getAndUpdate { + it.copy( + entryImagePath = entryImagePath, + entryImageHash = imageHash, + content = content + ) } } } @@ -203,6 +237,12 @@ class ViewEntryComponent( } } + override fun discardEdit() { + _state.getAndUpdate { + it.copy(editName = false, editText = false, confirmClose = false) + } + } + /** * Saves the entry as [transform] of its current content, sourcing unchanged fields from * the loaded state or, if the async load hasn't landed yet, straight from disk - so a diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/notes/NotesComponent.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/notes/NotesComponent.kt index 352c874fb..811983d81 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/notes/NotesComponent.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/notes/NotesComponent.kt @@ -59,19 +59,24 @@ class NotesComponent( } override fun shouldConfirmClose(): Set { - val unsaved = when (val destination = stack.value.active.instance) { - is Notes.Destination.CreateNoteDestination -> true - is Notes.Destination.ViewNoteDestination -> { - destination.component.isEditingAndDirty() - } + val unsaved = stack.value.items.any { isUnsaved(it.instance) } + return if (unsaved) setOf(CloseConfirm.Notes) else emptySet() + } - else -> false + private fun isUnsaved(destination: Notes.Destination) = when (destination) { + is Notes.Destination.CreateNoteDestination -> true + is Notes.Destination.ViewNoteDestination -> destination.component.isEditingAndDirty() + else -> false + } + + override fun discardUnsaved(item: CloseConfirm) { + if (item != CloseConfirm.Notes) return + stack.value.items.forEach { child -> + val view = child.instance as? Notes.Destination.ViewNoteDestination ?: return@forEach + if (view.component.state.value.isEditing) view.component.discardEdit() } - - return if (unsaved) { - setOf(CloseConfirm.Notes) - } else { - emptySet() + if (stack.value.active.instance is Notes.Destination.CreateNoteDestination) { + navigation.pop() } } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/notes/ViewNoteComponent.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/notes/ViewNoteComponent.kt index babd56f25..952abcbdf 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/notes/ViewNoteComponent.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/notes/ViewNoteComponent.kt @@ -18,6 +18,7 @@ import com.darkrockstudios.apps.hammer.common.data.projectInject import com.darkrockstudios.apps.hammer.notes_menu_delete import com.darkrockstudios.apps.hammer.notes_menu_group import io.github.aakira.napier.Napier +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable @@ -50,6 +51,8 @@ class ViewNoteComponent( private val _noteText = MutableValue(restoredDraft?.noteText ?: "") override val noteText: Value = _noteText + private var dismissed = false + private val backButtonHandler = BackCallback(isEnabled = false) { if (isEditingAndDirty()) { confirmDiscard() @@ -81,6 +84,29 @@ class ViewNoteComponent( } loadInitialContent() + watchNotes() + } + + // Follows writes made elsewhere (sync). A dirty edit keeps its draft, only the baseline moves. + private fun watchNotes() { + scope.launch { + notesRepository.notesListFlow.collect { notes -> + val updated = notes.find { it.note.id == noteId }?.note + withContext(dispatcherMain) { + if (updated == null) { + // Deleted elsewhere. A dirty edit stays open so its draft isn't lost. + if (state.value.note != null && !isEditingAndDirty()) dismiss() + return@withContext + } + if (updated == state.value.note) return@withContext + val keepDraft = isEditingAndDirty() + _state.getAndUpdate { + it.copy(note = updated, tags = if (keepDraft) it.tags else updated.tags) + } + if (!keepDraft) _noteText.update { updated.content } + } + } + } } override fun onContentChanged(newContent: String) { @@ -136,7 +162,7 @@ class ViewNoteComponent( override suspend fun deleteNote(id: Int) = withContext(dispatcherDefault) { notesRepository.deleteNote(id) notesRepository.loadNotes() - withContext(dispatcherMain) { dismissView() } + withContext(dispatcherMain) { dismiss() } } override fun confirmDelete() { @@ -149,6 +175,12 @@ class ViewNoteComponent( override fun closeNote() { if (state.value.isEditing) discardEdit() + dismiss() + } + + private fun dismiss() { + if (dismissed) return + dismissed = true dismissView() } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/DocxStoryRenderer.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/DocxStoryRenderer.kt index da3e63a2e..c329ae1f0 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/DocxStoryRenderer.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/DocxStoryRenderer.kt @@ -3,6 +3,7 @@ package com.darkrockstudios.apps.hammer.common.components.projecthome import com.darkrockstudios.apps.hammer.base.BuildMetadata import com.darkrockstudios.apps.hammer.base.http.projectdata.ProjectData import com.darkrockstudios.apps.hammer.base.http.projectdata.ProjectTheme +import com.darkrockstudios.apps.hammer.common.data.ExportOptions import nl.adaptivity.xmlutil.XMLConstants import nl.adaptivity.xmlutil.XmlDeclMode import nl.adaptivity.xmlutil.XmlWriter @@ -57,6 +58,7 @@ fun writeStoryAsDocx( projectData: ProjectData, chapters: List, strings: ExportStrings, + options: ExportOptions = ExportOptions(), ) { val authorName = projectData.authorName?.takeIf { it.isNotBlank() } val effective = chapters.ifEmpty { listOf(StoryChapter(projectName, "")) } @@ -64,14 +66,14 @@ fun writeStoryAsDocx( // The body walk collects hyperlink targets and ordered-list instances that // document.xml.rels and numbering.xml must declare, so it runs first. val ctx = DocxRenderContext() - val documentXml = buildXmlPart { writeDocument(it, ctx, projectName, strings, effective) } + val documentXml = buildXmlPart { writeDocument(it, ctx, projectName, strings, effective, options) } val zip = OkioZipOutputStream(sink) zip.putEntry("[Content_Types].xml", buildXmlPart(::writeContentTypes)) zip.putEntry("_rels/.rels", buildXmlPart(::writePackageRels)) zip.putEntry("docProps/core.xml", buildXmlPart { writeCoreProps(it, projectName, authorName) }) zip.putEntry("docProps/app.xml", buildXmlPart(::writeAppProps)) - zip.putEntry("word/styles.xml", buildXmlPart { writeStyles(it, projectData.theme) }) + zip.putEntry("word/styles.xml", buildXmlPart { writeStyles(it, projectData.theme, options.font.faceName) }) zip.putEntry("word/numbering.xml", buildXmlPart { writeNumbering(it, ctx.orderedListLevels) }) zip.putEntry( "word/_rels/document.xml.rels", @@ -219,7 +221,7 @@ private fun writeAppProps(writer: XmlWriter) { } } -private fun writeStyles(writer: XmlWriter, theme: ProjectTheme?) { +private fun writeStyles(writer: XmlWriter, theme: ProjectTheme?, bodyFont: String) { val primary = theme?.primary?.let(::argbHexToCssHex)?.removePrefix("#") val secondary = theme?.secondary?.let(::argbHexToCssHex)?.removePrefix("#") @@ -253,7 +255,7 @@ private fun writeStyles(writer: XmlWriter, theme: ProjectTheme?) { w("docDefaults") { w("rPrDefault") { w("rPr") { - rFonts(EXPORT_BODY_FONT) + rFonts(bodyFont) size(24) } } @@ -394,14 +396,15 @@ private fun writeDocument( projectName: String, strings: ExportStrings, chapters: List, + options: ExportOptions, ) { writer.smartStartTag(W_NS, "document", "w") { namespaceAttr("r", R_NS) w("body") { writeTitlePage(projectName, strings.authorByline) - writeContentsPage(chapters, strings.contentsTitle) + writeContentsPage(chapters, strings.contentsTitle, options.numberChapters) chapters.forEachIndexed { index, chapter -> - writeChapter(ctx, index, chapter) + writeChapter(ctx, index, chapter, options) } w("sectPr") { w("pgSz") { @@ -449,7 +452,11 @@ private fun XmlWriter.writeTitlePage(projectName: String, authorByline: String?) } } -private fun XmlWriter.writeContentsPage(chapters: List, contentsTitle: String) { +private fun XmlWriter.writeContentsPage( + chapters: List, + contentsTitle: String, + numberChapters: Boolean, +) { w("p") { w("pPr") { wVal("pStyle", "TocTitle") @@ -470,7 +477,7 @@ private fun XmlWriter.writeContentsPage(chapters: List, contentsTi w("rPr") { wVal("rStyle", "Hyperlink") } w("t") { preserveSpace() - text("${index + 1}. ${chapter.name}") + text(chapterTitle(index, chapter, numberChapters)) } } } @@ -478,7 +485,12 @@ private fun XmlWriter.writeContentsPage(chapters: List, contentsTi } } -private fun XmlWriter.writeChapter(ctx: DocxRenderContext, index: Int, chapter: StoryChapter) { +private fun XmlWriter.writeChapter( + ctx: DocxRenderContext, + index: Int, + chapter: StoryChapter, + options: ExportOptions, +) { w("p") { w("pPr") { wVal("pStyle", "Heading1") @@ -491,13 +503,13 @@ private fun XmlWriter.writeChapter(ctx: DocxRenderContext, index: Int, chapter: w("r") { w("t") { preserveSpace() - text("${index + 1}. ${chapter.name}") + text(chapterTitle(index, chapter, options.numberChapters)) } } w("bookmarkEnd") { wAttr("id", index.toString()) } } if (chapter.markdown.isNotBlank()) { - MarkdownDocxWriter(this, ctx).render(chapter.markdown) + MarkdownDocxWriter(this, ctx, options.keepBlankLines).render(chapter.markdown) } } @@ -510,9 +522,10 @@ private fun XmlWriter.writeChapter(ctx: DocxRenderContext, index: Int, chapter: private class MarkdownDocxWriter( private val writer: XmlWriter, private val ctx: DocxRenderContext, + private val keepBlankLines: Boolean, ) { fun render(markdown: String) { - val blocks = parseProseMarkdown(markdown) + val blocks = parseProseMarkdown(markdown, keepBlankLines) blocks.forEachIndexed { index, block -> // Prose runs tight: the indent parts one line from the next, and the space a passage // break wants is a blank line the author typed. Space after is for leaving prose behind. diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/EpubStoryRenderer.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/EpubStoryRenderer.kt index ab2e1f774..ba04ece5b 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/EpubStoryRenderer.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/EpubStoryRenderer.kt @@ -2,6 +2,8 @@ package com.darkrockstudios.apps.hammer.common.components.projecthome import com.darkrockstudios.apps.hammer.base.http.projectdata.ProjectData import com.darkrockstudios.apps.hammer.base.http.projectdata.ProjectTheme +import com.darkrockstudios.apps.hammer.common.data.ExportFont +import com.darkrockstudios.apps.hammer.common.data.ExportOptions import io.documentnode.epub4kmp.domain.* import io.documentnode.epub4kmp.epub.EpubWriter import kotlinx.html.* @@ -22,6 +24,7 @@ fun writeStoryAsEpub( chapters: List, language: String, strings: ExportStrings, + options: ExportOptions = ExportOptions(), ) { val authorName = projectData.authorName?.takeIf { it.isNotBlank() } @@ -30,11 +33,11 @@ fun writeStoryAsEpub( authorName?.let { metadata.addAuthor(parseAuthor(it)) } metadata.language = language - addStylesheet(buildStylesheet(projectData.theme)) + addStylesheet(buildStylesheet(projectData.theme, options.font)) val effective = chapters.ifEmpty { listOf(StoryChapter(projectName, "")) } val tocEntries = effective.mapIndexed { index, chapter -> - TocEntry(title = chapter.name, href = "ch${index + 1}.xhtml") + TocEntry(title = chapterTitle(index, chapter, options.numberChapters), href = "ch${index + 1}.xhtml") } // Title page, contents page, then the chapters. @@ -66,7 +69,7 @@ fun writeStoryAsEpub( id = "ch${index + 1}", href = entry.href, title = chapter.name, - bodyBuilder = { chapterBody(chapter.name, chapter.markdown) }, + bodyBuilder = { chapterBody(entry.title, chapter.markdown, options.keepBlankLines) }, ), ) } @@ -80,8 +83,9 @@ fun writeStoryAsEpub( * plus theme overlays — primary on h1 and links, secondary on h2, and a primary-colored title-page rule — * so the exported book inherits the project's chosen accent colors. */ -private fun buildStylesheet(theme: ProjectTheme?): Stylesheet = stylesheet { +private fun buildStylesheet(theme: ProjectTheme?, font: ExportFont): Stylesheet = stylesheet { raw(Stylesheets.defaultReader().css) + raw("body { font-family: ${font.cssFamily}; }") // Title page presentation — applies even without a theme. raw( @@ -173,12 +177,12 @@ private fun BODY.titlePageBody(projectName: String, authorName: String?) { } } -private fun BODY.chapterBody(chapterTitle: String, markdown: String) { +private fun BODY.chapterBody(chapterTitle: String, markdown: String, keepBlankLines: Boolean) { h1(classes = "chapter-title") { +chapterTitle } val flavour = GFMFlavourDescriptor() val source = ProseHtml.normalizeLineEndings(markdown) val parsed = MarkdownParser(flavour).buildMarkdownTreeFromString(source) - val providers = ProseHtml.providers(flavour, LinkMap.buildLinkMap(parsed, source)) + val providers = ProseHtml.providers(flavour, LinkMap.buildLinkMap(parsed, source), keepBlankLines) val htmlBody = HtmlGenerator(source, parsed, providers).generateHtml() val xhtmlBody = htmlBody.selfCloseHtmlVoidTags() unsafe { +xhtmlBody } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/ExportStoryUseCase.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/ExportStoryUseCase.kt index 5085a0ac5..68ac42b31 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/ExportStoryUseCase.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/ExportStoryUseCase.kt @@ -6,7 +6,8 @@ import com.darkrockstudios.apps.hammer.common.data.ExportOptions import com.darkrockstudios.apps.hammer.common.data.SceneItem import com.darkrockstudios.apps.hammer.common.data.projectdata.ProjectDataDatasource import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.SceneEditorService -import com.darkrockstudios.apps.hammer.common.data.tree.TreeValue +import com.darkrockstudios.apps.hammer.common.data.tree.ChapterScenes +import com.darkrockstudios.apps.hammer.common.data.tree.collectChapters import com.darkrockstudios.apps.hammer.common.dependencyinjection.injectDefaultDispatcher import com.darkrockstudios.apps.hammer.common.dependencyinjection.injectIoDispatcher import com.darkrockstudios.apps.hammer.common.fileio.HPath @@ -93,8 +94,8 @@ class ExportStoryUseCase( /** Reads source data off [ioDispatcher], then renders the document into an in-memory buffer on [defaultDispatcher]. */ private suspend fun render(projectName: String, options: ExportOptions): Buffer { val source = withContext(ioDispatcher) { - val perNodeChapters = sceneEditorRepository.getSceneTree().root.children.mapNotNull { node -> - chapterFor(node, options.sceneIds) + val perNodeChapters = sceneEditorRepository.getSceneTree().collectChapters().mapNotNull { chapter -> + chapterFor(chapter, options) } val projectData = if (options.format == ExportFormat.Markdown) null else projectDataDatasource.load().data @@ -115,6 +116,7 @@ class ExportStoryUseCase( projectName = projectName, chapters = source.perNodeChapters, treatTopLevelAsChapters = options.treatTopLevelAsChapters, + numberChapters = options.numberChapters, ) ExportFormat.Epub -> writeStoryAsEpub( @@ -124,6 +126,7 @@ class ExportStoryUseCase( chapters = chaptersFor(options, projectName, source.perNodeChapters), language = source.language, strings = exportStrings, + options = options, ) ExportFormat.Pdf -> writeStoryAsPdf( @@ -132,6 +135,7 @@ class ExportStoryUseCase( projectData = source.requireProjectData(), chapters = chaptersFor(options, projectName, source.perNodeChapters), strings = exportStrings, + options = options, ) ExportFormat.Docx -> writeStoryAsDocx( @@ -140,6 +144,7 @@ class ExportStoryUseCase( projectData = source.requireProjectData(), chapters = chaptersFor(options, projectName, source.perNodeChapters), strings = exportStrings, + options = options, ) ExportFormat.Rtf -> writeStoryAsRtf( @@ -148,6 +153,7 @@ class ExportStoryUseCase( projectData = source.requireProjectData(), chapters = chaptersFor(options, projectName, source.perNodeChapters), strings = exportStrings, + options = options, ) } buffer @@ -171,7 +177,7 @@ class ExportStoryUseCase( ): List = if (options.treatTopLevelAsChapters) { perNodeChapters } else { - listOf(StoryChapter(projectName, perNodeChapters.joinToString("\n\n") { it.markdown })) + listOf(StoryChapter(projectName, perNodeChapters.joinToString(sceneSeparator(options)) { it.markdown })) } /** @@ -180,22 +186,30 @@ class ExportStoryUseCase( * dropping it from the chapter list so later chapters renumber automatically. * A filter never widens: an empty or fully stale set yields zero chapters. */ - private fun chapterFor(node: TreeValue, sceneFilter: Set?): StoryChapter? { - val sceneNodes = if (node.value.type == SceneItem.Type.Scene) { - listOf(node) - } else { - node.filter { it.value.type == SceneItem.Type.Scene } - } + private fun chapterFor(chapter: ChapterScenes, options: ExportOptions): StoryChapter? { + val sceneFilter = options.sceneIds val included = if (sceneFilter == null) { - sceneNodes + chapter.scenes } else { - sceneNodes.filter { it.value.id in sceneFilter } + chapter.scenes.filter { it.id in sceneFilter } } if (sceneFilter != null && included.isEmpty()) return null return StoryChapter( - name = node.value.name, - markdown = included.joinToString("\n\n") { sceneEditorRepository.loadSceneMarkdownRaw(it.value) }, + name = chapter.chapter.name, + markdown = included.joinToString(sceneSeparator(options)) { currentMarkdown(it) }, ) } + /** The scene as the editor shows it: an unsaved buffer wins over the file on disk. */ + private fun currentMarkdown(scene: SceneItem): String = + sceneEditorRepository.getSceneBuffer(scene)?.content?.coerceMarkdown() + ?: sceneEditorRepository.loadSceneMarkdownRaw(scene) + + /** + * What goes between two scenes. A renderer that drops lone blank lines still keeps a run of two, + * so the wider gap is what carries the scene break through. + */ + private fun sceneSeparator(options: ExportOptions): String = + if (options.keepBlankLines || options.format == ExportFormat.Markdown) "\n\n" else "\n\n\n" + } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/MarkdownStoryRenderer.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/MarkdownStoryRenderer.kt index 3553f3ff9..ec86b9db1 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/MarkdownStoryRenderer.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/MarkdownStoryRenderer.kt @@ -7,12 +7,13 @@ fun writeStoryAsMarkdown( projectName: String, chapters: List, treatTopLevelAsChapters: Boolean, + numberChapters: Boolean = false, ) { sink.writeUtf8("# $projectName\n\n") chapters.forEachIndexed { index, chapter -> if (treatTopLevelAsChapters) { - sink.writeUtf8("\n## ${index + 1}. ${chapter.name}\n\n") + sink.writeUtf8("\n## ${chapterTitle(index, chapter, numberChapters)}\n\n") } else if (index > 0) { sink.writeUtf8("\n\n") } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/PdfProseMarkdown.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/PdfProseMarkdown.kt index ba53f9a6a..a5d854398 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/PdfProseMarkdown.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/PdfProseMarkdown.kt @@ -6,9 +6,15 @@ import com.darkrockstudios.apps.hammer.common.data.search.unescapeMarkdown import com.conamobile.pdfkmp.dsl.TextScope import com.conamobile.pdfkmp.geometry.Padding import com.conamobile.pdfkmp.layout.BoxAlignment +import com.conamobile.pdfkmp.layout.RichLine import com.conamobile.pdfkmp.layout.VerticalAlignment +import com.conamobile.pdfkmp.layout.layoutRichText +import com.conamobile.pdfkmp.node.Span +import com.conamobile.pdfkmp.render.FontMetrics import com.conamobile.pdfkmp.style.BorderSides import com.conamobile.pdfkmp.style.BorderStroke +import com.conamobile.pdfkmp.style.CornerRadius +import com.conamobile.pdfkmp.style.FontStyle import com.conamobile.pdfkmp.style.FontWeight import com.conamobile.pdfkmp.style.PdfColor import com.conamobile.pdfkmp.style.TableBorder @@ -35,6 +41,122 @@ internal data class ProseColors( val link: PdfColor get() = primary ?: PdfColor.Blue } +/** + * Parsed prose with its text already wrapped into lines. pdfkmp cannot split a rich-text + * paragraph, a row or a decorated container across pages, so each wrapped line is laid out as + * its own node and page breaks fall between lines. + * + * @property layouts parallel to [blocks]; null for a block that needs no measuring. + */ +internal class PreparedProse( + val blocks: List, + val layouts: List, +) + +/** The measured part of one [ProseBlock]. */ +internal sealed interface ProseLayout { + data class Paragraph(val lines: List) : ProseLayout + + /** One list of lines per list item. */ + data class Listing(val items: List>) : ProseLayout + + /** One list of lines per quoted paragraph. */ + data class Quote(val paragraphs: List>) : ProseLayout + + /** @property width the widest code line, so every line's background spans the same width. */ + data class Code(val width: Float) : ProseLayout + + /** Body rows of wrapped cells. A source row too tall for a page arrives as several rows. */ + data class Table(val rows: List>>) : ProseLayout +} + +/** + * Parses [markdown] and wraps its text to [width], the width of the container + * [proseMarkdown] will render into. + */ +internal fun prepareProse( + markdown: String, + colors: ProseColors, + metrics: FontMetrics, + width: Float, + keepBlankLines: Boolean = true, +): PreparedProse { + fun wrap( + spans: List, + style: TextStyle, + lineWidth: Float, + lineHeight: Sp = style.lineHeight, + indent: Boolean = false, + ): List { + val styled = spans.map { Span(it.text, style.withFlags(it, colors)) }.toMutableList() + if (indent) { + // Each line is laid out again on its own, where an indent in the first word's style + // is one token with that word; measure it as one here too. + val first = styled.firstOrNull() + if (first != null && first.style == style) { + styled[0] = first.copy(text = FIRST_LINE_INDENT + first.text) + } else { + styled.add(0, Span(FIRST_LINE_INDENT, style)) + } + } + val measured = layoutRichText(styled, lineWidth, style.align, lineHeight, metrics) + // A line laid out alone would resolve its direction from its own first strong character. + return measured.lines.map { line -> + line.copy( + segments = line.segments.map { + it.copy(style = it.style.copy(direction = measured.resolvedDirection)) + }, + ) + } + } + + val blocks = parseProseMarkdown(markdown, keepBlankLines) + val layouts = blocks.map { block -> + when (block) { + is ProseBlock.Paragraph -> + if (block.isStandaloneLink) null + else ProseLayout.Paragraph(wrap(block.spans, BODY_STYLE, width, indent = true)) + + is ProseBlock.Listing -> { + val textWidth = width - block.markerWidth.value + ProseLayout.Listing(block.items.map { wrap(it.spans, BODY_STYLE, textWidth) }) + } + + is ProseBlock.Quote -> { + val textWidth = width - QUOTE_INSET_LEFT.value - QUOTE_INSET_RIGHT.value - QUOTE_BAR_WIDTH.value + ProseLayout.Quote(block.paragraphs.map { wrap(it, QUOTE_STYLE, textWidth) }) + } + + is ProseBlock.CodeBlock -> { + val widest = block.lines.maxOf { metrics.measure(it, CODE_STYLE).width } + ProseLayout.Code(widest.coerceAtMost(width - CODE_PADDING.value * 2)) + } + + is ProseBlock.Table -> { + val cellWidth = width / block.columnCount - TABLE_CELL_PADDING.value * 2 - TABLE_CELL_SLACK + ProseLayout.Table( + block.rows.flatMap { row -> + val cells = List(block.columnCount) { column -> + wrap(row.getOrElse(column) { emptyList() }, BODY_STYLE, cellWidth, lineHeight = Sp.Zero) + } + val tallest = cells.maxOf { it.size } + if (tallest <= MAX_TABLE_ROW_LINES) { + listOf(cells) + } else { + List((tallest + SPLIT_TABLE_ROW_LINES - 1) / SPLIT_TABLE_ROW_LINES) { part -> + cells.map { it.drop(part * SPLIT_TABLE_ROW_LINES).take(SPLIT_TABLE_ROW_LINES) } + } + } + }, + ) + } + + else -> null + } + } + return PreparedProse(blocks, layouts) +} + /** * Renders markdown prose into a PDF container with book typography: every paragraph gets * a first-line indent, and consecutive paragraphs run without a blank line between them — @@ -44,10 +166,10 @@ internal data class ProseColors( * `~~strikethrough~~` and pipe tables work) instead of pdfkmp's markdown module, whose * layout offers no paragraph indent control. */ -internal fun ContainerScope.proseMarkdown(markdown: String, colors: ProseColors = ProseColors()) { - val blocks = parseProseMarkdown(markdown) +internal fun ContainerScope.proseMarkdown(prose: PreparedProse, colors: ProseColors = ProseColors()) { + val blocks = prose.blocks if (blocks.isEmpty()) return - val base = TextStyle(lineHeight = Sp(TextStyle().fontSize.value * BODY_LEADING)) + val base = BODY_STYLE column { blocks.forEachIndexed { index, block -> if (index > 0) { @@ -59,15 +181,16 @@ internal fun ContainerScope.proseMarkdown(markdown: String, colors: ProseColors else -> spacer(height = BLOCK_SPACING) } } + val layout = prose.layouts[index] when (block) { - is ProseBlock.Paragraph -> renderParagraph(block.spans, base, colors) + is ProseBlock.Paragraph -> renderParagraph(block, layout as ProseLayout.Paragraph?, colors) ProseBlock.Blank -> spacer(height = Dp(TextStyle().fontSize.value * BODY_LEADING)) is ProseBlock.Heading -> renderHeading(block, base, colors) - is ProseBlock.Listing -> renderListing(block, base, colors) - is ProseBlock.Quote -> renderQuote(block, base, colors) - is ProseBlock.CodeBlock -> renderCode(block, base) + is ProseBlock.Listing -> renderListing(block, layout as ProseLayout.Listing) + is ProseBlock.Quote -> renderQuote(layout as ProseLayout.Quote) + is ProseBlock.CodeBlock -> renderCode(block, layout as ProseLayout.Code) ProseBlock.Rule -> divider() - is ProseBlock.Table -> renderTable(block, base, colors) + is ProseBlock.Table -> renderTable(block, layout as ProseLayout.Table, base, colors) } } } @@ -117,11 +240,30 @@ internal sealed interface ProseBlock { * Prose keeps the shape the author gave it, which is what the editor shows them: every newline * starts a new [ProseBlock.Paragraph] and every blank line becomes a [ProseBlock.Blank]. CommonMark * would reflow both away, turning a page of dialogue into one packed block. + * + * With [keepBlankLines] off a lone blank line is dropped, leaving paragraphs to run together, and a + * run of two or more is reduced to one: the break between passages or scenes. */ -internal fun parseProseMarkdown(markdown: String): List { +internal fun parseProseMarkdown(markdown: String, keepBlankLines: Boolean = true): List { val source = ProseHtml.normalizeLineEndings(markdown) val root = MarkdownParser(GFMFlavourDescriptor()).buildMarkdownTreeFromString(source) - return ProseWalker(source).blocks(root) + val blocks = ProseWalker(source).blocks(root) + return if (keepBlankLines) blocks else blocks.withoutParagraphGaps() +} + +private fun List.withoutParagraphGaps(): List { + val out = mutableListOf() + var blanks = 0 + for (block in this) { + if (block == ProseBlock.Blank) { + blanks++ + continue + } + if (blanks >= 2) out += ProseBlock.Blank + blanks = 0 + out += block + } + return out } /** True for the block kinds that make up running prose, as opposed to a structural block. */ @@ -506,12 +648,86 @@ private val CODE_BACKGROUND = PdfColor(0.95f, 0.95f, 0.95f) private const val BODY_LEADING = 1.5f private const val HEADING_LEADING = 1.25f -private fun ContainerScope.renderParagraph(spans: List, base: TextStyle, colors: ProseColors) { - // A paragraph that is exactly one link renders through the clickable link DSL - // (flush — it reads as a block element, not prose); links inside running text - // are styled but not clickable (no per-span link areas). - val onlyLink = spans.singleOrNull()?.takeIf { it.link != null } - if (onlyLink != null) { +private val BODY_STYLE = TextStyle(lineHeight = Sp(TextStyle().fontSize.value * BODY_LEADING)) +private val QUOTE_STYLE = BODY_STYLE.copy(color = PdfColor.Gray) + +/** No bundled monospace face; code is approximated with a smaller size on a grey card. */ +private val CODE_STYLE = TextStyle(fontSize = Sp(TextStyle().fontSize.value * 0.9f)) + +/** Lines of a paragraph kept on one page at either side of a page break. */ +private const val MIN_LINES_AT_BREAK = 2 + +private val ITEM_SPACING = Dp(4f) +private val QUOTE_INSET_LEFT = Dp(12f) +private val QUOTE_INSET_RIGHT = Dp(4f) +private val QUOTE_INSET_VERTICAL = Dp(4f) +private val QUOTE_BAR_WIDTH = Dp(3f) +private val CODE_PADDING = Dp(12f) +private val CODE_CORNER = Dp(4f) +private val TABLE_CELL_PADDING = Dp(8f) + +/** Shaved off the wrap width of a cell so table rules never push a wrapped line onto two. */ +private const val TABLE_CELL_SLACK = 2f + +/** Tallest a table row may grow before it is split into rows of [SPLIT_TABLE_ROW_LINES]. */ +private const val MAX_TABLE_ROW_LINES = 30 + +/** Short enough that the split rows pack a page without leaving much of it empty. */ +private const val SPLIT_TABLE_ROW_LINES = 8 + +/** + * A paragraph that is exactly one link renders through the clickable link DSL (flush: it reads + * as a block element, not prose); links inside running text are styled but not clickable. + */ +private val ProseBlock.Paragraph.isStandaloneLink: Boolean + get() = spans.singleOrNull()?.link != null + +private val ProseBlock.Listing.markerWidth: Dp + get() = if (ordered) Dp(20f) else Dp(16f) + +private val ProseBlock.CodeBlock.lines: List + get() = code.split("\n").map { it.ifEmpty { " " } } + +private val ProseBlock.Table.columnCount: Int + get() = header.size.coerceAtLeast(1) + +/** + * Lays out [count] lines one node each, holding the first and last [MIN_LINES_AT_BREAK] + * together so a page break strands neither. + */ +private fun ContainerScope.breakableLines(count: Int, line: ContainerScope.(Int) -> Unit) { + if (count == 0) return + if (count < MIN_LINES_AT_BREAK * 2) { + keepTogether { repeat(count) { line(it) } } + return + } + keepTogether { for (i in 0 until MIN_LINES_AT_BREAK) line(i) } + for (i in MIN_LINES_AT_BREAK until count - MIN_LINES_AT_BREAK) line(i) + keepTogether { for (i in count - MIN_LINES_AT_BREAK until count) line(i) } +} + +/** One already-wrapped line. */ +private fun ContainerScope.proseLine(line: RichLine, lineHeight: Sp = BODY_STYLE.lineHeight) { + if (line.segments.isEmpty()) { + spacer(height = Dp(line.height)) + return + } + richText { + this.lineHeight = lineHeight + for (segment in line.segments) { + defaultSpanStyle = segment.style + span(segment.text) + } + } +} + +private fun ContainerScope.renderParagraph( + block: ProseBlock.Paragraph, + layout: ProseLayout.Paragraph?, + colors: ProseColors, +) { + if (layout == null) { + val onlyLink = block.spans.single() link(onlyLink.link!!) { text(onlyLink.text) { color = colors.link @@ -523,12 +739,7 @@ private fun ContainerScope.renderParagraph(spans: List, base: TextSty } return } - richText { - defaultSpanStyle = base - lineHeight = base.lineHeight - span(FIRST_LINE_INDENT) - for (s in spans) span(s.text) { applyFlags(s, colors) } - } + breakableLines(layout.lines.size) { proseLine(layout.lines[it]) } } private fun ContainerScope.renderHeading(block: ProseBlock.Heading, base: TextStyle, colors: ProseColors) { @@ -550,64 +761,85 @@ private fun ContainerScope.renderHeading(block: ProseBlock.Heading, base: TextSt } } -private fun ContainerScope.renderListing(block: ProseBlock.Listing, base: TextStyle, colors: ProseColors) { - column(spacing = Dp(4f)) { - block.items.forEachIndexed { index, item -> - val marker = if (block.ordered) "${index + 1}." else "•" +private fun ContainerScope.renderListing(block: ProseBlock.Listing, layout: ProseLayout.Listing) { + layout.items.forEachIndexed { index, lines -> + if (index > 0) spacer(height = ITEM_SPACING) + val marker = if (block.ordered) "${index + 1}." else "•" + breakableLines(lines.size) { lineIndex -> row(verticalAlignment = VerticalAlignment.Top) { - box(width = if (block.ordered) Dp(20f) else Dp(16f)) { - aligned(BoxAlignment.TopStart) { - text(marker) { color = base.color } - } - } - weighted(1f) { - richText { - defaultSpanStyle = base - lineHeight = base.lineHeight - for (s in item.spans) span(s.text) { applyFlags(s, colors) } + box(width = block.markerWidth) { + if (lineIndex == 0) { + aligned(BoxAlignment.TopStart) { + text(marker) { color = BODY_STYLE.color } + } } } + weighted(1f) { proseLine(lines[lineIndex]) } } } } } -private fun ContainerScope.renderQuote(block: ProseBlock.Quote, base: TextStyle, colors: ProseColors) { - column( - padding = Padding(left = Dp(12f), top = Dp(4f), right = Dp(4f), bottom = Dp(4f)), - borderEach = BorderSides( - left = BorderStroke(width = Dp(3f), color = PdfColor.LightGray), - ), - spacing = Dp(4f), - ) { - val quoteStyle = base.copy(color = PdfColor.Gray) - for (paragraph in block.paragraphs) { - richText { - defaultSpanStyle = quoteStyle - lineHeight = base.lineHeight - for (s in paragraph) span(s.text) { applyFlags(s, colors) } +private fun ContainerScope.renderQuote(layout: ProseLayout.Quote) { + val startsParagraph = layout.paragraphs.flatMap { lines -> lines.indices.map { it == 0 } } + val lines = layout.paragraphs.flatten() + breakableLines(lines.size) { index -> + // The bar is drawn per line; the gaps between paragraphs are padding so it runs unbroken. + column( + padding = Padding( + left = QUOTE_INSET_LEFT, + top = if (startsParagraph[index]) QUOTE_INSET_VERTICAL else Dp.Zero, + right = QUOTE_INSET_RIGHT, + bottom = if (index == lines.lastIndex) QUOTE_INSET_VERTICAL else Dp.Zero, + ), + borderEach = BorderSides( + left = BorderStroke(width = QUOTE_BAR_WIDTH, color = PdfColor.LightGray), + ), + ) { + proseLine(lines[index]) + } + } +} + +private fun ContainerScope.renderCode(block: ProseBlock.CodeBlock, layout: ProseLayout.Code) { + val lines = block.lines + lines.forEachIndexed { index, line -> + val first = index == 0 + val last = index == lines.lastIndex + column( + background = CODE_BACKGROUND, + cornerRadiusEach = CornerRadius( + topLeft = if (first) CODE_CORNER else Dp.Zero, + topRight = if (first) CODE_CORNER else Dp.Zero, + bottomLeft = if (last) CODE_CORNER else Dp.Zero, + bottomRight = if (last) CODE_CORNER else Dp.Zero, + ), + padding = Padding( + left = CODE_PADDING, + top = if (first) CODE_PADDING else Dp.Zero, + right = CODE_PADDING, + bottom = if (last) CODE_PADDING else Dp.Zero, + ), + ) { + spacer(width = Dp(layout.width)) + text(line) { + fontSize = CODE_STYLE.fontSize + color = CODE_STYLE.color } } } } -private fun ContainerScope.renderCode(block: ProseBlock.CodeBlock, base: TextStyle) { - // No bundled monospace face; approximate with a smaller size on a grey card. - card(background = CODE_BACKGROUND, cornerRadius = Dp(4f)) { - for (line in block.code.split("\n")) { - text(line.ifEmpty { " " }) { - fontSize = Sp(base.fontSize.value * 0.9f) - color = base.color - } - } - } -} - -private fun ContainerScope.renderTable(block: ProseBlock.Table, base: TextStyle, colors: ProseColors) { - val columnCount = block.header.size.coerceAtLeast(1) +private fun ContainerScope.renderTable( + block: ProseBlock.Table, + layout: ProseLayout.Table, + base: TextStyle, + colors: ProseColors, +) { table( - columns = List(columnCount) { TableColumn.Weight(1f) }, + columns = List(block.columnCount) { TableColumn.Weight(1f) }, border = TableBorder(), + cellPadding = Padding.all(TABLE_CELL_PADDING), ) { header { for (cellSpans in block.header) { @@ -619,14 +851,11 @@ private fun ContainerScope.renderTable(block: ProseBlock.Table, base: TextStyle, } } } - for (bodyRow in block.rows) { + for (bodyRow in layout.rows) { row { - for (cellSpans in bodyRow.take(columnCount) + List((columnCount - bodyRow.size).coerceAtLeast(0)) { emptyList() }) { + for (lines in bodyRow) { cell { - richText { - defaultSpanStyle = base - for (s in cellSpans) span(s.text) { applyFlags(s, colors) } - } + lines.forEach { proseLine(it, lineHeight = Sp.Zero) } } } } @@ -634,6 +863,15 @@ private fun ContainerScope.renderTable(block: ProseBlock.Table, base: TextStyle, } } +private fun TextStyle.withFlags(s: ProseSpan, colors: ProseColors): TextStyle = copy( + fontWeight = if (s.bold) FontWeight.Bold else fontWeight, + fontStyle = if (s.italic) FontStyle.Italic else fontStyle, + strikethrough = strikethrough || s.strikethrough, + fontSize = if (s.code) Sp(fontSize.value * 0.9f) else fontSize, + color = if (s.link != null) colors.link else color, + underline = underline || s.link != null, +) + private fun TextScope.applyFlags(s: ProseSpan, colors: ProseColors) { if (s.bold) bold = true if (s.italic) italic = true diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/PdfStoryRenderer.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/PdfStoryRenderer.kt index 7b9ae9ad4..b765d82f0 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/PdfStoryRenderer.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/PdfStoryRenderer.kt @@ -1,16 +1,24 @@ package com.darkrockstudios.apps.hammer.common.components.projecthome +import com.conamobile.pdfkmp.PdfDocument +import com.conamobile.pdfkmp.defaultPdfDriverFactory import com.conamobile.pdfkmp.geometry.Padding +import com.conamobile.pdfkmp.geometry.PageSize import com.conamobile.pdfkmp.layout.PageBreakStrategy +import com.conamobile.pdfkmp.metadata.PdfMetadata import com.conamobile.pdfkmp.pdf import com.conamobile.pdfkmp.style.PdfColor import com.conamobile.pdfkmp.unit.dp import com.conamobile.pdfkmp.unit.sp import com.darkrockstudios.apps.hammer.base.http.projectdata.ProjectData +import com.darkrockstudios.apps.hammer.common.data.ExportOptions import okio.BufferedSink private fun chapterAnchorId(index: Int): String = "chapter-$index" +internal val STORY_PAGE_SIZE = PageSize.A4 +internal val STORY_PAGE_PADDING = Padding.symmetric(horizontal = 56.dp, vertical = 64.dp) + /** * Renders the story as a PDF mirroring the EPUB layout: a title page, a clickable table of contents, * then one auto-paginating section per chapter. Chapter bodies are rendered from markdown with book @@ -24,17 +32,38 @@ fun writeStoryAsPdf( projectData: ProjectData, chapters: List, strings: ExportStrings, + options: ExportOptions = ExportOptions(), ) { + sink.write(buildStoryPdf(projectName, projectData, chapters, strings, options).toByteArray()) +} + +internal fun buildStoryPdf( + projectName: String, + projectData: ProjectData, + chapters: List, + strings: ExportStrings, + options: ExportOptions = ExportOptions(), +): PdfDocument { val authorName = projectData.authorName?.takeIf { it.isNotBlank() } val effective = chapters.ifEmpty { listOf(StoryChapter(projectName, "")) } val primary = projectData.theme?.primary?.let(::argbHexToPdfColor) val secondary = projectData.theme?.secondary?.let(::argbHexToPdfColor) val proseColors = ProseColors(primary = primary, secondary = secondary) - val document = pdf { + val contentWidth = STORY_PAGE_SIZE.width.value - STORY_PAGE_PADDING.left.value - STORY_PAGE_PADDING.right.value + val measuringDriver = defaultPdfDriverFactory().create(PdfMetadata(), emptyList()) + val prose = try { + effective.map { + prepareProse(it.markdown, proseColors, measuringDriver.fontMetrics, contentWidth, options.keepBlankLines) + } + } finally { + measuringDriver.close() + } + + return pdf { // Bottom/side margins so text doesn't run to the page edge; Slice lets long bodies flow // onto new pages at line boundaries instead of being clipped at the bottom. - defaultPagePadding = Padding.symmetric(horizontal = 56.dp, vertical = 64.dp) + defaultPagePadding = STORY_PAGE_PADDING defaultPageBreakStrategy = PageBreakStrategy.Slice metadata { @@ -43,7 +72,7 @@ fun writeStoryAsPdf( } // Title page — accent rule under the title mirrors the EPUB's title-page hr. - page { + page(STORY_PAGE_SIZE) { text(projectName) { fontSize = 36.sp bold = true @@ -60,7 +89,7 @@ fun writeStoryAsPdf( } // Contents page — each row links to the matching chapter's anchor below. - page { + page(STORY_PAGE_SIZE) { text(strings.contentsTitle) { fontSize = 26.sp bold = true @@ -70,7 +99,7 @@ fun writeStoryAsPdf( column(spacing = 6.dp) { effective.forEachIndexed { index, chapter -> linkToAnchor(chapterAnchorId(index)) { - text("${index + 1}. ${chapter.name}") { + text(chapterTitle(index, chapter, options.numberChapters)) { primary?.let { color = it } } } @@ -82,23 +111,21 @@ fun writeStoryAsPdf( // in a chapter name render verbatim; Slice keeps it on the same page as the body that follows. // bookmark drives the reader's outline panel; anchor is the jump target for the contents page. effective.forEachIndexed { index, chapter -> - page { + page(STORY_PAGE_SIZE) { bookmark(chapter.name) anchor(chapterAnchorId(index)) - text("${index + 1}. ${chapter.name}") { + text(chapterTitle(index, chapter, options.numberChapters)) { fontSize = 22.sp bold = true primary?.let { color = it } } if (chapter.markdown.isNotBlank()) { spacer(height = 14.dp) - proseMarkdown(chapter.markdown, proseColors) + proseMarkdown(prose[index], proseColors) } } } } - - sink.write(document.toByteArray()) } /** Project themes store colors as ARGB hex (`#FFRRGGBB`, sometimes `#RRGGBB`); null for anything else. */ diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/ProjectHomeComponent.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/ProjectHomeComponent.kt index e12139113..5ba344800 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/ProjectHomeComponent.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/ProjectHomeComponent.kt @@ -39,7 +39,7 @@ import kotlin.time.Clock class ProjectHomeComponent( componentContext: ComponentContext, projectDef: ProjectDef, - private val showProjectSync: () -> Unit, + private val requestProjectSync: () -> Unit, private val onShowGlobalSearch: () -> Unit, private val onShowGlobalSearchForTag: (String) -> Unit, private val onShowScene: (SceneItem) -> Unit, @@ -179,7 +179,7 @@ class ProjectHomeComponent( _state.getAndUpdate { it.copy(isExporting = true) } } - override fun startProjectSync() = showProjectSync() + override fun startProjectSync() = requestProjectSync() override fun showGlobalSearch() = onShowGlobalSearch() diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/RtfStoryRenderer.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/RtfStoryRenderer.kt index 8623d2731..408c5ceb0 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/RtfStoryRenderer.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/RtfStoryRenderer.kt @@ -2,6 +2,8 @@ package com.darkrockstudios.apps.hammer.common.components.projecthome import com.darkrockstudios.apps.hammer.base.BuildMetadata import com.darkrockstudios.apps.hammer.base.http.projectdata.ProjectData +import com.darkrockstudios.apps.hammer.common.data.ExportFont +import com.darkrockstudios.apps.hammer.common.data.ExportOptions import com.darkrockstudios.libs.rtfparserkmp.writer.RtfAlignment import com.darkrockstudios.libs.rtfparserkmp.writer.RtfBlock import com.darkrockstudios.libs.rtfparserkmp.writer.RtfBookmark @@ -23,7 +25,15 @@ import com.darkrockstudios.libs.rtfparserkmp.writer.RtfTab import com.darkrockstudios.libs.rtfparserkmp.writer.RtfTextRun import okio.BufferedSink -private val BODY_FONT = RtfFont(EXPORT_BODY_FONT, RtfFontFamily.Roman) +private fun ExportFont.toRtfFont(): RtfFont = RtfFont( + faceName, + when (this) { + ExportFont.Georgia, ExportFont.TimesNewRoman, ExportFont.Garamond -> RtfFontFamily.Roman + ExportFont.Arial, ExportFont.Verdana -> RtfFontFamily.Swiss + ExportFont.CourierNew -> RtfFontFamily.Modern + }, +) + private val MONO_FONT = RtfFont(EXPORT_MONO_FONT, RtfFontFamily.Modern) // Font sizes in half-points; the body is 12pt to match the other exporters. @@ -56,6 +66,7 @@ fun writeStoryAsRtf( projectData: ProjectData, chapters: List, strings: ExportStrings, + options: ExportOptions = ExportOptions(), ) { val authorName = projectData.authorName?.takeIf { it.isNotBlank() } val effective = chapters.ifEmpty { listOf(StoryChapter(projectName, "")) } @@ -64,13 +75,13 @@ fun writeStoryAsRtf( val blocks = buildList { addAll(titlePage(projectName, strings.authorByline, primary)) - addAll(contentsPage(effective, strings.contentsTitle, primary)) - effective.forEachIndexed { index, chapter -> addAll(chapterBlocks(index, chapter, primary, secondary)) } + addAll(contentsPage(effective, strings.contentsTitle, primary, options.numberChapters)) + effective.forEachIndexed { index, chapter -> addAll(chapterBlocks(index, chapter, primary, secondary, options)) } } val document = RtfDocument( blocks = blocks, - defaultFont = BODY_FONT, + defaultFont = options.font.toRtfFont(), defaultFontSizeHalfPoints = BODY_HALF_POINTS, info = RtfInfo(title = projectName, author = authorName), generator = "Hammer ${BuildMetadata.APP_VERSION}", @@ -107,7 +118,12 @@ private fun titlePage(projectName: String, authorByline: String?, primary: RtfCo } } -private fun contentsPage(chapters: List, contentsTitle: String, primary: RtfColor?): List = buildList { +private fun contentsPage( + chapters: List, + contentsTitle: String, + primary: RtfColor?, + numberChapters: Boolean, +): List = buildList { add(RtfPageBreak) add( RtfParagraph( @@ -120,7 +136,7 @@ private fun contentsPage(chapters: List, contentsTitle: String, pr ), ) chapters.forEachIndexed { index, chapter -> - val label = "${index + 1}. ${chapter.name}" + val label = chapterTitle(index, chapter, numberChapters) add( RtfParagraph( content = listOf( @@ -141,6 +157,7 @@ private fun chapterBlocks( chapter: StoryChapter, primary: RtfColor?, secondary: RtfColor?, + options: ExportOptions, ): List = buildList { add(RtfPageBreak) add( @@ -150,7 +167,7 @@ private fun chapterBlocks( name = chapterBookmark(index), content = listOf( RtfTextRun( - "${index + 1}. ${chapter.name}", + chapterTitle(index, chapter, options.numberChapters), RtfSpanStyle(bold = true, fontSizeHalfPoints = HEADING_HALF_POINTS[0], color = primary), ), ), @@ -164,7 +181,7 @@ private fun chapterBlocks( ), ) if (chapter.markdown.isNotBlank()) { - addAll(renderMarkdownRtf(chapter.markdown, primary, secondary)) + addAll(renderMarkdownRtf(chapter.markdown, primary, secondary, options.keepBlankLines)) } } @@ -181,9 +198,14 @@ private fun themeColor(argb: String?): RtfColor? { * consecutive same-link spans grouped into a single [RtfHyperlink]. Hard line breaks ride along as * `\n` in run text — the rtf-writer escaper turns those into `\line`. */ -private fun renderMarkdownRtf(markdown: String, primary: RtfColor?, secondary: RtfColor?): List { +private fun renderMarkdownRtf( + markdown: String, + primary: RtfColor?, + secondary: RtfColor?, + keepBlankLines: Boolean, +): List { val blocks = mutableListOf() - val parsed = parseProseMarkdown(markdown) + val parsed = parseProseMarkdown(markdown, keepBlankLines) parsed.forEachIndexed { index, block -> // Prose runs tight: the indent parts one line from the next, and the space a passage break // wants is a blank line the author actually typed. Space after is for leaving prose behind. diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/StoryExportCommon.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/StoryExportCommon.kt index b65cfbeb5..ab40bb8ef 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/StoryExportCommon.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/StoryExportCommon.kt @@ -16,13 +16,16 @@ data class ExportStrings( // Shared layout vocabulary mirrored across the EPUB / DOCX / RTF / PDF exporters. Keeping these in one // place is what lets the "mirrored" exports actually stay in step when the layout is tuned. -/** Prose and monospace font faces shared by the DOCX and RTF exporters. */ -internal const val EXPORT_BODY_FONT = "Georgia" +/** Monospace font face shared by the DOCX and RTF exporters. */ internal const val EXPORT_MONO_FONT = "Consolas" /** Heading 1..6 sizes in half-points, shared by the DOCX and RTF exporters. */ internal val HEADING_HALF_POINTS = listOf(48, 36, 32, 28, 26, 24) +/** The chapter title as it appears in headings and the Contents page. */ +internal fun chapterTitle(index: Int, chapter: StoryChapter, numbered: Boolean): String = + if (numbered) "${index + 1}. ${chapter.name}" else chapter.name + /** Bookmark / anchor name for the chapter at [index], the target of its Contents link. */ internal fun chapterBookmark(index: Int): String = "chapter${index + 1}" diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectroot/ProjectRoot.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectroot/ProjectRoot.kt index f897bd19f..d882f2ba6 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectroot/ProjectRoot.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectroot/ProjectRoot.kt @@ -29,6 +29,9 @@ interface ProjectRoot : AppCloseManager, HammerComponent, BackHandlerOwner { val modalRouterState: Value> val closeRequestHandlers: Value> val backEnabled: Value + + /** Observable form of [hasUnsavedBuffers]. */ + val unsavedBuffers: Value val projectTheme: Value val navRailState: Value @@ -46,7 +49,10 @@ interface ProjectRoot : AppCloseManager, HammerComponent, BackHandlerOwner { fun showProjectSync() - /** Opens the sync modal only when this project is linked to a server. */ + /** + * Gated sync entry point for server-linked projects: unsaved scenes and entity edits are + * confirmed through [closeRequestHandlers] first, then the sync modal opens. + */ fun startProjectSync() fun dismissProjectSync() @@ -68,6 +74,8 @@ interface ProjectRoot : AppCloseManager, HammerComponent, BackHandlerOwner { override fun shouldConfirmClose() = component.shouldConfirmClose() + override fun discardUnsaved(item: CloseConfirm) = component.discardUnsaved(item) + data class EditorDestination(override val component: StoryEditor) : Destination() data class NotesDestination(override val component: Notes) : Destination() @@ -114,4 +122,7 @@ interface ProjectRoot : AppCloseManager, HammerComponent, BackHandlerOwner { fun closeRequestDealtWith(item: CloseConfirm) fun requestClose() fun cancelCloseRequest() + + /** Drops the unsaved work behind [item] so a following sync or close cannot persist it. */ + suspend fun discardUnsaved(item: CloseConfirm) } \ No newline at end of file diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectroot/ProjectRootComponent.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectroot/ProjectRootComponent.kt index 7b82df879..8008ed964 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectroot/ProjectRootComponent.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectroot/ProjectRootComponent.kt @@ -80,6 +80,9 @@ class ProjectRootComponent( private val _backEnabled = MutableValue(true) override val backEnabled = _backEnabled + private val _unsavedBuffers = MutableValue(false) + override val unsavedBuffers: Value = _unsavedBuffers + override fun onBack() { router.onBack() } @@ -93,7 +96,7 @@ class ProjectRootComponent( addMenu, removeMenu, ::updateCloseConfirmRequirement, - ::showProjectSync, + ::startProjectSync, ::showGlobalSearch, ::showGlobalSearchForTag, ::showFocusMode, @@ -122,7 +125,9 @@ class ProjectRootComponent( override fun onCreate() { super.onCreate() + _unsavedBuffers.value = sceneEditor.hasDirtyBuffers() sceneEditor.subscribeToBufferUpdates(null, scope) { + _unsavedBuffers.value = sceneEditor.hasDirtyBuffers() updateCloseConfirmRequirement() } @@ -235,9 +240,11 @@ class ProjectRootComponent( override fun showProjectSync() = modalRouter.showProjectSync() override fun startProjectSync() { - if (syncJournal.isServerSynchronized()) { - showProjectSync() - } + if (!syncJournal.isServerSynchronized()) return + if (_closeRequestHandlers.value.isNotEmpty()) return + val list = unsavedConfirmations() + list.add(CloseConfirm.Sync) + _closeRequestHandlers.update { list } } override fun dismissProjectSync() = modalRouter.dismissProjectSync() @@ -327,12 +334,7 @@ class ProjectRootComponent( override fun requestClose() { scope.launch { - val list = mutableSetOf() - if (hasUnsavedBuffers()) { - list.add(CloseConfirm.Scenes) - } - - list.addAll(router.shouldConfirmClose()) + val list = unsavedConfirmations() if (syncJournal.shouldAutoSync()) { list.add(CloseConfirm.Sync) @@ -345,10 +347,32 @@ class ProjectRootComponent( } } + /** Ordered set of the unsaved-work confirmations currently needed, scenes first. */ + private fun unsavedConfirmations(): MutableSet { + val list = mutableSetOf() + if (hasUnsavedBuffers()) { + list.add(CloseConfirm.Scenes) + } + list.addAll(router.shouldConfirmClose()) + return list + } + override fun cancelCloseRequest() { _closeRequestHandlers.update { emptySet() } } + override suspend fun discardUnsaved(item: CloseConfirm) { + when (item) { + CloseConfirm.Scenes -> withContext(dispatcherDefault) { sceneEditor.discardAllBuffers() } + CloseConfirm.Notes, + CloseConfirm.Encyclopedia, + CloseConfirm.Timeline -> withContext(dispatcherMain) { router.discardUnsaved(item) } + + CloseConfirm.Sync, + CloseConfirm.Complete -> Unit + } + } + override fun onStart() { super.onStart() addMenuItems() @@ -371,7 +395,7 @@ class ProjectRootComponent( label = Res.string.sync_menu_item, icon = "", shortcut = KeyShortcut(keyCode = 0x72), - action = { showProjectSync() } + action = { startProjectSync() } ) ) ) diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectroot/ProjectRootRouter.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectroot/ProjectRootRouter.kt index 19b64e9c8..52ae8d830 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectroot/ProjectRootRouter.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectroot/ProjectRootRouter.kt @@ -31,7 +31,7 @@ internal class ProjectRootRouter( private val addMenu: (menu: MenuDescriptor) -> Unit, private val removeMenu: (id: String) -> Unit, private val updateShouldClose: () -> Unit, - private val showProjectSync: () -> Unit, + private val requestProjectSync: () -> Unit, private val showGlobalSearch: () -> Unit, private val onShowGlobalSearchForTag: (String) -> Unit, private val showFocusMode: (SceneItem) -> Unit, @@ -149,7 +149,7 @@ internal class ProjectRootRouter( return ProjectHomeComponent( componentContext = componentContext, projectDef = config.projectDef, - showProjectSync = showProjectSync, + requestProjectSync = requestProjectSync, onShowGlobalSearch = showGlobalSearch, onShowGlobalSearchForTag = onShowGlobalSearchForTag, onShowScene = showScene, @@ -190,6 +190,10 @@ internal class ProjectRootRouter( }.toSet() } + override fun discardUnsaved(item: CloseConfirm) { + state.value.items.forEach { it.instance.discardUnsaved(item) } + } + fun onBack() { navigation.pop() } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectroot/Router.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectroot/Router.kt index e53b17bd4..7d946b8fc 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectroot/Router.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectroot/Router.kt @@ -3,4 +3,7 @@ package com.darkrockstudios.apps.hammer.common.components.projectroot interface Router { fun isAtRoot(): Boolean fun shouldConfirmClose(): Set + + /** Throws away the in-progress edit that made [shouldConfirmClose] report [item]. */ + fun discardUnsaved(item: CloseConfirm) {} } \ No newline at end of file diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/ProjectSelection.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/ProjectSelection.kt index bdeef98e9..f1773a990 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/ProjectSelection.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/ProjectSelection.kt @@ -8,6 +8,7 @@ import com.darkrockstudios.apps.hammer.common.components.projectselection.abouta import com.darkrockstudios.apps.hammer.common.components.projectselection.accountsettings.AccountSettings import com.darkrockstudios.apps.hammer.common.components.projectselection.projectslist.ProjectsList import com.darkrockstudios.apps.hammer.common.components.projectselection.storyideas.StoryIdeas +import com.darkrockstudios.apps.hammer.common.data.appupdate.AppUpdateState import com.darkrockstudios.apps.hammer.common.dependencyinjection.HammerComponent import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.StringResource @@ -16,6 +17,7 @@ interface ProjectSelection : HammerComponent, BackHandlerOwner { val stack: Value> val navRailState: Value val changelog: Value + val appUpdate: Value fun isAtRoot(): Boolean fun onBack() @@ -27,6 +29,9 @@ interface ProjectSelection : HammerComponent, BackHandlerOwner { fun dismissChangelog() fun showChangelog() + fun updateApp() + fun dismissUpdate() + data class NavRailState(val expanded: Boolean) data class ChangelogState( diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/ProjectSelectionComponent.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/ProjectSelectionComponent.kt index 72bbfe18b..abce8b3b7 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/ProjectSelectionComponent.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/ProjectSelectionComponent.kt @@ -17,6 +17,8 @@ import com.darkrockstudios.apps.hammer.common.data.ExampleProjectRepository import com.darkrockstudios.apps.hammer.common.data.ProjectDef import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettingsStore import com.darkrockstudios.apps.hammer.base.RELEASES_LATEST_URL +import com.darkrockstudios.apps.hammer.common.data.appupdate.AppUpdateState +import com.darkrockstudios.apps.hammer.common.data.appupdate.AppUpdater import com.darkrockstudios.apps.hammer.common.data.changelog.Changelog import com.darkrockstudios.apps.hammer.common.data.changelog.ChangelogRepository import com.darkrockstudios.apps.hammer.common.util.UrlLauncher @@ -33,6 +35,7 @@ class ProjectSelectionComponent( private val urlLauncher: UrlLauncher by inject() private val settingsRepository: GlobalSettingsStore by inject() private val changelogRepository: ChangelogRepository by inject() + private val appUpdater: AppUpdater by inject() private val navigation = StackNavigation() override val stack = childStack( @@ -51,11 +54,22 @@ class ProjectSelectionComponent( private val _changelog = MutableValue(ProjectSelection.ChangelogState()) override val changelog: Value = _changelog + private val _appUpdate = MutableValue(appUpdater.state.value) + override val appUpdate: Value = _appUpdate + init { if (exampleProjectRepository.shouldInstallFirstTime()) { exampleProjectRepository.install() } + scope.launch { + appUpdater.state.collect { update -> + withContext(dispatcherMain) { + _appUpdate.update { update } + } + } + } + scope.launch { settingsRepository.globalSettingsUpdates.collect { settings -> if (_navRailState.value.expanded != settings.navRailExpanded) { @@ -108,6 +122,14 @@ class ProjectSelectionComponent( scope.launch { changelogRepository.markSeen() } } + override fun updateApp() { + appUpdater.update() + } + + override fun dismissUpdate() { + appUpdater.dismiss() + } + private fun createChild( config: ProjectSelection.Config, componentContext: ComponentContext diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/aboutapp/AboutApp.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/aboutapp/AboutApp.kt index cb07de6d3..996493c3c 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/aboutapp/AboutApp.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/aboutapp/AboutApp.kt @@ -1,8 +1,10 @@ package com.darkrockstudios.apps.hammer.common.components.projectselection.aboutapp import com.arkivanov.decompose.value.Value +import com.darkrockstudios.apps.hammer.common.data.appupdate.AppUpdateState import com.darkrockstudios.apps.hammer.common.util.getAppVersionString import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient interface AboutApp { @@ -14,10 +16,14 @@ interface AboutApp { fun viewChangelog() fun openLatestRelease() + fun checkForUpdate() + fun updateApp() + @Serializable data class State( val currentVersion: String = getAppVersionString(), val logDirectoryPath: String = "", + @Transient val appUpdate: AppUpdateState = AppUpdateState.Unsupported, ) -} \ No newline at end of file +} diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/aboutapp/AboutAppComponent.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/aboutapp/AboutAppComponent.kt index f3573e438..42b60c4ba 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/aboutapp/AboutAppComponent.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/aboutapp/AboutAppComponent.kt @@ -3,14 +3,19 @@ package com.darkrockstudios.apps.hammer.common.components.projectselection.about import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.value.MutableValue import com.arkivanov.decompose.value.Value +import com.arkivanov.decompose.value.update import com.darkrockstudios.apps.hammer.base.DISCORD_URL import com.darkrockstudios.apps.hammer.base.GITHUB_URL import com.darkrockstudios.apps.hammer.base.REDDIT_URL import com.darkrockstudios.apps.hammer.base.RELEASES_LATEST_URL import com.darkrockstudios.apps.hammer.common.components.ComponentBase +import com.darkrockstudios.apps.hammer.common.data.appupdate.AppUpdater import com.darkrockstudios.apps.hammer.common.getConfigDirectory import com.darkrockstudios.apps.hammer.common.getLogDirectory import com.darkrockstudios.apps.hammer.common.util.UrlLauncher +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koin.core.component.inject class AboutAppComponent( componentContext: ComponentContext, @@ -19,9 +24,26 @@ class AboutAppComponent( private val onShowChangelog: () -> Unit, ) : AboutApp, ComponentBase(componentContext) { - private val _state = MutableValue(AboutApp.State(logDirectoryPath = getLogDirectoryPath())) + private val appUpdater: AppUpdater by inject() + + private val _state = MutableValue( + AboutApp.State( + logDirectoryPath = getLogDirectoryPath(), + appUpdate = appUpdater.state.value, + ) + ) override val state: Value = _state + init { + scope.launch { + appUpdater.state.collect { update -> + withContext(dispatcherMain) { + _state.update { it.copy(appUpdate = update) } + } + } + } + } + override fun openDiscord() { urlLauncher.openInBrowser(DISCORD_URL) } @@ -42,6 +64,14 @@ class AboutAppComponent( urlLauncher.openInBrowser(RELEASES_LATEST_URL) } + override fun checkForUpdate() { + appUpdater.checkNow() + } + + override fun updateApp() { + appUpdater.update() + } + private fun getLogDirectoryPath(): String { return getLogDirectory() ?: (getConfigDirectory() + "/logs") } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/accountsettings/BackupManagerComponent.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/accountsettings/BackupManagerComponent.kt index 938202cf5..6af731188 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/accountsettings/BackupManagerComponent.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/accountsettings/BackupManagerComponent.kt @@ -8,13 +8,13 @@ import com.darkrockstudios.apps.hammer.Res import com.darkrockstudios.apps.hammer.backup_manager_error_delete_backup import com.darkrockstudios.apps.hammer.backup_manager_error_load_backups import com.darkrockstudios.apps.hammer.backup_manager_error_load_project_backups +import com.darkrockstudios.apps.hammer.backup_manager_error_restore_backup import com.darkrockstudios.apps.hammer.common.components.SavableComponent import com.darkrockstudios.apps.hammer.common.data.ClientMessage import com.darkrockstudios.apps.hammer.common.data.ProjectDef import com.darkrockstudios.apps.hammer.common.data.projectbackup.BackupManagerService import com.darkrockstudios.apps.hammer.common.data.projectbackup.ProjectBackupDef import com.darkrockstudios.apps.hammer.common.data.projectbackup.ProjectBackupRepository -import com.darkrockstudios.apps.hammer.common.data.projectsrepository.ProjectsRepository import com.darkrockstudios.apps.hammer.common.util.StrRes import io.github.aakira.napier.Napier import kotlinx.coroutines.launch @@ -27,7 +27,6 @@ class BackupManagerComponent( ) : BackupManager, KoinComponent, SavableComponent(componentContext) { private val backupRepository by inject() - private val projectsRepository by inject() private val strRes: StrRes by inject() private val backupManagerService by inject() @@ -44,15 +43,8 @@ class BackupManagerComponent( _state.update { it.copy(isLoading = true, error = null) } try { - val projects = withContext(dispatcherIo) { - projectsRepository.getProjects() - } - - // Get projects that have backups val projectsWithBackups = withContext(dispatcherIo) { - projects.filter { projectDef -> - backupRepository.getBackups(projectDef).isNotEmpty() - } + backupRepository.getBackedUpProjects() } val projectNames = projectsWithBackups @@ -121,7 +113,7 @@ class BackupManagerComponent( override fun selectProject(projectName: String) { scope.launch { val projectDef = withContext(dispatcherIo) { - projectsRepository.getProjects().find { it.name == projectName } + backupRepository.getBackedUpProjects().find { it.name == projectName } } withContext(dispatcherMain) { @@ -165,7 +157,19 @@ class BackupManagerComponent( override fun restoreBackup(backup: ProjectBackupDef) { scope.launch { - backupRepository.restoreBackup(backup, backup.projectDef.path) + val restored = withContext(dispatcherIo) { + backupRepository.restoreBackup(backup, backup.projectDef.path) + } + + if (restored.not()) { + val errorMsg = ClientMessage.Resource(Res.string.backup_manager_error_restore_backup, backup.path.name) + val errorText = errorMsg.text(strRes) + withContext(dispatcherMain) { + _state.update { + it.copy(error = errorText) + } + } + } } } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/projectslist/ProjectsListComponent.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/projectslist/ProjectsListComponent.kt index fd3d63742..959c8162b 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/projectslist/ProjectsListComponent.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/projectslist/ProjectsListComponent.kt @@ -44,6 +44,7 @@ import com.darkrockstudios.apps.hammer.common.util.StrRes import com.darkrockstudios.apps.hammer.common.util.lifecycleCoroutineScope import com.darkrockstudios.apps.hammer.project_home_action_import_toast_failure import com.darkrockstudios.apps.hammer.project_home_action_import_toast_success +import com.darkrockstudios.apps.hammer.projects_list_toast_delete_failed import com.darkrockstudios.apps.hammer.projects_list_toast_sync_complete import com.darkrockstudios.apps.hammer.projects_list_toast_sync_failed import com.darkrockstudios.apps.hammer.sync_log_account_failed @@ -294,22 +295,33 @@ class ProjectsListComponent( } override fun deleteProject(projectDef: ProjectDef) { - val projectId = projectsRepository.getProjectId(projectDef) - val syncedProject = if (projectId != null) { - SyncedProjectDefinition(projectDef, projectId) - } else { - null - } - - if (projectsRepository.deleteProject(projectDef)) { - Napier.i("Project deleted: ${projectDef.name}") - if (syncedProject != null) { - projectsSynchronizer.deleteProject(syncedProject) - } else if (projectsSynchronizer.isServerSynchronized()) { - projectsSynchronizer.deleteUnsyncedProject(projectDef.name) + scope.launch(dispatcherIo) { + val projectId = projectsRepository.getProjectId(projectDef) + val syncedProject = if (projectId != null) { + SyncedProjectDefinition(projectDef, projectId) + } else { + null } - loadProjectList() + val deleted = try { + projectsRepository.deleteProject(projectDef) + } catch (e: IOException) { + Napier.e("Failed to delete project: ${projectDef.name}", e) + false + } + + if (deleted) { + Napier.i("Project deleted: ${projectDef.name}") + if (syncedProject != null) { + projectsSynchronizer.deleteProject(syncedProject) + } else if (projectsSynchronizer.isServerSynchronized()) { + projectsSynchronizer.deleteUnsyncedProject(projectDef.name) + } + + loadProjectList() + } else { + showToast(scope, Res.string.projects_list_toast_delete_failed) + } } } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/DetailsRouter.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/DetailsRouter.kt index ba6d88410..e9205eda8 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/DetailsRouter.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/DetailsRouter.kt @@ -7,9 +7,12 @@ import com.darkrockstudios.apps.hammer.common.components.storyeditor.drafts.Draf import com.darkrockstudios.apps.hammer.common.components.storyeditor.drafts.DraftCompareComponent import com.darkrockstudios.apps.hammer.common.components.storyeditor.drafts.DraftsList import com.darkrockstudios.apps.hammer.common.components.storyeditor.drafts.DraftsListComponent +import com.darkrockstudios.apps.hammer.common.components.storyeditor.readermode.ReaderMode +import com.darkrockstudios.apps.hammer.common.components.storyeditor.readermode.ReaderModeComponent import com.darkrockstudios.apps.hammer.common.components.storyeditor.sceneeditor.SceneEditor import com.darkrockstudios.apps.hammer.common.components.storyeditor.sceneeditor.SceneEditorComponent import com.darkrockstudios.apps.hammer.common.data.MenuDescriptor +import com.darkrockstudios.apps.hammer.common.data.ProjectDef import com.darkrockstudios.apps.hammer.common.data.SceneItem import com.darkrockstudios.apps.hammer.common.data.drafts.DraftDef import com.darkrockstudios.apps.hammer.common.data.encyclopediarepository.entry.EntryDef @@ -18,6 +21,7 @@ import kotlinx.serialization.Serializable internal class DetailsRouter( componentContext: ComponentContext, + private val projectDef: ProjectDef, private val selectedSceneItem: MutableSharedFlow, private val removeMenu: (id: String) -> Unit, private val closeDetails: () -> Unit, @@ -61,6 +65,10 @@ internal class DetailsRouter( draftDef = config.draftDef ) ) + + is Config.Reader -> StoryEditor.ChildDestination.Detail.ReaderDestination( + readerMode(componentContext = componentContext, sceneDef = config.sceneDef) + ) } private fun sceneEditor(componentContext: ComponentContext, sceneDef: SceneItem): SceneEditor = @@ -72,6 +80,7 @@ internal class DetailsRouter( closeSceneEditor = closeDetails, showDraftsList = ::showDraftsList, showFocusMode = openFocusMode, + showReader = ::showReader, showEntry = openEntry, showGlobalSearchForTag = openGlobalSearchForTag, ) @@ -97,6 +106,36 @@ internal class DetailsRouter( backToEditor = ::backToEditor ) + private fun readerMode(componentContext: ComponentContext, sceneDef: SceneItem?): ReaderMode = + ReaderModeComponent( + componentContext = componentContext, + projectDef = projectDef, + initialScene = sceneDef, + closeReader = closeDetails, + openSceneEditor = ::showScene, + onSceneInView = { selectedSceneItem.tryEmit(it) }, + ) + + fun activeReader(): ReaderMode? = + (stack.value.active.instance as? StoryEditor.ChildDestination.Detail.ReaderDestination)?.component + + // An open reader is reused: its config holds only the scene it was opened at. + fun showReader(sceneDef: SceneItem?) { + val reader = activeReader() + if (reader != null) { + sceneDef?.let(reader::showScene) + return + } + + navigation.navigate( + transformer = { stack -> + stack.dropLastWhile { it !is Config.None } + .plus(Config.Reader(sceneDef = sceneDef)) + }, + onComplete = { _, _ -> } + ) + } + fun showScene(sceneDef: SceneItem) { val top = stack.value.active.configuration if (top is Config.SceneEditor && top.sceneDef.id == sceneDef.id) return @@ -149,6 +188,7 @@ internal class DetailsRouter( fun isAtRoot(): Boolean { return stack.active.configuration is Config.SceneEditor || + stack.active.configuration is Config.Reader || stack.active.configuration is Config.None } @@ -176,5 +216,8 @@ internal class DetailsRouter( @Serializable data class DraftCompare(val sceneDef: SceneItem, val draftDef: DraftDef) : Config() + + @Serializable + data class Reader(val sceneDef: SceneItem?) : Config() } } \ No newline at end of file diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/ListRouter.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/ListRouter.kt index 99f9c9ce8..79be0e000 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/ListRouter.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/ListRouter.kt @@ -14,7 +14,9 @@ internal class ListRouter( private val projectDef: ProjectDef, private val selectedSceneItem: SharedFlow, private val onSceneSelected: (sceneDef: SceneItem) -> Unit, + private val openSceneEditor: (sceneDef: SceneItem) -> Unit, private val showOutlineOverview: () -> Unit, + private val showReaderMode: () -> Unit, ) { private val navigation = StackNavigation() @@ -40,7 +42,9 @@ internal class ListRouter( projectDef = projectDef, selectedSceneItem = selectedSceneItem, sceneSelected = onSceneSelected, + openSceneEditor = openSceneEditor, showOutlineOverviewDialog = showOutlineOverview, + showReader = showReaderMode, ) fun moveToBackStack() { diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/StoryEditor.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/StoryEditor.kt index 02801d2eb..5c37e99cb 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/StoryEditor.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/StoryEditor.kt @@ -9,6 +9,7 @@ import com.darkrockstudios.apps.hammer.common.components.projectroot.Router import com.darkrockstudios.apps.hammer.common.components.storyeditor.drafts.DraftCompare import com.darkrockstudios.apps.hammer.common.components.storyeditor.drafts.DraftsList import com.darkrockstudios.apps.hammer.common.components.storyeditor.outlineoverview.OutlineOverview +import com.darkrockstudios.apps.hammer.common.components.storyeditor.readermode.ReaderMode import com.darkrockstudios.apps.hammer.common.components.storyeditor.sceneeditor.SceneEditor import com.darkrockstudios.apps.hammer.common.components.storyeditor.scenelist.SceneList import com.darkrockstudios.apps.hammer.common.data.ProjectDef @@ -38,6 +39,9 @@ interface StoryEditor : AppCloseManager, Router, HammerComponent, BackHandlerOwn fun closeDetails(): Boolean fun showScene(sceneItem: SceneItem) + /** Opens the reader at [sceneItem], or at the start of the story when null. */ + fun showReader(sceneItem: SceneItem?) + sealed class ChildDestination { sealed class List : ChildDestination() { data class Scenes(val component: SceneList) : List() @@ -48,6 +52,7 @@ interface StoryEditor : AppCloseManager, Router, HammerComponent, BackHandlerOwn data class EditorDestination(val component: SceneEditor) : Detail() data class DraftsDestination(val component: DraftsList) : Detail() data class DraftCompareDestination(val component: DraftCompare) : Detail() + data class ReaderDestination(val component: ReaderMode) : Detail() data object None : Detail() } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/StoryEditorComponent.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/StoryEditorComponent.kt index fc7b7e688..c5c9778b2 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/StoryEditorComponent.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/StoryEditorComponent.kt @@ -46,6 +46,7 @@ class StoryEditorComponent( private val detailsRouter = DetailsRouter( componentContext = this, + projectDef = projectDef, selectedSceneItem = selectedSceneItemFlow, addMenu = addMenu, closeDetails = ::closeDetails, @@ -61,7 +62,9 @@ class StoryEditorComponent( projectDef = projectDef, selectedSceneItem = selectedSceneItemFlow, onSceneSelected = ::onSceneSelected, - showOutlineOverview = ::showOutlineOverview + openSceneEditor = ::showScene, + showOutlineOverview = ::showOutlineOverview, + showReaderMode = { showReader(null) }, ) private val dialogNavigation = SlotNavigation() @@ -122,9 +125,26 @@ class StoryEditorComponent( detailsRouter.closeScene() } + // While the reader is open the scene list is its table of contents. private fun onSceneSelected(sceneItem: SceneItem) { - detailsRouter.showScene(sceneItem) + if (detailsRouter.activeReader() != null) { + showReader(sceneItem) + } else { + showScene(sceneItem) + } + } + override fun showScene(sceneItem: SceneItem) { + detailsRouter.showScene(sceneItem) + showDetailsPane() + } + + override fun showReader(sceneItem: SceneItem?) { + detailsRouter.showReader(sceneItem) + showDetailsPane() + } + + private fun showDetailsPane() { if (isMultiPaneMode()) { listRouter.show() } else { @@ -132,10 +152,6 @@ class StoryEditorComponent( } } - override fun showScene(sceneItem: SceneItem) { - onSceneSelected(sceneItem) - } - override fun setMultiPane(isMultiPane: Boolean) { _state.getAndUpdate { it.copy(isMultiPane = isMultiPane) } @@ -212,6 +228,7 @@ class StoryEditorComponent( is DetailsRouter.Config.DraftCompare -> config.sceneDef is DetailsRouter.Config.DraftsList -> config.sceneDef is DetailsRouter.Config.SceneEditor -> config.sceneDef + is DetailsRouter.Config.Reader -> null DetailsRouter.Config.None -> null } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/readermode/ReaderMode.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/readermode/ReaderMode.kt new file mode 100644 index 000000000..d5fe67d66 --- /dev/null +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/readermode/ReaderMode.kt @@ -0,0 +1,53 @@ +package com.darkrockstudios.apps.hammer.common.components.storyeditor.readermode + +import androidx.compose.runtime.Immutable +import com.arkivanov.decompose.value.Value +import com.darkrockstudios.apps.hammer.common.data.SceneItem +import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettings + +interface ReaderMode { + val state: Value + + fun showChapter(chapterId: Int) + fun nextChapter() + fun previousChapter() + + /** Brings [sceneItem] into view, switching chapters when it lives in another one. */ + fun showScene(sceneItem: SceneItem) + fun scrollHandled() + fun sceneInView(sceneItem: SceneItem?) + + /** Opens [sceneItem] in the scene editor, or the scene being read when null. */ + fun editScene(sceneItem: SceneItem? = null) + + fun toggleSceneHeadings() + fun increaseFontSize() + fun decreaseFontSize() + fun resetFontSize() + fun setEditorMaxWidth(width: Float) + fun resetEditorMaxWidth() + fun close() + + @Immutable + data class State( + val chapters: List = emptyList(), + val activeChapterId: Int? = null, + val scenes: List = emptyList(), + val isLoading: Boolean = true, + val showSceneHeadings: Boolean = false, + val fontSize: Float = GlobalSettings.DEFAULT_FONT_SIZE, + val editorMaxWidth: Float = GlobalSettings.DEFAULT_EDITOR_WIDTH, + /** One-shot request for the UI to scroll to a scene; cleared by [scrollHandled]. */ + val scrollToSceneId: Int? = null, + val sceneInView: SceneItem? = null, + ) { + val activeChapterIndex: Int + get() = chapters.indexOfFirst { it.chapter.id == activeChapterId } + } + + @Immutable + data class ReaderChapter(val chapter: SceneItem, val sceneCount: Int) + + @Immutable + data class ReaderScene(val sceneItem: SceneItem, val markdown: String) +} diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/readermode/ReaderModeComponent.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/readermode/ReaderModeComponent.kt new file mode 100644 index 000000000..b05b5509c --- /dev/null +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/readermode/ReaderModeComponent.kt @@ -0,0 +1,341 @@ +package com.darkrockstudios.apps.hammer.common.components.storyeditor.readermode + +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.value.MutableValue +import com.arkivanov.decompose.value.Value +import com.arkivanov.decompose.value.update +import com.darkrockstudios.apps.hammer.common.components.ProjectComponentBase +import com.darkrockstudios.apps.hammer.common.components.storyeditor.sceneeditor.clampEditorWidth +import com.darkrockstudios.apps.hammer.common.components.storyeditor.sceneeditor.decreaseEditorTextSize +import com.darkrockstudios.apps.hammer.common.components.storyeditor.sceneeditor.increaseEditorTextSize +import com.darkrockstudios.apps.hammer.common.data.ProjectDef +import com.darkrockstudios.apps.hammer.common.data.SceneBuffer +import com.darkrockstudios.apps.hammer.common.data.SceneItem +import com.darkrockstudios.apps.hammer.common.data.SceneSummary +import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettings +import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettingsStore +import com.darkrockstudios.apps.hammer.common.data.projectInject +import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.SceneEditorService +import com.darkrockstudios.apps.hammer.common.data.tree.ChapterScenes +import com.darkrockstudios.apps.hammer.common.data.tree.ImmutableTree +import com.darkrockstudios.apps.hammer.common.data.tree.collectChapters +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import org.koin.core.component.inject + +class ReaderModeComponent( + componentContext: ComponentContext, + projectDef: ProjectDef, + initialScene: SceneItem?, + private val closeReader: () -> Unit, + private val openSceneEditor: (SceneItem) -> Unit, + private val onSceneInView: (SceneItem?) -> Unit, +) : ProjectComponentBase(projectDef, componentContext), ReaderMode { + + private val sceneEditor: SceneEditorService by projectInject() + private val settingsStore: GlobalSettingsStore by inject() + + private var lastTree: ImmutableTree = sceneEditor.getSceneTree() + private var chapterScenes: List = lastTree.collectChapters() + + private var loadJob: Job? = null + private val bufferJobs = mutableMapOf() + + // Text from buffer updates that arrived during a load; newer than what the load read. + private val pendingMarkdown = mutableMapOf() + + private val _state = MutableValue(initialState(initialScene)) + override val state: Value = _state + + init { + stateKeeper.register(SAVED_STATE_KEY, ReaderSavedState.serializer()) { + val current = _state.value + ReaderSavedState( + activeChapterId = current.activeChapterId, + sceneId = current.scrollToSceneId ?: current.sceneInView?.id, + ) + } + } + + private fun initialState(initialScene: SceneItem?): ReaderMode.State { + val saved = stateKeeper.consume(SAVED_STATE_KEY, ReaderSavedState.serializer()) + val startSceneId = if (saved != null) saved.sceneId else initialScene?.id + val startChapter = chapterScenes.firstOrNull { it.chapter.id == saved?.activeChapterId } + ?: startSceneId?.let(::chapterContaining) + ?: chapterScenes.firstOrNull() + val settings = settingsStore.globalSettings + + return ReaderMode.State( + chapters = chapterScenes.toReaderChapters(), + activeChapterId = startChapter?.chapter?.id, + showSceneHeadings = settings.readerShowSceneHeadings, + fontSize = settings.readerFontSize, + editorMaxWidth = settings.editorMaxWidth, + scrollToSceneId = startSceneId?.takeIf { id -> startChapter?.scenes?.any { it.id == id } == true }, + ) + } + + override fun onCreate() { + super.onCreate() + + watchSettings() + watchSceneTree() + sceneEditor.subscribeToBufferUpdates(null, scope, ::onBufferUpdate) + + val chapterId = _state.value.activeChapterId + if (chapterId != null) { + loadChapter(chapterId, clearScenes = true) + } else { + _state.update { it.copy(isLoading = false) } + } + } + + private fun chapterContaining(sceneId: Int): ChapterScenes? = + chapterScenes.firstOrNull { chapter -> chapter.scenes.any { it.id == sceneId } } + + private fun List.toReaderChapters() = + map { ReaderMode.ReaderChapter(chapter = it.chapter, sceneCount = it.scenes.size) } + + private fun loadChapter(chapterId: Int, clearScenes: Boolean) { + val scenes = chapterScenes.firstOrNull { it.chapter.id == chapterId }?.scenes ?: return + + loadJob?.cancel() + bufferJobs.values.forEach { it.cancel() } + bufferJobs.clear() + pendingMarkdown.clear() + + _state.update { + it.copy( + activeChapterId = chapterId, + scenes = if (clearScenes) emptyList() else it.scenes, + isLoading = true, + ) + } + + loadJob = scope.launch { + val loaded = withContext(dispatcherIo) { + scenes.map { scene -> + ReaderMode.ReaderScene(scene, sceneEditor.getCurrentSceneContentOrNull(scene) ?: "") + } + } + withContext(dispatcherMain) { + if (_state.value.activeChapterId != chapterId) return@withContext + val current = loaded.map { scene -> + pendingMarkdown[scene.sceneItem.id]?.let { scene.copy(markdown = it) } ?: scene + } + pendingMarkdown.clear() + _state.update { it.copy(scenes = current, isLoading = false) } + } + } + } + + // Collected directly: subscribeToSceneUpdates would force a scene list reload from disk. + private fun watchSceneTree() { + scope.launch { + sceneEditor.sceneListChannel.collect { summary -> + withContext(dispatcherMain) { onSceneTreeUpdate(summary) } + } + } + } + + private fun watchSettings() { + scope.launch { + settingsStore.globalSettingsUpdates.collect { settings -> + withContext(dispatcherMain) { + _state.update { + it.copy( + showSceneHeadings = settings.readerShowSceneHeadings, + fontSize = settings.readerFontSize, + editorMaxWidth = settings.editorMaxWidth, + ) + } + } + } + } + } + + // Scene updates also fire on every autosave, so anything but a changed tree is ignored. + private fun onSceneTreeUpdate(summary: SceneSummary) { + val tree = summary.sceneTree + if (tree == lastTree) return + lastTree = tree + + val previousChapters = chapterScenes + chapterScenes = tree.collectChapters() + if (chapterScenes.isEmpty()) { + showEmptyStory() + return + } + + val activeId = _state.value.activeChapterId + val previousIndex = previousChapters.indexOfFirst { it.chapter.id == activeId } + val active = chapterScenes.firstOrNull { it.chapter.id == activeId } + ?: chapterScenes[previousIndex.coerceIn(0, chapterScenes.lastIndex)] + val previousSceneIds = previousChapters.getOrNull(previousIndex)?.scenes?.map { it.id } + val sceneIds = active.scenes.map { it.id } + + _state.update { it.copy(chapters = chapterScenes.toReaderChapters()) } + + if (active.chapter.id != activeId) { + _state.update { it.copy(scrollToSceneId = null, sceneInView = null) } + loadChapter(active.chapter.id, clearScenes = true) + onSceneInView(null) + } else if (sceneIds != previousSceneIds) { + loadChapter(active.chapter.id, clearScenes = false) + } else { + val current = active.scenes.associateBy { it.id } + _state.update { state -> + state.copy( + scenes = state.scenes.map { it.copy(sceneItem = current[it.sceneItem.id] ?: it.sceneItem) }, + sceneInView = state.sceneInView?.let { current[it.id] ?: it }, + ) + } + } + } + + private fun showEmptyStory() { + loadJob?.cancel() + bufferJobs.values.forEach { it.cancel() } + bufferJobs.clear() + pendingMarkdown.clear() + + val hadSceneInView = _state.value.sceneInView != null + _state.update { + it.copy( + chapters = emptyList(), + activeChapterId = null, + scenes = emptyList(), + isLoading = false, + scrollToSceneId = null, + sceneInView = null, + ) + } + if (hadSceneInView) onSceneInView(null) + } + + private fun activeChapterHasScene(sceneId: Int): Boolean = + chapterScenes.firstOrNull { it.chapter.id == _state.value.activeChapterId } + ?.scenes?.any { it.id == sceneId } == true + + private suspend fun onBufferUpdate(buffer: SceneBuffer) { + val sceneId = buffer.content.scene.id + if (!activeChapterHasScene(sceneId)) return + + bufferJobs[sceneId]?.cancel() + bufferJobs[sceneId] = scope.launch { + val markdown = withContext(dispatcherDefault) { buffer.content.coerceMarkdown() } + withContext(dispatcherMain) { + if (!activeChapterHasScene(sceneId)) return@withContext + if (_state.value.isLoading) pendingMarkdown[sceneId] = markdown + _state.update { state -> + state.copy( + scenes = state.scenes.map { + if (it.sceneItem.id == sceneId && it.markdown != markdown) it.copy(markdown = markdown) else it + } + ) + } + } + } + } + + override fun showChapter(chapterId: Int) { + if (chapterId == _state.value.activeChapterId) return + if (chapterScenes.none { it.chapter.id == chapterId }) return + + _state.update { it.copy(scrollToSceneId = null, sceneInView = null) } + loadChapter(chapterId, clearScenes = true) + onSceneInView(null) + } + + private fun showChapterAtOffset(offset: Int) { + val index = chapterScenes.indexOfFirst { it.chapter.id == _state.value.activeChapterId } + if (index < 0) return + val target = chapterScenes.getOrNull(index + offset) ?: return + showChapter(target.chapter.id) + } + + override fun nextChapter() = showChapterAtOffset(1) + + override fun previousChapter() = showChapterAtOffset(-1) + + override fun showScene(sceneItem: SceneItem) { + val chapter = chapterScenes.firstOrNull { it.chapter.id == sceneItem.id } + ?: chapterContaining(sceneItem.id) + if (chapter == null) { + // The list has already highlighted the tapped scene; put it back on the one in view. + onSceneInView(_state.value.sceneInView) + return + } + val targetId = if (sceneItem.type == SceneItem.Type.Scene) { + sceneItem.id + } else { + chapter.scenes.firstOrNull()?.id + } + + if (chapter.chapter.id != _state.value.activeChapterId) { + _state.update { it.copy(sceneInView = null) } + loadChapter(chapter.chapter.id, clearScenes = true) + } + _state.update { it.copy(scrollToSceneId = targetId) } + } + + override fun scrollHandled() { + val current = _state.value + val targetId = current.scrollToSceneId ?: return + val target = current.scenes.firstOrNull { it.sceneItem.id == targetId }?.sceneItem + + _state.update { it.copy(scrollToSceneId = null, sceneInView = target ?: it.sceneInView) } + if (target != null && target != current.sceneInView) onSceneInView(target) + } + + // Ignored while a scroll is pending, or the list highlight jumps to whatever is on screen first. + override fun sceneInView(sceneItem: SceneItem?) { + val current = _state.value + if (current.scrollToSceneId != null || sceneItem == current.sceneInView) return + + _state.update { it.copy(sceneInView = sceneItem) } + onSceneInView(sceneItem) + } + + override fun editScene(sceneItem: SceneItem?) { + val current = _state.value + val target = sceneItem + ?: current.sceneInView + ?: chapterScenes.firstOrNull { it.chapter.id == current.activeChapterId }?.scenes?.firstOrNull() + ?: return + openSceneEditor(target) + } + + private fun updateSettings(transform: (GlobalSettings) -> GlobalSettings) { + scope.launch { settingsStore.updateSettings(transform) } + } + + override fun toggleSceneHeadings() = + updateSettings { it.copy(readerShowSceneHeadings = !it.readerShowSceneHeadings) } + + override fun increaseFontSize() = + updateSettings { it.copy(readerFontSize = increaseEditorTextSize(it.readerFontSize)) } + + override fun decreaseFontSize() = + updateSettings { it.copy(readerFontSize = decreaseEditorTextSize(it.readerFontSize)) } + + override fun resetFontSize() = + updateSettings { it.copy(readerFontSize = GlobalSettings.DEFAULT_FONT_SIZE) } + + override fun setEditorMaxWidth(width: Float) = + updateSettings { it.copy(editorMaxWidth = clampEditorWidth(width)) } + + override fun resetEditorMaxWidth() = + updateSettings { it.copy(editorMaxWidth = GlobalSettings.DEFAULT_EDITOR_WIDTH) } + + override fun close() = closeReader() + + @Serializable + private data class ReaderSavedState(val activeChapterId: Int?, val sceneId: Int?) + + companion object { + private const val SAVED_STATE_KEY = "ReaderMode" + } +} diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/sceneeditor/SceneEditor.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/sceneeditor/SceneEditor.kt index 3e70959dd..de4c9270d 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/sceneeditor/SceneEditor.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/sceneeditor/SceneEditor.kt @@ -56,6 +56,7 @@ interface SceneEditor : HammerComponent, ComponentToaster { fun setEditorMaxWidth(width: Float) fun resetEditorMaxWidth() fun enterFocusMode() + fun readScene() /** Adds a flagged word to the project's spell-check dictionary. */ fun addWordToDictionary(word: String) diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/sceneeditor/SceneEditorComponent.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/sceneeditor/SceneEditorComponent.kt index 03abd5ed5..46b15dafe 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/sceneeditor/SceneEditorComponent.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/sceneeditor/SceneEditorComponent.kt @@ -39,6 +39,7 @@ class SceneEditorComponent( private val closeSceneEditor: () -> Unit, private val showDraftsList: (SceneItem) -> Unit, private val showFocusMode: (SceneItem) -> Unit, + private val showReader: (SceneItem) -> Unit, showEntry: (EntryDef) -> Unit, showGlobalSearchForTag: (String) -> Unit, ) : ProjectComponentBase(originalSceneItem.projectDef, componentContext), @@ -117,6 +118,7 @@ class SceneEditorComponent( sceneItem = newSceneItem.value ) } + sceneMetadataComponent.onSceneItemChanged(newSceneItem.value) } else { Napier.e("Scene ${sceneDef.id} no longer exists in the tree, this are probably going to break.") } @@ -283,6 +285,15 @@ class SceneEditorComponent( enterFocusMode() } + val readItem = MenuItemDescriptor( + "scene-editor-read", + Res.string.scene_editor_menu_item_read, + "" + ) { + Napier.i("Read scene") + readScene() + } + val menuItems = setOf( renameItem, saveItem, @@ -293,6 +304,7 @@ class SceneEditorComponent( saveDraftItem, metadataItem, focusModeItem, + readItem, closeItem, ) val menu = MenuDescriptor( @@ -492,6 +504,10 @@ class SceneEditorComponent( showFocusMode(sceneDef) } + override fun readScene() { + showReader(sceneDef) + } + override fun addWordToDictionary(word: String) { scope.launch { projectDictionary.addWord(word) } } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/sceneeditor/SceneEditorUtils.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/sceneeditor/SceneEditorUtils.kt index a42f9ed64..508f43dd5 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/sceneeditor/SceneEditorUtils.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/sceneeditor/SceneEditorUtils.kt @@ -3,17 +3,15 @@ package com.darkrockstudios.apps.hammer.common.components.storyeditor.sceneedito import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettings import korlibs.memory.clamp -val MIN_FONT_SIZE = 8f -val MAX_FONT_SIZE = 32f +/** The body text sizes the editor steps through. A size off the ladder snaps to it on the next step. */ +val EDITOR_TEXT_SIZES = listOf(12f, 14f, 16f, 18f, 20f, 24f, 28f, 32f) -fun increaseEditorTextSize(currentSize: Float): Float { - return (currentSize + 1f).clamp(MIN_FONT_SIZE, MAX_FONT_SIZE) -} +fun increaseEditorTextSize(currentSize: Float): Float = + EDITOR_TEXT_SIZES.firstOrNull { it > currentSize } ?: EDITOR_TEXT_SIZES.last() -fun decreaseEditorTextSize(currentSize: Float): Float { - return (currentSize - 1f).clamp(MIN_FONT_SIZE, MAX_FONT_SIZE) -} +fun decreaseEditorTextSize(currentSize: Float): Float = + EDITOR_TEXT_SIZES.lastOrNull { it < currentSize } ?: EDITOR_TEXT_SIZES.first() fun clampEditorWidth(width: Float): Float { return width.clamp(GlobalSettings.MIN_EDITOR_WIDTH, GlobalSettings.MAX_EDITOR_WIDTH) -} \ No newline at end of file +} diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/sceneeditor/scenemetadata/SceneMetadataPanel.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/sceneeditor/scenemetadata/SceneMetadataPanel.kt index 14643f3c6..8165c3d17 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/sceneeditor/scenemetadata/SceneMetadataPanel.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/sceneeditor/scenemetadata/SceneMetadataPanel.kt @@ -11,6 +11,7 @@ import com.darkrockstudios.apps.hammer.common.dependencyinjection.HammerComponen interface SceneMetadataPanel : HammerComponent, TagSuggesting { val state: Value + fun onSceneItemChanged(sceneItem: SceneItem) fun updateOutline(text: String) fun updateNotes(text: String) fun updateDraftName(text: String) diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/sceneeditor/scenemetadata/SceneMetadataPanelComponent.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/sceneeditor/scenemetadata/SceneMetadataPanelComponent.kt index afef91a40..58dc14624 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/sceneeditor/scenemetadata/SceneMetadataPanelComponent.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/sceneeditor/scenemetadata/SceneMetadataPanelComponent.kt @@ -7,7 +7,7 @@ import com.arkivanov.decompose.value.getAndUpdate import com.darkrockstudios.apps.hammer.common.components.ProjectComponentBase import com.darkrockstudios.apps.hammer.common.data.SceneBuffer import com.darkrockstudios.apps.hammer.common.data.SceneItem -import com.darkrockstudios.apps.hammer.common.data.SceneSummary +import com.darkrockstudios.apps.hammer.common.data.UpdateSource import com.darkrockstudios.apps.hammer.common.data.drafts.SceneDraftsDatasource import com.darkrockstudios.apps.hammer.common.data.encyclopediarepository.EncyclopediaService import com.darkrockstudios.apps.hammer.common.data.encyclopediarepository.entry.EntryDef @@ -34,6 +34,7 @@ import kotlinx.coroutines.withContext import okio.IOException import org.koin.core.component.inject import org.koin.core.qualifier.named +import kotlin.concurrent.Volatile import kotlin.time.Duration.Companion.milliseconds class SceneMetadataPanelComponent( @@ -67,6 +68,14 @@ class SceneMetadataPanelComponent( private var bufferUpdateSubscription: Job? = null private var metadataLoaded = false + // True while an outline/notes edit is waiting on the debounced store. + @Volatile + private var unsavedTextEdits = false + + // True while any local change is waiting on the debounced store. + @Volatile + private var storePending = false + private val _metadataUpdateFlow = MutableSharedFlow( extraBufferCapacity = 1, replay = 1, @@ -88,8 +97,6 @@ class SceneMetadataPanelComponent( loadSceneData() loadMetadataData() - - sceneEditor.subscribeToSceneUpdates(scope, ::onSceneTreeUpdate) } scope.launch { @@ -108,15 +115,18 @@ class SceneMetadataPanelComponent( private fun subscribeToMetadataUpdates() { scope.launch { sceneEditor.metadataUpdateFlow - .filter { (sceneId, _) -> sceneId == originalSceneItem.id } - .collect { (_, external) -> + .filter { it.sceneId == originalSceneItem.id } + .collect { update -> + val external = update.metadata val current = state.value.metadata // Keep the user-editable text fields local; let everything else (refs, tags, // future-added fields) take the external write. Listing the local-owned fields // is the more stable invariant when SceneMetadata gains new fields. + // A synced write also replaces outline/notes, unless a local edit is unsaved. + val keepLocalText = update.source != UpdateSource.Sync || unsavedTextEdits val merged = external.copy( - outline = current.outline, - notes = current.notes, + outline = if (keepLocalText) current.outline else external.outline, + notes = if (keepLocalText) current.notes else external.notes, currentDraftName = current.currentDraftName, ) if (merged == current) return@collect @@ -124,9 +134,12 @@ class SceneMetadataPanelComponent( _state.getAndUpdate { it.copy(metadata = merged) } } // Re-emit so a pending debounced write picks up the merged state instead of - // the stale value it had queued. - if (_metadataUpdateFlow.tryEmit(merged).not()) { - Napier.w { "Failed to re-emit merged metadata after external update" } + // the stale value it had queued. Storage already holds an unchanged merge. + if (storePending || merged != external) { + storePending = true + if (_metadataUpdateFlow.tryEmit(merged).not()) { + Napier.w { "Failed to re-emit merged metadata after external update" } + } } refreshReferences() } @@ -143,7 +156,8 @@ class SceneMetadataPanelComponent( } } - private fun onSceneTreeUpdate(sceneSummary: SceneSummary) { + override fun onSceneItemChanged(sceneItem: SceneItem) { + _state.getAndUpdate { it.copy(sceneItem = sceneItem) } scope.launch { loadSceneData() } @@ -207,7 +221,17 @@ class SceneMetadataPanelComponent( metadataStoreJob = scope.launch { metadataStoreFlow.debounceUntilQuiescent(STORE_COOL_DOWN).collect { metadata -> // Self-healing fail-safe: drop reference IDs whose entry no longer exists - sceneEditor.storeMetadata(scrubInvalidReferences(metadata), originalSceneItem.id) + try { + sceneEditor.storeMetadata(scrubInvalidReferences(metadata), originalSceneItem.id) + } catch (e: IOException) { + Napier.e("Failed to store metadata for scene ${originalSceneItem.id}", e) + return@collect + } + val current = state.value.metadata + if (current.outline == metadata.outline && current.notes == metadata.notes) { + unsavedTextEdits = false + } + if (current == metadata) storePending = false } } } @@ -230,6 +254,8 @@ class SceneMetadataPanelComponent( } override fun updateOutline(text: String) { + unsavedTextEdits = true + storePending = true _state.getAndUpdate { val updated = it.metadata.copy(outline = text) if (_metadataUpdateFlow.tryEmit(updated).not()) { @@ -242,6 +268,8 @@ class SceneMetadataPanelComponent( } override fun updateNotes(text: String) { + unsavedTextEdits = true + storePending = true _state.getAndUpdate { val updated = it.metadata.copy(notes = text) if (_metadataUpdateFlow.tryEmit(updated).not()) { @@ -254,6 +282,7 @@ class SceneMetadataPanelComponent( } override fun updateDraftName(text: String) { + storePending = true _state.getAndUpdate { val updated = it.metadata.copy(currentDraftName = text) if (_metadataUpdateFlow.tryEmit(updated).not()) { @@ -327,6 +356,7 @@ class SceneMetadataPanelComponent( } private fun mutateMetadata(transform: (SceneMetadata) -> SceneMetadata) { + storePending = true _state.getAndUpdate { val updated = transform(it.metadata) if (_metadataUpdateFlow.tryEmit(updated).not()) { diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/scenelist/SceneList.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/scenelist/SceneList.kt index 747b57996..4e33b5749 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/scenelist/SceneList.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/scenelist/SceneList.kt @@ -19,6 +19,7 @@ interface SceneList { fun onSceneListUpdate(scenes: SceneSummary) fun onSceneBufferUpdate(sceneBuffer: SceneBuffer) fun showOutlineOverview() + fun showReaderMode() suspend fun archiveScene(scene: SceneItem) suspend fun unarchiveScene(scene: SceneItem) diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/scenelist/SceneListComponent.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/scenelist/SceneListComponent.kt index 022bfc3e8..e094dbe2d 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/scenelist/SceneListComponent.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/storyeditor/scenelist/SceneListComponent.kt @@ -18,7 +18,9 @@ class SceneListComponent( projectDef: ProjectDef, selectedSceneItem: SharedFlow, private val sceneSelected: (sceneDef: SceneItem) -> Unit, + private val openSceneEditor: (sceneDef: SceneItem) -> Unit, private val showOutlineOverviewDialog: () -> Unit, + private val showReader: () -> Unit, ) : ProjectComponentBase(projectDef, componentContext), SceneList { private val projectEditor: SceneEditorService by projectInject() @@ -76,7 +78,7 @@ class SceneListComponent( val newSceneItem = projectEditor.createScene(foundParent, sceneName) if (newSceneItem != null) { Napier.i("Scene created: $sceneName") - sceneSelected(newSceneItem) + openSceneEditor(newSceneItem) } else { Napier.w("Failed to create Scene: $sceneName") } @@ -153,6 +155,10 @@ class SceneListComponent( showOutlineOverviewDialog() } + override fun showReaderMode() { + showReader() + } + override suspend fun archiveScene(scene: SceneItem) { if (projectEditor.archiveScene(scene)) { Napier.i("Scene archived: ${scene.name}") @@ -165,7 +171,7 @@ class SceneListComponent( val unarchived = projectEditor.unarchiveScene(scene) if (unarchived != null) { Napier.i("Scene unarchived: ${unarchived.name}") - sceneSelected(unarchived) + openSceneEditor(unarchived) dismissArchivedDialog() } else { Napier.w("Failed to unarchive scene: ${scene.name}") diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/timeline/TimeLineComponent.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/timeline/TimeLineComponent.kt index 1c51047c5..d15237085 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/timeline/TimeLineComponent.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/timeline/TimeLineComponent.kt @@ -115,19 +115,24 @@ class TimeLineComponent( } override fun shouldConfirmClose(): Set { - val unsaved = when (val destination = stack.value.active.instance) { - is TimeLine.Destination.CreateEventDestination -> true - is TimeLine.Destination.ViewEventDestination -> { - destination.component.isEditingAndDirty() - } + val unsaved = stack.value.items.any { isUnsaved(it.instance) } + return if (unsaved) setOf(CloseConfirm.Timeline) else emptySet() + } - else -> false + private fun isUnsaved(destination: TimeLine.Destination) = when (destination) { + is TimeLine.Destination.CreateEventDestination -> true + is TimeLine.Destination.ViewEventDestination -> destination.component.isEditingAndDirty() + else -> false + } + + override fun discardUnsaved(item: CloseConfirm) { + if (item != CloseConfirm.Timeline) return + stack.value.items.forEach { child -> + val view = child.instance as? TimeLine.Destination.ViewEventDestination ?: return@forEach + if (view.component.state.value.isEditing) view.component.discardEdit() } - - return if (unsaved) { - setOf(CloseConfirm.Timeline) - } else { - emptySet() + if (stack.value.active.instance is TimeLine.Destination.CreateEventDestination) { + navigation.pop() } } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/timeline/ViewTimeLineEventComponent.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/timeline/ViewTimeLineEventComponent.kt index ad71c8a32..add08bc73 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/timeline/ViewTimeLineEventComponent.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/timeline/ViewTimeLineEventComponent.kt @@ -93,12 +93,18 @@ class ViewTimeLineEventComponent( withContext(mainDispatcher) { val updatedEvent = timeLine.events.find { it.id == eventId } if (updatedEvent != state.value.event) { + val keepDraft = isEditingAndDirty() _state.getAndUpdate { it.copy( event = updatedEvent, - tags = if (it.isEditing) it.tags else (updatedEvent?.tags ?: emptySet()), + tags = if (keepDraft) it.tags else (updatedEvent?.tags ?: emptySet()), ) } + // A dirty edit keeps its draft; otherwise follow the stored event. + if (!keepDraft && updatedEvent != null) { + _contentText.update { updatedEvent.content } + _dateText.update { updatedEvent.date ?: "" } + } } } } @@ -238,7 +244,7 @@ class ViewTimeLineEventComponent( // Baseline not loaded yet (e.g. restored mid-edit): assume dirty so we don't silently discard. val event = state.value.event ?: return true return event.content != contentText.value || - event.date != dateText.value || + (event.date ?: "") != dateText.value || event.tags != state.value.tags } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/ExportOptions.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/ExportOptions.kt index fd0c18b37..3b38a92b6 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/ExportOptions.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/ExportOptions.kt @@ -5,10 +5,29 @@ import kotlinx.serialization.Serializable @Serializable enum class ExportFormat { Markdown, Epub, Pdf, Docx, Rtf } +/** Body typefaces offered for exports; the reader's own installed copy of [faceName] is what renders. */ +@Serializable +enum class ExportFont(val faceName: String, val cssFamily: String) { + Georgia("Georgia", "Georgia, \"Times New Roman\", serif"), + TimesNewRoman("Times New Roman", "\"Times New Roman\", Times, serif"), + Garamond("Garamond", "Garamond, \"EB Garamond\", Georgia, serif"), + Arial("Arial", "Arial, Helvetica, sans-serif"), + Verdana("Verdana", "Verdana, Geneva, sans-serif"), + CourierNew("Courier New", "\"Courier New\", Courier, monospace"), +} + @Serializable data class ExportOptions( val treatTopLevelAsChapters: Boolean = true, val format: ExportFormat = ExportFormat.Epub, /** Scene ids the export is limited to; null exports the entire story. */ val sceneIds: Set? = null, + /** Prefixes each chapter title with its position ("1. Title"). */ + val numberChapters: Boolean = false, + val font: ExportFont = ExportFont.Georgia, + /** + * Keeps the blank lines the author typed between paragraphs. Off drops a lone blank line, leaving the + * indent as the separator, while a run of two or more still leaves one break. + */ + val keepBlankLines: Boolean = true, ) diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/appupdate/AppUpdater.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/appupdate/AppUpdater.kt new file mode 100644 index 000000000..3d7c551ac --- /dev/null +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/appupdate/AppUpdater.kt @@ -0,0 +1,59 @@ +package com.darkrockstudios.apps.hammer.common.data.appupdate + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Self-update for the builds that may do it: desktop installs from a GitHub release or the + * AppImage. Every other build binds [NoOpAppUpdater] and never leaves + * [AppUpdateState.Unsupported], so store builds carry no update UI. + */ +interface AppUpdater { + val state: StateFlow + + /** Looks for a newer release now, regardless of the automatic schedule. */ + fun checkNow() + + /** Downloads the available release and hands it to the installer, which quits the app. */ + fun update() + + /** Hides the banner for the available version until a newer one appears. */ + fun dismiss() +} + +sealed interface AppUpdateState { + /** This build cannot update itself; nothing update-related is shown. */ + data object Unsupported : AppUpdateState + + /** No check has completed yet. */ + data object Idle : AppUpdateState + + data object Checking : AppUpdateState + + /** The last check found nothing newer. */ + data object UpToDate : AppUpdateState + + /** + * A newer release exists. [installable] is false when this install can only be told about it + * and sent to the release page; [dismissed] hides the banner but not the About screen row. + */ + data class Available( + val version: String, + val installable: Boolean, + val dismissed: Boolean = false, + ) : AppUpdateState + + data class Downloading(val version: String, val fraction: Float) : AppUpdateState + + /** The installer has been handed the file; the app is about to quit. */ + data class Installing(val version: String) : AppUpdateState + + data class Failed(val version: String, val reason: String) : AppUpdateState +} + +object NoOpAppUpdater : AppUpdater { + override val state: StateFlow = MutableStateFlow(AppUpdateState.Unsupported) + override fun checkNow() = Unit + override fun update() = Unit + override fun dismiss() = Unit +} diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/encyclopediarepository/EncyclopediaRepository.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/encyclopediarepository/EncyclopediaRepository.kt index 7dc875729..113e8de3d 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/encyclopediarepository/EncyclopediaRepository.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/encyclopediarepository/EncyclopediaRepository.kt @@ -57,6 +57,11 @@ class EncyclopediaRepository( ) val entryContentChangedFlow: SharedFlow = _entryContentChangedFlow + private val _entryChangedFlow = MutableSharedFlow(extraBufferCapacity = 64) + + /** Emits the id of each entry that was created, updated or deleted. */ + val entryChangedFlow: SharedFlow = _entryChangedFlow + private suspend fun updateEntries(entries: List) { _entryListFlow.emit(entries) } @@ -100,6 +105,7 @@ class EncyclopediaRepository( ) _entryContentChangedFlow.emit(Unit) + _entryChangedFlow.emit(oldEntryDef.id) return EntryResult(container, EntryError.NONE) } @@ -189,6 +195,7 @@ class EncyclopediaRepository( if (forceId == null) markForSynchronization(newDef) _entryContentChangedFlow.emit(Unit) + _entryChangedFlow.emit(newId) return EntryResult(container, EntryError.NONE) } @@ -196,6 +203,7 @@ class EncyclopediaRepository( datasource.deleteEntry(entryDef) syncJournal.recordIdDeletion(entryDef.id) _entryContentChangedFlow.emit(Unit) + _entryChangedFlow.emit(entryDef.id) return true } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/encyclopediarepository/EncyclopediaService.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/encyclopediarepository/EncyclopediaService.kt index b497e48d4..f401f3aed 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/encyclopediarepository/EncyclopediaService.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/encyclopediarepository/EncyclopediaService.kt @@ -73,6 +73,8 @@ class EncyclopediaService( val entryListFlow: SharedFlow> get() = repository.entryListFlow + val entryChangedFlow: SharedFlow get() = repository.entryChangedFlow + fun loadEntries() = repository.loadEntries() suspend fun ensureEntriesLoaded(): List = repository.ensureEntriesLoaded() diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsettings/GlobalSettings.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsettings/GlobalSettings.kt index 033336817..d12c3d025 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsettings/GlobalSettings.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsettings/GlobalSettings.kt @@ -31,6 +31,8 @@ data class GlobalSettings( */ val editorMaxWidth: Float = DEFAULT_EDITOR_WIDTH, val enableDndInFocusMode: Boolean = false, + val readerFontSize: Float = DEFAULT_FONT_SIZE, + val readerShowSceneHeadings: Boolean = false, /** * Whether the scene metadata panel is visible on wide layouts. UI state, * not really a user "preference" — lives here because we don't have a @@ -54,6 +56,10 @@ data class GlobalSettings( val deviceLabel: String? = null, val initialProjectScreen: InitialProjectScreen = InitialProjectScreen.Home, val lastSeenChangelogVersion: String? = null, + /** Whether the self-updating desktop builds look for a new release on a schedule. */ + val automaticUpdateChecks: Boolean = true, + /** The release the user chose "Later" on; the banner stays hidden until a newer one appears. */ + val dismissedUpdateVersion: String? = null, ) { companion object { @@ -91,6 +97,7 @@ data class SpellCheckerSettings( enum class UiTheme { Light, Dark, + OledBlack, FollowSystem } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/projectbackup/ProjectBackupRepository.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/projectbackup/ProjectBackupRepository.kt index dbde9dc14..34d9fad67 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/projectbackup/ProjectBackupRepository.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/projectbackup/ProjectBackupRepository.kt @@ -1,5 +1,6 @@ package com.darkrockstudios.apps.hammer.common.data.projectbackup +import com.darkrockstudios.apps.hammer.base.validate.validateProjectName import com.darkrockstudios.apps.hammer.common.data.ProjectDef import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettingsStore import com.darkrockstudios.apps.hammer.common.data.projectsrepository.ProjectsRepository @@ -10,6 +11,8 @@ import com.darkrockstudios.apps.hammer.common.util.format import com.darkrockstudios.apps.hammer.common.util.zip.unzipToDirectory import com.darkrockstudios.apps.hammer.common.util.zip.zipDirectory import io.github.aakira.napier.Napier +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.datetime.* import okio.FileNotFoundException import okio.FileSystem @@ -26,6 +29,8 @@ open class ProjectBackupRepository( protected val clock: Clock ) : KoinComponent { + private val restoreLock = Mutex() + fun getBackupsDirectory(): HPath { val dir = (projectsRepository.getProjectsDirectory().toOkioPath() / BACKUP_DIRECTORY) @@ -175,23 +180,92 @@ open class ProjectBackupRepository( } open suspend fun restoreBackup(backupDef: ProjectBackupDef, targetDir: HPath): Boolean { - return try { - val targetOkioPath = targetDir.toOkioPath() - fileSystem.deleteRecursively(targetOkioPath) - fileSystem.createDirectories(targetOkioPath) + val targetOkioPath = targetDir.toOkioPath() + val staging = getBackupsDirectory().toOkioPath() / RESTORE_STAGING_DIRECTORY + val extracted = staging / "new" + val displaced = staging / "old" - unzipToDirectory( - fileSystem = fileSystem, - zipPath = backupDef.path.toOkioPath(), - destinationDirectory = targetOkioPath - ) - true - } catch (e: Exception) { - Napier.e("Failed to restore backup: ${backupDef.path.name}", e) - false + return restoreLock.withLock { + try { + fileSystem.deleteRecursively(staging) + fileSystem.createDirectories(extracted) + + unzipToDirectory( + fileSystem = fileSystem, + zipPath = backupDef.path.toOkioPath(), + destinationDirectory = extracted + ) + + swapIntoPlace(archiveRoot(extracted), targetOkioPath, displaced) + true + } catch (e: Exception) { + Napier.e("Failed to restore backup: ${backupDef.path.name}", e) + false + } finally { + try { + fileSystem.deleteRecursively(staging) + } catch (e: IOException) { + Napier.w("Failed to clean up restore staging directory", e) + } + } } } + // The current project is moved aside, not deleted, so it can be put back if the swap fails. + private fun swapIntoPlace(restored: Path, target: Path, displaced: Path) { + val hadExisting = fileSystem.exists(target) + if (hadExisting) { + fileSystem.atomicMove(target, displaced) + } else { + target.parent?.let { fileSystem.createDirectories(it) } + } + + try { + fileSystem.atomicMove(restored, target) + } catch (e: IOException) { + if (hadExisting) fileSystem.atomicMove(displaced, target) + throw e + } + } + + // Backup archives hold the project inside a single directory named after the project. + private fun archiveRoot(extracted: Path): Path { + val onlyChild = fileSystem.list(extracted).singleOrNull() ?: return extracted + return if (fileSystem.metadata(onlyChild).isDirectory) onlyChild else extracted + } + + /** + * Every project that has at least one backup, including projects that no longer exist on + * disk, so a deleted project can still be restored from its backups. + */ + fun getBackedUpProjects(): List { + val backupKeys = fileSystem.list(getBackupsDirectory().toOkioPath()) + .mapNotNull { FILE_NAME_PATTERN.matchEntire(it.name)?.groups?.get(1)?.value } + .toSet() + + val projects = projectsRepository.getProjects() + val existing = projects.filter { project -> + backupKeysForProject(project.name).any { it in backupKeys } + } + + val claimedKeys = projects.flatMap { backupKeysForProject(it.name) }.toSet() + val missingNames = (backupKeys - claimedKeys) + .map { ProjectsRepository.decodeFromFilename(it) } + .filter { validateProjectName(it) } + .distinct() + + // A legacy-named backup belongs to the project whose current name also has backups. + val legacyAliases = missingNames + .filter { legacyBackupName(it) != it } + .map { legacyBackupName(it) } + .toSet() + val missing = missingNames + .filterNot { it in legacyAliases } + .map { projectsRepository.getProjectDefinition(it) } + + return existing + missing + } + private fun localDateTime(dateTimeStr: String): LocalDateTime { val match = DATE_PATTERN.matchEntire(dateTimeStr) ?: throw IllegalArgumentException("Failed to parse date time") val year = match.groupValues[1].toInt() @@ -213,6 +287,7 @@ open class ProjectBackupRepository( companion object { const val BACKUP_DIRECTORY = ".backups" + private const val RESTORE_STAGING_DIRECTORY = ".restore" val FILE_NAME_PATTERN = Regex("^(.+)-(\\d{4}-\\d{2}-\\d{2}T\\d+Z)\\.zip$") val DATE_PATTERN = Regex("^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2})(\\d{2})(\\d{2})Z$") } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/references/AutoConfirmReferencesUseCase.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/references/AutoConfirmReferencesUseCase.kt index 439e54071..cd2ff7b27 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/references/AutoConfirmReferencesUseCase.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/references/AutoConfirmReferencesUseCase.kt @@ -1,6 +1,7 @@ package com.darkrockstudios.apps.hammer.common.data.references import com.darkrockstudios.apps.hammer.common.data.SceneItem +import com.darkrockstudios.apps.hammer.common.data.UpdateSource import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.SceneEditorService import io.github.aakira.napier.Napier @@ -43,6 +44,6 @@ class AutoConfirmReferencesUseCase( ) val newConfirmed = metadata.confirmedReferences + newRefs.map { it.entryId } val newMetadata = metadata.copy(confirmedReferences = newConfirmed) - sceneEditor.storeMetadata(scrubInvalidReferences(newMetadata), sceneItem.id) + sceneEditor.storeMetadata(scrubInvalidReferences(newMetadata), sceneItem.id, UpdateSource.Repository) } } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/references/BackfillEntryReferencesUseCase.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/references/BackfillEntryReferencesUseCase.kt index 85a97f461..4466bc814 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/references/BackfillEntryReferencesUseCase.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/references/BackfillEntryReferencesUseCase.kt @@ -1,5 +1,6 @@ package com.darkrockstudios.apps.hammer.common.data.references +import com.darkrockstudios.apps.hammer.common.data.UpdateSource import com.darkrockstudios.apps.hammer.common.data.encyclopediarepository.entry.EntryContent import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.SceneEditorService @@ -40,7 +41,7 @@ class BackfillEntryReferencesUseCase( val newMetadata = metadata.copy( confirmedReferences = metadata.confirmedReferences + entry.id, ) - sceneEditor.storeMetadata(scrubInvalidReferences(newMetadata), sceneId) + sceneEditor.storeMetadata(scrubInvalidReferences(newMetadata), sceneId, UpdateSource.Repository) } } } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/references/CleanupReferencesOnEntryDeleteUseCase.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/references/CleanupReferencesOnEntryDeleteUseCase.kt index eb6727513..9cac94607 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/references/CleanupReferencesOnEntryDeleteUseCase.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/references/CleanupReferencesOnEntryDeleteUseCase.kt @@ -1,5 +1,6 @@ package com.darkrockstudios.apps.hammer.common.data.references +import com.darkrockstudios.apps.hammer.common.data.UpdateSource import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.SceneEditorService /** @@ -43,7 +44,7 @@ class CleanupReferencesOnEntryDeleteUseCase( confirmedReferences = newConfirmed, dismissedReferences = newDismissed, ) - sceneEditor.storeMetadata(scrubInvalidReferences(newMetadata), sceneId) + sceneEditor.storeMetadata(scrubInvalidReferences(newMetadata), sceneId, UpdateSource.Repository) } } } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/references/NameMatcher.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/references/NameMatcher.kt index aeb592244..ce58129b5 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/references/NameMatcher.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/references/NameMatcher.kt @@ -33,10 +33,12 @@ class WholeWordCaseSensitiveMatcher : NameMatcher { } companion object { - // Reject only adjacent letters/digits/underscores — punctuation and whitespace are fine - // on either side. This is more permissive than `\b`, which would fail when the name - // itself starts or ends with a non-word character (e.g. an alias "Mr. Smith"). - private const val NO_WORD_BEFORE = "(? = sceneBuffersLock.withLock { + fun getDirtyBufferScenes(): List = sceneBuffersLock.withLock { sceneBuffers.filter { it.value.dirty }.map { it.value.content.scene } } @@ -216,18 +216,18 @@ class SceneContentRepository( * Drops the in-memory buffer + temp file and reloads from disk. Returns the reloaded buffer * (so the caller can re-establish a writing baseline), or null if nothing was buffered. */ - fun discardBuffer(sceneItem: SceneItem, scenePath: HPath): SceneBuffer? { + fun discardBuffer(sceneItem: SceneItem, scenePath: HPath): SceneBuffer? = + if (dropBuffer(sceneItem)) loadBuffer(sceneItem, scenePath) else null + + /** Drops the in-memory buffer + temp file without reloading. Returns whether one was buffered. */ + fun dropBuffer(sceneItem: SceneItem): Boolean { val wasPresent = sceneBuffersLock.withLock { val removed = sceneBuffers.remove(sceneItem.id) != null if (removed) _dirtyBufferIds.value = getDirtyBufferIds() removed } - return if (wasPresent) { - clearTempScene(sceneItem) - loadBuffer(sceneItem, scenePath) - } else { - null - } + if (wasPresent) clearTempScene(sceneItem) + return wasPresent } /** Stores all currently-dirty buffers to disk via [persist], one per dirty scene. */ @@ -268,10 +268,12 @@ class SceneContentRepository( } ?: Napier.w("Timed out waiting for temp scene saves on close; forcing shutdown.") } editorScope.cancel("Editor Closed") - // During a proper shutdown, we clear any remaining temp buffers that haven't been saved yet - sceneDatasource.getSceneTempBufferContents().forEach { - clearTempScene(it.scene) - } + // Thar be dragons: a scope can close without the user choosing Save or Discard, and a dirty + // buffer's temp file is the only durable copy of that work. Only clean buffers are cleared. + val dirtyIds = getDirtyBufferIds() + sceneDatasource.getSceneTempBufferContents() + .filterNot { it.scene.id in dirtyIds } + .forEach { clearTempScene(it.scene) } Napier.i("SceneContentRepository Closed.") } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sceneeditorrepository/SceneEditorService.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sceneeditorrepository/SceneEditorService.kt index b2d582a35..a2c4d7d4a 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sceneeditorrepository/SceneEditorService.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sceneeditorrepository/SceneEditorService.kt @@ -118,6 +118,8 @@ class SceneEditorService( statisticsRepository.markDirty() referenceIndexRepository.markSceneDeleted(scene.id) writingSessionTracker.forgetBaseline(scene.id) + // A leftover temp file would attach itself to a later scene that reuses this id. + sceneContentRepository.dropBuffer(scene) } return deleted } @@ -148,9 +150,14 @@ class SceneEditorService( /** * Orchestrates a scene-metadata write: mark the scene's current identity for sync, persist * via [SceneMetadataRepository], then apply the reference-index delta if the confirmed set - * changed. The repo handles only the pure persist + flow emission. + * changed. The repo handles only the pure persist + flow emission. A write applying server + * state ([UpdateSource.Sync]) is not a local change, so it is not marked. */ - suspend fun storeMetadata(metadata: SceneMetadata, sceneId: Int) { + suspend fun storeMetadata( + metadata: SceneMetadata, + sceneId: Int, + source: UpdateSource = UpdateSource.Editor, + ) { // A flush can arrive after the scene was deleted (e.g. the metadata panel's save on // destroy). Nothing to persist for a scene that no longer exists. val scene = sceneEditorRepository.getSceneItemFromIdIncludingArchived(sceneId) @@ -158,8 +165,8 @@ class SceneEditorService( val previous = sceneMetadataRepository.loadRawMetadata(sceneId) - sceneEditorRepository.markSceneForSynchronization(scene) - sceneMetadataRepository.storeMetadata(metadata, sceneId) + if (source != UpdateSource.Sync) sceneEditorRepository.markSceneForSynchronization(scene) + sceneMetadataRepository.storeMetadata(metadata, sceneId, source) val previousConfirmed = previous?.confirmedReferences.orEmpty() val newConfirmed = metadata.confirmedReferences @@ -183,9 +190,21 @@ class SceneEditorService( return false } + // Outside the active tree an archived scene still has its file; a deleted one has nowhere to save. + val scenePath = if (sceneEditorRepository.getSceneItemFromId(sceneItem.id) != null) { + sceneEditorRepository.resolveSceneContentPath(sceneItem) + } else { + sceneEditorRepository.resolveScenePathFromFilesystemIncludingArchived(sceneItem.id) + } + if (scenePath == null) { + Napier.w { "Dropping buffer for scene ${sceneItem.id}, it no longer exists" } + sceneContentRepository.dropBuffer(sceneItem) + writingSessionTracker.forgetBaseline(sceneItem.id) + return false + } + sceneEditorRepository.markSceneForSynchronization(sceneItem) - val scenePath = sceneEditorRepository.resolveSceneContentPath(sceneItem) val success = sceneContentRepository.persistBuffer(buffer, scenePath) if (success) { @@ -208,6 +227,12 @@ class SceneEditorService( } fun discardSceneBuffer(sceneDef: SceneItem) { + // An archived or deleted scene has no active path to reload from. + if (sceneEditorRepository.getSceneItemFromId(sceneDef.id) == null) { + sceneContentRepository.dropBuffer(sceneDef) + writingSessionTracker.forgetBaseline(sceneDef.id) + return + } val scenePath = sceneEditorRepository.resolveSceneContentPath(sceneDef) val reloaded = sceneContentRepository.discardBuffer(sceneDef, scenePath) if (reloaded != null) { @@ -215,6 +240,11 @@ class SceneEditorService( } } + /** Reverts every dirty buffer to its on-disk content. */ + fun discardAllBuffers() { + sceneContentRepository.getDirtyBufferScenes().forEach { discardSceneBuffer(it) } + } + fun onContentChanged(content: SceneContent, source: UpdateSource) = sceneContentRepository.onContentChanged(content, source) @@ -239,7 +269,7 @@ class SceneEditorService( ) { tree, dirtyBufferIds -> SceneSummary(tree, dirtyBufferIds) } .shareIn(serviceScope, SharingStarted.Eagerly, replay = 1) - val metadataUpdateFlow: SharedFlow> + val metadataUpdateFlow: SharedFlow get() = sceneMetadataRepository.metadataUpdateFlow fun getSceneSummaries(): SceneSummary = SceneSummary( @@ -309,6 +339,15 @@ class SceneEditorService( suspend fun loadSceneMetadata(sceneId: Int): SceneMetadata = sceneMetadataRepository.loadSceneMetadata(sceneId) + /** + * The scene's live text (unsaved buffer first, then disk) with no caching and no + * writing-session baseline. Call off the main thread. + */ + fun getCurrentSceneContentOrNull(sceneItem: SceneItem): String? = + runCatching { sceneContentRepository.getCurrentSceneContent(sceneItem) } + .onFailure { Napier.w(it) { "Failed to read content for scene: ${sceneItem.id}" } } + .getOrNull() + fun loadSceneMarkdownRaw(sceneItem: SceneItem, scenePath: HPath? = null): String = if (scenePath != null) { sceneEditorRepository.loadSceneMarkdownRaw(sceneItem, scenePath) diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sceneeditorrepository/SceneMetadataRepository.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sceneeditorrepository/SceneMetadataRepository.kt index b902efc8b..d9e2cd0c1 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sceneeditorrepository/SceneMetadataRepository.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sceneeditorrepository/SceneMetadataRepository.kt @@ -3,6 +3,7 @@ package com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository import com.darkrockstudios.apps.hammer.Res import com.darkrockstudios.apps.hammer.common.components.storyeditor.metadata.ProjectMetadata import com.darkrockstudios.apps.hammer.common.data.ProjectDef +import com.darkrockstudios.apps.hammer.common.data.UpdateSource import com.darkrockstudios.apps.hammer.common.data.projectmetadata.ProjectMetadataDatasource import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.scenemetadata.SceneMetadata import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.scenemetadata.SceneMetadataDatasource @@ -14,6 +15,12 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.first import kotlin.time.Clock +data class SceneMetadataUpdate( + val sceneId: Int, + val metadata: SceneMetadata, + val source: UpdateSource, +) + /** * Owns scene metadata (per-scene outline/notes/draft/references/timestamps) and the project * metadata. @@ -36,11 +43,11 @@ class SceneMetadataRepository( onBufferOverflow = BufferOverflow.DROP_OLDEST ) - private val _metadataUpdateFlow = MutableSharedFlow>( + private val _metadataUpdateFlow = MutableSharedFlow( extraBufferCapacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST ) - val metadataUpdateFlow: SharedFlow> = _metadataUpdateFlow + val metadataUpdateFlow: SharedFlow = _metadataUpdateFlow /** Loads and caches the project metadata. Must be called once during project-scope init. */ suspend fun initialize() { @@ -69,9 +76,13 @@ class SceneMetadataRepository( * Pure persist + emit. Sync-marking and reference-index delta are the caller's concern * (see [SceneEditorService.storeMetadata]). */ - suspend fun storeMetadata(metadata: SceneMetadata, sceneId: Int) { + suspend fun storeMetadata( + metadata: SceneMetadata, + sceneId: Int, + source: UpdateSource = UpdateSource.Editor, + ) { sceneMetadataDatasource.storeMetadata(metadata, sceneId) - _metadataUpdateFlow.tryEmit(sceneId to metadata) + _metadataUpdateFlow.tryEmit(SceneMetadataUpdate(sceneId, metadata, source)) } /** Stamp a scene's last-edited timestamp (backfilling `created` for older scenes). */ @@ -86,6 +97,6 @@ class SceneMetadataRepository( ) sceneMetadataDatasource.storeMetadata(updated, sceneId) // Refresh open editors so their snapshot has the new timestamps. - _metadataUpdateFlow.tryEmit(sceneId to updated) + _metadataUpdateFlow.tryEmit(SceneMetadataUpdate(sceneId, updated, UpdateSource.Editor)) } } diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sync/projectsync/synchronizers/ClientSceneSynchronizer.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sync/projectsync/synchronizers/ClientSceneSynchronizer.kt index 727703a0e..45f491bab 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sync/projectsync/synchronizers/ClientSceneSynchronizer.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sync/projectsync/synchronizers/ClientSceneSynchronizer.kt @@ -12,7 +12,6 @@ import com.darkrockstudios.apps.hammer.common.data.SceneItem import com.darkrockstudios.apps.hammer.common.data.UpdateSource import com.darkrockstudios.apps.hammer.common.data.drafts.SceneDraftRepository import com.darkrockstudios.apps.hammer.common.data.projectmetadata.ProjectMetadataDatasource -import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.SceneContentRepository import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.SceneRepository import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.SceneEditorService import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.findById @@ -21,7 +20,6 @@ import com.darkrockstudios.apps.hammer.common.data.sync.projectsync.* import com.darkrockstudios.apps.hammer.common.server.ServerProjectApi import com.darkrockstudios.apps.hammer.common.util.StrRes import io.github.aakira.napier.Napier -import kotlinx.coroutines.delay class ClientSceneSynchronizer( projectDef: ProjectDef, @@ -76,9 +74,8 @@ class ClientSceneSynchronizer( ) } - override suspend fun prepareForSync() { - sceneEditorService.storeAllBuffers() - } + /** Unsaved buffers are left alone: whether they join the project is the user's call, not sync's. */ + override suspend fun prepareForSync() = Unit override suspend fun ownsEntity(id: Int): Boolean { return sceneEditorRepository.getSceneItemFromId(id) != null @@ -183,7 +180,7 @@ class ClientSceneSynchronizer( val content = SceneContent(sceneItem, serverEntity.content) if (sceneEditorRepository.storeSceneMarkdownRaw(content, scenePath)) { val updatedMetadata = mergeServerMetadata(serverEntity) - sceneEditorService.storeMetadata(updatedMetadata, serverEntity.id) + sceneEditorService.storeMetadata(updatedMetadata, serverEntity.id, UpdateSource.Sync) // Finally, log our success, and update the running apps data onLog(syncLogI(strRes.get(Res.string.sync_scene_downloading, id), projectDef)) @@ -293,7 +290,7 @@ class ClientSceneSynchronizer( sceneEditorRepository.storeSceneMarkdownRaw(content, scenePath) val updatedMetadata = mergeServerMetadata(serverEntity) - sceneEditorService.storeMetadata(updatedMetadata, serverEntity.id) + sceneEditorService.storeMetadata(updatedMetadata, serverEntity.id, UpdateSource.Sync) } } else { // Scene doesn't exist locally at all - create it directly in archive @@ -326,9 +323,12 @@ class ClientSceneSynchronizer( // Those fields are hashed, so backfill them with the project's creation time: the resulting // hash divergence from the server drives the heal upload in [EntityTransferOperation] that // enriches the server's copy, converging both sides on a non-null value. - private fun mergeServerMetadata(serverEntity: ApiProjectEntity.SceneEntity): SceneMetadata { + // The draft name is never synced, so the local one is carried over. + private suspend fun mergeServerMetadata(serverEntity: ApiProjectEntity.SceneEntity): SceneMetadata { val projectCreated = projectMetadataDatasource.loadMetadata(projectDef).info.created + val local = sceneEditorService.loadSceneMetadata(serverEntity.id) return SceneMetadata( + currentDraftName = local.currentDraftName, notes = serverEntity.notes, outline = serverEntity.outline, confirmedReferences = serverEntity.confirmedReferences, @@ -353,12 +353,7 @@ class ClientSceneSynchronizer( override suspend fun finalizeSync() { sceneEditorRepository.rationalizeTree() sceneEditorRepository.cleanupSceneOrder() - - // Wait for buffers to propagate before we save them - delay(SceneContentRepository.BUFFER_COOL_DOWN * 0.25) - sceneEditorRepository.forceSceneListReload() - sceneEditorService.storeAllBuffers() } override fun getEntityType() = EntityType.Scene diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/tree/SceneChapters.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/tree/SceneChapters.kt new file mode 100644 index 000000000..e684a0ea6 --- /dev/null +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/tree/SceneChapters.kt @@ -0,0 +1,16 @@ +package com.darkrockstudios.apps.hammer.common.data.tree + +import com.darkrockstudios.apps.hammer.common.data.SceneItem + +/** A top-level node of the scene tree and the scenes it contributes to the story, in story order. */ +data class ChapterScenes(val chapter: SceneItem, val scenes: List) + +/** A top-level scene is a one-scene chapter; a group is flattened depth-first to its descendant scenes. */ +fun ImmutableTree.collectChapters(): List = root.children.map { node -> + val scenes = if (node.value.type == SceneItem.Type.Scene) { + listOf(node.value) + } else { + node.filter { it.value.type == SceneItem.Type.Scene }.map { it.value } + } + ChapterScenes(chapter = node.value, scenes = scenes) +} diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/dependencyinjection/mainModule.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/dependencyinjection/mainModule.kt index 0365f36d1..dce6d2da8 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/dependencyinjection/mainModule.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/dependencyinjection/mainModule.kt @@ -9,7 +9,9 @@ import com.darkrockstudios.apps.hammer.common.components.projecthome.ImportStory import com.darkrockstudios.apps.hammer.common.data.ProjectDef import com.darkrockstudios.apps.hammer.common.data.account.AccountReauthUseCase import com.darkrockstudios.apps.hammer.common.data.account.AccountUseCase -import com.darkrockstudios.apps.hammer.common.data.sync.accountsync.SyncAccountUseCase +import com.darkrockstudios.apps.hammer.common.data.changelog.ChangelogDatasource +import com.darkrockstudios.apps.hammer.common.data.changelog.ChangelogRepository +import com.darkrockstudios.apps.hammer.common.data.changelog.ResourceChangelogDatasource import com.darkrockstudios.apps.hammer.common.data.drafts.SceneDraftRepository import com.darkrockstudios.apps.hammer.common.data.drafts.SceneDraftsDatasource import com.darkrockstudios.apps.hammer.common.data.encyclopediarepository.EncyclopediaDatasource @@ -33,8 +35,6 @@ import com.darkrockstudios.apps.hammer.common.data.ideasrepository.IdeasDatasour import com.darkrockstudios.apps.hammer.common.data.ideasrepository.IdeasRepository import com.darkrockstudios.apps.hammer.common.data.ideasrepository.PromoteIdeaUseCase import com.darkrockstudios.apps.hammer.common.data.ideasrepository.StoryIdeaCodec -import com.darkrockstudios.apps.hammer.common.data.sync.ideassync.ClientIdeasSynchronizer -import com.darkrockstudios.apps.hammer.common.data.sync.ideassync.IdeasSyncDatasource import com.darkrockstudios.apps.hammer.common.data.importer.MarkdownStoryImporter import com.darkrockstudios.apps.hammer.common.data.importer.RtfStoryImporter import com.darkrockstudios.apps.hammer.common.data.importer.StoryImporterRegistry @@ -50,6 +50,7 @@ import com.darkrockstudios.apps.hammer.common.data.projectstatistics.ProjectStat import com.darkrockstudios.apps.hammer.common.data.projectstatistics.StatisticsDatasource import com.darkrockstudios.apps.hammer.common.data.projectstatistics.StatisticsRepository import com.darkrockstudios.apps.hammer.common.data.projectstatistics.StatisticsService +import com.darkrockstudios.apps.hammer.common.data.protocolmismatch.ProtocolMismatchRepository import com.darkrockstudios.apps.hammer.common.data.references.AutoConfirmReferencesUseCase import com.darkrockstudios.apps.hammer.common.data.references.BackfillEntryReferencesUseCase import com.darkrockstudios.apps.hammer.common.data.references.CleanupReferencesOnEntryDeleteUseCase @@ -69,6 +70,9 @@ import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.SceneMe import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.SceneRepository import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.scenemetadata.SceneMetadataDatasource import com.darkrockstudios.apps.hammer.common.data.sync.accountsync.ClientAccountSynchronizer +import com.darkrockstudios.apps.hammer.common.data.sync.accountsync.SyncAccountUseCase +import com.darkrockstudios.apps.hammer.common.data.sync.ideassync.ClientIdeasSynchronizer +import com.darkrockstudios.apps.hammer.common.data.sync.ideassync.IdeasSyncDatasource import com.darkrockstudios.apps.hammer.common.data.sync.projectsync.ClientProjectSynchronizer import com.darkrockstudios.apps.hammer.common.data.sync.projectsync.EntitySynchronizers import com.darkrockstudios.apps.hammer.common.data.sync.projectsync.SyncDataDatasource @@ -95,12 +99,8 @@ import com.darkrockstudios.apps.hammer.common.data.tagindex.BuildTagIndexUseCase import com.darkrockstudios.apps.hammer.common.data.tagindex.TagIndexService import com.darkrockstudios.apps.hammer.common.data.timelinerepository.TimeLineDatasource import com.darkrockstudios.apps.hammer.common.data.timelinerepository.TimeLineRepository -import com.darkrockstudios.apps.hammer.common.data.changelog.ChangelogDatasource -import com.darkrockstudios.apps.hammer.common.data.changelog.ChangelogRepository -import com.darkrockstudios.apps.hammer.common.data.changelog.ResourceChangelogDatasource import com.darkrockstudios.apps.hammer.common.data.versioncheck.GithubVersionCheckDataSource import com.darkrockstudios.apps.hammer.common.data.versioncheck.VersionCheckDataSource -import com.darkrockstudios.apps.hammer.common.data.protocolmismatch.ProtocolMismatchRepository import com.darkrockstudios.apps.hammer.common.data.versioncheck.VersionCheckRepository import com.darkrockstudios.apps.hammer.common.data.writingactivity.WritingActivityDatasource import com.darkrockstudios.apps.hammer.common.data.writingactivity.WritingActivityRepository @@ -116,8 +116,8 @@ import com.darkrockstudios.apps.hammer.common.platformMainDispatcher import com.darkrockstudios.apps.hammer.common.server.ProjectDataApi import com.darkrockstudios.apps.hammer.common.server.ServerAccountApi import com.darkrockstudios.apps.hammer.common.server.ServerAdminApi -import com.darkrockstudios.apps.hammer.common.server.ServerProjectApi import com.darkrockstudios.apps.hammer.common.server.ServerIdeasApi +import com.darkrockstudios.apps.hammer.common.server.ServerProjectApi import com.darkrockstudios.apps.hammer.common.server.ServerProjectsApi import com.darkrockstudios.apps.hammer.common.server.WritingActivityApi import com.darkrockstudios.apps.hammer.common.spellcheck.ProjectDictionaryService @@ -134,10 +134,10 @@ import okio.Path.Companion.toPath import org.koin.core.qualifier.named import org.koin.dsl.bind import org.koin.dsl.module -import org.koin.plugin.module.dsl.single +import org.koin.plugin.module.dsl.create import org.koin.plugin.module.dsl.factory import org.koin.plugin.module.dsl.scoped -import org.koin.plugin.module.dsl.create +import org.koin.plugin.module.dsl.single import kotlin.time.Clock const val DISPATCHER_MAIN = "main-dispatcher" @@ -266,7 +266,7 @@ val mainModule = module { single { StoryImporterRegistry(listOf(MarkdownStoryImporter(), RtfStoryImporter())) } scope { - scoped { get().projectDef } + scoped { checkNotNull(getSource()).projectDef } scoped() scoped() diff --git a/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/RtfStoryRendererTest.kt b/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/RtfStoryRendererTest.kt index 736593917..3c781a588 100644 --- a/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/RtfStoryRendererTest.kt +++ b/common/src/commonTest/kotlin/com/darkrockstudios/apps/hammer/common/components/projecthome/RtfStoryRendererTest.kt @@ -2,6 +2,8 @@ package com.darkrockstudios.apps.hammer.common.components.projecthome import com.darkrockstudios.apps.hammer.base.http.projectdata.ProjectData import com.darkrockstudios.apps.hammer.base.http.projectdata.ProjectTheme +import com.darkrockstudios.apps.hammer.common.data.ExportFont +import com.darkrockstudios.apps.hammer.common.data.ExportOptions import com.darkrockstudios.apps.hammer.common.data.ImportOptions import com.darkrockstudios.apps.hammer.common.data.RtfSplitStrategy import com.darkrockstudios.apps.hammer.common.data.importer.PreviewItem @@ -17,6 +19,7 @@ class RtfStoryRendererTest { chapters: List, projectName: String = "Test Project", projectData: ProjectData = ProjectData(authorName = "Test Author"), + options: ExportOptions = ExportOptions(), ): String { val buffer = Buffer() val author = projectData.authorName?.takeIf { it.isNotBlank() } @@ -26,6 +29,7 @@ class RtfStoryRendererTest { projectData = projectData, chapters = chapters, strings = ExportStrings(contentsTitle = "Contents", authorByline = author?.let { "by $it" }), + options = options, ) return buffer.readByteArray().decodeToString() } @@ -116,12 +120,13 @@ class RtfStoryRendererTest { } @Test - fun `chapters are numbered and bookmarked for the contents links`() { + fun `chapters are bookmarked for the contents links and numbered on request`() { val rtf = render( listOf( StoryChapter("Alpha", "First."), StoryChapter("Beta", "Second."), - ) + ), + options = ExportOptions(numberChapters = true), ) assertTrue(rtf.contains("1. Alpha"), "First chapter should be numbered") @@ -130,6 +135,42 @@ class RtfStoryRendererTest { assertTrue(rtf.contains("HYPERLINK \\\\l \"chapter1\""), "Contents should link to chapter bookmarks") } + @Test + fun `chapters are unnumbered by default`() { + val rtf = render( + listOf( + StoryChapter("Prologue", "First."), + StoryChapter("Chapter 1", "Second."), + ), + ) + + assertTrue(rtf.contains("Prologue"), rtf) + assertTrue(!rtf.contains("1. Prologue"), "Chapter titles should not be numbered") + assertTrue(!rtf.contains("2. Chapter 1"), "Chapter titles should not be numbered") + } + + @Test + fun `the chosen font is the document font`() { + val rtf = render( + listOf(StoryChapter("Alpha", "Some text.")), + options = ExportOptions(font = ExportFont.CourierNew), + ) + + assertTrue(rtf.contains("Courier New"), rtf) + assertTrue(!rtf.contains("Georgia"), rtf) + } + + @Test + fun `blank lines between paragraphs can be skipped`() { + val chapters = listOf(StoryChapter("Alpha", "First passage.\n\nSecond passage.")) + + val kept = render(chapters) + assertEquals(3, Regex("""\\fi360""").findAll(kept).count(), kept) + + val skipped = render(chapters, options = ExportOptions(keepBlankLines = false)) + assertEquals(2, Regex("""\\fi360""").findAll(skipped).count(), skipped) + } + @Test fun `markdown emphasis becomes RTF bold and italic`() { val rtf = render(listOf(StoryChapter("Alpha", "A **bold** and _italic_ word."))) diff --git a/common/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/accountsettings/DesktopPlatformSettings.kt b/common/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/accountsettings/DesktopPlatformSettings.kt index 211c07c56..61f04d41b 100644 --- a/common/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/accountsettings/DesktopPlatformSettings.kt +++ b/common/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/accountsettings/DesktopPlatformSettings.kt @@ -8,9 +8,13 @@ interface DesktopPlatformSettings : PlatformSettings { val state: Value fun setProjectsDir(path: String) + fun setAutomaticUpdateChecks(value: Boolean) @Serializable data class PlatformState( val projectsDir: HPath, + /** False on builds that cannot update themselves, which then show no update setting. */ + val updateChecksSupported: Boolean = false, + val automaticUpdateChecks: Boolean = true, ) } diff --git a/common/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/accountsettings/PlatformSettingsComponent.desktop.kt b/common/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/accountsettings/PlatformSettingsComponent.desktop.kt index b83437391..ec4e146b6 100644 --- a/common/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/accountsettings/PlatformSettingsComponent.desktop.kt +++ b/common/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectselection/accountsettings/PlatformSettingsComponent.desktop.kt @@ -5,6 +5,8 @@ import com.arkivanov.decompose.value.Value import com.arkivanov.decompose.value.getAndUpdate import com.darkrockstudios.apps.hammer.common.components.SavableComponent import com.darkrockstudios.apps.hammer.common.components.savableState +import com.darkrockstudios.apps.hammer.common.data.appupdate.AppUpdateState +import com.darkrockstudios.apps.hammer.common.data.appupdate.AppUpdater import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettingsStore import com.darkrockstudios.apps.hammer.common.data.migrator.DataMigrator import com.darkrockstudios.apps.hammer.common.data.projectsrepository.ProjectsRepository @@ -27,10 +29,13 @@ class DesktopPlatformSettingsComponent(componentContext: ComponentContext) : Des private val globalSettingsStore: GlobalSettingsStore by inject() private val projectsRepository: ProjectsRepository by inject() private val sandboxFileAccess: SandboxFileAccess by inject() + private val appUpdater: AppUpdater by inject() private val _state by savableState { DesktopPlatformSettings.PlatformState( projectsDir = projectsRepository.getProjectsDirectory(), + updateChecksSupported = appUpdater.state.value !is AppUpdateState.Unsupported, + automaticUpdateChecks = globalSettingsStore.globalSettings.automaticUpdateChecks, ) } @@ -50,6 +55,7 @@ class DesktopPlatformSettingsComponent(componentContext: ComponentContext) : Des val projectsPath = settings.projectsDirectory.toPath().toHPath() it.copy( projectsDir = projectsPath, + automaticUpdateChecks = settings.automaticUpdateChecks, ) } } @@ -87,4 +93,10 @@ class DesktopPlatformSettingsComponent(componentContext: ComponentContext) : Des } } } + + override fun setAutomaticUpdateChecks(value: Boolean) { + scope.launch { + globalSettingsStore.updateSettings { it.copy(automaticUpdateChecks = value) } + } + } } diff --git a/common/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/data/changelog/InAppChangelogSupport.kt b/common/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/data/changelog/InAppChangelogSupport.kt index 7a7cfa84a..a8120eb00 100644 --- a/common/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/data/changelog/InAppChangelogSupport.kt +++ b/common/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/data/changelog/InAppChangelogSupport.kt @@ -1,6 +1,9 @@ package com.darkrockstudios.apps.hammer.common.data.changelog -import com.darkrockstudios.apps.hammer.common.IS_APP_STORE +import com.darkrockstudios.apps.hammer.base.DistributionChannel + +actual val supportsInAppChangelog: Boolean = supportsInAppChangelog(DistributionChannel.current) // Only the Mac App Store flavor faces review; direct downloads keep the dialog. -actual val supportsInAppChangelog: Boolean = !IS_APP_STORE +internal fun supportsInAppChangelog(channel: DistributionChannel): Boolean = + channel != DistributionChannel.MAC_APP_STORE diff --git a/common/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/platform.kt b/common/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/platform.kt index 723cd486b..8fcf99910 100644 --- a/common/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/platform.kt +++ b/common/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/platform.kt @@ -10,15 +10,6 @@ import kotlin.coroutines.CoroutineContext var appDirs: AppDirs = AppDirsFactory.getInstance() -/** - * True when the desktop build is the Mac App Store flavor. Set via - * `-Dhammer.app.store=true` JVM arg (wired through Gradle when - * `-PmacOsAppStoreRelease=true` is passed). Sandbox-only code paths - * (e.g. fixed projects directory) gate on this. - */ -val IS_APP_STORE: Boolean = - System.getProperty("hammer.app.store", "false").toBoolean() - actual fun getPlatformName(): String { return "Desktop" } diff --git a/common/src/desktopTest/kotlin/com/darkrockstudios/apps/hammer/common/data/projectbackup/ProjectBackupRepositoryTest.kt b/common/src/desktopTest/kotlin/com/darkrockstudios/apps/hammer/common/data/projectbackup/ProjectBackupRepositoryTest.kt index 5f429c533..92d42ef79 100644 --- a/common/src/desktopTest/kotlin/com/darkrockstudios/apps/hammer/common/data/projectbackup/ProjectBackupRepositoryTest.kt +++ b/common/src/desktopTest/kotlin/com/darkrockstudios/apps/hammer/common/data/projectbackup/ProjectBackupRepositoryTest.kt @@ -14,6 +14,7 @@ import okio.FileSystem import okio.IOException import okio.Path import okio.Path.Companion.toPath +import okio.ForwardingFileSystem import okio.fakefilesystem.FakeFileSystem import org.junit.jupiter.api.Test import kotlin.test.assertEquals @@ -377,6 +378,100 @@ class ProjectBackupRepositoryTest { assertEquals("once upon a time", fileSystem.read(restoredScene) { readUtf8() }) } + @Test + fun `restoreBackup puts the project files back at the root of the project directory`() = runTest { + every { clock.now() } returns Instant.parse("2025-12-28T16:29:00Z") + every { projectsRepository.getProjectsDirectory() } returns "/projects".toPath().toHPath() + every { projectsRepository.getProjectDirectory("Test Project") } returns + "/projects/Test Project".toPath().toHPath() + every { globalSettingsStore.globalSettings } returns + GlobalSettings(projectsDirectory = "/projects", maxBackups = 5) + + val projectDir = "/projects/Test Project".toPath() + fileSystem.createDirectories(projectDir / "scenes") + fileSystem.write(projectDir / "project.toml") { writeUtf8("original") } + fileSystem.write(projectDir / "scenes" / "scene.md") { writeUtf8("once upon a time") } + + val repo = realRepo() + val projectDef = ProjectDef("Test Project", projectDir.toHPath()) + val backupDef = repo.createBackup(projectDef) + assertNotNull(backupDef) + + fileSystem.write(projectDir / "project.toml") { writeUtf8("changed") } + + assertTrue(repo.restoreBackup(backupDef, projectDef.path)) + + assertEquals("original", fileSystem.read(projectDir / "project.toml") { readUtf8() }) + assertEquals("once upon a time", fileSystem.read(projectDir / "scenes" / "scene.md") { readUtf8() }) + assertFalse(fileSystem.exists(projectDir / "Test Project"), "Project must not be nested inside itself") + } + + @Test + fun `getBackedUpProjects includes projects that no longer exist`() { + every { projectsRepository.getProjectsDirectory() } returns "/projects".toPath().toHPath() + + val kept = ProjectDef("Kept", "/projects/Kept".toPath().toHPath()) + val noBackups = ProjectDef("No Backups", "/projects/No Backups".toPath().toHPath()) + val deleted = ProjectDef("Deleted", "/projects/Deleted".toPath().toHPath()) + every { projectsRepository.getProjects(any()) } returns listOf(kept, noBackups) + every { projectsRepository.getProjectDefinition("Deleted") } returns deleted + + writeBackupFile("Kept", Instant.parse("2025-12-26T10:00:00Z")) + writeBackupFile("Deleted", Instant.parse("2025-12-26T10:00:00Z")) + writeBackupFile("Deleted", Instant.parse("2025-12-27T10:00:00Z")) + + assertEquals(listOf(kept, deleted), realRepo().getBackedUpProjects()) + } + + @Test + fun `getBackedUpProjects lists a missing project once when it has legacy and current backups`() { + every { projectsRepository.getProjectsDirectory() } returns "/projects".toPath().toHPath() + + val deleted = ProjectDef("My Story", "/projects/My Story".toPath().toHPath()) + every { projectsRepository.getProjects(any()) } returns emptyList() + every { projectsRepository.getProjectDefinition("My Story") } returns deleted + + val legacy = writeBackupFile("My Story", Instant.parse("2025-12-26T10:00:00Z")) + fileSystem.write(legacy.parent!! / "My Story-2025-12-27T100000Z.zip") { writeUtf8("zip") } + + assertEquals(listOf(deleted), realRepo().getBackedUpProjects()) + } + + @Test + fun `restoreBackup leaves the project untouched when it cannot be replaced`() = runTest { + every { clock.now() } returns Instant.parse("2025-12-28T16:29:00Z") + every { projectsRepository.getProjectsDirectory() } returns "/projects".toPath().toHPath() + every { projectsRepository.getProjectDirectory("Test Project") } returns + "/projects/Test Project".toPath().toHPath() + every { globalSettingsStore.globalSettings } returns + GlobalSettings(projectsDirectory = "/projects", maxBackups = 5) + + val projectDir = "/projects/Test Project".toPath() + fileSystem.createDirectories(projectDir) + fileSystem.write(projectDir / "project.toml") { writeUtf8("original") } + + val repo = realRepo() + val projectDef = ProjectDef("Test Project", projectDir.toHPath()) + val backupDef = repo.createBackup(projectDef) + assertNotNull(backupDef) + + fileSystem.write(projectDir / "project.toml") { writeUtf8("changed") } + fileSystem.write(projectDir / "notes.md") { writeUtf8("newer work") } + + val failingSwap = object : ForwardingFileSystem(fileSystem) { + override fun atomicMove(source: Path, target: Path) { + if (target == projectDir && "new" in source.segments) throw IOException("locked") + super.atomicMove(source, target) + } + } + val failingRepo = ProjectBackupRepository(failingSwap, projectsRepository, globalSettingsStore, clock) + + assertFalse(failingRepo.restoreBackup(backupDef, projectDef.path)) + + assertEquals("changed", fileSystem.read(projectDir / "project.toml") { readUtf8() }) + assertEquals("newer work", fileSystem.read(projectDir / "notes.md") { readUtf8() }) + } + @Test fun `restoreBackup returns false when the backup file is missing`() = runTest { every { projectsRepository.getProjectsDirectory() } returns "/projects".toPath().toHPath() diff --git a/common/src/desktopTest/kotlin/components/encyclopedia/EncyclopediaComponentTest.kt b/common/src/desktopTest/kotlin/components/encyclopedia/EncyclopediaComponentTest.kt index 662c8b91e..cf0ec01da 100644 --- a/common/src/desktopTest/kotlin/components/encyclopedia/EncyclopediaComponentTest.kt +++ b/common/src/desktopTest/kotlin/components/encyclopedia/EncyclopediaComponentTest.kt @@ -18,6 +18,7 @@ import com.darkrockstudios.apps.hammer.common.dependencyinjection.ProjectDefScop import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.test.advanceUntilIdle @@ -57,6 +58,7 @@ class EncyclopediaComponentTest : ComponentTest() { encyclopediaService = mockk(relaxed = true) every { encyclopediaService.entryListFlow } returns MutableStateFlow(emptyList()) + every { encyclopediaService.entryChangedFlow } returns MutableSharedFlow() every { encyclopediaService.loadEntry(any()) } returns EntryContainer(entryContent) every { encyclopediaService.findEntryImagePath(any()) } returns null every { encyclopediaService.findEntryImageExtension(any()) } returns null @@ -219,4 +221,37 @@ class EncyclopediaComponentTest : ComponentTest() { assertEquals(entryDef, destination.component.state.value.entryDef) assertEquals(3, comp.stack.value.items.size) } + + @Test + fun `discardUnsaved ends an in-progress entry edit`() = runTest(mainTestDispatcher) { + val comp = newComponent() + context.resume() + advanceUntilIdle() + comp.showViewEntry(entryDef) + advanceUntilIdle() + + val destination = assertIs(comp.stack.value.active.instance) + destination.component.startNameEdit() + destination.component.startTextEdit() + assertEquals(setOf(CloseConfirm.Encyclopedia), comp.shouldConfirmClose()) + + comp.discardUnsaved(CloseConfirm.Encyclopedia) + + assertEquals(emptySet(), comp.shouldConfirmClose()) + } + + @Test + fun `discardUnsaved pops an open create entry screen`() = runTest(mainTestDispatcher) { + val comp = newComponent() + context.resume() + advanceUntilIdle() + comp.showCreateEntry() + advanceUntilIdle() + + comp.discardUnsaved(CloseConfirm.Encyclopedia) + advanceUntilIdle() + + assertIs(comp.stack.value.active.instance) + assertEquals(emptySet(), comp.shouldConfirmClose()) + } } diff --git a/common/src/desktopTest/kotlin/components/encyclopedia/ViewEntryComponentExternalChangeTest.kt b/common/src/desktopTest/kotlin/components/encyclopedia/ViewEntryComponentExternalChangeTest.kt new file mode 100644 index 000000000..8df54974c --- /dev/null +++ b/common/src/desktopTest/kotlin/components/encyclopedia/ViewEntryComponentExternalChangeTest.kt @@ -0,0 +1,141 @@ +package components.encyclopedia + +import com.darkrockstudios.apps.hammer.common.components.encyclopedia.ViewEntryComponent +import com.darkrockstudios.apps.hammer.common.data.encyclopediarepository.EncyclopediaService +import com.darkrockstudios.apps.hammer.common.data.encyclopediarepository.entry.EntryContainer +import com.darkrockstudios.apps.hammer.common.data.references.BackfillEntryReferencesUseCase +import com.darkrockstudios.apps.hammer.common.data.references.CleanupReferencesOnEntryDeleteUseCase +import com.darkrockstudios.apps.hammer.common.data.references.ReferenceIndexService +import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.SceneEditorService +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import okio.IOException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.koin.dsl.module +import repositories.encyclopedia.fakeEntry +import utils.ComponentTest +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class ViewEntryComponentExternalChangeTest : ComponentTest() { + + private lateinit var encyclopediaService: EncyclopediaService + private lateinit var entryChangedFlow: MutableSharedFlow + + @BeforeEach + override fun setup() { + super.setup() + + entryChangedFlow = MutableSharedFlow(extraBufferCapacity = 8) + encyclopediaService = mockk(relaxed = true) + every { encyclopediaService.entryChangedFlow } returns entryChangedFlow + every { encyclopediaService.findEntryImagePath(any()) } returns null + every { encyclopediaService.findEntryImageExtension(any()) } returns null + + val referenceIndexService = mockk(relaxed = true) + every { referenceIndexService.flowForEntry(any()) } returns emptyFlow() + + setupComponentKoin(module { + single { encyclopediaService } + single { referenceIndexService } + single { mockk(relaxed = true) } + single { mockk(relaxed = true) } + single { mockk(relaxed = true) } + }) + } + + @Test + fun `An entry renamed and edited elsewhere refreshes the open view`() = runTest(mainTestDispatcher) { + val original = fakeEntry() + val originalDef = original.toDef(projectDef) + every { encyclopediaService.loadEntry(originalDef) } returns EntryContainer(original) + + val comp = ViewEntryComponent( + componentContext = context, + entryDef = originalDef, + addMenu = {}, + removeMenu = {}, + closeEntry = {}, + showScene = {}, + onShowGlobalSearchForTag = {}, + ) + context.resume() + advanceUntilIdle() + assertEquals(original, comp.state.value.content) + + val synced = original.copy(name = "Server Name", text = "Server text") + val syncedDef = synced.toDef(projectDef) + every { encyclopediaService.findEntryDef(original.id) } returns syncedDef + every { encyclopediaService.loadEntry(syncedDef) } returns EntryContainer(synced) + + entryChangedFlow.emit(original.id) + advanceUntilIdle() + + assertEquals(syncedDef, comp.state.value.entryDef) + assertEquals(synced, comp.state.value.content) + } + + @Test + fun `A failed refresh does not stop later refreshes`() = runTest(mainTestDispatcher) { + val original = fakeEntry() + val originalDef = original.toDef(projectDef) + every { encyclopediaService.loadEntry(originalDef) } returns EntryContainer(original) + every { encyclopediaService.findEntryDef(original.id) } returns originalDef + + val comp = ViewEntryComponent( + componentContext = context, + entryDef = originalDef, + addMenu = {}, + removeMenu = {}, + closeEntry = {}, + showScene = {}, + onShowGlobalSearchForTag = {}, + ) + context.resume() + advanceUntilIdle() + + every { encyclopediaService.loadEntry(originalDef) } throws IOException("mid-move") + entryChangedFlow.emit(original.id) + advanceUntilIdle() + assertEquals(original, comp.state.value.content) + + val synced = original.copy(text = "Server text") + every { encyclopediaService.loadEntry(originalDef) } returns EntryContainer(synced) + entryChangedFlow.emit(original.id) + advanceUntilIdle() + + assertEquals(synced, comp.state.value.content) + } + + @Test + fun `A change to another entry does not reload this one`() = runTest(mainTestDispatcher) { + val original = fakeEntry() + val originalDef = original.toDef(projectDef) + every { encyclopediaService.loadEntry(originalDef) } returns EntryContainer(original) + + ViewEntryComponent( + componentContext = context, + entryDef = originalDef, + addMenu = {}, + removeMenu = {}, + closeEntry = {}, + showScene = {}, + onShowGlobalSearchForTag = {}, + ) + context.resume() + advanceUntilIdle() + + entryChangedFlow.emit(original.id + 1) + advanceUntilIdle() + + verify(exactly = 0) { encyclopediaService.findEntryDef(any()) } + verify(exactly = 1) { encyclopediaService.loadEntry(originalDef) } + } +} diff --git a/common/src/desktopTest/kotlin/components/encyclopedia/ViewEntryComponentTest.kt b/common/src/desktopTest/kotlin/components/encyclopedia/ViewEntryComponentTest.kt index 0016079bf..3deda4784 100644 --- a/common/src/desktopTest/kotlin/components/encyclopedia/ViewEntryComponentTest.kt +++ b/common/src/desktopTest/kotlin/components/encyclopedia/ViewEntryComponentTest.kt @@ -25,6 +25,7 @@ import org.koin.dsl.module import repositories.encyclopedia.fakeEntry import utils.BaseTest import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull @OptIn(ExperimentalCoroutinesApi::class) @@ -164,4 +165,37 @@ class ViewEntryComponentTest : BaseTest() { assertNull(comp.state.value.content) coVerify(exactly = 0) { backfillEntryReferences(any()) } } -} \ No newline at end of file + + @Test + fun `Discard edit leaves every edit mode without saving`() = runTest { + val proj = getProject1Def() + val entry = fakeEntry() + every { encyclopediaService.findEntryImagePath(any()) } returns null + every { encyclopediaService.findEntryImageExtension(any()) } returns null + coEvery { encyclopediaService.calculateEntryImageHash(any(), any()) } returns null + coEvery { encyclopediaService.loadEntry(entryDef = any()) } returns EntryContainer(entry) + + val comp = ViewEntryComponent( + componentContext = context, + entryDef = entry.toDef(proj), + addMenu = {}, + removeMenu = {}, + closeEntry = {}, + showScene = {}, + onShowGlobalSearchForTag = {}, + ) + comp.startNameEdit() + comp.startTextEdit() + comp.confirmClose() + + comp.discardEdit() + advanceUntilIdle() + + assertFalse(comp.state.value.editName) + assertFalse(comp.state.value.editText) + assertFalse(comp.state.value.confirmClose) + coVerify(exactly = 0) { + encyclopediaService.updateEntry(any(), any(), any(), any(), any(), any()) + } + } +} diff --git a/common/src/desktopTest/kotlin/components/notes/NotesComponentTest.kt b/common/src/desktopTest/kotlin/components/notes/NotesComponentTest.kt index 8cd1e3e25..5cf4fc3cb 100644 --- a/common/src/desktopTest/kotlin/components/notes/NotesComponentTest.kt +++ b/common/src/desktopTest/kotlin/components/notes/NotesComponentTest.kt @@ -47,7 +47,7 @@ class NotesComponentTest : ComponentTest() { super.setup() notesRepository = mockk(relaxed = true) - every { notesRepository.notesListFlow } returns MutableStateFlow(emptyList()) + every { notesRepository.notesListFlow } returns MutableStateFlow(listOf(NoteContainer(note))) every { notesRepository.findNoteForId(any()) } returns note tagIndexService = mockk(relaxed = true) @@ -190,4 +190,75 @@ class NotesComponentTest : ComponentTest() { assertTrue(shouldCloseUpdates > baseline) } + + @Test + fun `discardUnsaved reverts a dirty note edit`() = runTest(mainTestDispatcher) { + val comp = newComponent() + context.resume() + advanceUntilIdle() + comp.showViewNote(1) + advanceUntilIdle() + + val destination = assertIs(comp.stack.value.active.instance) + destination.component.beginEdit() + destination.component.onContentChanged("changed body") + advanceUntilIdle() + + comp.discardUnsaved(CloseConfirm.Notes) + advanceUntilIdle() + + assertEquals(emptySet(), comp.shouldConfirmClose()) + assertEquals(note.content, destination.component.noteText.value) + } + + @Test + fun `discardUnsaved pops an open create note screen`() = runTest(mainTestDispatcher) { + val comp = newComponent() + context.resume() + advanceUntilIdle() + comp.showCreateNote() + advanceUntilIdle() + + comp.discardUnsaved(CloseConfirm.Notes) + advanceUntilIdle() + + assertIs(comp.stack.value.active.instance) + assertEquals(emptySet(), comp.shouldConfirmClose()) + } + + @Test + fun `discardUnsaved clears a dirty note edit beneath a create screen`() = runTest(mainTestDispatcher) { + val comp = newComponent() + context.resume() + advanceUntilIdle() + comp.showViewNote(1) + advanceUntilIdle() + val view = assertIs(comp.stack.value.active.instance) + view.component.beginEdit() + view.component.onContentChanged("changed body") + comp.showCreateNote() + advanceUntilIdle() + assertEquals(setOf(CloseConfirm.Notes), comp.shouldConfirmClose()) + + comp.discardUnsaved(CloseConfirm.Notes) + advanceUntilIdle() + + assertIs(comp.stack.value.active.instance) + assertFalse(view.component.state.value.isEditing) + assertEquals(emptySet(), comp.shouldConfirmClose()) + } + + @Test + fun `discardUnsaved ignores other confirmation types`() = runTest(mainTestDispatcher) { + val comp = newComponent() + context.resume() + advanceUntilIdle() + comp.showCreateNote() + advanceUntilIdle() + + comp.discardUnsaved(CloseConfirm.Scenes) + advanceUntilIdle() + + assertIs(comp.stack.value.active.instance) + } } diff --git a/common/src/desktopTest/kotlin/components/notes/ViewNoteComponentTest.kt b/common/src/desktopTest/kotlin/components/notes/ViewNoteComponentTest.kt index 7c6a5fc25..7b47737d9 100644 --- a/common/src/desktopTest/kotlin/components/notes/ViewNoteComponentTest.kt +++ b/common/src/desktopTest/kotlin/components/notes/ViewNoteComponentTest.kt @@ -3,6 +3,7 @@ package components.notes import com.darkrockstudios.apps.hammer.common.components.notes.ViewNoteComponent import com.darkrockstudios.apps.hammer.common.data.MenuDescriptor import com.darkrockstudios.apps.hammer.common.data.notesrepository.NotesRepository +import com.darkrockstudios.apps.hammer.common.data.notesrepository.note.NoteContainer import com.darkrockstudios.apps.hammer.common.data.notesrepository.note.NoteContent import com.darkrockstudios.apps.hammer.common.dependencyinjection.DISPATCHER_DEFAULT import com.darkrockstudios.apps.hammer.common.dependencyinjection.DISPATCHER_MAIN @@ -11,8 +12,11 @@ import io.mockk.every import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -29,9 +33,11 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.time.Instant +@OptIn(ExperimentalCoroutinesApi::class) class ViewNoteComponentTest : ComponentTest() { private lateinit var notesRepository: NotesRepository + private lateinit var notesListFlow: MutableSharedFlow> private var dismissViewCount = 0 private var searchedTag: String? = null @@ -43,6 +49,8 @@ class ViewNoteComponentTest : ComponentTest() { super.setup() notesRepository = mockk(relaxed = true) + notesListFlow = MutableSharedFlow(replay = 1) + every { notesRepository.notesListFlow } returns notesListFlow setupComponentKoin(module { single { notesRepository } bind NotesRepository::class }) @@ -100,6 +108,96 @@ class ViewNoteComponentTest : ComponentTest() { assertEquals(note(content = "late"), comp.state.value.note) } + @Test + fun `a note changed elsewhere refreshes the open view`() = runTest(mainTestDispatcher) { + val comp = startWith(note(content = "orig", tags = setOf("a"))) + advanceUntilIdle() + + val synced = note(content = "from server", tags = setOf("b")) + notesListFlow.emit(listOf(NoteContainer(synced))) + advanceUntilIdle() + + assertEquals(synced, comp.state.value.note) + assertEquals("from server", comp.noteText.value) + assertEquals(setOf("b"), comp.state.value.tags) + } + + @Test + fun `a note changed elsewhere while editing keeps the draft and moves the baseline`() = + runTest(mainTestDispatcher) { + val comp = startWith(note(content = "orig", tags = setOf("a"))) + comp.beginEdit() + comp.onContentChanged("my draft") + comp.onTagsChanged(setOf("wip")) + advanceUntilIdle() + + val synced = note(content = "from server", tags = setOf("b")) + notesListFlow.emit(listOf(NoteContainer(synced))) + advanceUntilIdle() + + assertTrue(comp.state.value.isEditing) + assertEquals("my draft", comp.noteText.value) + assertEquals(setOf("wip"), comp.state.value.tags) + assertEquals(synced, comp.state.value.note) + + comp.discardEdit() + assertEquals("from server", comp.noteText.value) + assertEquals(setOf("b"), comp.state.value.tags) + } + + @Test + fun `a note changed elsewhere replaces an untouched edit`() = runTest(mainTestDispatcher) { + val comp = startWith(note(content = "orig", tags = setOf("a"))) + comp.beginEdit() + advanceUntilIdle() + + val synced = note(content = "from server", tags = setOf("b")) + notesListFlow.emit(listOf(NoteContainer(synced))) + advanceUntilIdle() + + assertTrue(comp.state.value.isEditing) + assertEquals("from server", comp.noteText.value) + assertEquals(setOf("b"), comp.state.value.tags) + assertFalse(comp.isEditingAndDirty()) + } + + @Test + fun `a note deleted elsewhere closes the view`() = runTest(mainTestDispatcher) { + startWith(note()) + advanceUntilIdle() + + notesListFlow.emit(emptyList()) + advanceUntilIdle() + + assertEquals(1, dismissViewCount) + } + + @Test + fun `a note deleted elsewhere while a dirty edit is open stays open`() = runTest(mainTestDispatcher) { + val comp = startWith(note(content = "orig")) + comp.beginEdit() + comp.onContentChanged("my draft") + advanceUntilIdle() + + notesListFlow.emit(emptyList()) + advanceUntilIdle() + + assertEquals(0, dismissViewCount) + assertEquals("my draft", comp.noteText.value) + } + + @Test + fun `deleting the open note dismisses only once`() = runTest(mainTestDispatcher) { + val comp = startWith(note()) + advanceUntilIdle() + + comp.deleteNote(1) + notesListFlow.emit(emptyList()) + advanceUntilIdle() + + assertEquals(1, dismissViewCount) + } + @Test fun `deleteNote invokes dismissView on the main dispatcher`() { // dismissView drives Decompose navigation, which must run on the main thread. diff --git a/common/src/desktopTest/kotlin/components/projecthome/DocxStoryRendererTest.kt b/common/src/desktopTest/kotlin/components/projecthome/DocxStoryRendererTest.kt index 460932fa4..1896ff373 100644 --- a/common/src/desktopTest/kotlin/components/projecthome/DocxStoryRendererTest.kt +++ b/common/src/desktopTest/kotlin/components/projecthome/DocxStoryRendererTest.kt @@ -5,6 +5,8 @@ import com.darkrockstudios.apps.hammer.base.http.projectdata.ProjectTheme import com.darkrockstudios.apps.hammer.common.components.projecthome.StoryChapter import com.darkrockstudios.apps.hammer.common.components.projecthome.ExportStrings import com.darkrockstudios.apps.hammer.common.components.projecthome.writeStoryAsDocx +import com.darkrockstudios.apps.hammer.common.data.ExportFont +import com.darkrockstudios.apps.hammer.common.data.ExportOptions import okio.Buffer import org.apache.poi.xwpf.usermodel.XWPFDocument import org.apache.poi.xwpf.usermodel.XWPFHyperlinkRun @@ -22,6 +24,7 @@ class DocxStoryRendererTest { chapters: List, projectName: String = "Test Project", projectData: ProjectData = ProjectData(authorName = "Test Author"), + options: ExportOptions = ExportOptions(), ): XWPFDocument { val buffer = Buffer() val author = projectData.authorName?.takeIf { it.isNotBlank() } @@ -31,6 +34,7 @@ class DocxStoryRendererTest { projectData = projectData, chapters = chapters, strings = ExportStrings(contentsTitle = "Contents", authorByline = author?.let { "by $it" }), + options = options, ) return XWPFDocument(ByteArrayInputStream(buffer.readByteArray())) } @@ -104,7 +108,8 @@ class DocxStoryRendererTest { listOf( StoryChapter("Alpha", "First."), StoryChapter("Beta", "Second."), - ) + ), + options = ExportOptions(numberChapters = true), ) val headings = doc.paragraphs.filter { it.style == "Heading1" } @@ -112,6 +117,46 @@ class DocxStoryRendererTest { assertTrue(headings.all { it.isPageBreak }, "Chapter headings should start on a new page") } + @Test + fun `chapters are unnumbered by default in headings and contents`() { + val doc = render( + listOf( + StoryChapter("Prologue", "First."), + StoryChapter("Chapter 1", "Second."), + ), + ) + + val headings = doc.paragraphs.filter { it.style == "Heading1" } + assertEquals(listOf("Prologue", "Chapter 1"), headings.map { it.text }) + + val contents = doc.paragraphs.filter { it.ctp.hyperlinkList.any { link -> link.anchor != null } } + assertEquals(listOf("Prologue", "Chapter 1"), contents.map { it.text }) + } + + @Test + fun `the chosen font becomes the document default`() { + val doc = render( + listOf(StoryChapter("Alpha", "Some text.")), + options = ExportOptions(font = ExportFont.TimesNewRoman), + ) + + val fonts = doc.styles.ctStyles.docDefaults.rPrDefault.rPr.rFontsList.single() + assertEquals("Times New Roman", fonts.ascii) + assertEquals("Times New Roman", fonts.hAnsi) + } + + @Test + fun `blank lines between paragraphs become empty paragraphs only when kept`() { + val chapters = listOf(StoryChapter("Alpha", "First passage.\n\nSecond passage.")) + + val kept = render(chapters).bodyParagraphs().map { it.text } + assertEquals(listOf("First passage.", "", "Second passage."), kept) + + val skipped = render(chapters, options = ExportOptions(keepBlankLines = false)) + .bodyParagraphs().map { it.text } + assertEquals(listOf("First passage.", "Second passage."), skipped) + } + @Test fun `bold and italic markdown become formatted runs`() { val doc = @@ -284,7 +329,7 @@ class DocxStoryRendererTest { assertEquals("Tom & Jerry's ", doc.paragraphs.first().text) val heading = doc.paragraphs.first { it.style == "Heading1" } - assertEquals("1. Q & A ", heading.text) + assertEquals("Q & A ", heading.text) val texts = doc.paragraphTexts() assertTrue( diff --git a/common/src/desktopTest/kotlin/components/projecthome/EpubStoryRendererTest.kt b/common/src/desktopTest/kotlin/components/projecthome/EpubStoryRendererTest.kt index 13712a257..6679f78f1 100644 --- a/common/src/desktopTest/kotlin/components/projecthome/EpubStoryRendererTest.kt +++ b/common/src/desktopTest/kotlin/components/projecthome/EpubStoryRendererTest.kt @@ -5,6 +5,8 @@ import com.darkrockstudios.apps.hammer.base.http.projectdata.ProjectTheme import com.darkrockstudios.apps.hammer.common.components.projecthome.ExportStrings import com.darkrockstudios.apps.hammer.common.components.projecthome.StoryChapter import com.darkrockstudios.apps.hammer.common.components.projecthome.writeStoryAsEpub +import com.darkrockstudios.apps.hammer.common.data.ExportFont +import com.darkrockstudios.apps.hammer.common.data.ExportOptions import okio.Buffer import java.io.ByteArrayInputStream import java.util.zip.ZipInputStream @@ -18,6 +20,7 @@ class EpubStoryRendererTest { chapters: List, projectName: String = "Test Project", projectData: ProjectData = ProjectData(authorName = "Jane Doe"), + options: ExportOptions = ExportOptions(), ): String { val buffer = Buffer() val author = projectData.authorName?.takeIf { it.isNotBlank() } @@ -28,6 +31,7 @@ class EpubStoryRendererTest { chapters = chapters, language = "en", strings = ExportStrings(contentsTitle = "Contents", authorByline = author?.let { "by $it" }), + options = options, ) val sb = StringBuilder() @@ -41,6 +45,49 @@ class EpubStoryRendererTest { return sb.toString() } + @Test + fun `blank lines between passages can be skipped`() { + val content = render( + listOf(StoryChapter("Alpha", "First passage.\n\nSecond passage.")), + options = ExportOptions(keepBlankLines = false), + ) + + assertTrue("

First passage.

Second passage.

" in content, content) + } + + @Test + fun `a run of blank lines leaves one break when blank lines are skipped`() { + val content = render( + listOf(StoryChapter("Alpha", "First passage.\n\n\n\nNew scene.")), + options = ExportOptions(keepBlankLines = false), + ) + + assertTrue("

First passage.


New scene.

" in content, content) + } + + @Test + fun `chapter titles are numbered only on request`() { + val chapters = listOf(StoryChapter("Prologue", "First."), StoryChapter("Chapter 1", "Second.")) + + val plain = render(chapters) + assertTrue("

Chapter 1

" in plain, plain) + assertTrue("2. Chapter 1" !in plain, plain) + + val numbered = render(chapters, options = ExportOptions(numberChapters = true)) + assertTrue("

2. Chapter 1

" in numbered, numbered) + assertTrue("2. Chapter 1" in numbered, numbered) + } + + @Test + fun `the chosen font overrides the stylesheet body font`() { + val content = render( + listOf(StoryChapter("Alpha", "Some text.")), + options = ExportOptions(font = ExportFont.Arial), + ) + + assertTrue("body { font-family: Arial, Helvetica, sans-serif; }" in content, content) + } + @Test fun `every authored line becomes its own paragraph`() { val content = render(listOf(StoryChapter("Alpha", "line one\nline two"))) diff --git a/common/src/desktopTest/kotlin/components/projecthome/ExportStoryUseCaseTest.kt b/common/src/desktopTest/kotlin/components/projecthome/ExportStoryUseCaseTest.kt index ee913c746..6776c7c09 100644 --- a/common/src/desktopTest/kotlin/components/projecthome/ExportStoryUseCaseTest.kt +++ b/common/src/desktopTest/kotlin/components/projecthome/ExportStoryUseCaseTest.kt @@ -56,7 +56,7 @@ class ExportStoryUseCaseTest : BaseIntegrationTest() { ) @Test - fun `markdown export renders project title, numbered chapters, and group children`() = runTest { + fun `markdown export renders project title, chapters, and group children`() = runTest { initRepo() val exportPath = useCase().execute( @@ -69,6 +69,24 @@ class ExportStoryUseCaseTest : BaseIntegrationTest() { assertEquals(EXPECTED_PROJECT_1_MARKDOWN.trim(), text.trim()) } + @Test + fun `export includes unsaved scene edits`() = runTest { + initRepo() + val tempBuffer = sceneDatasource.getSceneBufferDirectory().toOkioPath() / "1.md" + ffs.write(tempBuffer) { writeUtf8("Unsaved edit of scene id 1") } + sceneContentRepository.initialize() + + val exportPath = useCase().execute( + exportDir = projectPath, + options = ExportOptions(format = ExportFormat.Markdown, treatTopLevelAsChapters = true), + ) + + val text = ffs.read(exportPath.toOkioPath()) { readByteArray() }.decodeToString() + assertTrue("Unsaved edit of scene id 1" in text, "Export should carry the unsaved buffer, got: $text") + assertTrue("Content of scene id 1" !in text, "Export should not carry the stale on-disk scene, got: $text") + assertTrue("Content of scene id 3" in text, "Scenes without a buffer still come from disk, got: $text") + } + @Test fun `epub export produces a valid zip file with PK magic bytes`() = runTest { initRepo() @@ -276,6 +294,43 @@ class ExportStoryUseCaseTest : BaseIntegrationTest() { assertEquals(EXPECTED_FILTERED_MARKDOWN.trim(), text.trim()) } + @Test + fun `markdown chapter headings are numbered on request`() = runTest { + initRepo() + + val exportPath = useCase().execute( + exportDir = projectPath, + options = ExportOptions( + format = ExportFormat.Markdown, + treatTopLevelAsChapters = true, + numberChapters = true, + ), + ) + + val text = ffs.read(exportPath.toOkioPath()) { readByteArray() }.decodeToString() + assertTrue("## 1. Scene ID 1" in text, text) + assertTrue("## 2. Chapter ID 2" in text, text) + } + + @Test + fun `scene breaks survive when blank lines are skipped`() = runTest { + initRepo() + storedProjectData = StoredProjectData(data = ProjectData(authorName = "Test Author")) + + val exportPath = useCase().execute( + exportDir = projectPath, + options = ExportOptions( + format = ExportFormat.Rtf, + keepBlankLines = false, + sceneIds = setOf(3, 4), + ), + ) + + val rtf = ffs.read(exportPath.toOkioPath()) { readByteArray() }.decodeToString() + // \fi360 opens a body paragraph: the two scenes and the break between them. + assertEquals(3, Regex("""\\fi360""").findAll(rtf).count(), rtf) + } + @Test fun `scene filter drops chapters with no selected scenes`() = runTest { initRepo() @@ -294,7 +349,7 @@ class ExportStoryUseCaseTest : BaseIntegrationTest() { # Test Project 1 - ## 1. Scene ID 6 + ## Scene ID 6 Content of scene id 6 """.trimIndent() @@ -388,11 +443,11 @@ class ExportStoryUseCaseTest : BaseIntegrationTest() { # Test Project 1 - ## 1. Scene ID 1 + ## Scene ID 1 Content of scene id 1 - ## 2. Chapter ID 2 + ## Chapter ID 2 Content of scene id 4 """.trimIndent() @@ -401,11 +456,11 @@ class ExportStoryUseCaseTest : BaseIntegrationTest() { # Test Project 1 - ## 1. Scene ID 1 + ## Scene ID 1 Content of scene id 1 - ## 2. Chapter ID 2 + ## Chapter ID 2 Content of scene id 3 @@ -413,11 +468,11 @@ class ExportStoryUseCaseTest : BaseIntegrationTest() { Content of scene id 5 - ## 3. Scene ID 6 + ## Scene ID 6 Content of scene id 6 - ## 4. Scene ID 7 + ## Scene ID 7 Content of scene id 7 """.trimIndent() diff --git a/common/src/desktopTest/kotlin/components/projecthome/PdfProseMarkdownTest.kt b/common/src/desktopTest/kotlin/components/projecthome/PdfProseMarkdownTest.kt index bb53f751c..ab8e6eb30 100644 --- a/common/src/desktopTest/kotlin/components/projecthome/PdfProseMarkdownTest.kt +++ b/common/src/desktopTest/kotlin/components/projecthome/PdfProseMarkdownTest.kt @@ -42,6 +42,28 @@ class PdfProseMarkdownTest { assertEquals("Second passage.", paragraph(blocks[2]).spans.plain()) } + @Test + fun `a lone blank line is dropped when not kept`() { + val blocks = parseProseMarkdown("First passage.\n\nSecond passage.", keepBlankLines = false) + + assertEquals(2, blocks.size) + assertEquals("First passage.", paragraph(blocks[0]).spans.plain()) + assertEquals("Second passage.", paragraph(blocks[1]).spans.plain()) + } + + @Test + fun `a run of blank lines leaves one break when not kept`() { + val blocks = parseProseMarkdown( + "First.\n\nSecond.\n\n\n\n\nNew scene.", + keepBlankLines = false, + ) + + assertEquals(4, blocks.size) + assertEquals("Second.", paragraph(blocks[1]).spans.plain()) + assertIs(blocks[2]) + assertEquals("New scene.", paragraph(blocks[3]).spans.plain()) + } + @Test fun `each blank line of a run counts`() { val blocks = parseProseMarkdown("First passage.\n\n\n\nSecond passage.") diff --git a/common/src/desktopTest/kotlin/components/projecthome/PdfStoryRendererTest.kt b/common/src/desktopTest/kotlin/components/projecthome/PdfStoryRendererTest.kt new file mode 100644 index 000000000..840c48bd0 --- /dev/null +++ b/common/src/desktopTest/kotlin/components/projecthome/PdfStoryRendererTest.kt @@ -0,0 +1,109 @@ +package components.projecthome + +import com.conamobile.pdfkmp.PdfDocument +import com.darkrockstudios.apps.hammer.base.http.projectdata.ProjectData +import com.darkrockstudios.apps.hammer.common.components.projecthome.ExportStrings +import com.darkrockstudios.apps.hammer.common.components.projecthome.STORY_PAGE_PADDING +import com.darkrockstudios.apps.hammer.common.components.projecthome.STORY_PAGE_SIZE +import com.darkrockstudios.apps.hammer.common.components.projecthome.StoryChapter +import com.darkrockstudios.apps.hammer.common.components.projecthome.buildStoryPdf +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PdfStoryRendererTest { + + private fun render(markdown: String): PdfDocument = buildStoryPdf( + projectName = "Story", + projectData = ProjectData(authorName = "Author"), + chapters = listOf(StoryChapter("Chapter", markdown)), + strings = ExportStrings(contentsTitle = "Contents", authorByline = "by Author"), + ) + + private fun assertInsideMargins(document: PdfDocument) { + val top = STORY_PAGE_PADDING.top.value + val bottom = STORY_PAGE_SIZE.height.value - STORY_PAGE_PADDING.bottom.value + val right = STORY_PAGE_SIZE.width.value - STORY_PAGE_PADDING.right.value + for (run in document.textRuns) { + assertTrue(run.yPoints >= top - 0.5f, "run above the top margin: $run") + assertTrue(run.yPoints + run.heightPoints <= bottom + 0.5f, "run below the bottom margin: $run") + assertTrue(run.xPoints + run.widthPoints <= right + 0.5f, "run past the right margin: $run") + } + } + + private fun sentences(count: Int): String = + (1..count).joinToString(" ") { "Sentence number $it has some **bold** and *italic* words in it." } + + @Test + fun `a paragraph taller than a page flows onto the next page`() { + val document = render(sentences(400) + " THE-END") + + assertInsideMargins(document) + val chapterPages = document.textRuns.map { it.pageIndex }.distinct().count { it >= 2 } + assertTrue(chapterPages >= 3, "expected the paragraph to span pages, got $chapterPages") + assertTrue(document.textRuns.last().text.contains("THE-END")) + } + + @Test + fun `a paragraph that crosses a page boundary fills the page before breaking`() { + val filler = (1..30).joinToString("\n") { "Short line $it." } + val document = render(filler + "\n" + sentences(60)) + + assertInsideMargins(document) + val firstChapterPage = document.textRuns.filter { it.pageIndex == 2 } + val bottom = STORY_PAGE_SIZE.height.value - STORY_PAGE_PADDING.bottom.value + val lowest = firstChapterPage.maxOf { it.yPoints + it.heightPoints } + assertTrue(bottom - lowest < 40f, "page left ${bottom - lowest}pt empty before the break") + } + + private fun assertSpansPages(document: PdfDocument) { + assertInsideMargins(document) + val chapterPages = document.textRuns.map { it.pageIndex }.distinct().count { it >= 2 } + assertTrue(chapterPages >= 2, "expected the block to span pages, got $chapterPages") + assertTrue(document.textRuns.any { it.text.contains("THE-END") }) + } + + @Test + fun `a quote taller than a page flows onto the next page`() { + assertSpansPages(render("> " + sentences(300) + " THE-END")) + } + + @Test + fun `a list item taller than a page flows onto the next page`() { + assertSpansPages(render("- short item\n- " + sentences(300) + " THE-END")) + } + + @Test + fun `a code block taller than a page flows onto the next page`() { + val code = (1..120).joinToString("\n") { "line $it" } + assertSpansPages(render("```\n$code\nTHE-END\n```")) + } + + @Test + fun `a table row taller than a page continues in further rows`() { + assertSpansPages(render("| a | b |\n|---|---|\n| short | " + sentences(200) + " THE-END |")) + } + + @Test + fun `every line of a right-to-left paragraph keeps the paragraph direction`() { + val words = (1..240).joinToString(" ") { if (it % 3 == 0) "Alpha" else "שלום" } + val document = render(words) + + val right = STORY_PAGE_SIZE.width.value - STORY_PAGE_PADDING.right.value + val rows = document.textRuns.filter { it.pageIndex == 2 }.drop(1).groupBy { it.yPoints } + assertTrue(rows.size > 5) + for ((y, runs) in rows) { + val edge = runs.maxOf { it.xPoints + it.widthPoints } + assertTrue(right - edge < 8f, "line at y=$y ends ${right - edge}pt short of the right margin") + } + } + + @Test + fun `pre-wrapped lines are not wrapped again`() { + val document = render(sentences(40)) + + val rows = document.textRuns.filter { it.pageIndex == 2 }.map { it.yPoints }.distinct().sorted() + val gaps = rows.drop(1).zipWithNext { a, b -> b - a }.map { Math.round(it * 10) }.distinct() + assertEquals(1, gaps.size, "uneven line spacing: $gaps") + } +} diff --git a/common/src/desktopTest/kotlin/components/projecthome/ProjectHomeComponentTest.kt b/common/src/desktopTest/kotlin/components/projecthome/ProjectHomeComponentTest.kt index 2da9914f2..3e82f0760 100644 --- a/common/src/desktopTest/kotlin/components/projecthome/ProjectHomeComponentTest.kt +++ b/common/src/desktopTest/kotlin/components/projecthome/ProjectHomeComponentTest.kt @@ -165,7 +165,7 @@ class ProjectHomeComponentTest : ComponentTest() { private fun newComponent() = ProjectHomeComponent( componentContext = context, projectDef = projectDef, - showProjectSync = { syncShown = true }, + requestProjectSync = { syncShown = true }, onShowGlobalSearch = { globalSearchShown = true }, onShowGlobalSearchForTag = { searchedTag = it }, onShowScene = { shownScene = it }, diff --git a/common/src/desktopTest/kotlin/components/projectroot/ProjectRootComponentTest.kt b/common/src/desktopTest/kotlin/components/projectroot/ProjectRootComponentTest.kt index ded4670e8..f49512098 100644 --- a/common/src/desktopTest/kotlin/components/projectroot/ProjectRootComponentTest.kt +++ b/common/src/desktopTest/kotlin/components/projectroot/ProjectRootComponentTest.kt @@ -37,6 +37,7 @@ import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.slot +import io.mockk.verify import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.Channel @@ -282,8 +283,9 @@ class ProjectRootComponentTest : ComponentTest() { } @Test - fun `startProjectSync opens the modal for server synchronized projects`() = runTest(mainTestDispatcher) { + fun `startProjectSync with nothing unsaved requests only the sync`() = runTest(mainTestDispatcher) { every { syncJournal.isServerSynchronized() } returns true + every { sceneEditor.hasDirtyBuffers() } returns false val comp = newComponent() context.resume() @@ -292,12 +294,31 @@ class ProjectRootComponentTest : ComponentTest() { comp.startProjectSync() advanceUntilIdle() - assertIs(comp.modalRouterState.value.child?.instance) + assertEquals(setOf(CloseConfirm.Sync), comp.closeRequestHandlers.value) + } + + @Test + fun `startProjectSync gates on unsaved scenes before the sync`() = runTest(mainTestDispatcher) { + every { syncJournal.isServerSynchronized() } returns true + every { sceneEditor.hasDirtyBuffers() } returns true + + val comp = newComponent() + context.resume() + advanceUntilIdle() + + comp.startProjectSync() + advanceUntilIdle() + + assertEquals(listOf(CloseConfirm.Scenes, CloseConfirm.Sync), comp.closeRequestHandlers.value.toList()) + + comp.closeRequestDealtWith(CloseConfirm.Scenes) + assertEquals(setOf(CloseConfirm.Sync), comp.closeRequestHandlers.value) } @Test fun `startProjectSync does nothing for local-only projects`() = runTest(mainTestDispatcher) { every { syncJournal.isServerSynchronized() } returns false + every { sceneEditor.hasDirtyBuffers() } returns true val comp = newComponent() context.resume() @@ -306,9 +327,68 @@ class ProjectRootComponentTest : ComponentTest() { comp.startProjectSync() advanceUntilIdle() + assertTrue(comp.closeRequestHandlers.value.isEmpty()) assertIs(comp.modalRouterState.value.child?.instance) } + @Test + fun `Home sync action goes through the gate`() = runTest(mainTestDispatcher) { + every { syncJournal.isServerSynchronized() } returns true + every { sceneEditor.hasDirtyBuffers() } returns true + + val comp = newComponent() + context.resume() + advanceUntilIdle() + + val home = assertIs(comp.routerState.value.active.instance) + home.component.startProjectSync() + advanceUntilIdle() + + assertEquals(listOf(CloseConfirm.Scenes, CloseConfirm.Sync), comp.closeRequestHandlers.value.toList()) + assertIs(comp.modalRouterState.value.child?.instance) + } + + @Test + fun `startProjectSync leaves a pending close request alone`() = runTest(mainTestDispatcher) { + every { syncJournal.isServerSynchronized() } returns true + every { sceneEditor.hasDirtyBuffers() } returns true + coEvery { syncJournal.shouldAutoSync() } returns false + + val comp = newComponent() + context.resume() + advanceUntilIdle() + + comp.requestClose() + advanceUntilIdle() + val pending = comp.closeRequestHandlers.value + + comp.startProjectSync() + advanceUntilIdle() + + assertEquals(pending, comp.closeRequestHandlers.value) + assertTrue(CloseConfirm.Complete in comp.closeRequestHandlers.value) + } + + @Test + fun `Sync dialog closing clears a pending sync request`() = runTest(mainTestDispatcher) { + every { syncJournal.isServerSynchronized() } returns true + every { sceneEditor.hasDirtyBuffers() } returns false + + val comp = newComponent() + context.resume() + advanceUntilIdle() + + comp.startProjectSync() + comp.showProjectSync() + advanceUntilIdle() + assertEquals(setOf(CloseConfirm.Sync), comp.closeRequestHandlers.value) + + comp.dismissProjectSync() + advanceUntilIdle() + + assertTrue(comp.closeRequestHandlers.value.isEmpty()) + } + @Test fun `Global search modal opens and dismisses`() = runTest(mainTestDispatcher) { val comp = newComponent() @@ -468,8 +548,32 @@ class ProjectRootComponentTest : ComponentTest() { advanceUntilIdle() assertTrue(comp.hasUnsavedBuffers()) + assertTrue(comp.unsavedBuffers.value) comp.storeDirtyBuffers() coVerify { sceneEditor.storeAllBuffers() } } + + @Test + fun `Discarding unsaved scenes reverts the editor buffers`() = runTest(mainTestDispatcher) { + val comp = newComponent() + context.resume() + advanceUntilIdle() + + comp.discardUnsaved(CloseConfirm.Scenes) + + verify { sceneEditor.discardAllBuffers() } + } + + @Test + fun `Discarding sync or completion touches nothing`() = runTest(mainTestDispatcher) { + val comp = newComponent() + context.resume() + advanceUntilIdle() + + comp.discardUnsaved(CloseConfirm.Sync) + comp.discardUnsaved(CloseConfirm.Complete) + + verify(exactly = 0) { sceneEditor.discardAllBuffers() } + } } diff --git a/common/src/desktopTest/kotlin/components/projectselection/ProjectSelectionComponentTest.kt b/common/src/desktopTest/kotlin/components/projectselection/ProjectSelectionComponentTest.kt index 71e1884f2..11ce492c5 100644 --- a/common/src/desktopTest/kotlin/components/projectselection/ProjectSelectionComponentTest.kt +++ b/common/src/desktopTest/kotlin/components/projectselection/ProjectSelectionComponentTest.kt @@ -12,6 +12,8 @@ import com.darkrockstudios.apps.hammer.common.data.globalsettings.ServerSettings import com.darkrockstudios.apps.hammer.common.data.globalsettings.SpellCheckerSettings import com.darkrockstudios.apps.hammer.common.data.projectsrepository.ProjectsRepository import com.darkrockstudios.apps.hammer.common.data.sync.accountsync.ClientAccountSynchronizer +import com.darkrockstudios.apps.hammer.common.data.appupdate.AppUpdateState +import com.darkrockstudios.apps.hammer.common.data.appupdate.AppUpdater import com.darkrockstudios.apps.hammer.common.data.changelog.Changelog import com.darkrockstudios.apps.hammer.common.data.changelog.ChangelogRepository import com.darkrockstudios.apps.hammer.common.dependencyinjection.createTomlSerializer @@ -32,6 +34,7 @@ import org.junit.jupiter.api.Test import org.koin.dsl.bind import org.koin.dsl.module import utils.BaseTest +import utils.FakeAppUpdater @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) class ProjectSelectionComponentTest : BaseTest() { @@ -50,6 +53,7 @@ class ProjectSelectionComponentTest : BaseTest() { lateinit var projectsSynchronizer: ClientAccountSynchronizer lateinit var changelogRepository: ChangelogRepository lateinit var urlLauncher: UrlLauncher + lateinit var appUpdater: FakeAppUpdater private var globalSettings: GlobalSettings = GlobalSettings( projectsDirectory = "", @@ -73,6 +77,7 @@ class ProjectSelectionComponentTest : BaseTest() { projectsSynchronizer = mockk() changelogRepository = mockk(relaxed = true) urlLauncher = mockk(relaxed = true) + appUpdater = FakeAppUpdater() val testModule = module { single { globalSettingsStore } bind GlobalSettingsStore::class @@ -81,6 +86,7 @@ class ProjectSelectionComponentTest : BaseTest() { single { projectsSynchronizer } single { changelogRepository } single { urlLauncher } bind UrlLauncher::class + single { appUpdater } } setupKoin(testModule) lifecycle.resume() @@ -116,6 +122,34 @@ class ProjectSelectionComponentTest : BaseTest() { onProjectSelected = {}, ) + @Test + fun `app update state mirrors the updater`() = runTest { + coEvery { changelogRepository.hasUnseenChangelog() } returns false + + val component = newComponent() + advanceUntilIdle() + assertEquals(AppUpdateState.Idle, component.appUpdate.value) + + val available = AppUpdateState.Available(version = "9.9.9", installable = true) + appUpdater.state.value = available + advanceUntilIdle() + + assertEquals(available, component.appUpdate.value) + } + + @Test + fun `update and dismiss are handed to the updater`() = runTest { + coEvery { changelogRepository.hasUnseenChangelog() } returns false + + val component = newComponent() + advanceUntilIdle() + component.updateApp() + component.dismissUpdate() + + assertEquals(1, appUpdater.updates) + assertEquals(1, appUpdater.dismissals) + } + private val changelog = Changelog( version = "99.0.0", date = "2026-8-4", diff --git a/common/src/desktopTest/kotlin/components/projectselection/accountsettings/BackupManagerComponentTest.kt b/common/src/desktopTest/kotlin/components/projectselection/accountsettings/BackupManagerComponentTest.kt index dd9e861a4..60f28fa95 100644 --- a/common/src/desktopTest/kotlin/components/projectselection/accountsettings/BackupManagerComponentTest.kt +++ b/common/src/desktopTest/kotlin/components/projectselection/accountsettings/BackupManagerComponentTest.kt @@ -5,9 +5,9 @@ import com.darkrockstudios.apps.hammer.common.data.ProjectDef import com.darkrockstudios.apps.hammer.common.data.projectbackup.BackupManagerService import com.darkrockstudios.apps.hammer.common.data.projectbackup.ProjectBackupDef import com.darkrockstudios.apps.hammer.common.data.projectbackup.ProjectBackupRepository -import com.darkrockstudios.apps.hammer.common.data.projectsrepository.ProjectsRepository import com.darkrockstudios.apps.hammer.common.fileio.HPath import com.darkrockstudios.apps.hammer.common.util.StrRes +import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk @@ -30,12 +30,10 @@ import kotlin.time.Instant class BackupManagerComponentTest : ComponentTest() { private lateinit var backupRepository: ProjectBackupRepository - private lateinit var projectsRepository: ProjectsRepository private lateinit var backupManagerService: BackupManagerService private val projAlpha = ProjectDef("Alpha", HPath("/projects/Alpha", "Alpha", false)) private val projZulu = ProjectDef("Zulu", HPath("/projects/Zulu", "Zulu", false)) - private val projNoBackups = ProjectDef("NoBackups", HPath("/projects/NoBackups", "NoBackups", false)) private val alphaOld = backup(projAlpha, "alpha-old.zip", at = 100) private val alphaNew = backup(projAlpha, "alpha-new.zip", at = 200) @@ -56,17 +54,14 @@ class BackupManagerComponentTest : ComponentTest() { alphaBackups = listOf(alphaOld, alphaNew) backupRepository = mockk(relaxed = true) - projectsRepository = mockk(relaxed = true) backupManagerService = mockk(relaxed = true) - every { projectsRepository.getProjects() } returns listOf(projAlpha, projZulu, projNoBackups) + every { backupRepository.getBackedUpProjects() } returns listOf(projAlpha, projZulu) every { backupRepository.getBackups(projAlpha) } answers { alphaBackups } every { backupRepository.getBackups(projZulu) } returns listOf(zuluBackup) - every { backupRepository.getBackups(projNoBackups) } returns emptyList() setupKoin(module { single { backupRepository } - single { projectsRepository } single { backupManagerService } single { TestStrRes() } }) @@ -99,7 +94,7 @@ class BackupManagerComponentTest : ComponentTest() { @Test fun `a failure while loading projects surfaces an error`() = runTest(mainTestDispatcher) { - every { projectsRepository.getProjects() } throws RuntimeException("disk exploded") + every { backupRepository.getBackedUpProjects() } throws RuntimeException("disk exploded") val comp = newComponent() context.resume() @@ -168,6 +163,20 @@ class BackupManagerComponentTest : ComponentTest() { coVerify(exactly = 1) { backupRepository.restoreBackup(alphaNew, projAlpha.path) } } + @Test + fun `a failed restore surfaces an error`() = runTest(mainTestDispatcher) { + coEvery { backupRepository.restoreBackup(any(), any()) } returns false + + val comp = newComponent() + context.resume() + advanceUntilIdle() + + comp.restoreBackup(alphaNew) + advanceUntilIdle() + + assertNotNull(comp.state.value.error) + } + @Test fun `exportBackup delegates to the backup manager service`() = runTest(mainTestDispatcher) { val comp = newComponent() diff --git a/common/src/desktopTest/kotlin/components/projectselection/accountsettings/DesktopPlatformSettingsComponentTest.kt b/common/src/desktopTest/kotlin/components/projectselection/accountsettings/DesktopPlatformSettingsComponentTest.kt new file mode 100644 index 000000000..bf13efd69 --- /dev/null +++ b/common/src/desktopTest/kotlin/components/projectselection/accountsettings/DesktopPlatformSettingsComponentTest.kt @@ -0,0 +1,109 @@ +package components.projectselection.accountsettings + +import com.arkivanov.decompose.DefaultComponentContext +import com.arkivanov.essenty.lifecycle.LifecycleRegistry +import com.arkivanov.essenty.lifecycle.resume +import com.darkrockstudios.apps.hammer.common.components.projectselection.accountsettings.DesktopPlatformSettingsComponent +import com.darkrockstudios.apps.hammer.common.data.appupdate.AppUpdateState +import com.darkrockstudios.apps.hammer.common.data.appupdate.AppUpdater +import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettings +import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettingsStore +import com.darkrockstudios.apps.hammer.common.data.globalsettings.SpellCheckerSettings +import com.darkrockstudios.apps.hammer.common.data.projectsrepository.ProjectsRepository +import com.darkrockstudios.apps.hammer.common.fileio.HPath +import com.darkrockstudios.apps.hammer.common.sandbox.NoopSandboxFileAccess +import com.darkrockstudios.apps.hammer.common.sandbox.SandboxFileAccess +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.koin.dsl.bind +import org.koin.dsl.module +import utils.BaseTest +import utils.FakeAppUpdater + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class DesktopPlatformSettingsComponentTest : BaseTest() { + + private lateinit var settingsStore: GlobalSettingsStore + private lateinit var settingsUpdates: MutableSharedFlow + private lateinit var appUpdater: FakeAppUpdater + + private val settings = GlobalSettings( + projectsDirectory = "/projects", + spellCheckSettings = SpellCheckerSettings(locale = mockk()), + ) + + @BeforeEach + override fun setup() { + super.setup() + + settingsStore = mockk(relaxed = true) + settingsUpdates = MutableSharedFlow(extraBufferCapacity = 1) + every { settingsStore.globalSettings } returns settings + every { settingsStore.globalSettingsUpdates } returns settingsUpdates + + val projectsRepository = mockk() + every { projectsRepository.getProjectsDirectory() } returns HPath("/projects", "projects", isAbsolute = true) + + appUpdater = FakeAppUpdater() + + setupKoin( + module { + single { settingsStore } bind GlobalSettingsStore::class + single { projectsRepository } bind ProjectsRepository::class + single { NoopSandboxFileAccess } + single { appUpdater } + } + ) + } + + private fun newComponent(): DesktopPlatformSettingsComponent { + val lifecycle = LifecycleRegistry() + val component = DesktopPlatformSettingsComponent(DefaultComponentContext(lifecycle = lifecycle)) + lifecycle.resume() + return component + } + + @Test + fun `the update toggle is offered only on a build that can update`() = runTest { + appUpdater.state.value = AppUpdateState.Idle + assertTrue(newComponent().state.value.updateChecksSupported) + + appUpdater.state.value = AppUpdateState.Unsupported + assertFalse(newComponent().state.value.updateChecksSupported) + } + + @Test + fun `the stored preference is mirrored into the state`() = runTest { + val component = newComponent() + advanceUntilIdle() + assertTrue(component.state.value.automaticUpdateChecks) + + settingsUpdates.emit(settings.copy(automaticUpdateChecks = false)) + advanceUntilIdle() + + assertFalse(component.state.value.automaticUpdateChecks) + } + + @Test + fun `turning the preference off writes it to the settings`() = runTest { + val component = newComponent() + val update = slot<(GlobalSettings) -> GlobalSettings>() + coEvery { settingsStore.updateSettings(capture(update)) } just Runs + + component.setAutomaticUpdateChecks(false) + advanceUntilIdle() + + assertFalse(update.captured(settings).automaticUpdateChecks) + } +} diff --git a/common/src/desktopTest/kotlin/components/projectselection/projectslist/ProjectsListComponentTest.kt b/common/src/desktopTest/kotlin/components/projectselection/projectslist/ProjectsListComponentTest.kt index 9e142119a..4672d4ae9 100644 --- a/common/src/desktopTest/kotlin/components/projectselection/projectslist/ProjectsListComponentTest.kt +++ b/common/src/desktopTest/kotlin/components/projectselection/projectslist/ProjectsListComponentTest.kt @@ -1,6 +1,9 @@ package components.projectselection.projectslist import com.darkrockstudios.apps.hammer.base.ProjectId +import com.darkrockstudios.apps.hammer.Res +import com.darkrockstudios.apps.hammer.projects_list_toast_delete_failed +import com.darkrockstudios.apps.hammer.common.components.ToastMessage import com.darkrockstudios.apps.hammer.common.components.projectselection.projectslist.ProjectsList import com.darkrockstudios.apps.hammer.common.components.projectselection.projectslist.ProjectsListComponent import com.darkrockstudios.apps.hammer.common.components.storyeditor.metadata.Info @@ -40,8 +43,11 @@ import io.mockk.every import io.mockk.mockk import io.mockk.slot import io.mockk.verify +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import net.peanuuutz.tomlkt.Toml @@ -385,6 +391,21 @@ class ProjectsListComponentTest : ComponentTest() { verify { synchronizer.deleteProject(SyncedProjectDefinition(projectDefA, ProjectId("server-id"))) } } + @Test + fun `deleteProject does not touch the filesystem on the calling thread`() = runTest(mainTestDispatcher) { + every { projectsRepository.deleteProject(projectDefA) } returns true + val comp = newComponent() + + comp.deleteProject(projectDefA) + + verify(exactly = 0) { projectsRepository.getProjectId(any()) } + verify(exactly = 0) { projectsRepository.deleteProject(any()) } + + advanceUntilIdle() + + verify { projectsRepository.deleteProject(projectDefA) } + } + @Test fun `deleteProject skips the server when the project has no id`() = runTest(mainTestDispatcher) { every { projectsRepository.getProjectId(projectDefA) } returns null @@ -402,12 +423,29 @@ class ProjectsListComponentTest : ComponentTest() { every { projectsRepository.getProjectId(projectDefA) } returns ProjectId("server-id") every { projectsRepository.deleteProject(projectDefA) } returns false val comp = newComponent() + val toast = async(start = CoroutineStart.UNDISPATCHED) { comp.toast.first() } comp.deleteProject(projectDefA) advanceUntilIdle() verify(exactly = 0) { synchronizer.deleteProject(any()) } verify(exactly = 0) { projectsRepository.getProjects(any()) } + assertEquals(ToastMessage.Resource(Res.string.projects_list_toast_delete_failed), toast.await()) + } + + @Test + fun `deleteProject shows a failure when the filesystem throws`() = runTest(mainTestDispatcher) { + every { projectsRepository.getProjectId(projectDefA) } returns ProjectId("server-id") + every { projectsRepository.deleteProject(projectDefA) } throws IOException("Access denied") + val comp = newComponent() + val toast = async(start = CoroutineStart.UNDISPATCHED) { comp.toast.first() } + + comp.deleteProject(projectDefA) + advanceUntilIdle() + + verify(exactly = 0) { synchronizer.deleteProject(any()) } + verify(exactly = 0) { projectsRepository.getProjects(any()) } + assertEquals(ToastMessage.Resource(Res.string.projects_list_toast_delete_failed), toast.await()) } // --- renameProject ------------------------------------------------------- diff --git a/common/src/desktopTest/kotlin/components/storyeditor/StoryEditorComponentTest.kt b/common/src/desktopTest/kotlin/components/storyeditor/StoryEditorComponentTest.kt index c9f4e7754..6f01c65f9 100644 --- a/common/src/desktopTest/kotlin/components/storyeditor/StoryEditorComponentTest.kt +++ b/common/src/desktopTest/kotlin/components/storyeditor/StoryEditorComponentTest.kt @@ -3,6 +3,7 @@ package components.storyeditor import com.arkivanov.decompose.value.getValue import com.darkrockstudios.apps.hammer.common.components.storyeditor.StoryEditor import com.darkrockstudios.apps.hammer.common.components.storyeditor.StoryEditorComponent +import com.darkrockstudios.apps.hammer.common.components.storyeditor.readermode.ReaderMode import com.darkrockstudios.apps.hammer.common.components.storyeditor.sceneeditor.SceneEditorComponent import com.darkrockstudios.apps.hammer.common.data.MenuDescriptor import com.darkrockstudios.apps.hammer.common.data.SceneItem @@ -34,8 +35,11 @@ import org.junit.jupiter.api.Test import org.koin.core.qualifier.named import org.koin.dsl.module import utils.ComponentTest +import utils.TestComponentContext +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertIs +import kotlin.test.assertSame import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) @@ -45,6 +49,7 @@ class StoryEditorComponentTest : ComponentTest() { private lateinit var settingsStore: GlobalSettingsStore private val sceneTreeCallbacks = mutableListOf<(SceneSummary) -> Unit>() + private lateinit var sceneUpdates: MutableSharedFlow private val addedMenus = mutableListOf() private var focusModeScene: SceneItem? = null @@ -62,6 +67,8 @@ class StoryEditorComponentTest : ComponentTest() { sceneEditor = mockk(relaxed = true) every { sceneEditor.subscribeToSceneUpdates(any(), capture(sceneTreeCallbacks)) } returns mockk(relaxed = true) + sceneUpdates = MutableSharedFlow(replay = 1) + every { sceneEditor.sceneListChannel } returns sceneUpdates val spellCheckRepository = mockk(relaxed = true) every { spellCheckRepository.dictionaryFlow } returns MutableSharedFlow() @@ -83,8 +90,8 @@ class StoryEditorComponentTest : ComponentTest() { }) } - private fun newComponent() = StoryEditorComponent( - componentContext = context, + private fun newComponent(componentContext: TestComponentContext = context) = StoryEditorComponent( + componentContext = componentContext, projectDef = projectDef, addMenu = { addedMenus.add(it) }, removeMenu = { }, @@ -280,6 +287,163 @@ class StoryEditorComponentTest : ComponentTest() { ) } + private fun StoryEditor.reader(): ReaderMode = + assertIs(detailsRouterState.value.active.instance) + .component + + private fun StoryEditor.sceneList() = + assertIs(listRouterState.value.active.instance).component + + @Test + fun `Showing the reader opens it in the detail and hides the list in single pane`() = + runTest(mainTestDispatcher) { + every { sceneEditor.getSceneTree() } returns treeOf(sceneItem(1), sceneItem(2)) + + val comp = newComponent() + context.resume() + advanceUntilIdle() + + comp.showReader(null) + advanceUntilIdle() + + assertEquals(1, comp.reader().state.value.activeChapterId) + assertIs(comp.listRouterState.value.active.instance) + assertFalse(comp.isAtRoot()) + } + + @Test + fun `Showing the reader in multi pane keeps the list visible`() = runTest(mainTestDispatcher) { + every { sceneEditor.getSceneTree() } returns treeOf(sceneItem(1), sceneItem(2)) + + val comp = newComponent() + context.resume() + advanceUntilIdle() + + comp.setMultiPane(true) + comp.showReader(sceneItem(2)) + advanceUntilIdle() + + assertEquals(2, comp.reader().state.value.activeChapterId) + assertIs(comp.listRouterState.value.active.instance) + } + + @Test + fun `Tapping a scene in the list while reading moves the reader instead of opening the editor`() = + runTest(mainTestDispatcher) { + every { sceneEditor.getSceneTree() } returns treeOf(sceneItem(1), sceneItem(2)) + + val comp = newComponent() + context.resume() + advanceUntilIdle() + + comp.setMultiPane(true) + comp.showReader(null) + advanceUntilIdle() + val reader = comp.reader() + + comp.sceneList().onSceneSelected(sceneItem(2)) + advanceUntilIdle() + + assertSame(reader, comp.reader()) + assertEquals(2, reader.state.value.activeChapterId) + assertEquals(2, reader.state.value.scrollToSceneId) + } + + @Test + fun `Showing a scene while reading opens the editor`() = runTest(mainTestDispatcher) { + every { sceneEditor.getSceneTree() } returns treeOf(sceneItem(1), sceneItem(2)) + + val comp = newComponent() + context.resume() + advanceUntilIdle() + + comp.showReader(null) + advanceUntilIdle() + comp.showScene(sceneItem(2)) + advanceUntilIdle() + + assertIs(comp.detailsRouterState.value.active.instance) + + comp.onBack() + advanceUntilIdle() + assertFalse(comp.isDetailShown()) + } + + @Test + fun `The reader's edit action opens the editor for the scene being read`() = runTest(mainTestDispatcher) { + every { sceneEditor.getSceneTree() } returns treeOf(sceneItem(1), sceneItem(2)) + + val comp = newComponent() + context.resume() + advanceUntilIdle() + + comp.showReader(sceneItem(2)) + advanceUntilIdle() + comp.reader().editScene() + advanceUntilIdle() + + val editor = + assertIs(comp.detailsRouterState.value.active.instance) + assertEquals(2, editor.component.state.value.sceneItem.id) + } + + @Test + fun `Back from the reader shows the list again in single pane`() = runTest(mainTestDispatcher) { + every { sceneEditor.getSceneTree() } returns treeOf(sceneItem(1)) + + val comp = newComponent() + context.resume() + advanceUntilIdle() + + comp.showReader(null) + advanceUntilIdle() + comp.onBack() + advanceUntilIdle() + + assertFalse(comp.isDetailShown()) + assertIs(comp.listRouterState.value.active.instance) + } + + @Test + fun `Tree updates do not close the reader`() = runTest(mainTestDispatcher) { + every { sceneEditor.getSceneTree() } returns treeOf(sceneItem(1), sceneItem(2)) + + val comp = newComponent() + context.resume() + advanceUntilIdle() + + comp.showReader(sceneItem(1)) + advanceUntilIdle() + + val updated = SceneSummary(treeOf(sceneItem(2)), persistentSetOf()) + sceneTreeCallbacks.toList().forEach { it(updated) } + sceneUpdates.emit(updated) + advanceUntilIdle() + + assertEquals(2, comp.reader().state.value.activeChapterId) + } + + @Test + fun `The reader survives process death`() = runTest(mainTestDispatcher) { + every { sceneEditor.getSceneTree() } returns treeOf(sceneItem(1), sceneItem(2)) + + val comp = newComponent() + context.resume() + advanceUntilIdle() + + comp.showReader(null) + advanceUntilIdle() + comp.reader().showChapter(2) + advanceUntilIdle() + + val restoredContext = context.saveAndRecreate() + val restored = newComponent(restoredContext) + restoredContext.resume() + advanceUntilIdle() + + assertEquals(2, restored.reader().state.value.activeChapterId) + } + @Test fun `Unsaved buffer queries delegate to the scene editor service`() = runTest(mainTestDispatcher) { every { sceneEditor.hasDirtyBuffers() } returns true diff --git a/common/src/desktopTest/kotlin/components/storyeditor/readermode/ReaderModeComponentTest.kt b/common/src/desktopTest/kotlin/components/storyeditor/readermode/ReaderModeComponentTest.kt new file mode 100644 index 000000000..c3d3a4158 --- /dev/null +++ b/common/src/desktopTest/kotlin/components/storyeditor/readermode/ReaderModeComponentTest.kt @@ -0,0 +1,476 @@ +package components.storyeditor.readermode + +import com.darkrockstudios.apps.hammer.common.components.storyeditor.readermode.ReaderModeComponent +import com.darkrockstudios.apps.hammer.common.components.storyeditor.sceneeditor.EDITOR_TEXT_SIZES +import com.darkrockstudios.apps.hammer.common.data.SceneBuffer +import com.darkrockstudios.apps.hammer.common.data.SceneContent +import com.darkrockstudios.apps.hammer.common.data.SceneItem +import com.darkrockstudios.apps.hammer.common.data.SceneSummary +import com.darkrockstudios.apps.hammer.common.data.UpdateSource +import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettings +import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettingsStore +import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.SceneEditorService +import com.darkrockstudios.apps.hammer.common.data.tree.ImmutableTree +import com.darkrockstudios.apps.hammer.common.data.tree.Tree +import com.darkrockstudios.apps.hammer.common.data.tree.TreeNode +import com.darkrockstudios.apps.hammer.common.dependencyinjection.ProjectDefScope +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.koin.dsl.module +import utils.ComponentTest +import utils.TestComponentContext +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class ReaderModeComponentTest : ComponentTest() { + + private lateinit var sceneEditor: SceneEditorService + private lateinit var settingsStore: GlobalSettingsStore + private lateinit var settingsUpdates: MutableSharedFlow + + private lateinit var sceneUpdates: MutableSharedFlow + private val bufferCallback = slot Unit>() + private val settingsTransform = slot<(GlobalSettings) -> GlobalSettings>() + + private var closed = false + private var editedScene: SceneItem? = null + private val scenesInView = mutableListOf() + + private val chapterOne = item(10, SceneItem.Type.Group) + private val sceneA = item(11, SceneItem.Type.Scene) + private val sceneB = item(12, SceneItem.Type.Scene) + private val chapterTwo = item(20, SceneItem.Type.Group) + private val sceneC = item(21, SceneItem.Type.Scene) + private val loneScene = item(30, SceneItem.Type.Scene) + + private fun item(id: Int, type: SceneItem.Type, name: String = "Item $id") = + SceneItem(projectDef = projectDef, type = type, id = id, name = name, order = id) + + private fun tree(vararg chapters: Pair>): ImmutableTree { + val root = TreeNode(item(SceneItem.ROOT_ID, SceneItem.Type.Root)) + chapters.forEach { (chapter, scenes) -> + val node = TreeNode(chapter) + scenes.forEach { node.addChild(TreeNode(it)) } + root.addChild(node) + } + return Tree().apply { setRoot(root) }.toImmutableTree() + } + + private fun fullTree() = tree( + chapterOne to listOf(sceneA, sceneB), + chapterTwo to listOf(sceneC), + loneScene to emptyList(), + ) + + private fun summary(tree: ImmutableTree) = SceneSummary(tree, persistentSetOf()) + + @BeforeEach + override fun setup() { + super.setup() + + closed = false + editedScene = null + scenesInView.clear() + settingsUpdates = MutableSharedFlow(extraBufferCapacity = 4) + + settingsStore = mockk(relaxed = true) + every { settingsStore.globalSettings } returns GlobalSettings(projectsDirectory = "/projects") + every { settingsStore.globalSettingsUpdates } returns settingsUpdates + coEvery { settingsStore.updateSettings(capture(settingsTransform)) } returns Unit + + sceneEditor = mockk(relaxed = true) + every { sceneEditor.getSceneTree() } returns fullTree() + every { sceneEditor.getCurrentSceneContentOrNull(any()) } answers { "text of ${firstArg().id}" } + sceneUpdates = MutableSharedFlow(replay = 1) + every { sceneEditor.sceneListChannel } returns sceneUpdates + every { + sceneEditor.subscribeToBufferUpdates(any(), any(), capture(bufferCallback)) + } returns mockk(relaxed = true) + + setupComponentKoin(module { + single { settingsStore } + scope { + scoped { sceneEditor } + } + }) + } + + private fun newComponent( + initialScene: SceneItem? = null, + componentContext: TestComponentContext = context, + ) = ReaderModeComponent( + componentContext = componentContext, + projectDef = projectDef, + initialScene = initialScene, + closeReader = { closed = true }, + openSceneEditor = { editedScene = it }, + onSceneInView = { scenesInView.add(it) }, + ) + + private fun ReaderModeComponent.sceneIds() = state.value.scenes.map { it.sceneItem.id } + + @Test + fun `Opens on the first chapter with its scenes stitched in order`() = runTest(mainTestDispatcher) { + val comp = newComponent() + context.resume() + advanceUntilIdle() + + val state = comp.state.value + assertEquals(listOf(10, 20, 30), state.chapters.map { it.chapter.id }) + assertEquals(listOf(2, 1, 1), state.chapters.map { it.sceneCount }) + assertEquals(chapterOne.id, state.activeChapterId) + assertEquals(listOf("text of 11", "text of 12"), state.scenes.map { it.markdown }) + assertFalse(state.isLoading) + assertNull(state.scrollToSceneId) + } + + @Test + fun `Opens on the chapter holding the initial scene and asks to scroll to it`() = runTest(mainTestDispatcher) { + val comp = newComponent(initialScene = sceneC) + context.resume() + advanceUntilIdle() + + assertEquals(chapterTwo.id, comp.state.value.activeChapterId) + assertEquals(sceneC.id, comp.state.value.scrollToSceneId) + assertEquals(listOf(sceneC.id), comp.sceneIds()) + } + + @Test + fun `Next and previous stop at the ends of the book`() = runTest(mainTestDispatcher) { + val comp = newComponent() + context.resume() + advanceUntilIdle() + + comp.previousChapter() + advanceUntilIdle() + assertEquals(chapterOne.id, comp.state.value.activeChapterId) + + comp.nextChapter() + comp.nextChapter() + comp.nextChapter() + advanceUntilIdle() + + assertEquals(loneScene.id, comp.state.value.activeChapterId) + assertEquals(listOf(loneScene.id), comp.sceneIds()) + } + + @Test + fun `showScene switches chapter and requests a scroll`() = runTest(mainTestDispatcher) { + val comp = newComponent() + context.resume() + advanceUntilIdle() + + comp.showScene(sceneC) + advanceUntilIdle() + + assertEquals(chapterTwo.id, comp.state.value.activeChapterId) + assertEquals(sceneC.id, comp.state.value.scrollToSceneId) + assertEquals(listOf("text of 21"), comp.state.value.scenes.map { it.markdown }) + } + + @Test + fun `Scene in view is not reported while a scroll is pending`() = runTest(mainTestDispatcher) { + val comp = newComponent() + context.resume() + advanceUntilIdle() + + comp.showScene(sceneB) + comp.sceneInView(sceneA) + + assertTrue(scenesInView.isEmpty()) + + comp.scrollHandled() + + assertEquals(listOf(sceneB), scenesInView) + assertEquals(sceneB, comp.state.value.sceneInView) + assertNull(comp.state.value.scrollToSceneId) + } + + @Test + fun `Scene in view is reported only when it changes`() = runTest(mainTestDispatcher) { + val comp = newComponent() + context.resume() + advanceUntilIdle() + + comp.sceneInView(sceneA) + comp.sceneInView(sceneA) + comp.sceneInView(sceneB) + + assertEquals(listOf(sceneA, sceneB), scenesInView) + } + + @Test + fun `Showing a scene the reader does not know re-reports the scene in view`() = runTest(mainTestDispatcher) { + val comp = newComponent() + context.resume() + advanceUntilIdle() + comp.sceneInView(sceneA) + + comp.showScene(SceneItem(projectDef, SceneItem.Type.Scene, id = 99, name = "Unknown", order = 99)) + + assertEquals(listOf(sceneA, sceneA), scenesInView) + assertNull(comp.state.value.scrollToSceneId) + } + + @Test + fun `Opening the reader does not force a scene list reload`() = runTest(mainTestDispatcher) { + newComponent() + context.resume() + advanceUntilIdle() + + verify(exactly = 0) { sceneEditor.subscribeToSceneUpdates(any(), any()) } + } + + @Test + fun `Edit opens the scene in view, or the first scene when none is`() = runTest(mainTestDispatcher) { + val comp = newComponent() + context.resume() + advanceUntilIdle() + + comp.editScene() + assertEquals(sceneA, editedScene) + + comp.sceneInView(sceneB) + comp.editScene() + assertEquals(sceneB, editedScene) + } + + @Test + fun `An update with an unchanged tree does not reload the chapter`() = runTest(mainTestDispatcher) { + val comp = newComponent() + context.resume() + advanceUntilIdle() + + sceneUpdates.emit(summary(fullTree())) + advanceUntilIdle() + + verify(exactly = 2) { sceneEditor.getCurrentSceneContentOrNull(any()) } + assertEquals(listOf(11, 12), comp.sceneIds()) + } + + @Test + fun `A scene added to the active chapter reloads it`() = runTest(mainTestDispatcher) { + val comp = newComponent() + context.resume() + advanceUntilIdle() + + val added = item(13, SceneItem.Type.Scene) + sceneUpdates.emit( + summary(tree(chapterOne to listOf(sceneA, sceneB, added), chapterTwo to listOf(sceneC))) + ) + advanceUntilIdle() + + assertEquals(listOf(11, 12, 13), comp.sceneIds()) + assertEquals(listOf(3, 1), comp.state.value.chapters.map { it.sceneCount }) + } + + @Test + fun `A rename refreshes names without reloading`() = runTest(mainTestDispatcher) { + val comp = newComponent() + context.resume() + advanceUntilIdle() + + val renamed = sceneA.copy(name = "Renamed") + sceneUpdates.emit( + summary(tree(chapterOne to listOf(renamed, sceneB), chapterTwo to listOf(sceneC), loneScene to emptyList())) + ) + advanceUntilIdle() + + assertEquals("Renamed", comp.state.value.scenes.first().sceneItem.name) + verify(exactly = 2) { sceneEditor.getCurrentSceneContentOrNull(any()) } + } + + @Test + fun `Removing the active chapter moves to its neighbour instead of closing`() = runTest(mainTestDispatcher) { + val comp = newComponent(initialScene = loneScene) + context.resume() + advanceUntilIdle() + + sceneUpdates.emit(summary(tree(chapterOne to listOf(sceneA, sceneB), chapterTwo to listOf(sceneC)))) + advanceUntilIdle() + + assertFalse(closed) + assertEquals(chapterTwo.id, comp.state.value.activeChapterId) + assertEquals(listOf(sceneC.id), comp.sceneIds()) + } + + @Test + fun `An emptied book shows no chapters and recovers when scenes return`() = runTest(mainTestDispatcher) { + val comp = newComponent() + context.resume() + advanceUntilIdle() + + sceneUpdates.emit(summary(tree())) + advanceUntilIdle() + + assertFalse(closed) + assertTrue(comp.state.value.chapters.isEmpty()) + assertNull(comp.state.value.activeChapterId) + assertFalse(comp.state.value.isLoading) + + sceneUpdates.emit(summary(fullTree())) + advanceUntilIdle() + + assertEquals(chapterOne.id, comp.state.value.activeChapterId) + assertEquals(listOf(11, 12), comp.sceneIds()) + } + + @Test + fun `A buffer update that arrives while the chapter loads is not lost`() = runTest(mainTestDispatcher) { + val comp = newComponent() + context.resume() + + bufferCallback.captured( + SceneBuffer(SceneContent(sceneB, markdown = "fresh words"), dirty = true, source = UpdateSource.Editor) + ) + advanceUntilIdle() + + assertEquals(listOf("text of 11", "fresh words"), comp.state.value.scenes.map { it.markdown }) + } + + @Test + fun `A buffer update replaces the text of a scene being read`() = runTest(mainTestDispatcher) { + val comp = newComponent() + context.resume() + advanceUntilIdle() + + bufferCallback.captured( + SceneBuffer(SceneContent(sceneB, markdown = "fresh words"), dirty = true, source = UpdateSource.Editor) + ) + advanceUntilIdle() + + assertEquals(listOf("text of 11", "fresh words"), comp.state.value.scenes.map { it.markdown }) + } + + @Test + fun `A buffer update for another chapter is ignored`() = runTest(mainTestDispatcher) { + val comp = newComponent() + context.resume() + advanceUntilIdle() + val before = comp.state.value + + bufferCallback.captured( + SceneBuffer(SceneContent(sceneC, markdown = "elsewhere"), dirty = true, source = UpdateSource.Editor) + ) + advanceUntilIdle() + + assertEquals(before, comp.state.value) + } + + @Test + fun `A scene whose content cannot be read renders empty`() = runTest(mainTestDispatcher) { + every { sceneEditor.getCurrentSceneContentOrNull(sceneB) } returns null + + val comp = newComponent() + context.resume() + advanceUntilIdle() + + assertEquals(listOf("text of 11", ""), comp.state.value.scenes.map { it.markdown }) + } + + @Test + fun `An empty group is a chapter with no scenes`() = runTest(mainTestDispatcher) { + every { sceneEditor.getSceneTree() } returns tree(chapterOne to emptyList()) + + val comp = newComponent() + context.resume() + advanceUntilIdle() + + assertEquals(chapterOne.id, comp.state.value.activeChapterId) + assertTrue(comp.state.value.scenes.isEmpty()) + assertFalse(comp.state.value.isLoading) + } + + @Test + fun `Font size changes are written to the reader setting and clamped`() = runTest(mainTestDispatcher) { + val comp = newComponent() + context.resume() + advanceUntilIdle() + val settings = GlobalSettings(projectsDirectory = "/projects", editorFontSize = 20f) + + comp.increaseFontSize() + advanceUntilIdle() + val increased = settingsTransform.captured(settings) + assertEquals(18f, increased.readerFontSize) + assertEquals(20f, increased.editorFontSize) + + comp.decreaseFontSize() + advanceUntilIdle() + assertEquals(14f, settingsTransform.captured(settings).readerFontSize) + + comp.increaseFontSize() + advanceUntilIdle() + assertEquals( + EDITOR_TEXT_SIZES.last(), + settingsTransform.captured(settings.copy(readerFontSize = EDITOR_TEXT_SIZES.last())).readerFontSize, + ) + + comp.resetFontSize() + advanceUntilIdle() + assertEquals( + GlobalSettings.DEFAULT_FONT_SIZE, + settingsTransform.captured(settings.copy(readerFontSize = 30f)).readerFontSize, + ) + } + + @Test + fun `The scene headings toggle is persisted and mirrored from settings`() = runTest(mainTestDispatcher) { + val comp = newComponent() + context.resume() + advanceUntilIdle() + val settings = GlobalSettings(projectsDirectory = "/projects") + + comp.toggleSceneHeadings() + advanceUntilIdle() + val toggled = settingsTransform.captured(settings) + assertTrue(toggled.readerShowSceneHeadings) + + settingsUpdates.emit(toggled.copy(readerFontSize = 22f)) + advanceUntilIdle() + + assertTrue(comp.state.value.showSceneHeadings) + assertEquals(22f, comp.state.value.fontSize) + } + + @Test + fun `The chapter and scene being read survive process death`() = runTest(mainTestDispatcher) { + val comp = newComponent() + context.resume() + advanceUntilIdle() + comp.showChapter(chapterTwo.id) + advanceUntilIdle() + comp.sceneInView(sceneC) + + val restoredContext = context.saveAndRecreate() + val restored = newComponent(initialScene = sceneA, componentContext = restoredContext) + restoredContext.resume() + advanceUntilIdle() + + assertEquals(chapterTwo.id, restored.state.value.activeChapterId) + assertEquals(sceneC.id, restored.state.value.scrollToSceneId) + } + + @Test + fun `Close invokes the close callback`() = runTest(mainTestDispatcher) { + val comp = newComponent() + context.resume() + advanceUntilIdle() + + comp.close() + + assertTrue(closed) + } +} diff --git a/common/src/desktopTest/kotlin/components/storyeditor/sceneeditor/EditorTextSizeStepsTest.kt b/common/src/desktopTest/kotlin/components/storyeditor/sceneeditor/EditorTextSizeStepsTest.kt new file mode 100644 index 000000000..08dd702b1 --- /dev/null +++ b/common/src/desktopTest/kotlin/components/storyeditor/sceneeditor/EditorTextSizeStepsTest.kt @@ -0,0 +1,30 @@ +package components.storyeditor.sceneeditor + +import com.darkrockstudios.apps.hammer.common.components.storyeditor.sceneeditor.EDITOR_TEXT_SIZES +import com.darkrockstudios.apps.hammer.common.components.storyeditor.sceneeditor.decreaseEditorTextSize +import com.darkrockstudios.apps.hammer.common.components.storyeditor.sceneeditor.increaseEditorTextSize +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals + +class EditorTextSizeStepsTest { + + @Test + fun `steps walk the ladder`() { + assertEquals(18f, increaseEditorTextSize(16f)) + assertEquals(14f, decreaseEditorTextSize(16f)) + } + + @Test + fun `a size off the ladder snaps to it`() { + assertEquals(18f, increaseEditorTextSize(17f)) + assertEquals(16f, decreaseEditorTextSize(17f)) + } + + @Test + fun `steps stop at the ends`() { + assertEquals(EDITOR_TEXT_SIZES.last(), increaseEditorTextSize(EDITOR_TEXT_SIZES.last())) + assertEquals(EDITOR_TEXT_SIZES.first(), decreaseEditorTextSize(EDITOR_TEXT_SIZES.first())) + assertEquals(EDITOR_TEXT_SIZES.last(), increaseEditorTextSize(99f)) + assertEquals(EDITOR_TEXT_SIZES.first(), decreaseEditorTextSize(1f)) + } +} diff --git a/common/src/desktopTest/kotlin/components/storyeditor/sceneeditor/SceneEditorComponentTest.kt b/common/src/desktopTest/kotlin/components/storyeditor/sceneeditor/SceneEditorComponentTest.kt index dfeac6953..25f9194dc 100644 --- a/common/src/desktopTest/kotlin/components/storyeditor/sceneeditor/SceneEditorComponentTest.kt +++ b/common/src/desktopTest/kotlin/components/storyeditor/sceneeditor/SceneEditorComponentTest.kt @@ -59,6 +59,7 @@ class SceneEditorComponentTest : ComponentTest() { private var closeCount = 0 private var focusShownFor: SceneItem? = null + private var readerShownFor: SceneItem? = null private val addedMenus = mutableListOf() private val removedMenuIds = mutableListOf() @@ -114,6 +115,7 @@ class SceneEditorComponentTest : ComponentTest() { closeCount = 0 focusShownFor = null + readerShownFor = null addedMenus.clear() removedMenuIds.clear() } @@ -126,6 +128,7 @@ class SceneEditorComponentTest : ComponentTest() { closeSceneEditor = { closeCount++ }, showDraftsList = {}, showFocusMode = { focusShownFor = it }, + showReader = { readerShownFor = it }, showEntry = {}, showGlobalSearchForTag = {}, ) @@ -464,6 +467,14 @@ class SceneEditorComponentTest : ComponentTest() { assertEquals(sceneItem, focusShownFor) } + @Test + fun `readScene forwards the scene to the parent`() = runTest(mainTestDispatcher) { + val comp = newComponent() + + comp.readScene() + assertEquals(sceneItem, readerShownFor) + } + @Test fun `closeEditor invokes the parent callback`() = runTest(mainTestDispatcher) { val comp = newComponent() @@ -529,6 +540,7 @@ class SceneEditorComponentTest : ComponentTest() { sceneTreeCallback.captured.invoke(SceneSummary(treeOf(renamed), persistentSetOf())) assertEquals("Renamed Externally", comp.state.value.sceneItem.name) + assertEquals("Renamed Externally", comp.sceneMetadataComponent.state.value.sceneItem.name) } @Test diff --git a/common/src/desktopTest/kotlin/components/storyeditor/scenelist/SceneListComponentTest.kt b/common/src/desktopTest/kotlin/components/storyeditor/scenelist/SceneListComponentTest.kt index 4f542a827..6d8871634 100644 --- a/common/src/desktopTest/kotlin/components/storyeditor/scenelist/SceneListComponentTest.kt +++ b/common/src/desktopTest/kotlin/components/storyeditor/scenelist/SceneListComponentTest.kt @@ -37,14 +37,18 @@ class SceneListComponentTest : ComponentTest() { private val sceneListCallback = slot<(SceneSummary) -> Unit>() private val selectedScenes = mutableListOf() + private val openedScenes = mutableListOf() private var outlineShown = false + private var readerShown = false @BeforeEach override fun setup() { super.setup() selectedScenes.clear() + openedScenes.clear() outlineShown = false + readerShown = false selectedSceneFlow = MutableSharedFlow(replay = 1) sceneEditor = mockk(relaxed = true) @@ -68,7 +72,9 @@ class SceneListComponentTest : ComponentTest() { projectDef = projectDef, selectedSceneItem = selectedSceneFlow, sceneSelected = { selectedScenes.add(it) }, + openSceneEditor = { openedScenes.add(it) }, showOutlineOverviewDialog = { outlineShown = true }, + showReader = { readerShown = true }, ) private fun sceneItem(id: Int = 1, type: SceneItem.Type = SceneItem.Type.Scene) = SceneItem( @@ -115,7 +121,7 @@ class SceneListComponentTest : ComponentTest() { } @Test - fun `Creating a scene under the root passes a null parent and selects the new scene`() = + fun `Creating a scene under the root passes a null parent and opens the new scene in the editor`() = runTest(mainTestDispatcher) { val root = sceneItem(0, SceneItem.Type.Root) val created = sceneItem(9) @@ -129,11 +135,12 @@ class SceneListComponentTest : ComponentTest() { advanceUntilIdle() coVerify { sceneEditor.createScene(null, "New Scene") } - assertEquals(listOf(created), selectedScenes) + assertEquals(listOf(created), openedScenes) + assertTrue(selectedScenes.isEmpty()) } @Test - fun `Failed scene creation selects nothing`() = runTest(mainTestDispatcher) { + fun `Failed scene creation opens nothing`() = runTest(mainTestDispatcher) { coEvery { sceneEditor.createScene(any(), any()) } returns null val comp = newComponent() @@ -143,7 +150,7 @@ class SceneListComponentTest : ComponentTest() { comp.createScene(null, "Nope") advanceUntilIdle() - assertTrue(selectedScenes.isEmpty()) + assertTrue(openedScenes.isEmpty()) } @Test @@ -226,7 +233,7 @@ class SceneListComponentTest : ComponentTest() { } @Test - fun `Unarchiving selects the restored scene and closes the dialog`() = runTest(mainTestDispatcher) { + fun `Unarchiving opens the restored scene in the editor and closes the dialog`() = runTest(mainTestDispatcher) { val archived = sceneItem(8).copy(archived = true) val restored = archived.copy(archived = false) coEvery { sceneEditor.unarchiveScene(archived) } returns restored @@ -239,7 +246,7 @@ class SceneListComponentTest : ComponentTest() { comp.unarchiveScene(archived) advanceUntilIdle() - assertEquals(listOf(restored), selectedScenes) + assertEquals(listOf(restored), openedScenes) assertFalse(comp.state.value.showArchivedDialog) } @@ -256,7 +263,7 @@ class SceneListComponentTest : ComponentTest() { comp.unarchiveScene(archived) advanceUntilIdle() - assertTrue(selectedScenes.isEmpty()) + assertTrue(openedScenes.isEmpty()) assertTrue(comp.state.value.showArchivedDialog) } @@ -283,6 +290,16 @@ class SceneListComponentTest : ComponentTest() { assertTrue(outlineShown) } + @Test + fun `Reader mode request is forwarded`() = runTest(mainTestDispatcher) { + val comp = newComponent() + context.resume() + advanceUntilIdle() + + comp.showReaderMode() + assertTrue(readerShown) + } + @Test fun `Rename delegates to the repository`() = runTest(mainTestDispatcher) { val scene = sceneItem(1) diff --git a/common/src/desktopTest/kotlin/components/storyeditor/scenemetadata/SceneMetadataPanelComponentTest.kt b/common/src/desktopTest/kotlin/components/storyeditor/scenemetadata/SceneMetadataPanelComponentTest.kt index b9e9889c0..bebd83d05 100644 --- a/common/src/desktopTest/kotlin/components/storyeditor/scenemetadata/SceneMetadataPanelComponentTest.kt +++ b/common/src/desktopTest/kotlin/components/storyeditor/scenemetadata/SceneMetadataPanelComponentTest.kt @@ -2,9 +2,11 @@ package components.storyeditor.scenemetadata import com.darkrockstudios.apps.hammer.common.components.storyeditor.sceneeditor.scenemetadata.SceneMetadataPanelComponent import com.darkrockstudios.apps.hammer.common.data.SceneItem +import com.darkrockstudios.apps.hammer.common.data.UpdateSource import com.darkrockstudios.apps.hammer.common.data.encyclopediarepository.EncyclopediaService import com.darkrockstudios.apps.hammer.common.data.references.ScrubInvalidReferencesUseCase import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.SceneEditorService +import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.SceneMetadataUpdate import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.scenemetadata.SceneMetadata import com.darkrockstudios.apps.hammer.common.dependencyinjection.APP_SCOPE import com.darkrockstudios.apps.hammer.common.dependencyinjection.ProjectDefScope @@ -24,11 +26,13 @@ import org.junit.jupiter.api.Test import org.koin.core.qualifier.named import org.koin.dsl.module import utils.ComponentTest +import kotlin.test.assertEquals @OptIn(ExperimentalCoroutinesApi::class) class SceneMetadataPanelComponentTest : ComponentTest() { private lateinit var sceneEditor: SceneEditorService + private lateinit var metadataUpdateFlow: MutableSharedFlow private val sceneItem get() = SceneItem( @@ -44,7 +48,8 @@ class SceneMetadataPanelComponentTest : ComponentTest() { super.setup() sceneEditor = mockk(relaxed = true) - every { sceneEditor.metadataUpdateFlow } returns MutableSharedFlow() + metadataUpdateFlow = MutableSharedFlow(extraBufferCapacity = 8) + every { sceneEditor.metadataUpdateFlow } returns metadataUpdateFlow every { sceneEditor.getSceneBuffer(any()) } returns null every { sceneEditor.getSceneFilePathOrNull(any()) } returns null coEvery { sceneEditor.loadSceneMetadata(any()) } returns SceneMetadata() @@ -70,6 +75,100 @@ class SceneMetadataPanelComponentTest : ComponentTest() { onShowGlobalSearchForTag = { }, ) + @Test + fun `A changed scene item updates the title`() = runTest(mainTestDispatcher) { + val component = newComponent() + context.resume() + advanceUntilIdle() + + component.onSceneItemChanged(sceneItem.copy(name = "Renamed By Sync")) + advanceUntilIdle() + + assertEquals("Renamed By Sync", component.state.value.sceneItem.name) + } + + @Test + fun `A synced metadata write replaces the outline and notes`() = runTest(mainTestDispatcher) { + coEvery { sceneEditor.loadSceneMetadata(any()) } returns + SceneMetadata(outline = "old outline", notes = "old notes") + val component = newComponent() + context.resume() + advanceUntilIdle() + + metadataUpdateFlow.emit( + SceneMetadataUpdate( + sceneItem.id, + SceneMetadata(outline = "server outline", notes = "server notes"), + UpdateSource.Sync, + ) + ) + advanceUntilIdle() + + assertEquals("server outline", component.state.value.metadata.outline) + assertEquals("server notes", component.state.value.metadata.notes) + // Storage already holds the synced metadata, so the panel must not write it back. + coVerify(exactly = 0) { sceneEditor.storeMetadata(any(), any(), any()) } + } + + @Test + fun `A synced metadata write keeps an unsaved outline edit`() = runTest(mainTestDispatcher) { + // The debounced store never completes, so the local edit stays unsaved. + coEvery { sceneEditor.storeMetadata(any(), any()) } coAnswers { awaitCancellation() } + val component = newComponent() + context.resume() + advanceUntilIdle() + + component.updateOutline("my unsaved outline") + metadataUpdateFlow.emit( + SceneMetadataUpdate( + sceneItem.id, + SceneMetadata(outline = "server outline", tags = setOf("synced")), + UpdateSource.Sync, + ) + ) + advanceUntilIdle() + + assertEquals("my unsaved outline", component.state.value.metadata.outline) + assertEquals(setOf("synced"), component.state.value.metadata.tags) + } + + @Test + fun `A non-sync metadata write keeps the local outline`() = runTest(mainTestDispatcher) { + coEvery { sceneEditor.loadSceneMetadata(any()) } returns SceneMetadata(outline = "local outline") + val component = newComponent() + context.resume() + advanceUntilIdle() + + metadataUpdateFlow.emit( + SceneMetadataUpdate( + sceneItem.id, + SceneMetadata(outline = "stale outline", tags = setOf("auto")), + UpdateSource.Editor, + ) + ) + advanceUntilIdle() + + assertEquals("local outline", component.state.value.metadata.outline) + assertEquals(setOf("auto"), component.state.value.metadata.tags) + } + + @Test + fun `A failed store does not stop later stores`() = runTest(mainTestDispatcher) { + coEvery { sceneEditor.storeMetadata(any(), any()) } throws IOException("volume gone") + val component = newComponent() + context.resume() + advanceUntilIdle() + + component.updateOutline("first") + advanceUntilIdle() + + coEvery { sceneEditor.storeMetadata(any(), any()) } returns Unit + component.updateOutline("second") + advanceUntilIdle() + + coVerify { sceneEditor.storeMetadata(match { it.outline == "second" }, sceneItem.id) } + } + @Test fun `Destroy flushes pending metadata edits`() = runTest(mainTestDispatcher) { val component = newComponent() diff --git a/common/src/desktopTest/kotlin/components/timeline/TimeLineComponentTest.kt b/common/src/desktopTest/kotlin/components/timeline/TimeLineComponentTest.kt index 928634acc..701e2ee6a 100644 --- a/common/src/desktopTest/kotlin/components/timeline/TimeLineComponentTest.kt +++ b/common/src/desktopTest/kotlin/components/timeline/TimeLineComponentTest.kt @@ -20,6 +20,7 @@ import org.koin.dsl.bind import org.koin.dsl.module import utils.ComponentTest import kotlin.test.assertEquals +import kotlin.test.assertIs class TimeLineComponentTest : ComponentTest() { @@ -100,4 +101,38 @@ class TimeLineComponentTest : ComponentTest() { assertEquals(setOf(CloseConfirm.Timeline), component.shouldConfirmClose()) } + + @Test + fun `discardUnsaved reverts a dirty timeline event edit`() = runTest(mainTestDispatcher) { + val component = newComponent() + context.resume() + component.showViewEvent(event.id) + advanceUntilIdle() + + val viewEvent = viewEvent(component) + viewEvent.beginEdit() + viewEvent.onEventTextChanged("a different body") + advanceUntilIdle() + + component.discardUnsaved(CloseConfirm.Timeline) + advanceUntilIdle() + + assertEquals(emptySet(), component.shouldConfirmClose()) + assertEquals(event.content, viewEvent.contentText.value) + } + + @Test + fun `discardUnsaved pops an open create event screen`() = runTest(mainTestDispatcher) { + val component = newComponent() + context.resume() + component.showCreateEvent() + advanceUntilIdle() + assertEquals(setOf(CloseConfirm.Timeline), component.shouldConfirmClose()) + + component.discardUnsaved(CloseConfirm.Timeline) + advanceUntilIdle() + + assertIs(component.stack.value.active.instance) + assertEquals(emptySet(), component.shouldConfirmClose()) + } } diff --git a/common/src/desktopTest/kotlin/components/timeline/ViewTimeLineEventComponentTest.kt b/common/src/desktopTest/kotlin/components/timeline/ViewTimeLineEventComponentTest.kt index 2ea328230..674ebc4aa 100644 --- a/common/src/desktopTest/kotlin/components/timeline/ViewTimeLineEventComponentTest.kt +++ b/common/src/desktopTest/kotlin/components/timeline/ViewTimeLineEventComponentTest.kt @@ -65,4 +65,84 @@ class ViewTimeLineEventComponentTest : TimeLineTestBase() { assertFalse(component.state.value.isEditing, "storeEvent should exit edit mode") assertEquals(updatedEvent, component.state.value.event, "State should refresh to the stored event") } + + private fun newComponent(eventId: Int) = ViewTimeLineEventComponent( + componentContext = context, + projectDef = getProjectDef(PROJECT_EMPTY_NAME), + eventId = eventId, + onCloseEvent = {}, + addMenu = {}, + removeMenu = {}, + updateShouldClose = {}, + onShowGlobalSearchForTag = {}, + ) + + @Test + fun `An event changed elsewhere refreshes the open view`() = runTest { + val events = fakeEvents() + val component = newComponent(eventId = 0) + lifecycleCallbacks[1].onCreate() + advanceUntilIdle() + + timelineRepoCollectCallback.captured.emit(TimeLineContainer(events)) + advanceUntilIdle() + assertEquals("Event 0", component.contentText.value) + + val synced = events.first().copy(date = "server date", content = "server content") + timelineRepoCollectCallback.captured.emit(TimeLineContainer(listOf(synced) + events.drop(1))) + advanceUntilIdle() + + assertEquals(synced, component.state.value.event) + assertEquals("server content", component.contentText.value) + assertEquals("server date", component.dateText.value) + } + + @Test + fun `An event changed elsewhere replaces an untouched edit`() = runTest { + val events = fakeEvents() + // Event 1 has no date, which must not read as a dirty edit. + val component = newComponent(eventId = 1) + lifecycleCallbacks[1].onCreate() + advanceUntilIdle() + + timelineRepoCollectCallback.captured.emit(TimeLineContainer(events)) + advanceUntilIdle() + + component.beginEdit() + assertFalse(component.isEditingAndDirty()) + + val synced = events[1].copy(content = "server content") + timelineRepoCollectCallback.captured.emit( + TimeLineContainer(events.map { if (it.id == 1) synced else it }) + ) + advanceUntilIdle() + + assertTrue(component.state.value.isEditing) + assertEquals("server content", component.contentText.value) + assertFalse(component.isEditingAndDirty()) + } + + @Test + fun `An event changed elsewhere while editing keeps the draft`() = runTest { + val events = fakeEvents() + val component = newComponent(eventId = 0) + lifecycleCallbacks[1].onCreate() + advanceUntilIdle() + + timelineRepoCollectCallback.captured.emit(TimeLineContainer(events)) + advanceUntilIdle() + + component.beginEdit() + component.onEventTextChanged("my draft") + + val synced = events.first().copy(content = "server content") + timelineRepoCollectCallback.captured.emit(TimeLineContainer(listOf(synced) + events.drop(1))) + advanceUntilIdle() + + assertEquals(synced, component.state.value.event) + assertEquals("my draft", component.contentText.value) + + component.discardEdit() + assertEquals("server content", component.contentText.value) + } } \ No newline at end of file diff --git a/common/src/desktopTest/kotlin/data/changelog/InAppChangelogSupportTest.kt b/common/src/desktopTest/kotlin/data/changelog/InAppChangelogSupportTest.kt new file mode 100644 index 000000000..a1f9b17d4 --- /dev/null +++ b/common/src/desktopTest/kotlin/data/changelog/InAppChangelogSupportTest.kt @@ -0,0 +1,17 @@ +package data.changelog + +import com.darkrockstudios.apps.hammer.base.DistributionChannel +import com.darkrockstudios.apps.hammer.common.data.changelog.supportsInAppChangelog +import kotlin.test.Test +import kotlin.test.assertEquals + +class InAppChangelogSupportTest { + + @Test + fun `only the Mac App Store build hides the in-app changelog`() { + for (channel in DistributionChannel.entries) { + val expected = channel != DistributionChannel.MAC_APP_STORE + assertEquals(expected, supportsInAppChangelog(channel), "channel ${channel.token}") + } + } +} diff --git a/common/src/desktopTest/kotlin/repositories/globalsettings/GlobalSettingsDatasourceTest.kt b/common/src/desktopTest/kotlin/repositories/globalsettings/GlobalSettingsDatasourceTest.kt index 7a8cef09c..8e9c5b5f2 100644 --- a/common/src/desktopTest/kotlin/repositories/globalsettings/GlobalSettingsDatasourceTest.kt +++ b/common/src/desktopTest/kotlin/repositories/globalsettings/GlobalSettingsDatasourceTest.kt @@ -127,6 +127,25 @@ class GlobalSettingsDatasourceTest : BaseTest() { assertEquals(InitialProjectScreen.Home, loaded.initialProjectScreen) } + @Test + fun `Load Global Settings missing reader keys uses the reader defaults`() = runTest { + val datasource = createDatasource() + fileSystem.createDirectories(GlobalSettingsFilesystemDatasource.CONFIG_PATH.parent!!) + fileSystem.write(GlobalSettingsFilesystemDatasource.CONFIG_PATH) { + writeUtf8( + """ + projectsDirectory = "test" + editorFontSize = 22.0 + """.trimIndent() + ) + } + + val loaded: GlobalSettings = datasource.loadSettings() + + assertEquals(GlobalSettings.DEFAULT_FONT_SIZE, loaded.readerFontSize) + assertEquals(false, loaded.readerShowSceneHeadings) + } + @Test fun `Store and load preserves initialProjectScreen`() = runTest { val datasource = createDatasource() diff --git a/common/src/desktopTest/kotlin/repositories/references/AutoConfirmReferencesUseCaseTest.kt b/common/src/desktopTest/kotlin/repositories/references/AutoConfirmReferencesUseCaseTest.kt index 45b47be91..282409995 100644 --- a/common/src/desktopTest/kotlin/repositories/references/AutoConfirmReferencesUseCaseTest.kt +++ b/common/src/desktopTest/kotlin/repositories/references/AutoConfirmReferencesUseCaseTest.kt @@ -77,11 +77,11 @@ class AutoConfirmReferencesUseCaseTest : BaseTest() { } returns listOf(EntrySuggestion(entryId = 42, matchedAlias = "Bob")) val storedSlot = slot() - coEvery { sceneEditor.storeMetadata(capture(storedSlot), sceneItem.id) } returns Unit + coEvery { sceneEditor.storeMetadata(capture(storedSlot), sceneItem.id, any()) } returns Unit useCase(sceneItem) - coVerify(exactly = 1) { sceneEditor.storeMetadata(any(), sceneItem.id) } + coVerify(exactly = 1) { sceneEditor.storeMetadata(any(), sceneItem.id, any()) } assertEquals(setOf(42), storedSlot.captured.confirmedReferences) } @@ -96,7 +96,7 @@ class AutoConfirmReferencesUseCaseTest : BaseTest() { } returns listOf(EntrySuggestion(entryId = 42, matchedAlias = "Bob")) val storedSlot = slot() - coEvery { sceneEditor.storeMetadata(capture(storedSlot), sceneItem.id) } returns Unit + coEvery { sceneEditor.storeMetadata(capture(storedSlot), sceneItem.id, any()) } returns Unit useCase(sceneItem) @@ -114,7 +114,7 @@ class AutoConfirmReferencesUseCaseTest : BaseTest() { useCase(sceneItem) - coVerify(exactly = 0) { sceneEditor.storeMetadata(any(), any()) } + coVerify(exactly = 0) { sceneEditor.storeMetadata(any(), any(), any()) } } @Test @@ -128,7 +128,7 @@ class AutoConfirmReferencesUseCaseTest : BaseTest() { coVerify(exactly = 0) { referenceIndexService.computeAutoReferencesForScene(any(), any(), any()) } - coVerify(exactly = 0) { sceneEditor.storeMetadata(any(), any()) } + coVerify(exactly = 0) { sceneEditor.storeMetadata(any(), any(), any()) } } @Test @@ -145,7 +145,7 @@ class AutoConfirmReferencesUseCaseTest : BaseTest() { } returns listOf(EntrySuggestion(entryId = 42, matchedAlias = "Bob")) val storedSlot = slot() - coEvery { sceneEditor.storeMetadata(capture(storedSlot), sceneItem.id) } returns Unit + coEvery { sceneEditor.storeMetadata(capture(storedSlot), sceneItem.id, any()) } returns Unit useCase(sceneItem) diff --git a/common/src/desktopTest/kotlin/repositories/references/BackfillEntryReferencesUseCaseTest.kt b/common/src/desktopTest/kotlin/repositories/references/BackfillEntryReferencesUseCaseTest.kt index 50af77840..b2393827b 100644 --- a/common/src/desktopTest/kotlin/repositories/references/BackfillEntryReferencesUseCaseTest.kt +++ b/common/src/desktopTest/kotlin/repositories/references/BackfillEntryReferencesUseCaseTest.kt @@ -70,7 +70,7 @@ class BackfillEntryReferencesUseCaseTest : BaseTest() { val storedById = mutableMapOf() val metaSlot = slot() val idSlot = slot() - coEvery { sceneEditor.storeMetadata(capture(metaSlot), capture(idSlot)) } answers { + coEvery { sceneEditor.storeMetadata(capture(metaSlot), capture(idSlot), any()) } answers { storedById[idSlot.captured] = metaSlot.captured } @@ -97,7 +97,7 @@ class BackfillEntryReferencesUseCaseTest : BaseTest() { coEvery { referenceIndexService.findScenesMatchingEntry(id, listOf(name)) } returns listOf(10) coEvery { sceneEditor.loadSceneMetadata(10) } returns SceneMetadata() val metaSlot = slot() - coEvery { sceneEditor.storeMetadata(capture(metaSlot), 10) } just Runs + coEvery { sceneEditor.storeMetadata(capture(metaSlot), 10, any()) } just Runs useCase(entry) @@ -125,7 +125,7 @@ class BackfillEntryReferencesUseCaseTest : BaseTest() { makeUseCase(config).invoke(placeEntry) coVerify(exactly = 0) { referenceIndexService.findScenesMatchingEntry(any(), any()) } - coVerify(exactly = 0) { sceneEditor.storeMetadata(any(), any()) } + coVerify(exactly = 0) { sceneEditor.storeMetadata(any(), any(), any()) } } @Test @@ -137,7 +137,7 @@ class BackfillEntryReferencesUseCaseTest : BaseTest() { useCase(entry) - coVerify(exactly = 0) { sceneEditor.storeMetadata(any(), any()) } + coVerify(exactly = 0) { sceneEditor.storeMetadata(any(), any(), any()) } } @Test @@ -170,7 +170,7 @@ class BackfillEntryReferencesUseCaseTest : BaseTest() { useCase(entry) - coVerify(exactly = 0) { sceneEditor.storeMetadata(any(), any()) } + coVerify(exactly = 0) { sceneEditor.storeMetadata(any(), any(), any()) } } @Test @@ -184,6 +184,6 @@ class BackfillEntryReferencesUseCaseTest : BaseTest() { useCase(entry) - coVerify(exactly = 0) { sceneEditor.storeMetadata(any(), any()) } + coVerify(exactly = 0) { sceneEditor.storeMetadata(any(), any(), any()) } } } diff --git a/common/src/desktopTest/kotlin/repositories/references/CleanupReferencesOnEntryDeleteUseCaseTest.kt b/common/src/desktopTest/kotlin/repositories/references/CleanupReferencesOnEntryDeleteUseCaseTest.kt index 6b5504be6..9343194a7 100644 --- a/common/src/desktopTest/kotlin/repositories/references/CleanupReferencesOnEntryDeleteUseCaseTest.kt +++ b/common/src/desktopTest/kotlin/repositories/references/CleanupReferencesOnEntryDeleteUseCaseTest.kt @@ -55,7 +55,7 @@ class CleanupReferencesOnEntryDeleteUseCaseTest : BaseTest() { val written = mutableMapOf() val metaSlot = slot() val idSlot = slot() - coEvery { sceneEditor.storeMetadata(capture(metaSlot), capture(idSlot)) } answers { + coEvery { sceneEditor.storeMetadata(capture(metaSlot), capture(idSlot), any()) } answers { written[idSlot.captured] = metaSlot.captured } @@ -77,7 +77,7 @@ class CleanupReferencesOnEntryDeleteUseCaseTest : BaseTest() { ) val metaSlot = slot() - coEvery { sceneEditor.storeMetadata(capture(metaSlot), 10) } returns Unit + coEvery { sceneEditor.storeMetadata(capture(metaSlot), 10, any()) } returns Unit useCase(42) @@ -95,7 +95,7 @@ class CleanupReferencesOnEntryDeleteUseCaseTest : BaseTest() { useCase(42) coVerify(exactly = 0) { sceneEditor.loadSceneMetadata(any()) } - coVerify(exactly = 0) { sceneEditor.storeMetadata(any(), any()) } + coVerify(exactly = 0) { sceneEditor.storeMetadata(any(), any(), any()) } } @Test @@ -110,7 +110,7 @@ class CleanupReferencesOnEntryDeleteUseCaseTest : BaseTest() { useCase(42) - coVerify(exactly = 0) { sceneEditor.storeMetadata(any(), any()) } + coVerify(exactly = 0) { sceneEditor.storeMetadata(any(), any(), any()) } } @Test @@ -125,8 +125,8 @@ class CleanupReferencesOnEntryDeleteUseCaseTest : BaseTest() { useCase(42) - coVerify(exactly = 1) { sceneEditor.storeMetadata(any(), 10) } - coVerify(exactly = 1) { sceneEditor.storeMetadata(any(), 11) } - coVerify(exactly = 1) { sceneEditor.storeMetadata(any(), 12) } + coVerify(exactly = 1) { sceneEditor.storeMetadata(any(), 10, any()) } + coVerify(exactly = 1) { sceneEditor.storeMetadata(any(), 11, any()) } + coVerify(exactly = 1) { sceneEditor.storeMetadata(any(), 12, any()) } } } diff --git a/common/src/desktopTest/kotlin/repositories/references/WholeWordCaseSensitiveMatcherTest.kt b/common/src/desktopTest/kotlin/repositories/references/WholeWordCaseSensitiveMatcherTest.kt index de25962a0..bab9b10f2 100644 --- a/common/src/desktopTest/kotlin/repositories/references/WholeWordCaseSensitiveMatcherTest.kt +++ b/common/src/desktopTest/kotlin/repositories/references/WholeWordCaseSensitiveMatcherTest.kt @@ -114,6 +114,90 @@ class WholeWordCaseSensitiveMatcherTest { assertEquals("Mr. Smith", hits[0].matchedText) } + @Test + fun `Alias ending in punctuation matches`() { + val hits = matcher.findMatches("Smith Jr. arrived.", listOf(entry(1, "Smith Jr."))) + assertEquals(1, hits.size) + } + + @Test + fun `Accented letter after name blocks match`() { + val hits = matcher.findMatches("Anaïs and Anaé left.", listOf(entry(1, "Ana"))) + assertEquals(0, hits.size) + } + + @Test + fun `Accented letter before name blocks match`() { + val hits = matcher.findMatches("éBob and ñBob left.", listOf(entry(1, "Bob"))) + assertEquals(0, hits.size) + } + + @Test + fun `Name ending in accented letter matches whole word`() { + val hits = matcher.findMatches("Zoë smiled. Zoë's hat.", listOf(entry(1, "Zoë"))) + assertEquals(2, hits.size) + } + + @Test + fun `Name ending in accented letter does not match longer word`() { + val hits = matcher.findMatches("Zoëlle smiled.", listOf(entry(1, "Zoë"))) + assertEquals(0, hits.size) + } + + @Test + fun `Combining mark after name blocks match`() { + val hits = matcher.findMatches("Jose\u0301 arrived.", listOf(entry(1, "Jose"))) + assertEquals(0, hits.size) + } + + @Test + fun `Decomposed name matches decomposed text`() { + val hits = matcher.findMatches("Jose\u0301 arrived.", listOf(entry(1, "Jose\u0301"))) + assertEquals(1, hits.size) + } + + @Test + fun `Cyrillic name does not match inside longer word`() { + val hits = matcher.findMatches("Иванов пришёл.", listOf(entry(1, "Иван"))) + assertEquals(0, hits.size) + } + + @Test + fun `Cyrillic name matches whole word`() { + val hits = matcher.findMatches("Иван пришёл, и Иван ушёл.", listOf(entry(1, "Иван"))) + assertEquals(2, hits.size) + } + + @Test + fun `Greek name does not match inside longer word`() { + val hits = matcher.findMatches("Νίκοσα και αΝίκος", listOf(entry(1, "Νίκο"))) + assertEquals(0, hits.size) + } + + @Test + fun `Adjacent digits block match`() { + val hits = matcher.findMatches("Bob2 and 2Bob and Bob٣ left.", listOf(entry(1, "Bob"))) + assertEquals(0, hits.size) + } + + @Test + fun `Unicode punctuation and whitespace neighbors match`() { + val hits = matcher.findMatches( + "«Zoë» \u201cZoë\u201d Zoë\u00a0left ¿Zoë?", + listOf(entry(1, "Zoë")), + ) + assertEquals(4, hits.size) + } + + @Test + fun `Multi-token alias with accented letters matches whole word`() { + val hits = matcher.findMatches( + "Dr. Müller arrived. Dr. Müllers left.", + listOf(entry(1, "Dr. Müller")), + ) + assertEquals(1, hits.size) + } + @Test fun `Empty alias is skipped without crashing`() { val hits = matcher.findMatches("anything", listOf(entry(1, ""))) diff --git a/common/src/desktopTest/kotlin/repositories/sceneeditor/SceneContentRepositoryTest.kt b/common/src/desktopTest/kotlin/repositories/sceneeditor/SceneContentRepositoryTest.kt index f753d22b3..9fe777d75 100644 --- a/common/src/desktopTest/kotlin/repositories/sceneeditor/SceneContentRepositoryTest.kt +++ b/common/src/desktopTest/kotlin/repositories/sceneeditor/SceneContentRepositoryTest.kt @@ -305,6 +305,149 @@ class SceneContentRepositoryTest : BaseTest() { assertFalse(ffs.exists(temp2Path)) } + @Test + fun `Discard all dirty buffers`() = runTest(mainTestDispatcher) { + val projDef = getProject1Def() + createProject(ffs, PROJECT_1_NAME) + + createStack(projDef) + writeTempBuffer(1) + writeTempBuffer(3) + + service.initialize() + assertTrue(contentRepo.hasDirtyBuffers()) + + service.discardAllBuffers() + + assertFalse(contentRepo.hasDirtyBuffers()) + assertFalse(ffs.exists(getTempBufferPath(1))) + assertFalse(ffs.exists(getTempBufferPath(3))) + ffs.read(repo.getSceneFilePath(1).toOkioPath()) { + assertEquals("Content of scene id 1", readUtf8()) + } + } + + @Test + fun `Discard all drops the dirty buffer of an archived scene`() = runTest(mainTestDispatcher) { + val projDef = getProject1Def() + createProject(ffs, PROJECT_1_NAME) + + createStack(projDef) + writeTempBuffer(1) + writeTempBuffer(3) + + service.initialize() + assertTrue(service.archiveScene(SceneItem(projDef, SceneItem.Type.Scene, 1, "Scene ID 1", 0))) + assertTrue(contentRepo.hasDirtyBuffer(1)) + + service.discardAllBuffers() + + assertFalse(contentRepo.hasDirtyBuffers()) + assertNull(contentRepo.getSceneBuffer(1)) + assertFalse(ffs.exists(getTempBufferPath(1))) + assertFalse(ffs.exists(getTempBufferPath(3))) + } + + @Test + fun `Store all saves the dirty buffer of an archived scene into the archive`() = runTest(mainTestDispatcher) { + val projDef = getProject1Def() + createProject(ffs, PROJECT_1_NAME) + + createStack(projDef) + writeTempBuffer(1) + + service.initialize() + assertTrue(service.archiveScene(SceneItem(projDef, SceneItem.Type.Scene, 1, "Scene ID 1", 0))) + + service.storeAllBuffers() + + assertFalse(contentRepo.hasDirtyBuffers()) + assertFalse(ffs.exists(getTempBufferPath(1))) + val archivedPath = assertNotNull(repo.resolveScenePathFromFilesystemIncludingArchived(1)) + ffs.read(archivedPath.toOkioPath()) { + assertEquals(content(1), readUtf8()) + } + } + + @Test + fun `Store all drops the dirty buffer of a deleted scene`() = runTest(mainTestDispatcher) { + val projDef = getProject1Def() + createProject(ffs, PROJECT_1_NAME) + + createStack(projDef) + writeTempBuffer(1) + writeTempBuffer(3) + + service.initialize() + // Deleted behind the service's back, so the buffer outlives its scene. + assertTrue(repo.deleteScene(SceneItem(projDef, SceneItem.Type.Scene, 1, "Scene ID 1", 0))) + + service.storeAllBuffers() + + assertFalse(contentRepo.hasDirtyBuffers()) + assertFalse(ffs.exists(getTempBufferPath(1))) + ffs.read(repo.getSceneFilePath(3).toOkioPath()) { + assertEquals(content(3), readUtf8()) + } + } + + @Test + fun `Deleting a scene drops its unsaved buffer and temp file`() = runTest(mainTestDispatcher) { + val projDef = getProject1Def() + createProject(ffs, PROJECT_1_NAME) + + createStack(projDef) + writeTempBuffer(1) + writeTempBuffer(3) + + service.initialize() + assertTrue(service.deleteScene(SceneItem(projDef, SceneItem.Type.Scene, 1, "Scene ID 1", 0))) + + assertNull(contentRepo.getSceneBuffer(1)) + assertFalse(ffs.exists(getTempBufferPath(1))) + assertTrue(contentRepo.hasDirtyBuffer(3)) + assertTrue(ffs.exists(getTempBufferPath(3))) + } + + @Test + fun `Scope close keeps the temp file of an unsaved buffer`() = runTest(mainTestDispatcher) { + val projDef = getProject1Def() + createProject(ffs, PROJECT_1_NAME) + + createStack(projDef) + writeTempBuffer(1) + writeTempBuffer(3) + + service.initialize() + service.storeSceneBuffer(SceneItem(projDef, SceneItem.Type.Scene, 3, "Scene ID 3", 0)) + // A temp file whose buffer is clean, as a sync download leaves behind. + writeTempBuffer(3) + + contentRepo.onScopeClose(mockk()) + + ffs.read(getTempBufferPath(1)) { + assertEquals(content(1), readUtf8()) + } + assertFalse(ffs.exists(getTempBufferPath(3))) + } + + @Test + fun `Unsaved buffer survives a scope close and reopen`() = runTest(mainTestDispatcher) { + val projDef = getProject1Def() + createProject(ffs, PROJECT_1_NAME) + + createStack(projDef) + writeTempBuffer(1) + service.initialize() + contentRepo.onScopeClose(mockk()) + + createStack(projDef) + service.initialize() + + assertTrue(contentRepo.hasDirtyBuffer(1)) + assertEquals(content(1), contentRepo.getSceneBuffer(1)?.content?.markdown) + } + @Test fun `Store all dirty buffers`() = runTest(mainTestDispatcher) { val projDef = getProject1Def() diff --git a/common/src/desktopTest/kotlin/repositories/sceneeditor/SceneEditorServiceTest.kt b/common/src/desktopTest/kotlin/repositories/sceneeditor/SceneEditorServiceTest.kt index c98b624c6..eb02cbf38 100644 --- a/common/src/desktopTest/kotlin/repositories/sceneeditor/SceneEditorServiceTest.kt +++ b/common/src/desktopTest/kotlin/repositories/sceneeditor/SceneEditorServiceTest.kt @@ -375,6 +375,23 @@ class SceneEditorServiceTest : BaseTest() { assertFalse(service.hasDirtyBuffers()) } + @Test + fun `Discard-all-buffers reverts every dirty buffer`() = runTest(mainTestDispatcher) { + val service = initializedService() + val sceneOne = service.getSceneItemFromId(1)!! + val sceneThree = service.getSceneItemFromId(3)!! + + service.onContentChanged(SceneContent(sceneOne, "Unsaved 1"), UpdateSource.Editor) + service.onContentChanged(SceneContent(sceneThree, "Unsaved 3"), UpdateSource.Editor) + advanceUntilIdle() + assertTrue(service.hasDirtyBuffers()) + + service.discardAllBuffers() + + assertFalse(service.hasDirtyBuffers()) + assertEquals("Content of scene id 3", service.getSceneBuffer(sceneThree)?.content?.markdown) + } + @Test fun `Discard scene buffer reverts to on-disk content`() = runTest(mainTestDispatcher) { val service = initializedService() @@ -466,7 +483,7 @@ class SceneEditorServiceTest : BaseTest() { val updated = service.loadSceneMetadata(sceneId).copy(notes = "flow notes") // metadataUpdateFlow has no replay, so subscribe before storing. - val received = mutableListOf>() + val received = mutableListOf() val job = scope.launch { service.metadataUpdateFlow.collect { received.add(it) } } advanceUntilIdle() @@ -474,7 +491,7 @@ class SceneEditorServiceTest : BaseTest() { advanceUntilIdle() job.cancelAndJoin() - assertTrue(received.any { it.first == sceneId && it.second.notes == "flow notes" }) + assertTrue(received.any { it.sceneId == sceneId && it.metadata.notes == "flow notes" }) } // endregion diff --git a/common/src/desktopTest/kotlin/repositories/tagindex/TagIndexServiceTest.kt b/common/src/desktopTest/kotlin/repositories/tagindex/TagIndexServiceTest.kt index 97e9fc69e..118b51978 100644 --- a/common/src/desktopTest/kotlin/repositories/tagindex/TagIndexServiceTest.kt +++ b/common/src/desktopTest/kotlin/repositories/tagindex/TagIndexServiceTest.kt @@ -9,7 +9,9 @@ import com.darkrockstudios.apps.hammer.common.data.notesrepository.NotesReposito import com.darkrockstudios.apps.hammer.common.data.notesrepository.note.NoteContainer import com.darkrockstudios.apps.hammer.common.data.notesrepository.note.NoteContent import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.SceneRepository +import com.darkrockstudios.apps.hammer.common.data.UpdateSource import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.SceneMetadataRepository +import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.SceneMetadataUpdate import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.scenemetadata.SceneMetadata import com.darkrockstudios.apps.hammer.common.data.tagindex.BuildTagIndexUseCase import com.darkrockstudios.apps.hammer.common.data.tagindex.TagIndexService @@ -49,7 +51,7 @@ class TagIndexServiceTest : BaseTest() { private lateinit var eventContentChangedFlow: MutableSharedFlow private lateinit var notesListFlow: MutableSharedFlow> private lateinit var timelineFlow: MutableSharedFlow - private lateinit var sceneMetadataUpdateFlow: MutableSharedFlow> + private lateinit var sceneMetadataUpdateFlow: MutableSharedFlow @BeforeEach override fun setup() { @@ -364,7 +366,9 @@ class TagIndexServiceTest : BaseTest() { assertEquals(1, service.tagIndex.value.tagToEntities["alpha"]?.size) stubScenes(1 to setOf("alpha"), 2 to setOf("alpha")) - sceneMetadataUpdateFlow.tryEmit(2 to SceneMetadata(tags = setOf("alpha"))) + sceneMetadataUpdateFlow.tryEmit( + SceneMetadataUpdate(2, SceneMetadata(tags = setOf("alpha")), UpdateSource.Editor) + ) advanceUntilIdle() assertEquals(2, service.tagIndex.value.tagToEntities["alpha"]?.size) diff --git a/common/src/desktopTest/kotlin/repositories/tree/SceneChaptersTest.kt b/common/src/desktopTest/kotlin/repositories/tree/SceneChaptersTest.kt new file mode 100644 index 000000000..a2102f193 --- /dev/null +++ b/common/src/desktopTest/kotlin/repositories/tree/SceneChaptersTest.kt @@ -0,0 +1,67 @@ +package repositories.tree + +import com.darkrockstudios.apps.hammer.common.data.ProjectDef +import com.darkrockstudios.apps.hammer.common.data.SceneItem +import com.darkrockstudios.apps.hammer.common.data.tree.ChapterScenes +import com.darkrockstudios.apps.hammer.common.data.tree.ImmutableTree +import com.darkrockstudios.apps.hammer.common.data.tree.Tree +import com.darkrockstudios.apps.hammer.common.data.tree.TreeNode +import com.darkrockstudios.apps.hammer.common.data.tree.collectChapters +import com.darkrockstudios.apps.hammer.common.fileio.HPath +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SceneChaptersTest { + + private val projectDef = ProjectDef("Test", HPath("/projects/Test", "Test", false)) + + private fun item(id: Int, type: SceneItem.Type) = + SceneItem(projectDef = projectDef, type = type, id = id, name = "Item $id", order = id) + + private fun scene(id: Int) = TreeNode(item(id, SceneItem.Type.Scene)) + + private fun group(id: Int, vararg children: TreeNode) = + TreeNode(item(id, SceneItem.Type.Group)).apply { children.forEach { addChild(it) } } + + private fun tree(vararg topLevel: TreeNode): ImmutableTree { + val root = TreeNode(item(SceneItem.ROOT_ID, SceneItem.Type.Root)) + topLevel.forEach { root.addChild(it) } + return Tree().apply { setRoot(root) }.toImmutableTree() + } + + @Test + fun `a top-level scene is a one-scene chapter`() { + val chapters = tree(scene(1), scene(2)).collectChapters() + + assertEquals(listOf(1, 2), chapters.map { it.chapter.id }) + assertEquals(listOf(listOf(1), listOf(2)), chapters.map { c -> c.scenes.map { it.id } }) + } + + @Test + fun `a group is flattened depth-first to its descendant scenes`() { + val chapters = tree( + group(10, scene(11), group(12, scene(13), scene(14)), scene(15)), + scene(20), + ).collectChapters() + + assertEquals(listOf(10, 20), chapters.map { it.chapter.id }) + assertEquals(listOf(11, 13, 14, 15), chapters[0].scenes.map { it.id }) + assertTrue(chapters[0].scenes.all { it.type == SceneItem.Type.Scene }) + } + + @Test + fun `an empty group is still a chapter`() { + val chapters = tree(group(10), group(20, group(21))).collectChapters() + + assertEquals( + listOf(emptyList(), emptyList()), + chapters.map(ChapterScenes::scenes), + ) + } + + @Test + fun `an empty tree has no chapters`() { + assertTrue(tree().collectChapters().isEmpty()) + } +} diff --git a/common/src/desktopTest/kotlin/synchronizer/SceneSynchronizerTest.kt b/common/src/desktopTest/kotlin/synchronizer/SceneSynchronizerTest.kt index 18dc04a48..e92171f5a 100644 --- a/common/src/desktopTest/kotlin/synchronizer/SceneSynchronizerTest.kt +++ b/common/src/desktopTest/kotlin/synchronizer/SceneSynchronizerTest.kt @@ -365,6 +365,16 @@ class SceneSynchronizerTest : BaseTest() { coVerify(exactly = 1) { sceneEditorService.createGroup(any(), any(), any(), any()) } } + @Test + fun `prepare and finalize never persist unsaved buffers`() = runTest { + val sync = defaultSceneSynchronizer() + + sync.prepareForSync() + sync.finalizeSync() + + coVerify(exactly = 0) { sceneEditorService.storeAllBuffers() } + } + @Test fun `ownsEntity - includes archived scenes`() = runTest { //////////////////// @@ -519,6 +529,7 @@ class SceneSynchronizerTest : BaseTest() { sceneEditorService.storeMetadata( match { it.tags == setOf("magic", "spoiler") }, sceneId, + UpdateSource.Sync, ) } } diff --git a/common/src/desktopTest/kotlin/utils/FakeAppUpdater.kt b/common/src/desktopTest/kotlin/utils/FakeAppUpdater.kt new file mode 100644 index 000000000..ff345b31b --- /dev/null +++ b/common/src/desktopTest/kotlin/utils/FakeAppUpdater.kt @@ -0,0 +1,25 @@ +package utils + +import com.darkrockstudios.apps.hammer.common.data.appupdate.AppUpdateState +import com.darkrockstudios.apps.hammer.common.data.appupdate.AppUpdater +import kotlinx.coroutines.flow.MutableStateFlow + +class FakeAppUpdater(initial: AppUpdateState = AppUpdateState.Idle) : AppUpdater { + override val state = MutableStateFlow(initial) + + var checks = 0 + var updates = 0 + var dismissals = 0 + + override fun checkNow() { + checks++ + } + + override fun update() { + updates++ + } + + override fun dismiss() { + dismissals++ + } +} diff --git a/common/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/dependencyinjection/platformModule.kt b/common/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/dependencyinjection/platformModule.kt index f73f28adb..0da96c732 100644 --- a/common/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/dependencyinjection/platformModule.kt +++ b/common/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/dependencyinjection/platformModule.kt @@ -1,5 +1,8 @@ package com.darkrockstudios.apps.hammer.common.dependencyinjection +import com.darkrockstudios.apps.hammer.common.data.appupdate.AppUpdater +import com.darkrockstudios.apps.hammer.common.data.appupdate.NoOpAppUpdater + import com.darkrockstudios.apps.hammer.common.components.projectselection.accountsettings.IosSettingsComponent import com.darkrockstudios.apps.hammer.common.components.projectselection.accountsettings.PlatformSettings import com.darkrockstudios.apps.hammer.common.components.storyeditor.focusmode.FocusModeService @@ -26,4 +29,5 @@ actual val platformModule = module { factory() single() single() bind GlobalSettingsDatasource::class + single { NoOpAppUpdater } } \ No newline at end of file diff --git a/composeUi/build.gradle.kts b/composeUi/build.gradle.kts index 485f48677..23c02036c 100644 --- a/composeUi/build.gradle.kts +++ b/composeUi/build.gradle.kts @@ -8,7 +8,7 @@ plugins { alias(libs.plugins.android.kotlin.multiplatform.library) alias(libs.plugins.jetbrains.kover) //alias(libs.plugins.compose.report.generator) - id("ee.schimke.composeai.preview") version "0.16.44" + id("ee.schimke.composeai.preview") version "2.34.0" } group = "com.darkrockstudios.apps.hammer.composeui" @@ -95,6 +95,7 @@ kotlin { implementation(libs.aboutlibraries.core) implementation(libs.aboutlibraries.compose) implementation(libs.compose.texteditor) + implementation(libs.compose.texteditor.markdown) implementation(libs.compose.texteditor.find) implementation(libs.compose.texteditor.spellcheck) implementation(libs.platform.spellcheckerkt) diff --git a/composeUi/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/EditorAction.kt b/composeUi/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/EditorAction.kt index 2701d85e6..48413e566 100644 --- a/composeUi/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/EditorAction.kt +++ b/composeUi/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/EditorAction.kt @@ -17,16 +17,17 @@ import org.jetbrains.compose.resources.ExperimentalResourceApi internal actual fun EditorAction( icon: ImageVector, active: Boolean, + enabled: Boolean, onClick: () -> Unit ) { val painter = rememberVectorPainter(icon) - IconButton(onClick = onClick) { + IconButton(onClick = onClick, enabled = enabled) { Icon( modifier = Modifier.size(24.dp), painter = painter, - tint = if (active) MaterialTheme.colorScheme.inversePrimary else MaterialTheme.colorScheme.onSurfaceVariant, + tint = editorActionColor(active, enabled), contentDescription = null ) } @@ -36,13 +37,14 @@ internal actual fun EditorAction( internal actual fun EditorTextAction( label: String, active: Boolean, + enabled: Boolean, onClick: () -> Unit, ) { - IconButton(onClick = onClick) { + IconButton(onClick = onClick, enabled = enabled) { Text( text = label, style = MaterialTheme.typography.labelLarge, - color = if (active) MaterialTheme.colorScheme.inversePrimary else MaterialTheme.colorScheme.onSurfaceVariant, + color = editorActionColor(active, enabled), ) } } diff --git a/composeUi/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/theme/OledBlackAvailability.android.kt b/composeUi/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/theme/OledBlackAvailability.android.kt new file mode 100644 index 000000000..dedca9705 --- /dev/null +++ b/composeUi/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/theme/OledBlackAvailability.android.kt @@ -0,0 +1,3 @@ +package com.darkrockstudios.apps.hammer.common.compose.theme + +actual val oledBlackThemeAvailable: Boolean = true diff --git a/composeUi/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/EditorTopBar.kt b/composeUi/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/EditorTopBar.kt index 90a153f65..f7885d52a 100644 --- a/composeUi/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/EditorTopBar.kt +++ b/composeUi/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/EditorTopBar.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.MenuBook import androidx.compose.material.icons.filled.Fullscreen import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Save @@ -41,6 +42,7 @@ import com.darkrockstudios.apps.hammer.common.compose.resources.get import com.darkrockstudios.apps.hammer.common.storyeditor.sceneTypeMeta import com.darkrockstudios.apps.hammer.scene_editor_cancel_button import com.darkrockstudios.apps.hammer.scene_editor_focus_mode_button +import com.darkrockstudios.apps.hammer.scene_editor_menu_item_read import com.darkrockstudios.apps.hammer.scene_editor_metadata_button import com.darkrockstudios.apps.hammer.scene_editor_name_hint import com.darkrockstudios.apps.hammer.scene_editor_rename_button @@ -109,6 +111,16 @@ actual fun EditorTopBar( } } + if (screen.windowWidthClass != WindowWidthSizeClass.Compact) { + IconButton(onClick = component::readScene) { + Icon( + imageVector = Icons.AutoMirrored.Filled.MenuBook, + contentDescription = Res.string.scene_editor_menu_item_read.get(), + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + if (screen.windowWidthClass == WindowWidthSizeClass.Expanded) { IconButton(onClick = component::enterFocusMode) { Icon( diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/ComposeRichText.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/ComposeRichText.kt index 5916743ed..59329a103 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/ComposeRichText.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/ComposeRichText.kt @@ -1,6 +1,7 @@ package com.darkrockstudios.apps.hammer.common.compose import androidx.compose.ui.text.AnnotatedString +import com.darkrockstudios.apps.hammer.common.compose.markdown.HammerMarkdownConfiguration import com.darkrockstudios.apps.hammer.common.data.PlatformRichText import com.darkrockstudios.texteditor.markdown.MarkdownExtension import com.darkrockstudios.texteditor.markdown.toMarkdown @@ -16,7 +17,7 @@ data class ComposeRichText( override fun convertToMarkdown(): String { return when { - snapshot != null -> snapshot.toMarkdown() + snapshot != null -> snapshot.toMarkdown(HammerMarkdownConfiguration) state != null -> state.exportAsMarkdown() else -> error("ComposeRichText must contain non-null data ") } diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/MarkdownConfig.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/MarkdownConfig.kt deleted file mode 100644 index 79da0a400..000000000 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/MarkdownConfig.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.darkrockstudios.apps.hammer.common.compose - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.compositionLocalOf -import com.darkrockstudios.apps.hammer.common.compose.markdown.changeFontSize -import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettings -import com.darkrockstudios.texteditor.markdown.MarkdownConfiguration - -val LocalMarkdownConfig = compositionLocalOf { - MarkdownConfiguration.DEFAULT -} - -@Composable -fun ProvideMarkdownConfig( - isDark: Boolean, - settings: GlobalSettings, - content: @Composable () -> Unit -) { - val baseMarkdownConfig = - if (isDark) MarkdownConfiguration.DEFAULT_DARK else MarkdownConfiguration.DEFAULT - val scaledMarkdownConfig = baseMarkdownConfig.changeFontSize(settings.editorFontSize) - - CompositionLocalProvider( - LocalMarkdownConfig provides scaledMarkdownConfig, - content = content - ) -} \ No newline at end of file diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/RichTextStylesProvider.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/RichTextStylesProvider.kt new file mode 100644 index 000000000..41438a72d --- /dev/null +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/RichTextStylesProvider.kt @@ -0,0 +1,55 @@ +package com.darkrockstudios.apps.hammer.common.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextIndent +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettings +import com.darkrockstudios.texteditor.RichTextStyles + +/** + * Heading sizes are relative to the editor's text style, so the body size lives in + * [LocalEditorTextStyle] alone and changing it rewrites no document. + */ +val HammerRichTextStyles = RichTextStyles( + defaultTextStyle = SpanStyle(), + header1Style = SpanStyle(fontSize = 2.em, fontWeight = FontWeight.Bold), + header2Style = SpanStyle(fontSize = 1.5.em, fontWeight = FontWeight.Bold), + header3Style = SpanStyle(fontSize = 1.17.em, fontWeight = FontWeight.Bold), + header4Style = SpanStyle(fontSize = 1.em, fontWeight = FontWeight.Bold), + header5Style = SpanStyle(fontSize = 0.83.em, fontWeight = FontWeight.Bold), + header6Style = SpanStyle(fontSize = 0.75.em, fontWeight = FontWeight.Bold), +) + +val HammerRichTextStylesDark = HammerRichTextStyles.copy( + codeStyle = RichTextStyles.DEFAULT_DARK.codeStyle, + highlightStyle = RichTextStyles.DEFAULT_DARK.highlightStyle, +) + +val LocalRichTextStyles = compositionLocalOf { HammerRichTextStyles } + +/** The editor's body text style; every rich text size scales from it. */ +val LocalEditorTextStyle = compositionLocalOf { + TextStyle(fontSize = GlobalSettings.DEFAULT_FONT_SIZE.sp) +} + +@Composable +fun ProvideRichTextStyles( + isDark: Boolean, + settings: GlobalSettings, + content: @Composable () -> Unit +) { + CompositionLocalProvider( + LocalRichTextStyles provides if (isDark) HammerRichTextStylesDark else HammerRichTextStyles, + LocalEditorTextStyle provides TextStyle(fontSize = settings.editorFontSize.sp), + content = content + ) +} + +/** A first-line indent that scales with the body size. */ +fun TextStyle.withParagraphIndent(): TextStyle = copy(textIndent = TextIndent(firstLine = 1.5.em)) diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/ShortcutModifiers.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/ShortcutModifiers.kt index 36b3425c9..08d85ca7b 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/ShortcutModifiers.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/ShortcutModifiers.kt @@ -2,8 +2,12 @@ package com.darkrockstudios.apps.hammer.common.compose import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.* +import com.darkrockstudios.texteditor.input.layoutKey -/** Matches a key-down with exactly these modifiers: extra ones do not count as a match. */ +/** + * Matches a key-down with exactly these modifiers: extra ones do not count as a match. + * Letters match on [layoutKey], as the editor's built-in chords do. + */ fun KeyEvent.matchesShortcut( key: Key, ctrl: Boolean = false, @@ -12,7 +16,7 @@ fun KeyEvent.matchesShortcut( ): Boolean { val ctrlOrMeta = isCtrlPressed || isMetaPressed return type == KeyEventType.KeyDown && - this.key == key && + layoutKey == key && ctrlOrMeta == ctrl && isShiftPressed == shift && isAltPressed == alt @@ -47,3 +51,18 @@ fun Modifier.italicShortcutModifier(onItalic: () -> Unit): Modifier = fun Modifier.strikethroughShortcutModifier(onStrikethrough: () -> Unit): Modifier = onKeyShortcut(Key.X, ctrl = true, shift = true, action = onStrikethrough) + +/** Ctrl/Cmd with Plus, Minus and 0, as browsers zoom. Plus is Shift+Equals on most layouts. */ +fun Modifier.textSizeShortcutModifier( + onIncrease: () -> Unit, + onDecrease: () -> Unit, + onReset: () -> Unit, +): Modifier = this + .onKeyShortcut(Key.Equals, ctrl = true, action = onIncrease) + .onKeyShortcut(Key.Equals, ctrl = true, shift = true, action = onIncrease) + .onKeyShortcut(Key.Plus, ctrl = true, action = onIncrease) + .onKeyShortcut(Key.NumPadAdd, ctrl = true, action = onIncrease) + .onKeyShortcut(Key.Minus, ctrl = true, action = onDecrease) + .onKeyShortcut(Key.NumPadSubtract, ctrl = true, action = onDecrease) + .onKeyShortcut(Key.Zero, ctrl = true, action = onReset) + .onKeyShortcut(Key.NumPad0, ctrl = true, action = onReset) diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/HdChapter.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/HdChapter.kt new file mode 100644 index 000000000..e2559c4fc --- /dev/null +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/HdChapter.kt @@ -0,0 +1,284 @@ +package com.darkrockstudios.apps.hammer.common.compose.designsystem + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.darkrockstudios.apps.hammer.Res +import com.darkrockstudios.apps.hammer.common.compose.Ui +import com.darkrockstudios.apps.hammer.common.compose.resources.get +import com.darkrockstudios.apps.hammer.scene_list_outline_overview_chapter_eyebrow +import com.darkrockstudios.apps.hammer.scene_list_outline_overview_jump_to +import com.darkrockstudios.apps.hammer.scene_list_outline_overview_position +import com.darkrockstudios.apps.hammer.scene_list_outline_overview_scene_count + +/** One chapter as listed by [HdChapterRailItem] and [HdChapterDropdown]. */ +@Immutable +data class HdChapterRow(val title: String, val sceneCount: Int) + +/** + * A chapter entry for a jump list: roman numeral, title and scene count, + * with a primary-colored edge bar when [selected]. + */ +@Composable +fun HdChapterRailItem( + index: Int, + row: HdChapterRow, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val accent = MaterialTheme.colorScheme.primary + val rowBg = if (selected) { + MaterialTheme.colorScheme.surfaceContainer + } else { + MaterialTheme.colorScheme.surfaceContainerLow + } + + Row( + modifier = modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .background(rowBg) + .clickable(onClick = onClick), + verticalAlignment = Alignment.Top, + ) { + Box( + modifier = Modifier + .width(2.dp) + .fillMaxHeight() + .background(if (selected) accent else Color.Transparent), + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + start = Ui.Padding.L, + end = Ui.Padding.XL, + top = Ui.Padding.L, + bottom = Ui.Padding.L, + ), + horizontalArrangement = Arrangement.spacedBy(Ui.Padding.L), + verticalAlignment = Alignment.Top, + ) { + Text( + text = romanNumeral(index + 1), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Light, + color = if (selected) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.widthIn(min = 24.dp), + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = row.title, + style = MaterialTheme.typography.titleSmall, + fontWeight = if (selected) FontWeight.Medium else FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + HdMonoLabel( + text = Res.string.scene_list_outline_overview_scene_count.get(row.sceneCount), + ) + } + } + } +} + +/** + * Collapsed chapter picker for narrow layouts: a full-width bar showing the + * selected chapter and its position, expanding in place to the chapter list. + */ +@Composable +fun HdChapterDropdown( + rows: List, + selectedIndex: Int, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + val rotation by animateFloatAsState( + targetValue = if (expanded) 180f else 0f, + label = "chapterDropdownRotation", + ) + val current = rows.getOrNull(selectedIndex) + + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceContainerLow) + .clickable { expanded = !expanded } + .padding(horizontal = Ui.Padding.XL, vertical = Ui.Padding.L), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Ui.Padding.L), + ) { + HdMonoLabel(text = Res.string.scene_list_outline_overview_jump_to.get()) + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(Ui.Padding.M), + ) { + Text( + text = romanNumeral(selectedIndex + 1), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Light, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = current?.title.orEmpty(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + HdMonoLabel( + text = Res.string.scene_list_outline_overview_position.get( + selectedIndex + 1, + rows.size, + ), + ) + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .size(20.dp) + .rotate(rotation), + ) + } + HorizontalDivider( + thickness = Dp.Hairline, + color = MaterialTheme.colorScheme.outlineVariant, + ) + + AnimatedVisibility( + visible = expanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 320.dp) + .background(MaterialTheme.colorScheme.surface), + ) { + LazyColumn(modifier = Modifier.fillMaxWidth()) { + itemsIndexed(rows, key = { idx, _ -> "drop-$idx" }) { idx, row -> + HdChapterRailItem( + index = idx, + row = row, + selected = idx == selectedIndex, + onClick = { + onSelect(idx) + expanded = false + }, + ) + HorizontalDivider( + thickness = Dp.Hairline, + color = MaterialTheme.colorScheme.outlineVariant, + ) + } + } + } + } + } +} + +/** Chapter opener: mono eyebrow with numeral and scene count, light title, hairline rule. */ +@Composable +fun HdChapterHeader( + index: Int, + title: String, + sceneCount: Int, + wide: Boolean, + modifier: Modifier = Modifier, +) { + val titleStyle = if (wide) { + MaterialTheme.typography.headlineLarge + } else { + MaterialTheme.typography.headlineMedium + } + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(Ui.Padding.M)) { + HdMonoLabel( + text = Res.string.scene_list_outline_overview_chapter_eyebrow.get( + romanNumeral(index + 1), + sceneCount, + ), + ) + Text( + text = title, + style = titleStyle, + fontWeight = FontWeight.Light, + color = MaterialTheme.colorScheme.onSurface, + ) + HorizontalDivider( + thickness = Dp.Hairline, + color = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.padding(top = Ui.Padding.M), + ) + } +} + +/** Centered `· · ·` ornament marking a break between passages. */ +@Composable +fun HdSceneBreak(modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Text( + text = "· · ·", + style = MaterialTheme.typography.titleLarge.copy(letterSpacing = 8.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** Closing mark for a long read: a hairline rule over a centered mono [label]. */ +@Composable +fun HdEndMark(label: String, modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxWidth().padding(top = Ui.Padding.L), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(Ui.Padding.L), + ) { + HorizontalDivider( + thickness = Dp.Hairline, + color = MaterialTheme.colorScheme.outlineVariant, + ) + HdMonoLabel(text = label) + } +} diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/HdHairlineChipInput.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/HdHairlineChipInput.kt index db27e9e34..c9e66e387 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/HdHairlineChipInput.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/HdHairlineChipInput.kt @@ -102,7 +102,10 @@ internal fun HdHairlineChipInput( singleLine = true, textStyle = textStyle, cursorBrush = SolidColor(onSurface), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + autoCorrectEnabled = false, + ), modifier = Modifier .fillMaxWidth() .heightIn(min = 24.dp) diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/HdNoticeStrip.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/HdNoticeStrip.kt new file mode 100644 index 000000000..132f3d33a --- /dev/null +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/designsystem/HdNoticeStrip.kt @@ -0,0 +1,90 @@ +package com.darkrockstudios.apps.hammer.common.compose.designsystem + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * A full-width notice pinned above a list: a title line, an optional detail line, an optional + * hairline progress bar, and up to two hairline actions. Hairline rules top and bottom over a + * `surfaceContainerLow` fill separate it from the page without elevation. + */ +@Composable +fun HdNoticeStrip( + title: String, + modifier: Modifier = Modifier, + detail: String? = null, + progress: Float? = null, + primaryLabel: String? = null, + onPrimary: () -> Unit = {}, + secondaryLabel: String? = null, + onSecondary: () -> Unit = {}, + actionsEnabled: Boolean = true, +) { + val rule = MaterialTheme.colorScheme.outlineVariant + Column( + modifier = modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceContainerLow), + ) { + HorizontalDivider(thickness = Dp.Hairline, color = rule) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + if (detail != null) { + Text( + text = detail, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (progress != null) { + HdHairlineProgressBar( + progress = progress, + modifier = Modifier.padding(top = 4.dp), + ) + } + } + if (secondaryLabel != null) { + HdHairlineButton( + label = secondaryLabel, + onClick = onSecondary, + enabled = actionsEnabled, + ) + } + if (primaryLabel != null) { + HdHairlineButton( + label = primaryLabel, + onClick = onPrimary, + emphasised = true, + enabled = actionsEnabled, + ) + } + } + HorizontalDivider(thickness = Dp.Hairline, color = rule) + } +} diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdown/FontControlUtils.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdown/FontControlUtils.kt deleted file mode 100644 index bd6cc7a05..000000000 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdown/FontControlUtils.kt +++ /dev/null @@ -1,114 +0,0 @@ -package com.darkrockstudios.apps.hammer.common.compose.markdown - -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.unit.sp -import com.darkrockstudios.texteditor.markdown.MarkdownConfiguration -import io.github.aakira.napier.Napier - -internal fun MarkdownConfiguration.changeFontSize( - newBaseFontSize: Float -): MarkdownConfiguration { - // Determine the baseline font size to adjust from - val baselineFontSize = defaultTextStyle.fontSize.value - if (baselineFontSize == newBaseFontSize) return this - - Napier.d("Font size OLD: $baselineFontSize NEW: $newBaseFontSize") - - val scaleFactor = newBaseFontSize / baselineFontSize - - // Create a new configuration with scaled font sizes - // The key is to handle each property individually without relying on the copy method's handling of colors - return MarkdownConfiguration( - defaultTextStyle = defaultTextStyle.copy( - fontSize = newBaseFontSize.sp - ), - boldStyle = SpanStyle( - fontSize = newBaseFontSize.sp, - fontWeight = boldStyle.fontWeight, - color = boldStyle.color, - fontFamily = boldStyle.fontFamily, - fontStyle = boldStyle.fontStyle, - background = boldStyle.background - ), - italicStyle = SpanStyle( - fontSize = newBaseFontSize.sp, - fontWeight = italicStyle.fontWeight, - color = italicStyle.color, - fontFamily = italicStyle.fontFamily, - fontStyle = italicStyle.fontStyle, - background = italicStyle.background - ), - codeStyle = SpanStyle( - fontSize = newBaseFontSize.sp, - fontWeight = codeStyle.fontWeight, - color = codeStyle.color, - fontFamily = codeStyle.fontFamily, - fontStyle = codeStyle.fontStyle, - background = codeStyle.background - ), - linkStyle = SpanStyle( - fontSize = newBaseFontSize.sp, - fontWeight = linkStyle.fontWeight, - color = linkStyle.color, - fontFamily = linkStyle.fontFamily, - fontStyle = linkStyle.fontStyle, - background = linkStyle.background - ), - blockquoteStyle = SpanStyle( - fontSize = newBaseFontSize.sp, - fontWeight = blockquoteStyle.fontWeight, - color = blockquoteStyle.color, - fontFamily = blockquoteStyle.fontFamily, - fontStyle = blockquoteStyle.fontStyle, - background = blockquoteStyle.background - ), - header1Style = SpanStyle( - fontSize = (header1Style.fontSize.value * scaleFactor).sp, - fontWeight = header1Style.fontWeight, - color = header1Style.color, - fontFamily = header1Style.fontFamily, - fontStyle = header1Style.fontStyle, - background = header1Style.background - ), - header2Style = SpanStyle( - fontSize = (header2Style.fontSize.value * scaleFactor).sp, - fontWeight = header2Style.fontWeight, - color = header2Style.color, - fontFamily = header2Style.fontFamily, - fontStyle = header2Style.fontStyle, - background = header2Style.background - ), - header3Style = SpanStyle( - fontSize = (header3Style.fontSize.value * scaleFactor).sp, - fontWeight = header3Style.fontWeight, - color = header3Style.color, - fontFamily = header3Style.fontFamily, - fontStyle = header3Style.fontStyle, - background = header3Style.background - ), - header4Style = SpanStyle( - fontSize = (header4Style.fontSize.value * scaleFactor).sp, - fontWeight = header4Style.fontWeight, - color = header4Style.color, - fontFamily = header4Style.fontFamily, - fontStyle = header4Style.fontStyle, - background = header4Style.background - ), - header5Style = SpanStyle( - fontSize = (header5Style.fontSize.value * scaleFactor).sp, - fontWeight = header5Style.fontWeight, - color = header5Style.color, - fontFamily = header5Style.fontFamily, - fontStyle = header5Style.fontStyle, - background = header5Style.background - ), - header6Style = SpanStyle( - fontSize = (header6Style.fontSize.value * scaleFactor).sp, - fontWeight = header6Style.fontWeight, - color = header6Style.color, - fontFamily = header6Style.fontFamily, - fontStyle = header6Style.fontStyle, - background = header6Style.background - ) - ) -} \ No newline at end of file diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdown/HammerMarkdownConfiguration.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdown/HammerMarkdownConfiguration.kt new file mode 100644 index 000000000..94bdae95e --- /dev/null +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdown/HammerMarkdownConfiguration.kt @@ -0,0 +1,10 @@ +package com.darkrockstudios.apps.hammer.common.compose.markdown + +import com.darkrockstudios.texteditor.markdown.MarkdownConfiguration +import com.darkrockstudios.texteditor.markdown.ParagraphSeparator + +/** + * One editor line per markdown line. The library default, BLANK_LINE, drops the blank lines + * between paragraphs on import and rewrites single-newline paragraphs on export. + */ +val HammerMarkdownConfiguration = MarkdownConfiguration(paragraphSeparator = ParagraphSeparator.NEWLINE) diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdown/updateMarkdownStyles.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdown/updateMarkdownStyles.kt index ffa731076..421a23490 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdown/updateMarkdownStyles.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdown/updateMarkdownStyles.kt @@ -3,36 +3,34 @@ package com.darkrockstudios.apps.hammer.common.compose.markdown import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString -import com.darkrockstudios.texteditor.markdown.MarkdownConfiguration -import com.darkrockstudios.texteditor.markdown.MarkdownExtension +import com.darkrockstudios.texteditor.RichTextStyles import com.darkrockstudios.texteditor.state.TextEditorState -internal fun MarkdownExtension.updateMarkdownConfiguration(newConfig: MarkdownConfiguration) { - val oldConfig = markdownConfiguration +internal fun TextEditorState.updateRichTextStyles(newStyles: RichTextStyles) { updateMarkdownStyles( - state = editorState, - oldConfig = oldConfig, - newConfig = newConfig + state = this, + oldConfig = richTextStyles, + newConfig = newStyles ) - markdownConfiguration = newConfig + richTextStyles = newStyles } /** - * Updates all text in the editor to use the new markdown configuration styles. + * Updates all text in the editor to use the new styles. * This preserves the semantic meaning of styles while updating their visual appearance. * * @param state The TextEditorState to update - * @param oldConfig The previous configuration that was used - * @param newConfig The new configuration to apply + * @param oldConfig The previous styles that were used + * @param newConfig The new styles to apply */ internal fun updateMarkdownStyles( state: TextEditorState, - oldConfig: MarkdownConfiguration, - newConfig: MarkdownConfiguration + oldConfig: RichTextStyles, + newConfig: RichTextStyles ) { if (state.textLines.isEmpty()) return - // Heading styles are deliberately absent: MarkdownExtension.markdownConfiguration's + // Heading styles are deliberately absent: TextEditorState.richTextStyles' // setter rebakes them from their HeaderSpanStyle spans. Remapping them here too // leaves the rebake unable to find the old style to strip, so it appends a second // copy to every heading on each config change. @@ -42,6 +40,7 @@ internal fun updateMarkdownStyles( oldConfig.italicStyle to newConfig.italicStyle, oldConfig.codeStyle to newConfig.codeStyle, oldConfig.linkStyle to newConfig.linkStyle, + oldConfig.highlightStyle to newConfig.highlightStyle, oldConfig.blockquoteStyle to newConfig.blockquoteStyle, ) @@ -98,5 +97,8 @@ private fun deepCompareSpanStyles(style1: SpanStyle, style2: SpanStyle): Boolean if (style1.baselineShift != style2.baselineShift) return false if (style1.textGeometricTransform != style2.textGeometricTransform) return false if (style1.localeList != style2.localeList) return false + // What tells a heading's look from an inline style it equals (RichTextStyles.headingLook) + if (style1.platformStyle != style2.platformStyle) return false + if (style1.drawStyle != style2.drawStyle) return false return true } \ No newline at end of file diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/EditorAction.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/EditorAction.kt index d9a118820..fdf66ba2c 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/EditorAction.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/EditorAction.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.graphics.vector.ImageVector internal expect fun EditorAction( icon: ImageVector, active: Boolean, + enabled: Boolean = true, onClick: () -> Unit, ) @@ -14,5 +15,6 @@ internal expect fun EditorAction( internal expect fun EditorTextAction( label: String, active: Boolean, + enabled: Boolean = true, onClick: () -> Unit, ) diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/EditorActionColor.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/EditorActionColor.kt new file mode 100644 index 000000000..62e562a8f --- /dev/null +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/EditorActionColor.kt @@ -0,0 +1,13 @@ +package com.darkrockstudios.apps.hammer.common.compose.markdowneditor + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +/** An action's tint: Material's disabled content colour, else the active or the resting one. */ +@Composable +internal fun editorActionColor(active: Boolean, enabled: Boolean): Color = when { + !enabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + active -> MaterialTheme.colorScheme.inversePrimary + else -> MaterialTheme.colorScheme.onSurfaceVariant +} diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/MarkdownEditField.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/MarkdownEditField.kt index 6c97867a7..9f1a69687 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/MarkdownEditField.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/MarkdownEditField.kt @@ -11,19 +11,19 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.style.TextIndent import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.darkrockstudios.apps.hammer.common.compose.LocalMarkdownConfig -import com.darkrockstudios.apps.hammer.common.compose.markdown.updateMarkdownConfiguration +import com.darkrockstudios.apps.hammer.common.compose.LocalEditorTextStyle +import com.darkrockstudios.apps.hammer.common.compose.LocalRichTextStyles +import com.darkrockstudios.apps.hammer.common.compose.markdown.HammerMarkdownConfiguration +import com.darkrockstudios.apps.hammer.common.compose.markdown.updateRichTextStyles import com.darkrockstudios.apps.hammer.common.compose.rememberKoinInject +import com.darkrockstudios.apps.hammer.common.compose.withParagraphIndent import com.darkrockstudios.apps.hammer.common.spellcheck.SpellCheckRepository import com.darkrockstudios.apps.hammer.common.utils.toEditorSpellChecker +import com.darkrockstudios.texteditor.markdown.withMarkdown import com.darkrockstudios.texteditor.rememberTextEditorStyle import com.darkrockstudios.texteditor.spellcheck.SpellCheckingTextEditor -import com.darkrockstudios.texteditor.spellcheck.markdown.withMarkdown import com.darkrockstudios.texteditor.spellcheck.rememberSpellCheckState /** @@ -46,7 +46,7 @@ fun MarkdownEditField( minEditorHeight: Dp = 200.dp, testTag: String? = null, ) { - val markdownConfig = LocalMarkdownConfig.current + val richTextStyles = LocalRichTextStyles.current val spellCheckRepository = rememberKoinInject() val platformSpellChecker by spellCheckRepository.dictionaryFlow.collectAsState(initial = null) @@ -61,7 +61,10 @@ fun MarkdownEditField( // so a null checker also means "disabled" — clear decorations rather than keep checking. enableSpellChecking = enableSpellChecking && platformSpellChecker != null, ) - val markdownExtension = remember { textEditorState.withMarkdown(markdownConfig) } + val markdownExtension = remember { + textEditorState.textState.richTextStyles = richTextStyles + textEditorState.textState.withMarkdown(HammerMarkdownConfiguration) + } LaunchedEffect(markdownExtension) { markdownExtension.importMarkdown(initialMarkdown) @@ -72,8 +75,8 @@ fun MarkdownEditField( } } - LaunchedEffect(markdownConfig) { - markdownExtension.updateMarkdownConfiguration(markdownConfig) + LaunchedEffect(richTextStyles) { + textEditorState.textState.updateRichTextStyles(richTextStyles) } Column( @@ -91,9 +94,7 @@ fun MarkdownEditField( enabled = enabled, autoFocus = autoFocus, style = rememberTextEditorStyle( - textStyle = TextStyle.Default.copy( - textIndent = TextIndent(firstLine = 24.sp) - ) + textStyle = LocalEditorTextStyle.current.withParagraphIndent() ), contentPadding = contentPadding, modifier = Modifier diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/MarkdownEditorActions.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/MarkdownEditorActions.kt index 1c342444e..3822b365b 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/MarkdownEditorActions.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/MarkdownEditorActions.kt @@ -2,12 +2,21 @@ package com.darkrockstudios.apps.hammer.common.compose.markdowneditor import com.darkrockstudios.texteditor.CharLineOffset import com.darkrockstudios.texteditor.TextEditorRange -import com.darkrockstudios.texteditor.markdown.MarkdownExtension import com.darkrockstudios.texteditor.richstyle.HR_PLACEHOLDER import com.darkrockstudios.texteditor.richstyle.HorizontalRuleSpanStyle import com.darkrockstudios.texteditor.state.TextEditorState +import com.darkrockstudios.texteditor.state.headerLevel +import com.darkrockstudios.texteditor.state.isTableCell +import com.darkrockstudios.texteditor.state.toggleBlockquote +import com.darkrockstudios.texteditor.state.toggleBulletList +import com.darkrockstudios.texteditor.state.toggleHeader +import com.darkrockstudios.texteditor.state.toggleOrderedList internal fun insertHorizontalRule(state: TextEditorState) { + // A table cell holds inline text alone, and a rule would break the table apart. + val selection = state.selector.selection + val touched = selection?.let { listOf(it.start.line, it.end.line) } ?: listOf(state.cursorPosition.line) + if (touched.any { state.isTableCell(it) }) return state.insertNewlineAtCursor() val hrLine = state.cursorPosition.line state.insertStringAtCursor(HR_PLACEHOLDER) @@ -49,49 +58,37 @@ internal fun reconcileHorizontalRules(state: TextEditorState) { } } -internal fun toggleBulletList(state: TextEditorState, markdown: MarkdownExtension) { +private fun selectedLines(state: TextEditorState): IntRange { val selection = state.selector.selection - val lines = if (selection != null) { + return if (selection != null) { selection.start.line..selection.end.line } else { state.cursorPosition.line..state.cursorPosition.line } - markdown.toggleBulletList(lines) } -internal fun toggleOrderedList(state: TextEditorState, markdown: MarkdownExtension) { - val selection = state.selector.selection - val lines = if (selection != null) { - selection.start.line..selection.end.line - } else { - state.cursorPosition.line..state.cursorPosition.line - } - markdown.toggleOrderedList(lines) +internal fun toggleBlockquote(state: TextEditorState) { + state.toggleBlockquote(selectedLines(state)) +} + +internal fun toggleBulletList(state: TextEditorState) { + state.toggleBulletList(selectedLines(state)) +} + +internal fun toggleOrderedList(state: TextEditorState) { + state.toggleOrderedList(selectedLines(state)) } internal val HEADER_CYCLE_LEVELS = 1..3 -internal fun cycleHeader( - state: TextEditorState, - markdown: MarkdownExtension, - currentLevel: Int, -) { - val maxLevel = HEADER_CYCLE_LEVELS.last - val nextLevel = (currentLevel + 1) % (maxLevel + 1) - val selection = state.selector.selection - if (selection != null) { - HEADER_CYCLE_LEVELS.forEach { lvl -> - state.removeStyleSpan(selection, markdown.markdownStyles.header(lvl)) - } - if (nextLevel != 0) { - state.addStyleSpan(selection, markdown.markdownStyles.header(nextLevel)) +internal fun cycleHeader(state: TextEditorState, currentLevel: Int) { + val lines = selectedLines(state) + if (currentLevel >= HEADER_CYCLE_LEVELS.last) { + // toggleHeader only removes a level every line already has, so clear each line by its own + lines.forEach { line -> + state.headerLevel(line)?.let { state.toggleHeader(line..line, it) } } } else { - HEADER_CYCLE_LEVELS.forEach { lvl -> - state.cursor.removeStyle(markdown.markdownStyles.header(lvl)) - } - if (nextLevel != 0) { - state.cursor.addStyle(markdown.markdownStyles.header(nextLevel)) - } + state.toggleHeader(lines, currentLevel + 1) } } diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/MarkdownFormatBar.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/MarkdownFormatBar.kt index 7f96e580f..ffaec5a30 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/MarkdownFormatBar.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/MarkdownFormatBar.kt @@ -8,7 +8,9 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.FormatListBulleted @@ -21,6 +23,7 @@ import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.PlainTooltip import androidx.compose.material3.Text @@ -33,10 +36,13 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.darkrockstudios.apps.hammer.Res +import com.darkrockstudios.apps.hammer.common.compose.LocalEditorTextStyle import com.darkrockstudios.apps.hammer.common.compose.boldShortcutModifier import com.darkrockstudios.apps.hammer.common.compose.icons.EditorIcons import com.darkrockstudios.apps.hammer.common.compose.icons.IconBold @@ -50,6 +56,7 @@ import com.darkrockstudios.apps.hammer.common.compose.icons.IconUndo import com.darkrockstudios.apps.hammer.common.compose.italicShortcutModifier import com.darkrockstudios.apps.hammer.common.compose.resources.get import com.darkrockstudios.apps.hammer.common.compose.strikethroughShortcutModifier +import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettings import com.darkrockstudios.apps.hammer.markdown_format_bar_blockquote import com.darkrockstudios.apps.hammer.markdown_format_bar_bold import com.darkrockstudios.apps.hammer.markdown_format_bar_bullet_list @@ -62,16 +69,23 @@ import com.darkrockstudios.apps.hammer.markdown_format_bar_italic import com.darkrockstudios.apps.hammer.markdown_format_bar_numbered_list import com.darkrockstudios.apps.hammer.markdown_format_bar_redo import com.darkrockstudios.apps.hammer.markdown_format_bar_reset_text_size +import com.darkrockstudios.apps.hammer.markdown_format_bar_text_size import com.darkrockstudios.apps.hammer.markdown_format_bar_strikethrough import com.darkrockstudios.apps.hammer.markdown_format_bar_undo import com.darkrockstudios.apps.hammer.more_menu_button +import com.darkrockstudios.texteditor.TextEditorRange import com.darkrockstudios.texteditor.markdown.MarkdownExtension +import com.darkrockstudios.texteditor.richstyle.BlockquoteSpanStyle import com.darkrockstudios.texteditor.richstyle.BulletListSpanStyle import com.darkrockstudios.texteditor.richstyle.OrderedListSpanStyle import com.darkrockstudios.texteditor.state.TextEditorState import com.darkrockstudios.texteditor.state.getRichSpansAtPosition import com.darkrockstudios.texteditor.state.getRichSpansInRange -import com.darkrockstudios.texteditor.state.getSpanStylesInRange +import com.darkrockstudios.texteditor.state.hasStyleThroughout +import com.darkrockstudios.texteditor.state.headerLevel +import com.darkrockstudios.texteditor.state.isInlineOnlyLine +import com.darkrockstudios.texteditor.state.isTableCell +import com.darkrockstudios.texteditor.state.toggleSpanStyle @Composable fun MarkdownFormatBar( @@ -89,36 +103,50 @@ fun MarkdownFormatBar( var isBulletListActive by remember { mutableStateOf(false) } var isOrderedListActive by remember { mutableStateOf(false) } var currentHeaderLevel by remember { mutableStateOf(0) } + var blocksRefused by remember { mutableStateOf(false) } + var ruleRefused by remember { mutableStateOf(false) } val state = remember(markdownState) { markdownState.editorState } + // A table cell holds inline text alone: no list, heading or rule goes on it, and a rule + // would break a table a selection reaches into. + fun readCaretLine(line: Int, selection: TextEditorRange?) { + blocksRefused = state.isInlineOnlyLine(line) + ruleRefused = blocksRefused || selection != null && (state.isTableCell(selection.start.line) || state.isTableCell(selection.end.line)) + } + LaunchedEffect(Unit) { state.cursorDataFlow.collect { (position, cursorStyles, selection) -> - val styles = if (selection != null) { - state.getSpanStylesInRange(selection) - } else { - cursorStyles - } + // Active exactly when the toggle would remove the style. + fun isActive(style: SpanStyle) = + if (selection != null && selection.start != selection.end) { + state.hasStyleThroughout(selection, style) + } else { + style in cursorStyles + } val richSpans = if (selection != null) { state.getRichSpansInRange(selection) } else { state.getRichSpansAtPosition(position) } - isBoldActive = styles.contains(markdownState.markdownStyles.BOLD) - isItalicActive = styles.contains(markdownState.markdownStyles.ITALICS) - isStrikethroughActive = styles.contains(markdownState.markdownStyles.STRIKETHROUGH) - isBlockquoteActive = styles.contains(markdownState.markdownStyles.BLOCKQUOTE) - isBulletListActive = richSpans.any { it.style === BulletListSpanStyle } - isOrderedListActive = richSpans.any { it.style === OrderedListSpanStyle } - currentHeaderLevel = HEADER_CYCLE_LEVELS.firstOrNull { lvl -> - styles.contains(markdownState.markdownStyles.header(lvl)) - } ?: 0 + isBoldActive = isActive(state.richTextStyles.boldStyle) + isItalicActive = isActive(state.richTextStyles.italicStyle) + isStrikethroughActive = isActive(state.richTextStyles.strikethroughStyle) + isBlockquoteActive = richSpans.any { it.style === BlockquoteSpanStyle } + isBulletListActive = richSpans.any { it.style is BulletListSpanStyle } + isOrderedListActive = richSpans.any { it.style is OrderedListSpanStyle } + currentHeaderLevel = state.headerLevel(selection?.start?.line ?: position.line) ?: 0 + readCaretLine(position.line, selection) } } LaunchedEffect(Unit) { - state.editOperations.collect { reconcileHorizontalRules(state) } + state.editOperations.collect { + reconcileHorizontalRules(state) + // An edit can make the caret's line a table cell without moving the caret. + readCaretLine(state.cursorPosition.line, state.selector.selection) + } } val showOverflow = decreaseTextSize != null || increaseTextSize != null || @@ -134,7 +162,6 @@ fun MarkdownFormatBar( Row(modifier = rowModifier) { FormatButtons( state = state, - markdownState = markdownState, isBoldActive = isBoldActive, isItalicActive = isItalicActive, isStrikethroughActive = isStrikethroughActive, @@ -142,6 +169,8 @@ fun MarkdownFormatBar( isBulletListActive = isBulletListActive, isOrderedListActive = isOrderedListActive, currentHeaderLevel = currentHeaderLevel, + blocksRefused = blocksRefused, + ruleRefused = ruleRefused, ) if (compact) { @@ -167,7 +196,6 @@ private val TOOLBAR_COMPACT_THRESHOLD = 520.dp @Composable private fun RowScope.FormatButtons( state: TextEditorState, - markdownState: MarkdownExtension, isBoldActive: Boolean, isItalicActive: Boolean, isStrikethroughActive: Boolean, @@ -175,13 +203,15 @@ private fun RowScope.FormatButtons( isBulletListActive: Boolean, isOrderedListActive: Boolean, currentHeaderLevel: Int, + blocksRefused: Boolean, + ruleRefused: Boolean, ) { EditorTooltip("${Res.string.markdown_format_bar_bold.get()} (${shortcutHint("B")})") { EditorAction( icon = EditorIcons.IconBold, active = isBoldActive, ) { - toggleStyle(state, markdownState.markdownStyles.BOLD) + state.toggleSpanStyle(state.richTextStyles.boldStyle) } } EditorTooltip("${Res.string.markdown_format_bar_italic.get()} (${shortcutHint("I")})") { @@ -189,7 +219,7 @@ private fun RowScope.FormatButtons( icon = EditorIcons.IconItalic, active = isItalicActive, ) { - toggleStyle(state, markdownState.markdownStyles.ITALICS) + state.toggleSpanStyle(state.richTextStyles.italicStyle) } } EditorTooltip( @@ -204,45 +234,50 @@ private fun RowScope.FormatButtons( icon = EditorIcons.IconStrikethrough, active = isStrikethroughActive, ) { - toggleStyle(state, markdownState.markdownStyles.STRIKETHROUGH) + state.toggleSpanStyle(state.richTextStyles.strikethroughStyle) } } EditorTooltip(Res.string.markdown_format_bar_heading.get()) { EditorTextAction( label = if (currentHeaderLevel == 0) "H" else "H$currentHeaderLevel", active = currentHeaderLevel != 0, + enabled = !blocksRefused, ) { - cycleHeader(state, markdownState, currentHeaderLevel) + cycleHeader(state, currentHeaderLevel) } } EditorTooltip(Res.string.markdown_format_bar_blockquote.get()) { EditorAction( icon = Icons.Default.FormatQuote, active = isBlockquoteActive, + enabled = !blocksRefused, ) { - toggleStyle(state, markdownState.markdownStyles.BLOCKQUOTE) + toggleBlockquote(state) } } EditorTooltip(Res.string.markdown_format_bar_bullet_list.get()) { EditorAction( icon = Icons.AutoMirrored.Filled.FormatListBulleted, active = isBulletListActive, + enabled = !blocksRefused, ) { - toggleBulletList(state, markdownState) + toggleBulletList(state) } } EditorTooltip(Res.string.markdown_format_bar_numbered_list.get()) { EditorAction( icon = Icons.Default.FormatListNumbered, active = isOrderedListActive, + enabled = !blocksRefused, ) { - toggleOrderedList(state, markdownState) + toggleOrderedList(state) } } EditorTooltip(Res.string.markdown_format_bar_horizontal_rule.get()) { EditorAction( icon = Icons.Default.HorizontalRule, active = false, + enabled = !ruleRefused, ) { insertHorizontalRule(state) } @@ -276,7 +311,8 @@ private fun HistoryAndOverflow( EditorTooltip(Res.string.markdown_format_bar_undo.get()) { EditorAction( icon = EditorIcons.IconUndo, - active = state.canUndo + active = false, + enabled = state.canUndo, ) { state.undo() } @@ -284,7 +320,8 @@ private fun HistoryAndOverflow( EditorTooltip(Res.string.markdown_format_bar_redo.get()) { EditorAction( icon = EditorIcons.IconRedo, - active = state.canRedo + active = false, + enabled = state.canRedo, ) { state.redo() } @@ -321,49 +358,60 @@ private fun HistoryAndOverflow( }, ) } - if (decreaseTextSize != null) { - DropdownMenuItem( - text = { Text(Res.string.markdown_format_bar_decrease_text_size.get()) }, - leadingIcon = { - Icon( - imageVector = EditorIcons.IconTextDecrease, - contentDescription = null, - ) - }, - onClick = { - decreaseTextSize() - menuExpanded = false - }, + if (decreaseTextSize != null || increaseTextSize != null || resetTextSize != null) { + TextSizeMenuRow(decreaseTextSize, increaseTextSize, resetTextSize) + } + } + } +} + +/** Steps the body text size and shows it; the menu stays open while stepping. */ +@Composable +private fun TextSizeMenuRow( + decreaseTextSize: (() -> Unit)?, + increaseTextSize: (() -> Unit)?, + resetTextSize: (() -> Unit)?, +) { + val textSize = LocalEditorTextStyle.current.fontSize.value + Row( + modifier = Modifier.padding(start = 12.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = Res.string.markdown_format_bar_text_size.get(), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(end = 12.dp), + ) + if (decreaseTextSize != null) { + IconButton(onClick = decreaseTextSize) { + Icon( + imageVector = EditorIcons.IconTextDecrease, + contentDescription = Res.string.markdown_format_bar_decrease_text_size.get(), ) } - if (increaseTextSize != null) { - DropdownMenuItem( - text = { Text(Res.string.markdown_format_bar_increase_text_size.get()) }, - leadingIcon = { - Icon( - imageVector = EditorIcons.IconTextIncrease, - contentDescription = null, - ) - }, - onClick = { - increaseTextSize() - menuExpanded = false - }, + } + Text( + text = textSize.toInt().toString(), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier.widthIn(min = 28.dp), + ) + if (increaseTextSize != null) { + IconButton(onClick = increaseTextSize) { + Icon( + imageVector = EditorIcons.IconTextIncrease, + contentDescription = Res.string.markdown_format_bar_increase_text_size.get(), ) } - if (resetTextSize != null) { - DropdownMenuItem( - text = { Text(Res.string.markdown_format_bar_reset_text_size.get()) }, - leadingIcon = { - Icon( - imageVector = EditorIcons.IconTextReset, - contentDescription = null, - ) - }, - onClick = { - resetTextSize() - menuExpanded = false - }, + } + if (resetTextSize != null) { + IconButton( + onClick = resetTextSize, + enabled = textSize != GlobalSettings.DEFAULT_FONT_SIZE, + ) { + Icon( + imageVector = EditorIcons.IconTextReset, + contentDescription = Res.string.markdown_format_bar_reset_text_size.get(), ) } } @@ -379,34 +427,7 @@ private fun HistoryAndOverflow( fun Modifier.markdownFormatShortcuts(markdownExtension: MarkdownExtension): Modifier { val state = markdownExtension.editorState return this - .boldShortcutModifier { toggleStyle(state, markdownExtension.markdownStyles.BOLD) } - .italicShortcutModifier { toggleStyle(state, markdownExtension.markdownStyles.ITALICS) } - .strikethroughShortcutModifier { toggleStyle(state, markdownExtension.markdownStyles.STRIKETHROUGH) } -} - -/** - * Toggles [spanStyle] over the current selection, or at the cursor when there is - * no selection. The active state is read synchronously from the editor so this is - * safe to call from a keyboard shortcut as well as a toolbar button. - */ -private fun toggleStyle( - state: TextEditorState, - spanStyle: SpanStyle, -) { - val selection = state.selector.selection - if (selection != null) { - val isActive = state.getSpanStylesInRange(selection).contains(spanStyle) - if (isActive) { - state.removeStyleSpan(selection, spanStyle) - } else { - state.addStyleSpan(selection, spanStyle) - } - } else { - val isActive = state.cursor.styles.contains(spanStyle) - if (isActive) { - state.cursor.removeStyle(spanStyle) - } else { - state.cursor.addStyle(spanStyle) - } - } + .boldShortcutModifier { state.toggleSpanStyle(state.richTextStyles.boldStyle) } + .italicShortcutModifier { state.toggleSpanStyle(state.richTextStyles.italicStyle) } + .strikethroughShortcutModifier { state.toggleSpanStyle(state.richTextStyles.strikethroughStyle) } } diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/MarkdownView.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/MarkdownView.kt index cf0ed3285..c5f6520ad 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/MarkdownView.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/MarkdownView.kt @@ -3,16 +3,19 @@ package com.darkrockstudios.apps.hammer.common.compose.markdowneditor import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.text import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.style.TextIndent import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.darkrockstudios.apps.hammer.common.compose.LocalMarkdownConfig +import com.darkrockstudios.apps.hammer.common.compose.LocalEditorTextStyle +import com.darkrockstudios.apps.hammer.common.compose.LocalRichTextStyles +import com.darkrockstudios.apps.hammer.common.compose.markdown.HammerMarkdownConfiguration +import com.darkrockstudios.apps.hammer.common.compose.withParagraphIndent import com.darkrockstudios.texteditor.RichTextView import com.darkrockstudios.texteditor.markdown.withMarkdown import com.darkrockstudios.texteditor.rememberTextEditorStyle @@ -25,27 +28,43 @@ import com.darkrockstudios.texteditor.state.rememberTextEditorState * * The raw [markdown] is exposed via semantics so test matchers (`onNodeWithText`, * `assertTextSatisfies`) can read it; the Canvas-based render itself has no semantic text. + * + * [importDuringComposition] parses the first text in composition instead of a frame later, + * so the view has its real height on the first frame. A lazy list cannot scroll past a row + * that is still empty. + * + * [isSelectable] lets the user select and copy text. */ @Composable fun MarkdownView( markdown: String, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(0.dp), + importDuringComposition: Boolean = false, + isSelectable: Boolean = false, ) { - val markdownConfig = LocalMarkdownConfig.current + val richTextStyles = LocalRichTextStyles.current val state = rememberTextEditorState() - val markdownExtension = remember(state) { state.withMarkdown(markdownConfig) } + var imported by remember(state) { mutableStateOf(if (importDuringComposition) markdown else null) } + val markdownExtension = remember(state) { + state.richTextStyles = richTextStyles + state.withMarkdown(HammerMarkdownConfiguration).apply { + if (importDuringComposition) importMarkdown(markdown) + } + } LaunchedEffect(markdownExtension, markdown) { - markdownExtension.importMarkdown(markdown) + if (imported != markdown) { + markdownExtension.importMarkdown(markdown) + imported = markdown + } } RichTextView( state = state, modifier = modifier.semantics { text = AnnotatedString(markdown) }, contentPadding = contentPadding, + isSelectable = isSelectable, style = rememberTextEditorStyle( - textStyle = TextStyle.Default.copy( - textIndent = TextIndent(firstLine = 24.sp) - ) + textStyle = LocalEditorTextStyle.current.withParagraphIndent() ) ) } diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/theme/OledBlackAvailability.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/theme/OledBlackAvailability.kt new file mode 100644 index 000000000..19223a4dd --- /dev/null +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/theme/OledBlackAvailability.kt @@ -0,0 +1,3 @@ +package com.darkrockstudios.apps.hammer.common.compose.theme + +expect val oledBlackThemeAvailable: Boolean diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/theme/ProjectColorScheme.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/theme/ProjectColorScheme.kt index 9c21c100c..9dd4b324f 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/theme/ProjectColorScheme.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/theme/ProjectColorScheme.kt @@ -60,7 +60,11 @@ fun ProjectThemeOverride( } val parentIsDark = MaterialTheme.colorScheme.background.luminance() < 0.5f - val derived = remember(theme, parentIsDark) { theme.toColorScheme(parentIsDark) } + val oledBlack = LocalOledBlack.current + val derived = remember(theme, parentIsDark, oledBlack) { + val scheme = theme.toColorScheme(parentIsDark) + if (oledBlack) scheme?.withOledBlack() else scheme + } if (derived == null) { content() diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/theme/Theme.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/theme/Theme.kt index 9e9008ae8..5dce83239 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/theme/Theme.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/theme/Theme.kt @@ -6,9 +6,12 @@ import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import com.darkrockstudios.apps.hammer.common.compose.ProvideMarkdownConfig +import com.darkrockstudios.apps.hammer.common.compose.ProvideRichTextStyles import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettings +import com.darkrockstudios.apps.hammer.common.data.globalsettings.UiTheme val LightColors = lightColorScheme( @@ -84,6 +87,15 @@ fun resolveColorScheme(useDarkTheme: Boolean): ColorScheme { } } +val LocalOledBlack = staticCompositionLocalOf { false } + +fun ColorScheme.withOledBlack(): ColorScheme = copy( + background = Color.Black, + surface = Color.Black, + surfaceDim = Color.Black, + surfaceContainerLowest = Color.Black, +) + // Manuscript shape system — every M3 component reads square corners by // default. Per-component rounding (e.g. chart bars) is opted in // explicitly. See designsystem/DESIGN_README.md. @@ -103,19 +115,24 @@ fun AppTheme( getOverrideColorScheme: ((Boolean) -> ColorScheme?)? = null, content: @Composable () -> Unit ) { - val colors = remember(useDarkTheme) { - getOverrideColorScheme?.invoke(useDarkTheme) ?: resolveColorScheme(useDarkTheme) + val oledBlack = useDarkTheme && settings.uiTheme == UiTheme.OledBlack + val colors = remember(useDarkTheme, oledBlack) { + val base = getOverrideColorScheme?.invoke(useDarkTheme) ?: resolveColorScheme(useDarkTheme) + if (oledBlack) base.withOledBlack() else base } val extendedColors = if (useDarkTheme) DarkHammerColors else LightHammerColors - CompositionLocalProvider(LocalHammerColors provides extendedColors) { + CompositionLocalProvider( + LocalHammerColors provides extendedColors, + LocalOledBlack provides oledBlack, + ) { MaterialTheme( colorScheme = colors, shapes = HammerShapes, typography = hammerTypography(), ) { - ProvideMarkdownConfig(isDark = useDarkTheme, settings = settings) { + ProvideRichTextStyles(isDark = useDarkTheme, settings = settings) { content() } } diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/encyclopedia/ViewEntryUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/encyclopedia/ViewEntryUi.kt index 738810010..ed952c1e6 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/encyclopedia/ViewEntryUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/encyclopedia/ViewEntryUi.kt @@ -56,6 +56,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -200,11 +201,12 @@ internal fun ViewEntryUi( val screen = LocalScreenCharacteristic.current val isCompact = screen.windowWidthClass == WindowWidthSizeClass.Compact - LaunchedEffect(state.content) { + LaunchedEffect(state.content, state.editName, state.editText) { val loaded = state.content ?: return@LaunchedEffect // Once the fields hold the entry, an open edit owns them: re-seeding would discard // unsaved text. Until then they must be filled even if the edit started first, - // otherwise a save writes an empty entry. + // otherwise a save writes an empty entry. Leaving edit mode reseeds, which is what + // makes a discard from outside this screen drop the typed text. if (seeded && (state.editName || state.editText)) return@LaunchedEffect entryNameText = loaded.name entryText = loaded.text @@ -1283,6 +1285,8 @@ private fun TagAddDialog( label = Res.string.encyclopedia_create_entry_tags_label.get(), value = newTagsText, onValueChange = { newTagsText = it }, + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, ) HdTagSuggestionStrip( suggestions = suggestions, diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projecthome/ExportOptionsDialog.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projecthome/ExportOptionsDialog.kt index 0a1ac7ec8..fd9b11a69 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projecthome/ExportOptionsDialog.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projecthome/ExportOptionsDialog.kt @@ -47,14 +47,18 @@ import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdMonoLabel import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdPickerList import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdPickerRow import com.darkrockstudios.apps.hammer.common.compose.resources.get +import com.darkrockstudios.apps.hammer.common.data.ExportFont import com.darkrockstudios.apps.hammer.common.data.ExportFormat import com.darkrockstudios.apps.hammer.common.data.ExportOptions import com.darkrockstudios.apps.hammer.common.data.ExportableScene +import com.darkrockstudios.apps.hammer.project_home_export_blank_lines_hint +import com.darkrockstudios.apps.hammer.project_home_export_blank_lines_label import com.darkrockstudios.apps.hammer.project_home_export_cancel import com.darkrockstudios.apps.hammer.project_home_export_chapters_label import com.darkrockstudios.apps.hammer.project_home_export_close import com.darkrockstudios.apps.hammer.project_home_export_dialog_title import com.darkrockstudios.apps.hammer.project_home_export_execute +import com.darkrockstudios.apps.hammer.project_home_export_font_label import com.darkrockstudios.apps.hammer.project_home_export_format_docx import com.darkrockstudios.apps.hammer.project_home_export_format_epub import com.darkrockstudios.apps.hammer.project_home_export_format_label @@ -64,6 +68,7 @@ import com.darkrockstudios.apps.hammer.project_home_export_format_rtf import com.darkrockstudios.apps.hammer.project_home_export_help_icon_description import com.darkrockstudios.apps.hammer.project_home_export_limit_scenes_hint import com.darkrockstudios.apps.hammer.project_home_export_limit_scenes_label +import com.darkrockstudios.apps.hammer.project_home_export_number_chapters_label import com.darkrockstudios.apps.hammer.project_home_export_scenes_clear_all import com.darkrockstudios.apps.hammer.project_home_export_scenes_label import com.darkrockstudios.apps.hammer.project_home_export_scenes_select_all @@ -206,6 +211,33 @@ internal fun ExportOptionsDialogContent( label = { (it.labelRes()).get() }, ) + if (options.numbersChapters) { + HdHairlineToggleRow( + checked = options.numberChapters, + onCheckedChange = { onOptionsChanged(options.copy(numberChapters = it)) }, + label = Res.string.project_home_export_number_chapters_label.get(), + ) + } + + if (options.format in FONT_EXPORT_FORMATS) { + HdHairlineDropdown( + title = Res.string.project_home_export_font_label.get(), + options = ExportFont.entries, + selected = options.font, + onSelect = { onOptionsChanged(options.copy(font = it)) }, + label = { it.faceName }, + ) + } + + if (options.format != ExportFormat.Markdown) { + HdHairlineToggleRow( + checked = options.keepBlankLines, + onCheckedChange = { onOptionsChanged(options.copy(keepBlankLines = it)) }, + label = Res.string.project_home_export_blank_lines_label.get(), + hint = Res.string.project_home_export_blank_lines_hint.get(), + ) + } + if (allSceneIds.isNotEmpty()) { HdHairlineToggleRow( checked = options.sceneIds != null, @@ -362,6 +394,16 @@ private val AVAILABLE_EXPORT_FORMATS = ExportFormat.Markdown, ) +/** PDF renders in its library's built-in face, and Markdown carries no font. */ +private val FONT_EXPORT_FORMATS = setOf(ExportFormat.Epub, ExportFormat.Docx, ExportFormat.Rtf) + +/** Markdown only has chapter headings in chapter mode. */ +private val ExportOptions.numbersChapters: Boolean + get() = when (format) { + ExportFormat.Markdown -> treatTopLevelAsChapters + else -> true + } + private fun ExportFormat.labelRes(): StringResource = when (this) { ExportFormat.Markdown -> Res.string.project_home_export_format_markdown ExportFormat.Epub -> Res.string.project_home_export_format_epub diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projecthome/ProjectInfoSettingsUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projecthome/ProjectInfoSettingsUi.kt index bb760b5e2..915b8c3d8 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projecthome/ProjectInfoSettingsUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projecthome/ProjectInfoSettingsUi.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -294,6 +295,7 @@ internal fun WordCountGoalSection( onValueChange = { input -> if (enabled) count = input.filter(Char::isDigit) }, counter = "W", imeAction = ImeAction.Done, + keyboardType = KeyboardType.Number, ) HdHairlineSegmentedPicker( options = WordCountGoal.Cadence.entries, diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectroot/ConfirmCloseDialogs.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectroot/ConfirmCloseDialogs.kt index 8ccaf312e..3ebcb4a2e 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectroot/ConfirmCloseDialogs.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectroot/ConfirmCloseDialogs.kt @@ -39,7 +39,10 @@ fun ConfirmUnsavedScenesDialog( } }, onDiscard = { - component.closeRequestDealtWith(CloseConfirm.Scenes) + scope.launch { + component.discardUnsaved(CloseConfirm.Scenes) + component.closeRequestDealtWith(CloseConfirm.Scenes) + } }, onCancel = { component.cancelCloseRequest() @@ -48,7 +51,7 @@ fun ConfirmUnsavedScenesDialog( } @Composable -fun ConfirmCloseUnsavedEncyclopediaDialog(component: ProjectRoot) { +fun ConfirmCloseUnsavedEncyclopediaDialog(component: ProjectRoot, scope: CoroutineScope) { SimpleConfirm( title = Res.string.unsaved_encyclopedia_dialog_title.get(), message = Res.string.unsaved_encyclopedia_dialog_message.get(), @@ -58,12 +61,15 @@ fun ConfirmCloseUnsavedEncyclopediaDialog(component: ProjectRoot) { component.cancelCloseRequest() } ) { - component.closeRequestDealtWith(CloseConfirm.Encyclopedia) + scope.launch { + component.discardUnsaved(CloseConfirm.Encyclopedia) + component.closeRequestDealtWith(CloseConfirm.Encyclopedia) + } } } @Composable -fun ConfirmCloseUnsavedNotesDialog(component: ProjectRoot) { +fun ConfirmCloseUnsavedNotesDialog(component: ProjectRoot, scope: CoroutineScope) { SimpleConfirm( title = Res.string.unsaved_notes_dialog_title.get(), message = Res.string.unsaved_notes_dialog_message.get(), @@ -73,12 +79,15 @@ fun ConfirmCloseUnsavedNotesDialog(component: ProjectRoot) { component.cancelCloseRequest() } ) { - component.closeRequestDealtWith(CloseConfirm.Notes) + scope.launch { + component.discardUnsaved(CloseConfirm.Notes) + component.closeRequestDealtWith(CloseConfirm.Notes) + } } } @Composable -fun ConfirmCloseUnsavedTimelineDialog(component: ProjectRoot) { +fun ConfirmCloseUnsavedTimelineDialog(component: ProjectRoot, scope: CoroutineScope) { SimpleConfirm( title = Res.string.unsaved_timeline_dialog_title.get(), message = Res.string.unsaved_timeline_dialog_message.get(), @@ -88,6 +97,9 @@ fun ConfirmCloseUnsavedTimelineDialog(component: ProjectRoot) { component.cancelCloseRequest() } ) { - component.closeRequestDealtWith(CloseConfirm.Timeline) + scope.launch { + component.discardUnsaved(CloseConfirm.Timeline) + component.closeRequestDealtWith(CloseConfirm.Timeline) + } } } diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectroot/ProjectRootScaffold.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectroot/ProjectRootScaffold.kt index afae44627..1396064e9 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectroot/ProjectRootScaffold.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectroot/ProjectRootScaffold.kt @@ -66,9 +66,9 @@ fun ProjectRootScaffold( if (shouldConfirmClose.isNotEmpty()) { when (shouldConfirmClose.first()) { CloseConfirm.Scenes -> ConfirmUnsavedScenesDialog(component, coroutineScope) - CloseConfirm.Notes -> ConfirmCloseUnsavedNotesDialog(component) - CloseConfirm.Encyclopedia -> ConfirmCloseUnsavedEncyclopediaDialog(component) - CloseConfirm.Timeline -> ConfirmCloseUnsavedTimelineDialog(component) + CloseConfirm.Notes -> ConfirmCloseUnsavedNotesDialog(component, coroutineScope) + CloseConfirm.Encyclopedia -> ConfirmCloseUnsavedEncyclopediaDialog(component, coroutineScope) + CloseConfirm.Timeline -> ConfirmCloseUnsavedTimelineDialog(component, coroutineScope) CloseConfirm.Sync -> component.showProjectSync() CloseConfirm.Complete -> onCloseRequest() } diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/AppUpdateBanner.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/AppUpdateBanner.kt new file mode 100644 index 000000000..cc51235c2 --- /dev/null +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/AppUpdateBanner.kt @@ -0,0 +1,101 @@ +package com.darkrockstudios.apps.hammer.common.projectselection + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.darkrockstudios.apps.hammer.Res +import com.darkrockstudios.apps.hammer.about_update_available +import com.darkrockstudios.apps.hammer.about_update_checking +import com.darkrockstudios.apps.hammer.about_update_downloading +import com.darkrockstudios.apps.hammer.about_update_failed +import com.darkrockstudios.apps.hammer.about_update_install_button +import com.darkrockstudios.apps.hammer.about_update_installing +import com.darkrockstudios.apps.hammer.about_update_later_button +import com.darkrockstudios.apps.hammer.about_update_quit_notice +import com.darkrockstudios.apps.hammer.about_update_retry_button +import com.darkrockstudios.apps.hammer.about_update_up_to_date +import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdNoticeStrip +import com.darkrockstudios.apps.hammer.common.compose.resources.get +import com.darkrockstudios.apps.hammer.common.data.appupdate.AppUpdateState +import com.darkrockstudios.apps.hammer.update_dialog_open_release_button +import kotlin.math.roundToInt + +private const val PERCENT = 100f + +/** "42%" for a 0..1 fraction; built here so the string resource needs no escaped percent sign. */ +fun percentLabel(fraction: Float): String = "${(fraction * PERCENT).roundToInt()}%" + +/** + * The one-line description of an update state, shared by the project-list banner and the + * About screen row, or null when there is nothing to say. + */ +@Composable +fun appUpdateDescription(state: AppUpdateState): String? = when (state) { + AppUpdateState.Checking -> Res.string.about_update_checking.get() + AppUpdateState.UpToDate -> Res.string.about_update_up_to_date.get() + is AppUpdateState.Available -> Res.string.about_update_available.get(state.version) + is AppUpdateState.Downloading -> + Res.string.about_update_downloading.get(state.version, percentLabel(state.fraction)) + + is AppUpdateState.Installing -> Res.string.about_update_installing.get(state.version) + is AppUpdateState.Failed -> Res.string.about_update_failed.get(state.version, state.reason) + AppUpdateState.Idle, + AppUpdateState.Unsupported -> null +} + +/** + * The strip above the project list that offers a newer release. Nothing is drawn until a check + * has found one, and nothing ever is on a build that cannot update itself. + */ +@Composable +fun AppUpdateBanner( + state: AppUpdateState, + onUpdate: () -> Unit, + onOpenRelease: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val title = appUpdateDescription(state) ?: return + when (state) { + is AppUpdateState.Available -> if (!state.dismissed) { + HdNoticeStrip( + title = title, + detail = if (state.installable) Res.string.about_update_quit_notice.get() else null, + primaryLabel = if (state.installable) { + Res.string.about_update_install_button.get(state.version) + } else { + Res.string.update_dialog_open_release_button.get() + }, + onPrimary = if (state.installable) onUpdate else onOpenRelease, + secondaryLabel = Res.string.about_update_later_button.get(), + onSecondary = onDismiss, + modifier = modifier, + ) + } + + is AppUpdateState.Downloading -> HdNoticeStrip( + title = title, + progress = state.fraction, + modifier = modifier, + ) + + is AppUpdateState.Installing -> HdNoticeStrip( + title = title, + modifier = modifier, + ) + + is AppUpdateState.Failed -> HdNoticeStrip( + title = title, + primaryLabel = Res.string.about_update_retry_button.get(), + onPrimary = onUpdate, + secondaryLabel = Res.string.update_dialog_open_release_button.get(), + onSecondary = onOpenRelease, + modifier = modifier, + ) + + // Described, but only the About row says so. + AppUpdateState.Checking, + AppUpdateState.UpToDate, + AppUpdateState.Idle, + AppUpdateState.Unsupported -> Unit + } +} diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ProjectDeleteDialog.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ProjectDeleteDialog.kt index 05c5beb85..6af690603 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ProjectDeleteDialog.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ProjectDeleteDialog.kt @@ -110,6 +110,7 @@ fun ProjectDeleteDialog( hint = projectDef.name, singleLine = true, imeAction = ImeAction.Done, + autoCorrectEnabled = false, error = if (showMismatch) "Does not match" else null, ) } diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ProjectListUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ProjectListUi.kt index 2c3da7118..ddedb711e 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ProjectListUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ProjectListUi.kt @@ -161,7 +161,9 @@ private fun applySort(projects: List, mode: ProjectsSortMode): List fun ProjectListUi( component: ProjectsList, rootSnackbar: RootSnackbarHostState, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + /** A slip laid under the masthead, given the page's inset; composes nothing when there is no notice. */ + notice: @Composable (Modifier) -> Unit = {}, ) { // Cut at WindowWidthSizeClass.Compact (600dp) rather than the project-wide // 720dp `isWide` so portrait tablets keep the desktop masthead instead of @@ -223,6 +225,8 @@ fun ProjectListUi( HdFolioDivider() + notice(Modifier.padding(start = horizontalPadding, end = horizontalPadding, top = 12.dp, bottom = 12.dp)) + AnimatedVisibility(visible = showSearchBar) { SearchStrip( query = query, diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ProjectSelectionUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ProjectSelectionUi.kt index da27f01e7..544935c62 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ProjectSelectionUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/ProjectSelectionUi.kt @@ -77,6 +77,7 @@ fun ProjectSelectionUi( val rootSnackbar = rememberRootSnackbarHostState() val stackState by component.stack.subscribeAsState() + val appUpdate by component.appUpdate.subscribeAsState() Box(modifier = modifier) { Children( stack = stackState, @@ -96,6 +97,16 @@ fun ProjectSelectionUi( is ProjectSelection.Destination.ProjectsListDestination -> ProjectListUi( destination.component, rootSnackbar, + // Only the list carries the update notice: the About screen has its own row. + notice = { noticeModifier -> + AppUpdateBanner( + state = appUpdate, + onUpdate = component::updateApp, + onOpenRelease = component::openLatestRelease, + onDismiss = component::dismissUpdate, + modifier = noticeModifier, + ) + }, ) is ProjectSelection.Destination.StoryIdeasDestination -> StoryIdeasUi( diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/about/AboutAppUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/about/AboutAppUi.kt index 382663899..9bde86f49 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/about/AboutAppUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/about/AboutAppUi.kt @@ -66,6 +66,7 @@ import com.darkrockstudios.apps.hammer.common.compose.icons.Github import com.darkrockstudios.apps.hammer.common.compose.icons.Reddit import com.darkrockstudios.apps.hammer.common.compose.resources.get import com.darkrockstudios.apps.hammer.common.compose.scrollBarOverlay +import com.darkrockstudios.apps.hammer.common.data.appupdate.AppUpdateState import com.darkrockstudios.apps.hammer.common.data.changelog.supportsInAppChangelog import com.darkrockstudios.apps.hammer.hammer_icon import org.jetbrains.compose.resources.painterResource @@ -161,6 +162,8 @@ fun AboutAppUi( state = state, onViewChangelog = component::viewChangelog, onOpenLatestRelease = component::openLatestRelease, + onCheckForUpdate = component::checkForUpdate, + onUpdateApp = component::updateApp, ) AttributionSection( @@ -310,6 +313,8 @@ private fun VersionCard( state: AboutApp.State, onViewChangelog: () -> Unit, onOpenLatestRelease: () -> Unit, + onCheckForUpdate: () -> Unit, + onUpdateApp: () -> Unit, ) { HdCatalogueCard( topStart = "§ III · VERSION", @@ -332,6 +337,14 @@ private fun VersionCard( onClick = onOpenLatestRelease, ) } + if (state.appUpdate !is AppUpdateState.Unsupported) { + Spacer(Modifier.height(16.dp)) + AppUpdateStatus( + state = state.appUpdate, + onCheckForUpdate = onCheckForUpdate, + onUpdateApp = onUpdateApp, + ) + } } } diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/about/AppUpdateStatus.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/about/AppUpdateStatus.kt new file mode 100644 index 000000000..53f921bb3 --- /dev/null +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/about/AppUpdateStatus.kt @@ -0,0 +1,96 @@ +package com.darkrockstudios.apps.hammer.common.projectselection.about + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.darkrockstudios.apps.hammer.Res +import com.darkrockstudios.apps.hammer.about_update_check_button +import com.darkrockstudios.apps.hammer.about_update_install_button +import com.darkrockstudios.apps.hammer.about_update_retry_button +import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdHairlineButton +import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdHairlineProgressBar +import com.darkrockstudios.apps.hammer.common.compose.resources.get +import com.darkrockstudios.apps.hammer.common.data.appupdate.AppUpdateState +import com.darkrockstudios.apps.hammer.common.projectselection.appUpdateDescription + +/** + * The update row on the About screen's version card. Hidden on builds that cannot update, so + * store builds show no trace of it. A release this install cannot install itself is only + * announced; the card's existing release-page button covers the rest. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun AppUpdateStatus( + state: AppUpdateState, + onCheckForUpdate: () -> Unit, + onUpdateApp: () -> Unit, + modifier: Modifier = Modifier, +) { + if (state is AppUpdateState.Unsupported) return + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + appUpdateDescription(state)?.let { text -> + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (state is AppUpdateState.Downloading) { + HdHairlineProgressBar(progress = state.fraction) + } + FlowRow( + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + when (state) { + is AppUpdateState.Available -> { + if (state.installable) { + HdHairlineButton( + label = Res.string.about_update_install_button.get(state.version), + onClick = onUpdateApp, + emphasised = true, + ) + } + CheckButton(onCheckForUpdate) + } + + is AppUpdateState.Failed -> { + HdHairlineButton( + label = Res.string.about_update_retry_button.get(), + onClick = onUpdateApp, + emphasised = true, + ) + CheckButton(onCheckForUpdate) + } + + AppUpdateState.Checking -> CheckButton(onCheckForUpdate, enabled = false) + + AppUpdateState.Idle, + AppUpdateState.UpToDate -> CheckButton(onCheckForUpdate) + + is AppUpdateState.Downloading, + is AppUpdateState.Installing, + AppUpdateState.Unsupported -> Unit + } + } + } +} + +@Composable +private fun CheckButton(onClick: () -> Unit, enabled: Boolean = true) { + HdHairlineButton( + label = Res.string.about_update_check_button.get(), + onClick = onClick, + enabled = enabled, + ) +} diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/settings/AccountSettingsUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/settings/AccountSettingsUi.kt index ab728e9e2..43ba6ebec 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/settings/AccountSettingsUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/settings/AccountSettingsUi.kt @@ -29,6 +29,7 @@ import com.darkrockstudios.apps.hammer.common.compose.designsystem.* import com.darkrockstudios.apps.hammer.common.compose.resources.get import com.darkrockstudios.apps.hammer.common.compose.scrollBarOverlay import com.darkrockstudios.apps.hammer.common.compose.theme.LocalHammerColors +import com.darkrockstudios.apps.hammer.common.compose.theme.oledBlackThemeAvailable import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettings import com.darkrockstudios.apps.hammer.common.data.globalsettings.InitialProjectScreen import com.darkrockstudios.apps.hammer.common.data.globalsettings.UiTheme @@ -96,7 +97,7 @@ internal fun AccountSettingsUi( contentSpacing = 12.dp, ) { HdHairlineSegmentedPicker( - options = UiTheme.entries, + options = UiTheme.entries.filter { it != UiTheme.OledBlack || oledBlackThemeAvailable }, selected = state.uiTheme, onSelect = { component.setUiTheme(it) }, label = { uiThemeLabel(it) }, @@ -315,6 +316,7 @@ private fun FolioCaption( private fun uiThemeLabel(theme: UiTheme): String = when (theme) { UiTheme.Light -> Res.string.settings_theme_light.get() UiTheme.Dark -> Res.string.settings_theme_dark.get() + UiTheme.OledBlack -> Res.string.settings_theme_oled_black.get() UiTheme.FollowSystem -> Res.string.settings_theme_followsystem.get() } diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/settings/ServerSetupDialog.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/settings/ServerSetupDialog.kt index e418b811f..aa926ef35 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/settings/ServerSetupDialog.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/settings/ServerSetupDialog.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -130,6 +131,8 @@ fun ServerSetupDialogContent( placeholder = Res.string.settings_server_setup_url_hint.get(), singleLine = true, imeAction = ImeAction.Next, + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, keyboardType = KeyboardType.Uri, enabled = !state.serverWorking && !existingServer, ) diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/settings/backups/BackupsSettingsUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/settings/backups/BackupsSettingsUi.kt index 24d79ff2f..92dc9f571 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/settings/backups/BackupsSettingsUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/settings/backups/BackupsSettingsUi.kt @@ -9,6 +9,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.darkrockstudios.apps.hammer.* @@ -59,6 +60,7 @@ fun BackupsSettingsUi( }, hint = "1–${GlobalSettings.MAX_BACKUPS}", imeAction = ImeAction.Done, + keyboardType = KeyboardType.Number, error = if (isMaxBackupsError) { Res.string.settings_server_max_backups_error.get(GlobalSettings.MAX_BACKUPS) } else { diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/OutlineOverviewUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/OutlineOverviewUi.kt index f326b8a67..d7d3fe0d3 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/OutlineOverviewUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/OutlineOverviewUi.kt @@ -1,7 +1,5 @@ package com.darkrockstudios.apps.hammer.common.storyeditor -import androidx.compose.animation.* -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -13,21 +11,17 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.darkrockstudios.apps.hammer.* import com.darkrockstudios.apps.hammer.common.components.storyeditor.outlineoverview.OutlineOverview @@ -35,8 +29,14 @@ import com.darkrockstudios.apps.hammer.common.compose.AnimatedFullScreenDialog import com.darkrockstudios.apps.hammer.common.compose.MpScrollBarGutter import com.darkrockstudios.apps.hammer.common.compose.MpScrollBarList import com.darkrockstudios.apps.hammer.common.compose.Ui +import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdChapterDropdown +import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdChapterHeader +import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdChapterRailItem +import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdChapterRow +import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdEndMark import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdFolioDivider import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdMonoLabel +import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdSceneBreak import com.darkrockstudios.apps.hammer.common.compose.designsystem.romanNumeral import com.darkrockstudios.apps.hammer.common.compose.resources.get import com.darkrockstudios.apps.hammer.common.compose.scrollBarOverlay @@ -49,7 +49,9 @@ private const val FRONTISPIECE_ITEM_COUNT = 1 private data class OutlineChapter( val chapter: OutlineOverview.OutlineItem.ChapterOutline?, val scenes: List, -) +) { + fun title(untitled: String): String = chapter?.sceneItem?.name ?: untitled +} private fun groupByChapter(items: List): List { if (items.isEmpty()) return emptyList() @@ -102,6 +104,10 @@ fun OutlineOverviewContent( val state by component.state.subscribeAsState() val chapters = remember(state.overview) { groupByChapter(state.overview) } + val untitled = untitledChapter() + val chapterRows = remember(chapters, untitled) { + chapters.map { HdChapterRow(title = it.title(untitled), sceneCount = it.scenes.size) } + } val totalScenes = remember(chapters) { chapters.sumOf { it.scenes.size } } val totalChapters = chapters.count { it.chapter != null } @@ -175,7 +181,7 @@ fun OutlineOverviewContent( .fillMaxWidth(), ) { ChapterRail( - chapters = chapters, + chapters = chapterRows, activeChapterIndex = activeChapterIndex, onJumpTo = scrollToChapter, modifier = Modifier @@ -198,10 +204,10 @@ fun OutlineOverviewContent( ) } } else { - ChapterDropdown( - chapters = chapters, - activeChapterIndex = activeChapterIndex, - onJumpTo = scrollToChapter, + HdChapterDropdown( + rows = chapterRows, + selectedIndex = activeChapterIndex, + onSelect = scrollToChapter, ) ReadingColumn( chapters = chapters, @@ -310,7 +316,7 @@ private fun CloseButton(onClose: () -> Unit) { @Composable private fun ChapterRail( - chapters: List, + chapters: List, activeChapterIndex: Int, onJumpTo: (Int) -> Unit, modifier: Modifier = Modifier, @@ -335,9 +341,9 @@ private fun ChapterRail( contentPadding = PaddingValues(end = MpScrollBarGutter), ) { itemsIndexed(chapters, key = { idx, _ -> "rail-$idx" }) { index, ch -> - ChapterRailItem( + HdChapterRailItem( index = index, - chapter = ch, + row = ch, selected = index == activeChapterIndex, onClick = { onJumpTo(index) }, ) @@ -352,178 +358,10 @@ private fun ChapterRail( } } -@Composable -private fun ChapterRailItem( - index: Int, - chapter: OutlineChapter, - selected: Boolean, - onClick: () -> Unit, -) { - val accent = MaterialTheme.colorScheme.primary - val rowBg = if (selected) { - MaterialTheme.colorScheme.surfaceContainer - } else { - MaterialTheme.colorScheme.surfaceContainerLow - } - - Row( - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min) - .background(rowBg) - .clickable(onClick = onClick), - verticalAlignment = Alignment.Top, - ) { - Box( - modifier = Modifier - .width(2.dp) - .fillMaxHeight() - .background(if (selected) accent else androidx.compose.ui.graphics.Color.Transparent), - ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding( - start = Ui.Padding.L, - end = Ui.Padding.XL, - top = Ui.Padding.L, - bottom = Ui.Padding.L, - ), - horizontalArrangement = Arrangement.spacedBy(Ui.Padding.L), - verticalAlignment = Alignment.Top, - ) { - Text( - text = romanNumeral(index + 1), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Light, - color = if (selected) { - MaterialTheme.colorScheme.onSurface - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = Modifier.widthIn(min = 24.dp), - ) - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - Text( - text = chapter.chapter?.sceneItem?.name ?: untitledChapter(), - style = MaterialTheme.typography.titleSmall, - fontWeight = if (selected) FontWeight.Medium else FontWeight.Normal, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - HdMonoLabel( - text = Res.string.scene_list_outline_overview_scene_count.get(chapter.scenes.size), - ) - } - } - } -} - @Composable private fun untitledChapter(): String = Res.string.scene_list_outline_overview_untitled_chapter.get() -@Composable -private fun ChapterDropdown( - chapters: List, - activeChapterIndex: Int, - onJumpTo: (Int) -> Unit, -) { - var expanded by remember { mutableStateOf(false) } - val rotation by animateFloatAsState( - targetValue = if (expanded) 180f else 0f, - label = "outlineChapterDropdownRotation", - ) - val current = chapters.getOrNull(activeChapterIndex) - - Column(modifier = Modifier.fillMaxWidth()) { - Row( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceContainerLow) - .clickable { expanded = !expanded } - .padding(horizontal = Ui.Padding.XL, vertical = Ui.Padding.L), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Ui.Padding.L), - ) { - HdMonoLabel(text = Res.string.scene_list_outline_overview_jump_to.get()) - Row( - modifier = Modifier.weight(1f), - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.spacedBy(Ui.Padding.M), - ) { - Text( - text = romanNumeral(activeChapterIndex + 1), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Light, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = current?.chapter?.sceneItem?.name ?: untitledChapter(), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - HdMonoLabel( - text = Res.string.scene_list_outline_overview_position.get( - activeChapterIndex + 1, - chapters.size, - ), - ) - Icon( - imageVector = Icons.Default.KeyboardArrowDown, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .size(20.dp) - .rotate(rotation), - ) - } - HorizontalDivider( - thickness = Dp.Hairline, - color = MaterialTheme.colorScheme.outlineVariant, - ) - - AnimatedVisibility( - visible = expanded, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut(), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 320.dp) - .background(MaterialTheme.colorScheme.surface), - ) { - LazyColumn(modifier = Modifier.fillMaxWidth()) { - itemsIndexed(chapters, key = { idx, _ -> "drop-$idx" }) { idx, ch -> - ChapterRailItem( - index = idx, - chapter = ch, - selected = idx == activeChapterIndex, - onClick = { - onJumpTo(idx) - expanded = false - }, - ) - HorizontalDivider( - thickness = Dp.Hairline, - color = MaterialTheme.colorScheme.outlineVariant, - ) - } - } - } - } - } -} - @Composable private fun ReadingColumn( chapters: List, @@ -558,7 +396,7 @@ private fun ReadingColumn( ) } item(key = "end") { - EndOfOutline() + HdEndMark(label = Res.string.scene_list_outline_overview_end.get()) } } @@ -620,9 +458,10 @@ private fun ChapterBlock( } Column(modifier = Modifier.fillMaxWidth().padding(bottom = 48.dp)) { - ChapterHeader( + HdChapterHeader( index = chapterIndex, - chapter = chapter, + title = chapter.title(untitledChapter()), + sceneCount = chapter.scenes.size, wide = wide, ) Spacer(Modifier.height(Ui.Padding.XXL)) @@ -642,52 +481,11 @@ private fun ChapterBlock( } if (showSeparator) { Spacer(Modifier.height(Ui.Padding.XXL)) - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center, - ) { - Text( - text = "· · ·", - style = MaterialTheme.typography.titleLarge.copy(letterSpacing = 8.sp), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + HdSceneBreak() } } } -@Composable -private fun ChapterHeader( - index: Int, - chapter: OutlineChapter, - wide: Boolean, -) { - val titleStyle = if (wide) { - MaterialTheme.typography.headlineLarge - } else { - MaterialTheme.typography.headlineMedium - } - Column(verticalArrangement = Arrangement.spacedBy(Ui.Padding.M)) { - HdMonoLabel( - text = Res.string.scene_list_outline_overview_chapter_eyebrow.get( - romanNumeral(index + 1), - chapter.scenes.size, - ), - ) - Text( - text = chapter.chapter?.sceneItem?.name ?: untitledChapter(), - style = titleStyle, - fontWeight = FontWeight.Light, - color = MaterialTheme.colorScheme.onSurface, - ) - HorizontalDivider( - thickness = Dp.Hairline, - color = MaterialTheme.colorScheme.outlineVariant, - modifier = Modifier.padding(top = Ui.Padding.M), - ) - } -} - @Composable private fun SceneBlock( chapterRoman: String, @@ -736,21 +534,6 @@ private fun SceneBlock( } } -@Composable -private fun EndOfOutline() { - Column( - modifier = Modifier.fillMaxWidth().padding(top = Ui.Padding.L), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(Ui.Padding.L), - ) { - HorizontalDivider( - thickness = Dp.Hairline, - color = MaterialTheme.colorScheme.outlineVariant, - ) - HdMonoLabel(text = Res.string.scene_list_outline_overview_end.get()) - } -} - @Composable private fun EmptyOutline(modifier: Modifier = Modifier) { Box( diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/StoryEditorUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/StoryEditorUi.kt index 70d6e3cf5..7b22bcf4e 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/StoryEditorUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/StoryEditorUi.kt @@ -30,6 +30,7 @@ import com.darkrockstudios.apps.hammer.common.compose.resources.get import com.darkrockstudios.apps.hammer.common.compose.rightBorder import com.darkrockstudios.apps.hammer.common.storyeditor.drafts.DraftCompareUi import com.darkrockstudios.apps.hammer.common.storyeditor.drafts.DraftsListUi +import com.darkrockstudios.apps.hammer.common.storyeditor.readermode.ReaderModeUi import com.darkrockstudios.apps.hammer.common.storyeditor.sceneeditor.SceneEditorUi import com.darkrockstudios.apps.hammer.common.storyeditor.scenelist.SceneListUi import com.darkrockstudios.apps.hammer.scene_editor_no_scene_selected @@ -191,6 +192,10 @@ private fun DetailsPane( is StoryEditor.ChildDestination.Detail.DraftCompareDestination -> { DraftCompareUi(component = child.component) } + + is StoryEditor.ChildDestination.Detail.ReaderDestination -> { + ReaderModeUi(component = child.component) + } } } } diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/drafts/DraftCompareUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/drafts/DraftCompareUi.kt index 56506e2a1..7f178a4a9 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/drafts/DraftCompareUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/drafts/DraftCompareUi.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.VerticalDivider +import com.darkrockstudios.apps.hammer.common.compose.LocalEditorTextStyle import com.darkrockstudios.apps.hammer.common.compose.WindowWidthSizeClass import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable @@ -21,18 +22,20 @@ import com.darkrockstudios.apps.hammer.base.diff.OffsetMap import com.darkrockstudios.apps.hammer.common.components.storyeditor.drafts.DraftCompare import com.darkrockstudios.apps.hammer.common.compose.* import com.darkrockstudios.apps.hammer.common.compose.designsystem.* -import com.darkrockstudios.apps.hammer.common.compose.markdown.updateMarkdownConfiguration +import com.darkrockstudios.apps.hammer.common.compose.markdown.HammerMarkdownConfiguration +import com.darkrockstudios.apps.hammer.common.compose.markdown.updateRichTextStyles import com.darkrockstudios.apps.hammer.common.compose.resources.get import com.darkrockstudios.apps.hammer.common.compose.theme.LocalHammerColors import com.darkrockstudios.apps.hammer.common.data.SceneContent import com.darkrockstudios.apps.hammer.common.storyeditor.sceneeditor.loadSceneContent import com.darkrockstudios.apps.hammer.common.storyeditor.sceneeditor.sceneDiffText import com.darkrockstudios.texteditor.CharLineOffset +import com.darkrockstudios.texteditor.RichTextStyles import com.darkrockstudios.texteditor.TextEditor import com.darkrockstudios.texteditor.TextEditorRange -import com.darkrockstudios.texteditor.markdown.MarkdownConfiguration import com.darkrockstudios.texteditor.markdown.MarkdownExtension import com.darkrockstudios.texteditor.markdown.withMarkdown +import com.darkrockstudios.texteditor.rememberTextEditorStyle import com.darkrockstudios.texteditor.richstyle.HighlightSpanStyle import com.darkrockstudios.texteditor.richstyle.RichSpan import com.darkrockstudios.texteditor.state.TextEditorState @@ -54,16 +57,16 @@ fun DraftCompareUi(component: DraftCompare) { val screen = LocalScreenCharacteristic.current val state by component.state.subscribeAsState() val draftName = component.draftDef.draftName - val markdownConfig = LocalMarkdownConfig.current + val richTextStyles = LocalRichTextStyles.current // Hoisted above the width branch: the two layouts are separate composables, so building // the editors inside them would discard the user's merge edits on a resize or rotate // while component.mergedContent still pointed at the torn-down editor. val draftMarkdown = key(state.draftContent) { - rememberSceneContentEditor(state.draftContent, markdownConfig) + rememberSceneContentEditor(state.draftContent, richTextStyles) } val currentMarkdown = key(state.sceneContent) { - rememberSceneContentEditor(state.sceneContent, markdownConfig) + rememberSceneContentEditor(state.sceneContent, richTextStyles) } Column(modifier = Modifier.fillMaxSize()) { @@ -252,6 +255,7 @@ private fun DraftPane( TextEditor( modifier = Modifier.fillMaxSize(), state = textEditorState, + style = rememberTextEditorStyle(textStyle = LocalEditorTextStyle.current), enabled = false, ) } @@ -324,6 +328,7 @@ private fun CurrentPane( TextEditor( modifier = Modifier.fillMaxSize(), state = textEditorState, + style = rememberTextEditorStyle(textStyle = LocalEditorTextStyle.current), ) } } @@ -333,16 +338,17 @@ private fun CurrentPane( @Composable private fun rememberSceneContentEditor( content: SceneContent?, - markdownConfig: MarkdownConfiguration, + richTextStyles: RichTextStyles, ): MarkdownExtension { val editorState = rememberTextEditorState() val markdownExtension = remember(editorState) { - editorState.withMarkdown(markdownConfig).also { loadSceneContent(it, content) } + editorState.richTextStyles = richTextStyles + editorState.withMarkdown(HammerMarkdownConfiguration).also { loadSceneContent(it, content) } } - // Both panes restyle together; the config is otherwise baked in at import time and + // Both panes restyle together; the styles are otherwise baked in at import time and // the two would drift apart on a font size or theme change. - LaunchedEffect(markdownExtension, markdownConfig) { - markdownExtension.updateMarkdownConfiguration(markdownConfig) + LaunchedEffect(markdownExtension, richTextStyles) { + editorState.updateRichTextStyles(richTextStyles) } return markdownExtension } diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/focusmode/FocusModeUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/focusmode/FocusModeUi.kt index ef0da8799..06590e9ba 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/focusmode/FocusModeUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/focusmode/FocusModeUi.kt @@ -30,25 +30,26 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.style.TextIndent import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.darkrockstudios.apps.hammer.Res import com.darkrockstudios.apps.hammer.common.TextEditorDefaults import com.darkrockstudios.apps.hammer.common.components.storyeditor.focusmode.FocusMode import com.darkrockstudios.apps.hammer.common.components.storyeditor.sceneeditor.clampEditorWidth import com.darkrockstudios.apps.hammer.common.compose.ComposeRichText -import com.darkrockstudios.apps.hammer.common.compose.LocalMarkdownConfig +import com.darkrockstudios.apps.hammer.common.compose.LocalEditorTextStyle +import com.darkrockstudios.apps.hammer.common.compose.LocalRichTextStyles import com.darkrockstudios.apps.hammer.common.compose.Ui import com.darkrockstudios.apps.hammer.common.compose.findShortcutModifier +import com.darkrockstudios.apps.hammer.common.compose.markdown.HammerMarkdownConfiguration import com.darkrockstudios.apps.hammer.common.compose.rememberDefaultDispatcher -import com.darkrockstudios.apps.hammer.common.compose.markdown.updateMarkdownConfiguration +import com.darkrockstudios.apps.hammer.common.compose.markdown.updateRichTextStyles import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdResizeHandle import com.darkrockstudios.apps.hammer.common.compose.designsystem.rememberHdResizeHandleState import com.darkrockstudios.apps.hammer.common.compose.markdowneditor.MarkdownFormatBar import com.darkrockstudios.apps.hammer.common.compose.resources.get +import com.darkrockstudios.apps.hammer.common.compose.textSizeShortcutModifier +import com.darkrockstudios.apps.hammer.common.compose.withParagraphIndent import com.darkrockstudios.apps.hammer.common.data.UpdateSource import com.darkrockstudios.apps.hammer.common.storyeditor.sceneeditor.loadSceneContent import com.darkrockstudios.apps.hammer.common.storyeditor.sceneeditor.sceneContentMarkdown @@ -58,9 +59,9 @@ import com.darkrockstudios.apps.hammer.scene_editor_add_to_dictionary import com.darkrockstudios.apps.hammer.scene_editor_menu_item_close import com.darkrockstudios.texteditor.find.FindBar import com.darkrockstudios.texteditor.find.rememberFindState +import com.darkrockstudios.texteditor.markdown.withMarkdown import com.darkrockstudios.texteditor.rememberTextEditorStyle import com.darkrockstudios.texteditor.spellcheck.SpellCheckingTextEditor -import com.darkrockstudios.texteditor.spellcheck.markdown.withMarkdown import com.darkrockstudios.texteditor.spellcheck.rememberSpellCheckState import kotlinx.coroutines.withContext @@ -68,7 +69,7 @@ import kotlinx.coroutines.withContext fun FocusModeUi(component: FocusMode) { val state by component.state.subscribeAsState() val lastForceUpdate by component.lastForceUpdate.subscribeAsState() - val markdownConfig = LocalMarkdownConfig.current + val richTextStyles = LocalRichTextStyles.current val defaultDispatcher = rememberDefaultDispatcher() val addToDictionaryLabel = Res.string.scene_editor_add_to_dictionary.get() @@ -83,13 +84,16 @@ fun FocusModeUi(component: FocusMode) { initialText = null, enableSpellChecking = state.spellCheckingEnabled, ) - val markdownExtension = remember { textEditorState.withMarkdown(markdownConfig) } + val markdownExtension = remember { + textEditorState.textState.richTextStyles = richTextStyles + textEditorState.textState.withMarkdown(HammerMarkdownConfiguration) + } val findState = rememberFindState(textEditorState.textState) var showFindBar by remember { mutableStateOf(false) } - LaunchedEffect(markdownConfig) { - markdownExtension.updateMarkdownConfiguration(markdownConfig) + LaunchedEffect(richTextStyles) { + textEditorState.textState.updateRichTextStyles(richTextStyles) } var hasReceivedInitialBuffer by remember { mutableStateOf(false) } @@ -105,9 +109,6 @@ fun FocusModeUi(component: FocusMode) { // Squiggles are decoration, so the editor takes input the moment the // text is in and the check catches up behind it. hasReceivedInitialBuffer = true - // Importing emits no edit operations, so the incremental checker never - // sees this text. The one-shot check already ran against an empty document. - textEditorState.runFullSpellCheck() } } } @@ -136,6 +137,11 @@ fun FocusModeUi(component: FocusMode) { .fillMaxSize() .focusModeChromePadding() .findShortcutModifier { showFindBar = true } + .textSizeShortcutModifier( + onIncrease = component::increaseTextSize, + onDecrease = component::decreaseTextSize, + onReset = component::resetTextSize, + ) ) { Row( modifier = Modifier @@ -201,9 +207,7 @@ fun FocusModeUi(component: FocusMode) { enabled = hasReceivedInitialBuffer, spellCheckMenuItems = spellCheckMenuItems, style = rememberTextEditorStyle( - textStyle = TextStyle.Default.copy( - textIndent = TextIndent(firstLine = 24.sp) - ), + textStyle = LocalEditorTextStyle.current.withParagraphIndent(), focusedBorderColor = Color.Transparent, ), modifier = Modifier diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/readermode/ReaderModeUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/readermode/ReaderModeUi.kt new file mode 100644 index 000000000..69356d77e --- /dev/null +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/readermode/ReaderModeUi.kt @@ -0,0 +1,478 @@ +package com.darkrockstudios.apps.hammer.common.storyeditor.readermode + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.darkrockstudios.apps.hammer.* +import com.darkrockstudios.apps.hammer.common.TextEditorDefaults +import com.darkrockstudios.apps.hammer.common.components.storyeditor.readermode.ReaderMode +import com.darkrockstudios.apps.hammer.common.components.storyeditor.sceneeditor.clampEditorWidth +import com.darkrockstudios.apps.hammer.common.compose.LocalEditorTextStyle +import com.darkrockstudios.apps.hammer.common.compose.LocalScreenCharacteristic +import com.darkrockstudios.apps.hammer.common.compose.MpScrollBarList +import com.darkrockstudios.apps.hammer.common.compose.Ui +import com.darkrockstudios.apps.hammer.common.compose.designsystem.* +import com.darkrockstudios.apps.hammer.common.compose.markdowneditor.MarkdownView +import com.darkrockstudios.apps.hammer.common.compose.resources.get +import com.darkrockstudios.apps.hammer.common.compose.scrollBarOverlay +import com.darkrockstudios.apps.hammer.common.data.SceneItem +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlin.math.roundToInt + +const val READER_MODE_LIST_TAG = "reader-mode-list" +const val READER_MODE_EDIT_TAG = "reader-mode-edit" +const val READER_MODE_HEADINGS_TOGGLE_TAG = "reader-mode-headings-toggle" +const val READER_MODE_INCREASE_FONT_TAG = "reader-mode-increase-font" +const val READER_MODE_DECREASE_FONT_TAG = "reader-mode-decrease-font" +const val READER_MODE_SCENE_BREAK_TAG = "reader-mode-scene-break" +const val READER_MODE_NEXT_CHAPTER_TAG = "reader-mode-next-chapter" +const val READER_MODE_PREVIOUS_CHAPTER_TAG = "reader-mode-previous-chapter" + +fun readerSceneHeadingTag(sceneId: Int) = "reader-mode-scene-heading-$sceneId" +fun readerSceneBodyTag(sceneId: Int) = "reader-mode-scene-body-$sceneId" + +/** One lazy item of the reading column. [scene] is the scene the row counts as when it tops the viewport. */ +private sealed interface ReaderRow { + val key: String + val scene: SceneItem? + + data class ChapterHeader(override val scene: SceneItem?) : ReaderRow { + override val key = "chapter-header" + } + + data class SceneHeading(override val scene: SceneItem, val number: Int) : ReaderRow { + override val key = "heading-${scene.id}" + } + + data class SceneBreak(override val scene: SceneItem) : ReaderRow { + override val key = "break-${scene.id}" + } + + data class Body(override val scene: SceneItem, val markdown: String) : ReaderRow { + override val key = "body-${scene.id}" + } + + data object EmptyChapter : ReaderRow { + override val key = "empty-chapter" + override val scene: SceneItem? = null + } + + data class ChapterNav(override val scene: SceneItem?) : ReaderRow { + override val key = "chapter-nav" + } +} + +private fun buildReaderRows( + scenes: List, + showSceneHeadings: Boolean, + isLoading: Boolean, +): List = buildList { + add(ReaderRow.ChapterHeader(scenes.firstOrNull()?.sceneItem)) + scenes.forEachIndexed { index, scene -> + if (showSceneHeadings) { + add(ReaderRow.SceneHeading(scene.sceneItem, number = index + 1)) + } else if (index > 0) { + add(ReaderRow.SceneBreak(scene.sceneItem)) + } + add(ReaderRow.Body(scene.sceneItem, scene.markdown)) + } + if (scenes.isEmpty() && !isLoading) add(ReaderRow.EmptyChapter) + add(ReaderRow.ChapterNav(scenes.lastOrNull()?.sceneItem)) +} + +// The chapter's first scene scrolls to the very top so the chapter header stays in view. +private fun List.scrollIndexFor(sceneId: Int): Int { + val index = indexOfFirst { it !is ReaderRow.ChapterHeader && it.scene?.id == sceneId } + val firstSceneIndex = indexOfFirst { it !is ReaderRow.ChapterHeader && it.scene != null } + return if (index == firstSceneIndex) 0 else index +} + +@Composable +fun ReaderModeUi(component: ReaderMode, modifier: Modifier = Modifier) { + val state by component.state.subscribeAsState() + val screen = LocalScreenCharacteristic.current + val compact = !screen.isWide + val chapterIndex = state.activeChapterIndex + + Column(modifier = modifier.fillMaxSize()) { + HdMasthead( + section = Res.string.reader_mode_section.get(), + modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars), + leadingMeta = if (compact || chapterIndex < 0) { + emptyList() + } else { + listOf(Res.string.reader_mode_position.get(chapterIndex + 1, state.chapters.size)) + }, + trailing = { + if (!compact) { + ReaderTools(state = state, component = component) + } + if (chapterIndex >= 0) { + HdMastheadAction( + label = Res.string.reader_mode_edit.get(), + onClick = { component.editScene() }, + modifier = Modifier.testTag(READER_MODE_EDIT_TAG), + ) + } + if (screen.needsExplicitClose) { + HdMastheadAction(label = Res.string.reader_mode_close.get(), onClick = component::close) + } + }, + ) + HdFolioDivider() + + if (compact) { + if (chapterIndex >= 0) { + val chapterRows = remember(state.chapters) { + state.chapters.map { HdChapterRow(title = it.chapter.name, sceneCount = it.sceneCount) } + } + HdChapterDropdown( + rows = chapterRows, + selectedIndex = chapterIndex, + onSelect = { component.showChapter(state.chapters[it].chapter.id) }, + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Ui.Padding.XL, vertical = Ui.Padding.M), + horizontalArrangement = Arrangement.End, + ) { + ReaderTools(state = state, component = component) + } + HorizontalDivider(thickness = Dp.Hairline, color = MaterialTheme.colorScheme.outlineVariant) + } + + if (chapterIndex < 0) { + EmptyStory(modifier = Modifier.weight(1f)) + } else { + ReadingPane( + state = state, + component = component, + chapterIndex = chapterIndex, + wide = !compact, + modifier = Modifier.weight(1f).fillMaxWidth(), + ) + } + } +} + +@Composable +private fun ReaderTools(state: ReaderMode.State, component: ReaderMode) { + val decreaseLabel = Res.string.reader_mode_decrease_font_size.get() + val increaseLabel = Res.string.reader_mode_increase_font_size.get() + val resetLabel = Res.string.reader_mode_reset_font_size.get() + val headingsLabel = Res.string.reader_mode_toggle_scene_headings.get() + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Ui.Padding.M), + ) { + HdToolButton( + active = false, + onClick = component::decreaseFontSize, + modifier = Modifier + .testTag(READER_MODE_DECREASE_FONT_TAG) + .semantics { contentDescription = decreaseLabel }, + ) { + ToolGlyph("A−") + } + HdMonoLabel( + text = state.fontSize.roundToInt().toString(), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .clickable(onClickLabel = resetLabel, onClick = component::resetFontSize) + .padding(Ui.Padding.S), + ) + HdToolButton( + active = false, + onClick = component::increaseFontSize, + modifier = Modifier + .testTag(READER_MODE_INCREASE_FONT_TAG) + .semantics { contentDescription = increaseLabel }, + ) { + ToolGlyph("A+") + } + HdToolButton( + active = state.showSceneHeadings, + onClick = component::toggleSceneHeadings, + modifier = Modifier + .testTag(READER_MODE_HEADINGS_TOGGLE_TAG) + .semantics { contentDescription = headingsLabel }, + ) { + ToolGlyph("§") + } + } +} + +@Composable +private fun ToolGlyph(text: String) { + Text( + text = text, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@Composable +private fun ReadingPane( + state: ReaderMode.State, + component: ReaderMode, + chapterIndex: Int, + wide: Boolean, + modifier: Modifier = Modifier, +) { + val rows = remember(state.scenes, state.showSceneHeadings, state.isLoading) { + buildReaderRows(state.scenes, state.showSceneHeadings, state.isLoading) + } + val listState = key(state.activeChapterId) { rememberLazyListState() } + + if (!state.isLoading) { + LaunchedEffect(listState, rows) { + snapshotFlow { rows.getOrNull(listState.firstVisibleItemIndex)?.scene } + .distinctUntilChanged() + .collect { component.sceneInView(it) } + } + } + + // A jump within the chapter on screen animates; landing in a freshly opened chapter does not. + var settledChapterId by remember { mutableStateOf(null) } + LaunchedEffect(state.scrollToSceneId, rows, state.isLoading) { + if (state.isLoading) return@LaunchedEffect + val sceneId = state.scrollToSceneId + if (sceneId != null) { + val index = rows.scrollIndexFor(sceneId) + if (index >= 0) { + // Offset past the top content padding, or the previous scene shows through it. + val offset = if (index > 0) listState.layoutInfo.beforeContentPadding else 0 + if (settledChapterId == state.activeChapterId) { + listState.animateScrollToItem(index, offset) + } else { + listState.scrollToItem(index, offset) + } + } + component.scrollHandled() + } + settledChapterId = state.activeChapterId + } + + val readerTextStyle = remember(state.fontSize) { TextStyle(fontSize = state.fontSize.sp) } + + BoxWithConstraints(modifier = modifier) { + val widthState = rememberHdResizeHandleState( + persistedWidth = state.editorMaxWidth, + availableWidth = maxWidth, + clampWidth = ::clampEditorWidth, + onCommit = component::setEditorMaxWidth, + onReset = component::resetEditorMaxWidth, + ) + + Row( + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.Center, + ) { + if (widthState.showHandle) { + HdResizeHandle( + onOutwardDrag = widthState::onOutwardDrag, + onDragEnd = widthState::onDragEnd, + onReset = widthState::reset, + ) + } + + CompositionLocalProvider(LocalEditorTextStyle provides readerTextStyle) { + ReadingColumn( + rows = rows, + state = state, + component = component, + chapterIndex = chapterIndex, + wide = wide, + listState = listState, + modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)) + .fillMaxHeight() + .widthIn(TextEditorDefaults.MIN_WIDTH, widthState.width) + .fillMaxWidth(), + ) + } + } + + MpScrollBarList( + modifier = scrollBarOverlay(), + state = listState, + ) + } +} + +@Composable +private fun ReadingColumn( + rows: List, + state: ReaderMode.State, + component: ReaderMode, + chapterIndex: Int, + wide: Boolean, + listState: LazyListState, + modifier: Modifier = Modifier, +) { + val chapter = state.chapters[chapterIndex] + val horizontalPad = if (wide) Ui.Padding.XXL else Ui.Padding.XL + + LazyColumn( + modifier = modifier.testTag(READER_MODE_LIST_TAG), + state = listState, + contentPadding = PaddingValues(horizontal = horizontalPad, vertical = Ui.Padding.XXL), + ) { + items(rows, key = { it.key }, contentType = { it::class }) { row -> + when (row) { + is ReaderRow.ChapterHeader -> HdChapterHeader( + index = chapterIndex, + title = chapter.chapter.name, + sceneCount = chapter.sceneCount, + wide = wide, + modifier = Modifier.padding(bottom = Ui.Padding.XXL), + ) + + is ReaderRow.SceneHeading -> SceneHeading( + chapterIndex = chapterIndex, + row = row, + wide = wide, + onClick = { component.editScene(row.scene) }, + ) + + is ReaderRow.SceneBreak -> HdSceneBreak( + modifier = Modifier + .testTag(READER_MODE_SCENE_BREAK_TAG) + .padding(vertical = Ui.Padding.XL), + ) + + is ReaderRow.Body -> MarkdownView( + markdown = row.markdown, + modifier = Modifier.fillMaxWidth().testTag(readerSceneBodyTag(row.scene.id)), + importDuringComposition = true, + isSelectable = true, + ) + + ReaderRow.EmptyChapter -> Text( + text = Res.string.reader_mode_empty_chapter.get(), + style = MaterialTheme.typography.bodyLarge, + fontStyle = FontStyle.Italic, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + is ReaderRow.ChapterNav -> ChapterNav( + hasPrevious = chapterIndex > 0, + hasNext = chapterIndex < state.chapters.lastIndex, + onPrevious = component::previousChapter, + onNext = component::nextChapter, + ) + } + } + } +} + +@Composable +private fun SceneHeading( + chapterIndex: Int, + row: ReaderRow.SceneHeading, + wide: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = if (row.number > 1) Ui.Padding.XXL else 0.dp, bottom = Ui.Padding.L) + .testTag(readerSceneHeadingTag(row.scene.id)) + .clickable(onClick = onClick), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(Ui.Padding.L), + ) { + HdMonoLabel(text = "§ ${romanNumeral(chapterIndex + 1)}.${row.number}") + Text( + text = row.scene.name, + style = if (wide) MaterialTheme.typography.titleLarge else MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f, fill = false), + ) + HdMonoLabel(text = "↗") + } +} + +@Composable +private fun ChapterNav( + hasPrevious: Boolean, + hasNext: Boolean, + onPrevious: () -> Unit, + onNext: () -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth().padding(top = Ui.Padding.XXL)) { + if (hasPrevious || hasNext) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + if (hasPrevious) { + HdHairlineButton( + label = Res.string.reader_mode_previous_chapter.get(), + onClick = onPrevious, + modifier = Modifier.testTag(READER_MODE_PREVIOUS_CHAPTER_TAG), + ) + } else { + Spacer(Modifier) + } + if (hasNext) { + HdHairlineButton( + label = Res.string.reader_mode_next_chapter.get(), + onClick = onNext, + modifier = Modifier.testTag(READER_MODE_NEXT_CHAPTER_TAG), + ) + } + } + } + if (!hasNext) { + HdEndMark( + label = Res.string.reader_mode_end.get(), + modifier = Modifier.padding(top = Ui.Padding.L), + ) + } + } +} + +@Composable +private fun EmptyStory(modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Text( + text = Res.string.reader_mode_empty_story.get(), + style = MaterialTheme.typography.bodyLarge, + fontStyle = FontStyle.Italic, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/SceneEditorUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/SceneEditorUi.kt index 63f4fa0c4..8386208f0 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/SceneEditorUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/SceneEditorUi.kt @@ -40,11 +40,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.style.TextIndent import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.darkrockstudios.apps.hammer.Res import com.darkrockstudios.apps.hammer.common.TextEditorDefaults @@ -52,19 +49,23 @@ import com.darkrockstudios.apps.hammer.common.components.storyeditor.sceneeditor import com.darkrockstudios.apps.hammer.common.components.storyeditor.sceneeditor.clampEditorWidth import com.darkrockstudios.apps.hammer.common.compose.AnimatedDialog import com.darkrockstudios.apps.hammer.common.compose.ComposeRichText -import com.darkrockstudios.apps.hammer.common.compose.LocalMarkdownConfig +import com.darkrockstudios.apps.hammer.common.compose.LocalEditorTextStyle +import com.darkrockstudios.apps.hammer.common.compose.LocalRichTextStyles import com.darkrockstudios.apps.hammer.common.compose.RootSnackbarHostState +import com.darkrockstudios.apps.hammer.common.compose.markdown.HammerMarkdownConfiguration import com.darkrockstudios.apps.hammer.common.compose.rememberDefaultDispatcher import com.darkrockstudios.apps.hammer.common.compose.Toaster import com.darkrockstudios.apps.hammer.common.compose.Ui import com.darkrockstudios.apps.hammer.common.compose.findShortcutModifier -import com.darkrockstudios.apps.hammer.common.compose.markdown.updateMarkdownConfiguration +import com.darkrockstudios.apps.hammer.common.compose.markdown.updateRichTextStyles import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdResizeHandle import com.darkrockstudios.apps.hammer.common.compose.designsystem.rememberHdResizeHandleState import com.darkrockstudios.apps.hammer.common.compose.markdowneditor.MarkdownFormatBar import com.darkrockstudios.apps.hammer.common.compose.markdowneditor.markdownFormatShortcuts import com.darkrockstudios.apps.hammer.common.compose.resources.get import com.darkrockstudios.apps.hammer.common.compose.saveShortcutModifier +import com.darkrockstudios.apps.hammer.common.compose.textSizeShortcutModifier +import com.darkrockstudios.apps.hammer.common.compose.withParagraphIndent import com.darkrockstudios.apps.hammer.common.data.UpdateSource import com.darkrockstudios.apps.hammer.common.storyeditor.scenelist.SceneDeleteDialog import com.darkrockstudios.apps.hammer.common.utils.addToDictionaryMenuItems @@ -72,10 +73,10 @@ import com.darkrockstudios.apps.hammer.common.utils.toEditorSpellChecker import com.darkrockstudios.apps.hammer.scene_editor_add_to_dictionary import com.darkrockstudios.texteditor.find.FindBar import com.darkrockstudios.texteditor.find.rememberFindState +import com.darkrockstudios.texteditor.markdown.withMarkdown import com.darkrockstudios.texteditor.rememberTextEditorStyle import com.darkrockstudios.texteditor.spellcheck.SpellCheckMode import com.darkrockstudios.texteditor.spellcheck.SpellCheckingTextEditor -import com.darkrockstudios.texteditor.spellcheck.markdown.withMarkdown import com.darkrockstudios.texteditor.spellcheck.rememberSpellCheckState import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -93,7 +94,7 @@ fun SceneEditorUi( ) { val state by component.state.subscribeAsState() val lastForceUpdate by component.lastForceUpdate.subscribeAsState() - val markdownConfig = LocalMarkdownConfig.current + val richTextStyles = LocalRichTextStyles.current val scope = rememberCoroutineScope() val defaultDispatcher = rememberDefaultDispatcher() @@ -110,13 +111,16 @@ fun SceneEditorUi( enableSpellChecking = state.spellCheckingEnabled, spellCheckMode = SpellCheckMode.Word, ) - val markdownExtension = remember { textEditorState.withMarkdown(markdownConfig) } + val markdownExtension = remember { + textEditorState.textState.richTextStyles = richTextStyles + textEditorState.textState.withMarkdown(HammerMarkdownConfiguration) + } val findState = rememberFindState(textEditorState.textState) var showFindBar by remember { mutableStateOf(false) } - LaunchedEffect(markdownConfig) { - markdownExtension.updateMarkdownConfiguration(markdownConfig) + LaunchedEffect(richTextStyles) { + textEditorState.textState.updateRichTextStyles(richTextStyles) } var hasReceivedInitialBuffer by remember { mutableStateOf(false) } @@ -133,9 +137,6 @@ fun SceneEditorUi( // Squiggles are decoration, so the editor takes input the moment the // text is in and the check catches up behind it. hasReceivedInitialBuffer = true - // Importing emits no edit operations, so the incremental checker never - // sees this text. The one-shot check already ran against an empty document. - textEditorState.runFullSpellCheck() } } } @@ -186,6 +187,11 @@ fun SceneEditorUi( modifier = Modifier .fillMaxHeight() .findShortcutModifier { showFindBar = true } + .textSizeShortcutModifier( + onIncrease = component::increaseTextSize, + onDecrease = component::decreaseTextSize, + onReset = component::resetTextSize, + ) .saveShortcutModifier { scope.launch { component.storeSceneContent() } } .markdownFormatShortcuts(markdownExtension) ) { @@ -237,9 +243,7 @@ fun SceneEditorUi( enabled = hasReceivedInitialBuffer, spellCheckMenuItems = spellCheckMenuItems, style = rememberTextEditorStyle( - textStyle = TextStyle.Default.copy( - textIndent = TextIndent(firstLine = 24.sp) - ), + textStyle = LocalEditorTextStyle.current.withParagraphIndent(), focusedBorderColor = Color.Transparent, unfocusedBorderColor = Color.Transparent, ), diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/SceneMetadataPanelUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/SceneMetadataPanelUi.kt index 30a86e0a7..c20f2c3d2 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/SceneMetadataPanelUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/SceneMetadataPanelUi.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.text.input.KeyboardCapitalization import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.darkrockstudios.apps.hammer.* import com.darkrockstudios.apps.hammer.common.components.storyeditor.sceneeditor.scenemetadata.SceneMetadataPanel @@ -650,6 +651,8 @@ private fun AddTagDialog( onValueChange = { draft = it }, placeholder = Res.string.scene_editor_metadata_tags_add_dialog_placeholder.get(), singleLine = true, + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, ) SpacerM() diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/scenelist/SceneListUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/scenelist/SceneListUi.kt index 20236bd17..89c12c854 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/scenelist/SceneListUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/scenelist/SceneListUi.kt @@ -82,7 +82,8 @@ import com.darkrockstudios.apps.hammer.scene_list_create_menu_scene import com.darkrockstudios.apps.hammer.scene_list_create_scene_dialog_message import com.darkrockstudios.apps.hammer.scene_list_create_scene_dialog_title import com.darkrockstudios.apps.hammer.scene_list_header -import com.darkrockstudios.apps.hammer.scene_list_outline_overview_button +import com.darkrockstudios.apps.hammer.scene_list_outline_button +import com.darkrockstudios.apps.hammer.scene_list_reader_button import com.darkrockstudios.apps.hammer.view_archived_scenes_button import io.github.aakira.napier.Napier import kotlinx.coroutines.CoroutineScope @@ -96,6 +97,7 @@ const val SCENE_LIST_ADD_SCENE_TAG = "scene-list-add-scene" const val SCENE_LIST_ADD_GROUP_TAG = "scene-list-add-group" const val CREATE_ITEM_NAME_FIELD_TAG = "create-item-name-field" const val SCENE_LIST_TREE_TAG = "scene-list-tree" +const val SCENE_LIST_READER_BUTTON_TAG = "scene-list-reader" @OptIn( ExperimentalMaterialApi::class, @@ -232,10 +234,15 @@ fun SceneListUi( containerColor = surfaceColor, ) { HdHairlineButton( - label = Res.string.scene_list_outline_overview_button.get(), + label = Res.string.scene_list_outline_button.get(), onClick = component::showOutlineOverview, modifier = Modifier.weight(1f), ) + HdHairlineButton( + label = Res.string.scene_list_reader_button.get(), + onClick = component::showReaderMode, + modifier = Modifier.weight(1f).testTag(SCENE_LIST_READER_BUTTON_TAG), + ) Box { IconButton( onClick = { addMenuOpen = true }, diff --git a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/EditorAction.kt b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/EditorAction.kt index b03716899..2cb1a5853 100644 --- a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/EditorAction.kt +++ b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/EditorAction.kt @@ -22,19 +22,20 @@ import com.darkrockstudios.apps.hammer.common.compose.Ui internal actual fun EditorAction( icon: ImageVector, active: Boolean, + enabled: Boolean, onClick: () -> Unit ) { val painter = rememberVectorPainter(icon) Box( modifier = Modifier - .onClick { onClick() } + .onClick(enabled = enabled) { onClick() } .padding(Ui.Padding.L) ) { Icon( modifier = Modifier.size(24.dp), painter = painter, - tint = if (active) MaterialTheme.colorScheme.inversePrimary else MaterialTheme.colorScheme.onSurfaceVariant, + tint = editorActionColor(active, enabled), contentDescription = null ) } @@ -45,11 +46,12 @@ internal actual fun EditorAction( internal actual fun EditorTextAction( label: String, active: Boolean, + enabled: Boolean, onClick: () -> Unit, ) { Box( modifier = Modifier - .onClick { onClick() } + .onClick(enabled = enabled) { onClick() } .padding(Ui.Padding.L) .defaultMinSize(minWidth = 24.dp, minHeight = 24.dp), contentAlignment = Alignment.Center, @@ -57,7 +59,7 @@ internal actual fun EditorTextAction( Text( text = label, style = MaterialTheme.typography.labelLarge, - color = if (active) MaterialTheme.colorScheme.inversePrimary else MaterialTheme.colorScheme.onSurfaceVariant, + color = editorActionColor(active, enabled), ) } } diff --git a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/theme/OledBlackAvailability.desktop.kt b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/theme/OledBlackAvailability.desktop.kt new file mode 100644 index 000000000..819a4aca5 --- /dev/null +++ b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/theme/OledBlackAvailability.desktop.kt @@ -0,0 +1,3 @@ +package com.darkrockstudios.apps.hammer.common.compose.theme + +actual val oledBlackThemeAvailable: Boolean = false diff --git a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/AccountSettingsUi.Preview.kt b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/AccountSettingsUi.Preview.kt index 8b609f314..9fab243d6 100644 --- a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/AccountSettingsUi.Preview.kt +++ b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/AccountSettingsUi.Preview.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview import com.darkrockstudios.apps.hammer.common.compose.rememberRootSnackbarHostState import com.darkrockstudios.apps.hammer.common.compose.theme.AppTheme +import com.darkrockstudios.apps.hammer.common.data.globalsettings.UiTheme import com.darkrockstudios.apps.hammer.common.projectselection.accountSettingsComponent import com.darkrockstudios.apps.hammer.common.projectselection.defaultAccountSettingsComponentState import com.darkrockstudios.apps.hammer.common.projectselection.settings.AccountSettingsUi @@ -23,6 +24,21 @@ internal fun ScreenAccountSettingsUiPreview() { } } +@Preview +@Composable +internal fun ScreenAccountSettingsUiOledBlackPreview() { + val component = accountSettingsComponent( + defaultAccountSettingsComponentState.copy(serverSetup = false, uiTheme = UiTheme.OledBlack) + ) + val rootSnackbar = rememberRootSnackbarHostState() + + KoinApplicationPreview { + AppTheme(globalSettingsPreview.copy(uiTheme = UiTheme.OledBlack), useDarkTheme = true) { + AccountSettingsUi(component, rootSnackbar) + } + } +} + @Preview(widthDp = TABLET_WIDTH_DP, heightDp = TABLET_HEIGHT_DP) @Composable internal fun ScreenAccountSettingsUiTabletPreview() { diff --git a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/AppUpdateBanner.Preview.kt b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/AppUpdateBanner.Preview.kt new file mode 100644 index 000000000..6b6c7eb10 --- /dev/null +++ b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/AppUpdateBanner.Preview.kt @@ -0,0 +1,72 @@ +package com.darkrockstudios.apps.hammer.common.preview + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdNoticeStrip +import com.darkrockstudios.apps.hammer.common.compose.theme.AppTheme +import com.darkrockstudios.apps.hammer.common.data.appupdate.AppUpdateState +import com.darkrockstudios.apps.hammer.common.projectselection.AppUpdateBanner + +private val bannerStates = listOf( + AppUpdateState.Available(version = "9.9.9", installable = true), + AppUpdateState.Available(version = "9.9.9", installable = false), + AppUpdateState.Downloading(version = "9.9.9", fraction = 0.42f), + AppUpdateState.Installing(version = "9.9.9"), + AppUpdateState.Failed(version = "9.9.9", reason = "HTTP 503"), +) + +@Preview +@Composable +fun AppUpdateBannerStatesPreview() { + AppTheme(globalSettingsPreview, false) { + Column(modifier = Modifier.width(720.dp).background(MaterialTheme.colorScheme.background)) { + for (state in bannerStates) { + AppUpdateBanner(state = state, onUpdate = {}, onOpenRelease = {}, onDismiss = {}) + Spacer(Modifier.height(16.dp)) + } + } + } +} + +@Preview +@Composable +fun AppUpdateBannerStatesDarkPreview() { + AppTheme(globalSettingsPreview, true) { + Column(modifier = Modifier.width(720.dp).background(MaterialTheme.colorScheme.background)) { + for (state in bannerStates) { + AppUpdateBanner(state = state, onUpdate = {}, onOpenRelease = {}, onDismiss = {}) + Spacer(Modifier.height(16.dp)) + } + } + } +} + +@Preview +@Composable +fun HdNoticeStripPreview() { + AppTheme(globalSettingsPreview, false) { + Column(modifier = Modifier.width(720.dp).background(MaterialTheme.colorScheme.background)) { + HdNoticeStrip( + title = "A title with two actions", + detail = "And a detail line underneath it.", + primaryLabel = "Primary", + secondaryLabel = "Secondary", + ) + Spacer(Modifier.height(16.dp)) + HdNoticeStrip( + title = "Progress, no actions", + progress = 0.6f, + ) + Spacer(Modifier.height(16.dp)) + HdNoticeStrip(title = "Title only") + } + } +} diff --git a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/EncyclopediaUi.Preview.kt b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/EncyclopediaUi.Preview.kt index 1e92624e7..c6db45601 100644 --- a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/EncyclopediaUi.Preview.kt +++ b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/EncyclopediaUi.Preview.kt @@ -317,6 +317,7 @@ val fakeViewEntryComponent: ViewEntry = object : ViewEntry { override fun startTextEdit() {} override fun finishNameEdit() {} override fun finishTextEdit() {} + override fun discardEdit() {} override fun confirmClose() {} override fun dismissConfirmClose() {} override fun removeTag(tag: String) {} diff --git a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/SceneListUi.Preview.kt b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/SceneListUi.Preview.kt index 6bc44ae83..b2e2b5833 100644 --- a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/SceneListUi.Preview.kt +++ b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/SceneListUi.Preview.kt @@ -239,6 +239,7 @@ private fun fakeComponent(state: SceneList.State) = object : SceneList { override fun onSceneListUpdate(scenes: SceneSummary) {} override fun onSceneBufferUpdate(sceneBuffer: SceneBuffer) {} override fun showOutlineOverview() {} + override fun showReaderMode() {} override suspend fun archiveScene(scene: SceneItem) {} override suspend fun unarchiveScene(scene: SceneItem) {} override fun showArchivedScenes() {} diff --git a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/designsystem/DesignSystem.Preview.kt b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/designsystem/DesignSystem.Preview.kt index 12830ae33..3420e3265 100644 --- a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/designsystem/DesignSystem.Preview.kt +++ b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/designsystem/DesignSystem.Preview.kt @@ -34,10 +34,15 @@ import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdBottomBar import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdBottomBarDestination import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdCategoryChip import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdCategorySwatch +import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdChapterDropdown +import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdChapterHeader +import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdChapterRailItem +import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdChapterRow import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdCollapseGlyph import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdDailyGoalProgress import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdDeltaBadge import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdEmailField +import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdEndMark import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdEngravingPlaceholder import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdEntryCard import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdEntryFilterBar @@ -63,6 +68,7 @@ import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdReferenceCh import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdReferenceChipVariant import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdResizeHandle import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdResponsiveStrip +import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdSceneBreak import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdScrollAwayFooter import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdSearchField import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdSectionHeader @@ -1207,6 +1213,25 @@ private fun GallerySection(title: String, content: @Composable () -> Unit) { } } +@Preview +@Composable +fun ChapterPiecesPreview() { + val rows = listOf( + HdChapterRow(title = "Arrival", sceneCount = 2), + HdChapterRow(title = "The Dark Night", sceneCount = 5), + ) + AppTheme(globalSettingsPreview, useDarkTheme = true) { + PreviewSurface { + HdChapterDropdown(rows = rows, selectedIndex = 1, onSelect = {}) + HdChapterRailItem(index = 0, row = rows[0], selected = true, onClick = {}) + HdChapterRailItem(index = 1, row = rows[1], selected = false, onClick = {}) + HdChapterHeader(index = 1, title = "The Dark Night", sceneCount = 5, wide = false) + HdSceneBreak() + HdEndMark(label = "End of story") + } + } +} + @Composable private fun PreviewSurface(content: @Composable () -> Unit) { Column( diff --git a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/projecthome/ExportOptionsDialog.Preview.kt b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/projecthome/ExportOptionsDialog.Preview.kt index 0da4ec884..172348130 100644 --- a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/projecthome/ExportOptionsDialog.Preview.kt +++ b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/projecthome/ExportOptionsDialog.Preview.kt @@ -9,6 +9,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import com.darkrockstudios.apps.hammer.common.compose.theme.AppTheme +import com.darkrockstudios.apps.hammer.common.data.ExportFont +import com.darkrockstudios.apps.hammer.common.data.ExportFormat import com.darkrockstudios.apps.hammer.common.data.ExportOptions import com.darkrockstudios.apps.hammer.common.data.ExportableScene import com.darkrockstudios.apps.hammer.common.preview.KoinApplicationPreview @@ -48,12 +50,25 @@ private fun ExportOptionsPreviewFrame(options: ExportOptions) { } } -@Preview(widthDp = 600, heightDp = 480) +@Preview(widthDp = 600, heightDp = 640) @Composable fun ExportOptionsDialogPreview() { ExportOptionsPreviewFrame(ExportOptions()) } +@Preview(widthDp = 600, heightDp = 640) +@Composable +fun ExportOptionsDialogWordPreview() { + ExportOptionsPreviewFrame( + ExportOptions( + format = ExportFormat.Docx, + numberChapters = true, + font = ExportFont.TimesNewRoman, + keepBlankLines = false, + ) + ) +} + @Preview(widthDp = 600, heightDp = 760) @Composable fun ExportOptionsDialogLimitScenesPreview() { diff --git a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/sceneeditor/FocusModeUi.Preview.kt b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/sceneeditor/FocusModeUi.Preview.kt index 6b8de3e7a..d308a1619 100644 --- a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/sceneeditor/FocusModeUi.Preview.kt +++ b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/sceneeditor/FocusModeUi.Preview.kt @@ -15,12 +15,13 @@ import com.darkrockstudios.apps.hammer.common.preview.TabletPreviewSurface import com.darkrockstudios.apps.hammer.common.preview.fakeProjectDef import com.darkrockstudios.apps.hammer.common.preview.fakeSceneItem import com.darkrockstudios.apps.hammer.common.storyeditor.focusmode.FocusModeUi +import com.darkrockstudios.libs.platformspellchecker.PlatformSpellChecker @Preview @Composable fun ScreenFocusModeUiPreview() { KoinApplicationPreview { - FocusModeUi(focusMode) + FocusModeUi(fakeFocusMode()) } } @@ -29,12 +30,15 @@ fun ScreenFocusModeUiPreview() { fun ScreenFocusModeUiTabletPreview() { KoinApplicationPreview { TabletPreviewSurface { - FocusModeUi(focusMode) + FocusModeUi(fakeFocusMode()) } } } -private val focusMode = object : FocusMode { +internal fun fakeFocusMode( + markdown: String = "This is some demo text for the focus mode preview.", + spellChecker: PlatformSpellChecker? = null, +) = object : FocusMode { override val state = MutableValue( FocusMode.State( projectDef = fakeProjectDef(), @@ -42,14 +46,14 @@ private val focusMode = object : FocusMode { sceneBuffer = SceneBuffer( content = SceneContent( scene = fakeSceneItem(), - markdown = "This is some demo text for the focus mode preview." + markdown = markdown ), source = UpdateSource.Editor ), isLoading = false, textSize = 24f, - spellChecker = null, - spellCheckingEnabled = false + spellChecker = spellChecker, + spellCheckingEnabled = spellChecker != null ) ) override var lastForceUpdate = MutableValue(1L) diff --git a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/sceneeditor/SceneEditorUi.Preview.kt b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/sceneeditor/SceneEditorUi.Preview.kt index 8773ca235..5036ed62d 100644 --- a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/sceneeditor/SceneEditorUi.Preview.kt +++ b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/sceneeditor/SceneEditorUi.Preview.kt @@ -8,6 +8,7 @@ import com.darkrockstudios.apps.hammer.common.components.ToastMessage import com.darkrockstudios.apps.hammer.common.components.storyeditor.sceneeditor.SceneEditor import com.darkrockstudios.apps.hammer.common.components.storyeditor.sceneeditor.scenemetadata.SceneMetadataPanel import com.darkrockstudios.apps.hammer.common.compose.rememberRootSnackbarHostState +import com.darkrockstudios.apps.hammer.common.data.SceneItem import com.darkrockstudios.apps.hammer.common.data.Msg import com.darkrockstudios.apps.hammer.common.data.PlatformRichText import com.darkrockstudios.apps.hammer.common.data.ProjectDef @@ -23,6 +24,7 @@ import com.darkrockstudios.apps.hammer.common.preview.TABLET_WIDTH_DP import com.darkrockstudios.apps.hammer.common.preview.TabletPreviewSurface import com.darkrockstudios.apps.hammer.common.preview.fakeSceneItem import com.darkrockstudios.apps.hammer.common.storyeditor.sceneeditor.SceneEditorUi +import com.darkrockstudios.libs.platformspellchecker.PlatformSpellChecker import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import org.jetbrains.compose.resources.StringResource @@ -31,7 +33,7 @@ import org.jetbrains.compose.resources.StringResource @Composable fun ScreenSceneEditorUiPreview() { KoinApplicationPreview { - val component = fakeComponent() + val component = fakeSceneEditor() val rootSnackbar = rememberRootSnackbarHostState() SceneEditorUi(component, rootSnackbar) } @@ -41,7 +43,7 @@ fun ScreenSceneEditorUiPreview() { @Composable fun ScreenSceneEditorUiTabletPreview() { KoinApplicationPreview { - val component = fakeComponent() + val component = fakeSceneEditor() val rootSnackbar = rememberRootSnackbarHostState() TabletPreviewSurface { SceneEditorUi(component, rootSnackbar) @@ -58,7 +60,10 @@ private fun fakeProjectDef(): ProjectDef = ProjectDef( ) ) -private fun fakeComponent() = object : SceneEditor { +internal fun fakeSceneEditor( + markdown: String = "The keeper steps off the ferry into a town that already knows his name.", + spellChecker: PlatformSpellChecker? = null, +) = object : SceneEditor { override val state: Value get() = MutableValue( SceneEditor.State( @@ -66,18 +71,20 @@ private fun fakeComponent() = object : SceneEditor { sceneBuffer = SceneBuffer( content = SceneContent( scene = fakeSceneItem(), - markdown = "The keeper steps off the ferry into a town that already knows his name." + markdown = markdown ), source = UpdateSource.Editor ), isLoading = false, isSavingDraft = false, - isEditingName = false + isEditingName = false, + spellChecker = spellChecker, ) ) override var lastForceUpdate = MutableValue(0L) override val sceneMetadataComponent = object : SceneMetadataPanel { override val state = MutableValue(SceneMetadataPanel.State(fakeSceneItem())) + override fun onSceneItemChanged(sceneItem: SceneItem) {} override fun updateOutline(text: String) {} override fun updateNotes(text: String) {} override fun updateDraftName(text: String) {} @@ -133,4 +140,5 @@ private fun fakeComponent() = object : SceneEditor { override fun setEditorMaxWidth(width: Float) {} override fun resetEditorMaxWidth() {} override fun enterFocusMode() {} + override fun readScene() {} } \ No newline at end of file diff --git a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/sceneeditor/SceneMetadataPanelUi.Preview.kt b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/sceneeditor/SceneMetadataPanelUi.Preview.kt index 31a5dc6a4..ea6e42aae 100644 --- a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/sceneeditor/SceneMetadataPanelUi.Preview.kt +++ b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/sceneeditor/SceneMetadataPanelUi.Preview.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.tooling.preview.Preview import com.arkivanov.decompose.value.MutableValue import com.darkrockstudios.apps.hammer.common.components.storyeditor.sceneeditor.scenemetadata.SceneMetadataPanel import com.darkrockstudios.apps.hammer.common.compose.theme.AppTheme +import com.darkrockstudios.apps.hammer.common.data.SceneItem import com.darkrockstudios.apps.hammer.common.data.encyclopediarepository.entry.EntryDef import com.darkrockstudios.apps.hammer.common.data.encyclopediarepository.entry.EntryType import com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.scenemetadata.SceneMetadata @@ -54,6 +55,8 @@ private fun previewComponent( ) ) + override fun onSceneItemChanged(sceneItem: SceneItem) {} + override fun updateOutline(text: String) {} override fun updateNotes(text: String) {} override fun updateDraftName(text: String) {} diff --git a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/storyeditor/ReaderModeUi.Preview.kt b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/storyeditor/ReaderModeUi.Preview.kt new file mode 100644 index 000000000..c805dfe7f --- /dev/null +++ b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/storyeditor/ReaderModeUi.Preview.kt @@ -0,0 +1,115 @@ +package com.darkrockstudios.apps.hammer.common.preview.storyeditor + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.arkivanov.decompose.value.MutableValue +import com.arkivanov.decompose.value.Value +import com.darkrockstudios.apps.hammer.common.components.storyeditor.readermode.ReaderMode +import com.darkrockstudios.apps.hammer.common.compose.theme.AppTheme +import com.darkrockstudios.apps.hammer.common.data.SceneItem +import com.darkrockstudios.apps.hammer.common.preview.KoinApplicationPreview +import com.darkrockstudios.apps.hammer.common.preview.TABLET_HEIGHT_DP +import com.darkrockstudios.apps.hammer.common.preview.TABLET_WIDTH_DP +import com.darkrockstudios.apps.hammer.common.preview.TabletPreviewSurface +import com.darkrockstudios.apps.hammer.common.preview.fakeProjectDef +import com.darkrockstudios.apps.hammer.common.preview.globalSettingsPreview +import com.darkrockstudios.apps.hammer.common.storyeditor.readermode.ReaderModeUi + +@Preview +@Composable +fun ScreenReaderModeUiPreview() { + KoinApplicationPreview { + AppTheme(globalSettingsPreview) { + Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { + ReaderModeUi(fakeReader(readerState())) + } + } + } +} + +@Preview(widthDp = TABLET_WIDTH_DP, heightDp = TABLET_HEIGHT_DP) +@Composable +fun ScreenReaderModeUiTabletPreview() { + KoinApplicationPreview { + TabletPreviewSurface { + ReaderModeUi(fakeReader(readerState().copy(showSceneHeadings = true, fontSize = 20f))) + } + } +} + +@Preview +@Composable +fun ScreenReaderModeUiEmptyChapterPreview() { + KoinApplicationPreview { + AppTheme(globalSettingsPreview) { + Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { + ReaderModeUi( + fakeReader(readerState().copy(activeChapterId = darkNight.id, scenes = emptyList())) + ) + } + } + } +} + +private fun item(id: Int, name: String, type: SceneItem.Type) = SceneItem( + projectDef = fakeProjectDef(), + type = type, + id = id, + name = name, + order = id, +) + +private val arrival = item(1, "Arrival", SceneItem.Type.Group) +private val harbour = item(2, "The Harbour", SceneItem.Type.Scene) +private val lampRoom = item(3, "The Lamp Room", SceneItem.Type.Scene) +private val darkNight = item(4, "The Dark Night", SceneItem.Type.Group) + +private val harbourText = """ +The keeper stepped off the ferry into a town that already knew his name. The gulls knew it too, or seemed to, wheeling over the quay and crying it back at him in pieces. + +"You'll be wanting the light," said the woman at the chandlery, not looking up. She had the **key** on the counter before he had finished his breath. + +He took it. It was heavier than a key had any right to be, and *cold*, as if it had been kept somewhere out of the sun for a long time. +""".trimIndent() + +private val lampRoomText = """ +One hundred and nine steps. He counted them the first night and never again, because the number did not change and the counting made the stair feel longer. + +The lamp room smelled of oil and old brass. Somebody had left a chair facing the sea, and a cup beside it, and in the cup a dry brown ring where the tea had been. +""".trimIndent() + +private fun readerState() = ReaderMode.State( + chapters = listOf( + ReaderMode.ReaderChapter(arrival, sceneCount = 2), + ReaderMode.ReaderChapter(darkNight, sceneCount = 0), + ), + activeChapterId = arrival.id, + scenes = listOf( + ReaderMode.ReaderScene(harbour, harbourText), + ReaderMode.ReaderScene(lampRoom, lampRoomText), + ), + isLoading = false, +) + +private fun fakeReader(state: ReaderMode.State) = object : ReaderMode { + override val state: Value = MutableValue(state) + override fun showChapter(chapterId: Int) {} + override fun nextChapter() {} + override fun previousChapter() {} + override fun showScene(sceneItem: SceneItem) {} + override fun scrollHandled() {} + override fun sceneInView(sceneItem: SceneItem?) {} + override fun editScene(sceneItem: SceneItem?) {} + override fun toggleSceneHeadings() {} + override fun increaseFontSize() {} + override fun decreaseFontSize() {} + override fun resetFontSize() {} + override fun setEditorMaxWidth(width: Float) {} + override fun resetEditorMaxWidth() {} + override fun close() {} +} diff --git a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/AccountSettingsUtils.kt b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/AccountSettingsUtils.kt index 59cabc6e9..180783347 100644 --- a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/AccountSettingsUtils.kt +++ b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/AccountSettingsUtils.kt @@ -47,6 +47,7 @@ internal fun accountSettingsComponent(state: AccountSettings.State = defaultAcco ) override fun setProjectsDir(path: String) {} + override fun setAutomaticUpdateChecks(value: Boolean) {} } override val backupManagerSlot: Value> = MutableValue(ChildSlot(child = null)) diff --git a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/about/AboutAppUi.Preview.kt b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/about/AboutAppUi.Preview.kt index 3381827d7..88492199c 100644 --- a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/about/AboutAppUi.Preview.kt +++ b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/about/AboutAppUi.Preview.kt @@ -1,10 +1,17 @@ package com.darkrockstudios.apps.hammer.common.projectselection.about +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import com.arkivanov.decompose.value.MutableValue import com.darkrockstudios.apps.hammer.common.components.projectselection.aboutapp.AboutApp import com.darkrockstudios.apps.hammer.common.compose.theme.AppTheme +import com.darkrockstudios.apps.hammer.common.data.appupdate.AppUpdateState import com.darkrockstudios.apps.hammer.common.preview.KoinApplicationPreview import com.darkrockstudios.apps.hammer.common.preview.TABLET_HEIGHT_DP import com.darkrockstudios.apps.hammer.common.preview.TABLET_WIDTH_DP @@ -15,7 +22,7 @@ import com.darkrockstudios.apps.hammer.common.preview.globalSettingsPreview @Composable fun ScreenAboutAppUiPreview() { AppTheme(globalSettingsPreview) { - AboutAppUi(previewComponent, onShowStudio = {}) + AboutAppUi(previewComponent(), onShowStudio = {}) } } @@ -24,14 +31,56 @@ fun ScreenAboutAppUiPreview() { fun ScreenAboutAppUiTabletPreview() { KoinApplicationPreview { TabletPreviewSurface { - AboutAppUi(previewComponent, onShowStudio = {}) + AboutAppUi(previewComponent(), onShowStudio = {}) } } } -private val previewComponent = object : AboutApp { +@Preview +@Composable +fun ScreenAboutAppUiUpdateAvailablePreview() { + AppTheme(globalSettingsPreview) { + AboutAppUi( + previewComponent(AppUpdateState.Available(version = "9.9.9", installable = true)), + onShowStudio = {}, + ) + } +} + +@Preview +@Composable +fun AppUpdateStatusStatesPreview() { + val states = listOf( + AppUpdateState.Idle, + AppUpdateState.Checking, + AppUpdateState.UpToDate, + AppUpdateState.Available(version = "9.9.9", installable = true), + AppUpdateState.Available(version = "9.9.9", installable = false), + AppUpdateState.Downloading(version = "9.9.9", fraction = 0.42f), + AppUpdateState.Installing(version = "9.9.9"), + AppUpdateState.Failed(version = "9.9.9", reason = "HTTP 503"), + ) + AppTheme(globalSettingsPreview) { + Column( + modifier = Modifier + .background(MaterialTheme.colorScheme.surface) + .padding(24.dp), + ) { + for (state in states) { + AppUpdateStatus( + state = state, + onCheckForUpdate = {}, + onUpdateApp = {}, + modifier = Modifier.padding(bottom = 24.dp), + ) + } + } + } +} + +private fun previewComponent(appUpdate: AppUpdateState = AppUpdateState.Unsupported) = object : AboutApp { override val state = MutableValue( - AboutApp.State(currentVersion = "v1.0.0") + AboutApp.State(currentVersion = "v1.0.0", appUpdate = appUpdate) ) override fun openDiscord() {} @@ -39,4 +88,6 @@ private val previewComponent = object : AboutApp { override fun openGithub() {} override fun viewChangelog() {} override fun openLatestRelease() {} -} \ No newline at end of file + override fun checkForUpdate() {} + override fun updateApp() {} +} diff --git a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/settings/PlatformSettingsUi.desktop.kt b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/settings/PlatformSettingsUi.desktop.kt index 237648352..a2a3d8647 100644 --- a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/settings/PlatformSettingsUi.desktop.kt +++ b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/projectselection/settings/PlatformSettingsUi.desktop.kt @@ -19,8 +19,11 @@ import com.darkrockstudios.apps.hammer.Res import com.darkrockstudios.apps.hammer.common.components.projectselection.accountsettings.DesktopPlatformSettings import com.darkrockstudios.apps.hammer.common.components.projectselection.accountsettings.PlatformSettings import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdHairlineButton +import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdHairlineToggleRow import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdMonoLabel import com.darkrockstudios.apps.hammer.common.compose.resources.get +import com.darkrockstudios.apps.hammer.settings_automatic_update_checks +import com.darkrockstudios.apps.hammer.settings_automatic_update_checks_hint import com.darkrockstudios.apps.hammer.settings_projects_directory import com.darkrockstudios.apps.hammer.settings_projects_directory_button import com.darkrockstudios.apps.hammer.settings_projects_directory_description @@ -67,5 +70,13 @@ actual fun ColumnScope.PlatformSettingsUi(component: PlatformSettings) { } }, ) + if (state.updateChecksSupported) { + HdHairlineToggleRow( + checked = state.automaticUpdateChecks, + onCheckedChange = component::setAutomaticUpdateChecks, + label = Res.string.settings_automatic_update_checks.get(), + hint = Res.string.settings_automatic_update_checks_hint.get(), + ) + } } } diff --git a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/EditorTopBar.kt b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/EditorTopBar.kt index 61ec48cbb..750bb2025 100644 --- a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/EditorTopBar.kt +++ b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/EditorTopBar.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Arrangement.Start import androidx.compose.foundation.text.ClickableText import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.MenuBook import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* @@ -115,6 +116,14 @@ actual fun EditorTopBar( ) } + IconButton(onClick = component::readScene) { + Icon( + imageVector = Icons.AutoMirrored.Filled.MenuBook, + contentDescription = Res.string.scene_editor_menu_item_read.get(), + tint = MaterialTheme.colorScheme.onBackground + ) + } + IconButton(onClick = component::enterFocusMode) { Icon( imageVector = Icons.Default.Fullscreen, diff --git a/composeUi/src/desktopTest/kotlin/AppUpdateBannerTest.kt b/composeUi/src/desktopTest/kotlin/AppUpdateBannerTest.kt new file mode 100644 index 000000000..a494a8e81 --- /dev/null +++ b/composeUi/src/desktopTest/kotlin/AppUpdateBannerTest.kt @@ -0,0 +1,83 @@ +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.darkrockstudios.apps.hammer.common.compose.theme.AppTheme +import com.darkrockstudios.apps.hammer.common.data.appupdate.AppUpdateState +import com.darkrockstudios.apps.hammer.common.preview.globalSettingsPreview +import com.darkrockstudios.apps.hammer.common.projectselection.AppUpdateBanner +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals + +class AppUpdateBannerTest { + @get:Rule + val compose = createComposeRule() + + private var updates = 0 + private var releasePages = 0 + private var dismissals = 0 + + private fun show(state: AppUpdateState) { + compose.setContent { + AppTheme(globalSettingsPreview) { + AppUpdateBanner( + state = state, + onUpdate = { updates++ }, + onOpenRelease = { releasePages++ }, + onDismiss = { dismissals++ }, + ) + } + } + } + + @Test + fun `nothing is drawn until a release is available`() { + show(AppUpdateState.Idle) + compose.onNodeWithText("Later").assertDoesNotExist() + + show(AppUpdateState.UpToDate) + compose.onNodeWithText("Later").assertDoesNotExist() + } + + @Test + fun `an installable release offers update and later`() { + show(AppUpdateState.Available(version = "9.9.9", installable = true)) + + compose.onNodeWithText("Hammer 9.9.9 is available.").assertIsDisplayed() + compose.onNodeWithText("Update to 9.9.9").performClick() + compose.onNodeWithText("Later").performClick() + + assertEquals(1, updates) + assertEquals(1, dismissals) + } + + @Test + fun `a release this install cannot apply links to the release page`() { + show(AppUpdateState.Available(version = "9.9.9", installable = false)) + + compose.onNodeWithText("Update to 9.9.9").assertDoesNotExist() + compose.onNodeWithText("Open Release").performClick() + + assertEquals(1, releasePages) + assertEquals(0, updates) + } + + @Test + fun `a dismissed release hides the banner`() { + show(AppUpdateState.Available(version = "9.9.9", installable = true, dismissed = true)) + + compose.onNodeWithText("Hammer 9.9.9 is available.").assertDoesNotExist() + } + + @Test + fun `progress and failure are shown`() { + show(AppUpdateState.Downloading(version = "9.9.9", fraction = 0.42f)) + compose.onNodeWithText("Downloading Hammer 9.9.9 (42%)").assertIsDisplayed() + + show(AppUpdateState.Failed(version = "9.9.9", reason = "HTTP 503")) + compose.onNodeWithText("Could not update to 9.9.9: HTTP 503").assertIsDisplayed() + compose.onNodeWithText("Retry").performClick() + assertEquals(1, updates) + } +} diff --git a/composeUi/src/desktopTest/kotlin/AppUpdateStatusTest.kt b/composeUi/src/desktopTest/kotlin/AppUpdateStatusTest.kt new file mode 100644 index 000000000..0f53fc822 --- /dev/null +++ b/composeUi/src/desktopTest/kotlin/AppUpdateStatusTest.kt @@ -0,0 +1,81 @@ +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.darkrockstudios.apps.hammer.common.compose.theme.AppTheme +import com.darkrockstudios.apps.hammer.common.data.appupdate.AppUpdateState +import com.darkrockstudios.apps.hammer.common.preview.globalSettingsPreview +import com.darkrockstudios.apps.hammer.common.projectselection.about.AppUpdateStatus +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals + +class AppUpdateStatusTest { + @get:Rule + val compose = createComposeRule() + + private var checks = 0 + private var updates = 0 + + private fun show(state: AppUpdateState) { + compose.setContent { + AppTheme(globalSettingsPreview) { + AppUpdateStatus( + state = state, + onCheckForUpdate = { checks++ }, + onUpdateApp = { updates++ }, + ) + } + } + } + + @Test + fun `a build that cannot update shows nothing`() { + show(AppUpdateState.Unsupported) + compose.onNodeWithText("Check for Updates").assertDoesNotExist() + } + + @Test + fun `check for updates is offered before and after a check`() { + show(AppUpdateState.Idle) + compose.onNodeWithText("Check for Updates").performClick() + assertEquals(1, checks) + + show(AppUpdateState.UpToDate) + compose.onNodeWithText("Hammer is up to date.").assertIsDisplayed() + compose.onNodeWithText("Check for Updates").assertIsDisplayed() + } + + @Test + fun `the button is disabled while checking`() { + show(AppUpdateState.Checking) + compose.onNodeWithText("Check for Updates").assertIsNotEnabled() + } + + @Test + fun `an installable release offers the update`() { + show(AppUpdateState.Available(version = "9.9.9", installable = true)) + + compose.onNodeWithText("Hammer 9.9.9 is available.").assertIsDisplayed() + compose.onNodeWithText("Update to 9.9.9").performClick() + assertEquals(1, updates) + } + + @Test + fun `a release this install cannot apply is only announced`() { + show(AppUpdateState.Available(version = "9.9.9", installable = false)) + + compose.onNodeWithText("Hammer 9.9.9 is available.").assertIsDisplayed() + compose.onNodeWithText("Update to 9.9.9").assertDoesNotExist() + } + + @Test + fun `a failure offers a retry`() { + show(AppUpdateState.Failed(version = "9.9.9", reason = "HTTP 503")) + + compose.onNodeWithText("Could not update to 9.9.9: HTTP 503").assertIsDisplayed() + compose.onNodeWithText("Retry").performClick() + assertEquals(1, updates) + } +} diff --git a/composeUi/src/desktopTest/kotlin/ChangelogDialogTest.kt b/composeUi/src/desktopTest/kotlin/ChangelogDialogTest.kt index 8d5ce3bcf..d5f62097f 100644 --- a/composeUi/src/desktopTest/kotlin/ChangelogDialogTest.kt +++ b/composeUi/src/desktopTest/kotlin/ChangelogDialogTest.kt @@ -9,6 +9,7 @@ import com.arkivanov.decompose.value.Value import com.arkivanov.essenty.backhandler.BackHandler import com.darkrockstudios.apps.hammer.common.components.projectselection.ProjectSelection import com.darkrockstudios.apps.hammer.common.compose.theme.AppTheme +import com.darkrockstudios.apps.hammer.common.data.appupdate.AppUpdateState import com.darkrockstudios.apps.hammer.common.preview.globalSettingsPreview import com.darkrockstudios.apps.hammer.common.projectselection.ChangelogDialog import io.mockk.mockk @@ -27,11 +28,15 @@ class ChangelogDialogTest { var openReleaseCount = 0 override val changelog: Value = MutableValue(state) + override val appUpdate: Value = MutableValue(AppUpdateState.Unsupported) override fun dismissChangelog() { dismissCount++ } + override fun updateApp() = Unit + override fun dismissUpdate() = Unit + override fun openLatestRelease() { openReleaseCount++ } diff --git a/composeUi/src/desktopTest/kotlin/CycleHeaderTest.kt b/composeUi/src/desktopTest/kotlin/CycleHeaderTest.kt new file mode 100644 index 000000000..5b4d09ab6 --- /dev/null +++ b/composeUi/src/desktopTest/kotlin/CycleHeaderTest.kt @@ -0,0 +1,53 @@ +import com.darkrockstudios.apps.hammer.common.compose.markdown.HammerMarkdownConfiguration +import com.darkrockstudios.apps.hammer.common.compose.markdowneditor.cycleHeader +import com.darkrockstudios.texteditor.CharLineOffset +import com.darkrockstudios.texteditor.markdown.MarkdownExtension +import com.darkrockstudios.texteditor.markdown.withMarkdown +import com.darkrockstudios.texteditor.state.TextEditorState +import com.darkrockstudios.texteditor.state.headerLevel +import io.mockk.mockk +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class CycleHeaderTest { + + private fun TestScope.newEditor(): MarkdownExtension = + TextEditorState(scope = this, measurer = mockk(relaxed = true)).withMarkdown(HammerMarkdownConfiguration) + + @Test + fun `cycling exports as a markdown heading`() = runTest { + val editor = newEditor() + editor.importMarkdown("Title\n\nBody") + + cycleHeader(editor.editorState, currentLevel = 0) + + assertEquals(1, editor.editorState.headerLevel(0)) + assertEquals("# Title", editor.exportAsMarkdown().lineSequence().first()) + } + + @Test + fun `cycling past the last level removes the heading`() = runTest { + val editor = newEditor() + editor.importMarkdown("### Title") + + cycleHeader(editor.editorState, currentLevel = 3) + + assertNull(editor.editorState.headerLevel(0)) + assertEquals("Title", editor.exportAsMarkdown().lineSequence().first()) + } + + @Test + fun `cycling past the last level clears a mixed selection`() = runTest { + val editor = newEditor() + editor.importMarkdown("### Title\nBody") + editor.editorState.selector.updateSelection(CharLineOffset(0, 0), CharLineOffset(1, 4)) + + cycleHeader(editor.editorState, currentLevel = 3) + + assertNull(editor.editorState.headerLevel(0)) + assertNull(editor.editorState.headerLevel(1)) + } +} diff --git a/composeUi/src/desktopTest/kotlin/EditorTextSizeTest.kt b/composeUi/src/desktopTest/kotlin/EditorTextSizeTest.kt new file mode 100644 index 000000000..19f341f7d --- /dev/null +++ b/composeUi/src/desktopTest/kotlin/EditorTextSizeTest.kt @@ -0,0 +1,86 @@ +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.sp +import com.darkrockstudios.apps.hammer.common.compose.HammerRichTextStyles +import com.darkrockstudios.apps.hammer.common.compose.markdown.HammerMarkdownConfiguration +import com.darkrockstudios.apps.hammer.common.compose.withParagraphIndent +import com.darkrockstudios.texteditor.TextEditor +import com.darkrockstudios.texteditor.markdown.withMarkdown +import com.darkrockstudios.texteditor.rememberTextEditorStyle +import com.darkrockstudios.texteditor.state.TextEditorState +import com.darkrockstudios.texteditor.state.rememberTextEditorState +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +private const val DOC = + "# Title\n\n**bold** and *it* and [l](http://x.y) and `code`\n\n## Two\n\n> quote\n\n- a\n- b\n\n#### Four\n\nplain" + +@OptIn(ExperimentalTestApi::class) +class EditorTextSizeTest { + + @Test + fun `markdown round trips through the relative styles`() = runTest { + val state = TextEditorState(scope = this, measurer = mockk(relaxed = true)) + state.richTextStyles = HammerRichTextStyles + val markdown = state.withMarkdown(HammerMarkdownConfiguration) + + markdown.importMarkdown(DOC) + + assertEquals(DOC, markdown.exportAsMarkdown()) + } + + @Test + fun `the body size scales every line without touching the document`() { + var bodySize by mutableStateOf(16f) + var state: TextEditorState? = null + runComposeUiTest { + setContent { + val editorState = rememberTextEditorState() + state = editorState + TextEditor( + state = editorState, + style = rememberTextEditorStyle( + textStyle = TextStyle(fontSize = bodySize.sp).withParagraphIndent(), + ), + ) + } + waitForIdle() + val editorState = state!! + editorState.richTextStyles = HammerRichTextStyles + editorState.withMarkdown(HammerMarkdownConfiguration).importMarkdown(DOC) + waitForIdle() + + fun lineHeights() = editorState.lineOffsets + .filter { it.virtualLineIndex == 0 } + .associate { it.line to it.textLayoutResult.size.height } + fun indentOf(line: Int) = editorState.lineOffsets + .first { it.line == line && it.virtualLineIndex == 0 } + .textLayoutResult.getHorizontalPosition(0, usePrimaryDirection = true) + + val small = lineHeights() + val smallIndent = indentOf(small.keys.max()) + val linesBefore = editorState.textLines.toList() + bodySize = 32f + waitForIdle() + val large = lineHeights() + val largeIndent = indentOf(small.keys.max()) + + assertEquals(linesBefore, editorState.textLines.toList()) + assertEquals(24f, smallIndent) + assertEquals(48f, largeIndent) + val lastLine = small.keys.max() + assertTrue(small.getValue(0) > small.getValue(lastLine) * 1.6f) + small.keys.forEach { line -> + val ratio = large.getValue(line).toFloat() / small.getValue(line) + assertTrue(ratio in 1.8f..2.2f) + } + } + } +} diff --git a/composeUi/src/desktopTest/kotlin/ExportOptionsDialogTest.kt b/composeUi/src/desktopTest/kotlin/ExportOptionsDialogTest.kt index f3a0c460d..ca6d3a42c 100644 --- a/composeUi/src/desktopTest/kotlin/ExportOptionsDialogTest.kt +++ b/composeUi/src/desktopTest/kotlin/ExportOptionsDialogTest.kt @@ -4,6 +4,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import com.darkrockstudios.apps.hammer.common.data.ExportFont +import com.darkrockstudios.apps.hammer.common.data.ExportFormat import com.darkrockstudios.apps.hammer.common.data.ExportOptions import com.darkrockstudios.apps.hammer.common.data.ExportableScene import com.darkrockstudios.apps.hammer.common.preview.KoinApplicationPreview @@ -93,4 +95,73 @@ class ExportOptionsDialogTest : BaseTest() { compose.onNodeWithText("Limit to specific scenes").assertDoesNotExist() } + + private fun setContent(initial: ExportOptions, onChanged: (ExportOptions) -> Unit = {}) { + var options by mutableStateOf(initial) + compose.setContent { + KoinApplicationPreview { + ExportOptionsDialogContent( + options = options, + exportableScenes = entries, + onOptionsChanged = { + options = it + onChanged(it) + }, + onCancel = {}, + onConfirm = {}, + onShowHelp = {}, + ) + } + } + } + + @Test + fun `Formatting toggles update the options`() { + var latest = ExportOptions(format = ExportFormat.Docx) + setContent(latest) { latest = it } + + compose.onNodeWithText("Number chapter titles").performClick() + compose.onNodeWithText("Keep blank lines between paragraphs").performClick() + compose.waitForIdle() + + assertEquals(true, latest.numberChapters) + assertEquals(false, latest.keepBlankLines) + } + + @Test + fun `Word export offers a font choice`() { + var latest = ExportOptions(format = ExportFormat.Docx) + setContent(latest) { latest = it } + + compose.onNodeWithText("Georgia").performClick() + compose.onNodeWithText("Times New Roman").performClick() + compose.waitForIdle() + + assertEquals(ExportFont.TimesNewRoman, latest.font) + } + + @Test + fun `PDF export offers no font choice`() { + setContent(ExportOptions(format = ExportFormat.Pdf)) + + compose.onNodeWithText("Georgia").assertDoesNotExist() + compose.onNodeWithText("Number chapter titles").assertExists() + } + + @Test + fun `EPUB export offers chapter numbering and a font`() { + setContent(ExportOptions(format = ExportFormat.Epub)) + + compose.onNodeWithText("Number chapter titles").assertExists() + compose.onNodeWithText("Georgia").assertExists() + } + + @Test + fun `Markdown export offers only numbering`() { + setContent(ExportOptions(format = ExportFormat.Markdown)) + + compose.onNodeWithText("Number chapter titles").assertExists() + compose.onNodeWithText("Georgia").assertDoesNotExist() + compose.onNodeWithText("Keep blank lines between paragraphs").assertDoesNotExist() + } } diff --git a/composeUi/src/desktopTest/kotlin/FormatBarQuoteTest.kt b/composeUi/src/desktopTest/kotlin/FormatBarQuoteTest.kt new file mode 100644 index 000000000..067769166 --- /dev/null +++ b/composeUi/src/desktopTest/kotlin/FormatBarQuoteTest.kt @@ -0,0 +1,29 @@ +import com.darkrockstudios.apps.hammer.common.compose.markdown.HammerMarkdownConfiguration +import com.darkrockstudios.apps.hammer.common.compose.markdowneditor.toggleBlockquote +import com.darkrockstudios.texteditor.CharLineOffset +import com.darkrockstudios.texteditor.markdown.withMarkdown +import com.darkrockstudios.texteditor.state.TextEditorState +import com.darkrockstudios.texteditor.state.isBlockquote +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class FormatBarQuoteTest { + + @Test + fun `a line quoted from the format bar stays quoted after saving`() = runTest { + val editor = TextEditorState(scope = this, measurer = mockk(relaxed = true)).withMarkdown(HammerMarkdownConfiguration) + editor.importMarkdown("A line to quote.") + editor.editorState.cursor.updatePosition(CharLineOffset(0, 2)) + toggleBlockquote(editor.editorState) + + val saved = editor.exportAsMarkdown() + assertEquals("> A line to quote.", saved) + + val reopened = TextEditorState(scope = this, measurer = mockk(relaxed = true)).withMarkdown(HammerMarkdownConfiguration) + reopened.importMarkdown(saved) + assertTrue(reopened.editorState.isBlockquote(0)) + } +} diff --git a/composeUi/src/desktopTest/kotlin/MarkdownConfigRestyleTest.kt b/composeUi/src/desktopTest/kotlin/MarkdownConfigRestyleTest.kt deleted file mode 100644 index 4e5e3bd46..000000000 --- a/composeUi/src/desktopTest/kotlin/MarkdownConfigRestyleTest.kt +++ /dev/null @@ -1,45 +0,0 @@ -import androidx.compose.ui.unit.sp -import com.darkrockstudios.apps.hammer.common.compose.markdown.updateMarkdownConfiguration -import com.darkrockstudios.texteditor.markdown.MarkdownConfiguration -import com.darkrockstudios.texteditor.markdown.MarkdownExtension -import com.darkrockstudios.texteditor.state.TextEditorState -import io.mockk.mockk -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.junit.Test -import kotlin.test.assertEquals - -class MarkdownConfigRestyleTest { - - private fun TestScope.newEditor(config: MarkdownConfiguration): MarkdownExtension { - val state = TextEditorState(scope = this, measurer = mockk(relaxed = true)) - return MarkdownExtension(state, config) - } - - private fun configWithBodySize(size: Float) = MarkdownConfiguration.DEFAULT.copy( - defaultTextStyle = MarkdownConfiguration.DEFAULT.defaultTextStyle.copy(fontSize = size.sp), - header1Style = MarkdownConfiguration.DEFAULT.header1Style.copy(fontSize = (size * 2).sp), - ) - - @Test - fun `stepping the font size leaves one baked style per heading`() = runTest { - val editor = newEditor(configWithBodySize(16f)) - editor.importMarkdown("# Chapter One\n\nSome body text.") - val before = editor.editorState.textLines[0].spanStyles.size - - repeat(5) { step -> editor.updateMarkdownConfiguration(configWithBodySize(18f + step * 2)) } - - assertEquals(before, editor.editorState.textLines[0].spanStyles.size) - } - - @Test - fun `heading tracks the new font size`() = runTest { - val editor = newEditor(configWithBodySize(16f)) - editor.importMarkdown("# Chapter One") - - editor.updateMarkdownConfiguration(configWithBodySize(20f)) - - val sizes = editor.editorState.textLines[0].spanStyles.map { it.item.fontSize } - assertEquals(listOf(40f.sp), sizes.filter { it != 20f.sp }) - } -} diff --git a/composeUi/src/desktopTest/kotlin/MarkdownEditFieldSpellCheckTest.kt b/composeUi/src/desktopTest/kotlin/MarkdownEditFieldSpellCheckTest.kt new file mode 100644 index 000000000..24b658321 --- /dev/null +++ b/composeUi/src/desktopTest/kotlin/MarkdownEditFieldSpellCheckTest.kt @@ -0,0 +1,132 @@ +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.onRoot +import com.arkivanov.decompose.value.MutableValue +import com.arkivanov.decompose.value.Value +import com.darkrockstudios.apps.hammer.common.components.notes.ViewNote +import com.darkrockstudios.apps.hammer.common.compose.rememberRootSnackbarHostState +import com.darkrockstudios.apps.hammer.common.data.ProjectDef +import com.darkrockstudios.apps.hammer.common.data.notesrepository.note.NoteContent +import com.darkrockstudios.apps.hammer.common.fileio.HPath +import com.darkrockstudios.apps.hammer.common.notes.ViewNoteUi +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import com.darkrockstudios.apps.hammer.common.compose.markdowneditor.MarkdownEditField +import com.darkrockstudios.apps.hammer.common.preview.KoinApplicationPreview +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.time.Clock + +class MarkdownEditFieldSpellCheckTest { + + @get:Rule + val compose = createComposeRule() + + private fun mount(markdown: String) { + compose.setContent { + KoinApplicationPreview { + MarkdownEditField( + initialMarkdown = markdown, + onMarkdownChanged = {}, + testTag = EDITOR_TAG, + ) + } + } + } + + // The squiggle is the only pure red the editor draws. + private fun squigglePixels(node: SemanticsNodeInteraction = compose.onNodeWithTag(EDITOR_TAG)): Int { + val pixels = node.captureToImage().toPixelMap() + var count = 0 + for (x in 0 until pixels.width) { + for (y in 0 until pixels.height) { + val c = pixels[x, y] + if (c.red > 0.8f && c.green < 0.3f && c.blue < 0.3f) count++ + } + } + return count + } + + @Test + fun `a misspelling in the initial text is underlined`() { + mount("This sentense has a tyypo in it.") + + compose.waitUntil(timeoutMillis = 10_000) { squigglePixels() > 0 } + } + + @Test + fun `correctly spelled initial text is not underlined`() { + mount("This sentence has no typo in it.") + + compose.mainClock.advanceTimeBy(2_000) + compose.waitForIdle() + assertEquals(0, squigglePixels()) + } + + @OptIn(ExperimentalSharedTransitionApi::class) + @Test + fun `a misspelling in a note being edited is underlined`() { + val text = "This sentense has a tyypo in it." + val component = EditingViewNote(text) + compose.setContent { + KoinApplicationPreview { + SharedTransitionLayout { + AnimatedVisibility(visible = true) { + ViewNoteUi( + component = component, + modifier = Modifier, + rootSnackbar = rememberRootSnackbarHostState(), + sharedTransitionScope = this@SharedTransitionLayout, + animatedVisibilityScope = this@AnimatedVisibility, + ) + } + } + } + } + + compose.waitUntil(timeoutMillis = 10_000) { squigglePixels(compose.onRoot()) > 0 } + } + + private class EditingViewNote(text: String) : ViewNote { + private val projectDef = ProjectDef( + name = "Test", + path = HPath(name = "Test", path = "/", isAbsolute = true), + ) + override val state: Value = MutableValue( + ViewNote.State( + projectDef = projectDef, + note = NoteContent(id = 1, created = Clock.System.now(), content = text), + isEditing = true, + ) + ) + override val noteText: Value = MutableValue(text) + + override fun discardEdit() {} + override fun onContentChanged(newContent: String) {} + override fun onTagsChanged(newTags: Set) {} + override suspend fun removeTag(tag: String) {} + override fun showGlobalSearchForTag(tag: String) {} + override suspend fun deleteNote(id: Int) {} + override fun confirmDelete() {} + override fun dismissConfirmDelete() {} + override suspend fun storeNoteUpdate() {} + override fun closeNote() {} + override fun beginEdit() {} + override fun isEditingAndDirty(): Boolean = false + override fun confirmDiscard() {} + override fun cancelDiscard() {} + override fun confirmClose() {} + override fun cancelClose() {} + override fun suggestTags(prefix: String, limit: Int): List = emptyList() + } + + private companion object { + const val EDITOR_TAG = "markdown-edit-field" + } +} diff --git a/composeUi/src/desktopTest/kotlin/MarkdownRestyleBlockIndentTest.kt b/composeUi/src/desktopTest/kotlin/MarkdownRestyleBlockIndentTest.kt index e1b16d116..160830683 100644 --- a/composeUi/src/desktopTest/kotlin/MarkdownRestyleBlockIndentTest.kt +++ b/composeUi/src/desktopTest/kotlin/MarkdownRestyleBlockIndentTest.kt @@ -1,8 +1,9 @@ -import androidx.compose.ui.unit.sp -import com.darkrockstudios.apps.hammer.common.compose.markdown.changeFontSize -import com.darkrockstudios.apps.hammer.common.compose.markdown.updateMarkdownConfiguration -import com.darkrockstudios.texteditor.markdown.MarkdownConfiguration +import com.darkrockstudios.apps.hammer.common.compose.HammerRichTextStyles +import com.darkrockstudios.apps.hammer.common.compose.HammerRichTextStylesDark +import com.darkrockstudios.apps.hammer.common.compose.markdown.HammerMarkdownConfiguration +import com.darkrockstudios.apps.hammer.common.compose.markdown.updateRichTextStyles import com.darkrockstudios.texteditor.markdown.MarkdownExtension +import com.darkrockstudios.texteditor.markdown.withMarkdown import com.darkrockstudios.texteditor.state.TextEditorState import io.mockk.mockk import kotlinx.coroutines.test.TestScope @@ -11,34 +12,25 @@ import org.junit.Test import kotlin.test.assertEquals /** - * Changing the editor font size restyles every line in place, which must not cost - * block lines the ParagraphStyle their gutter markers are positioned against. + * A theme change restyles every line in place, which must not cost block lines the + * ParagraphStyle their gutter markers are positioned against. */ class MarkdownRestyleBlockIndentTest { - private fun TestScope.newEditor(config: MarkdownConfiguration): MarkdownExtension = - MarkdownExtension(TextEditorState(scope = this, measurer = mockk(relaxed = true)), config) + private fun TestScope.newEditor(): MarkdownExtension { + val state = TextEditorState(scope = this, measurer = mockk(relaxed = true)) + state.richTextStyles = HammerRichTextStyles + return state.withMarkdown(HammerMarkdownConfiguration) + } @Test - fun `list indents survive a font size change`() = runTest { - val editor = newEditor(MarkdownConfiguration.DEFAULT) + fun `list indents survive a theme change`() = runTest { + val editor = newEditor() editor.importMarkdown("A paragraph.\n\n1. one\n2. two\n\n- bullet\n\n> quoted") val before = editor.editorState.textLines.map { it.paragraphStyles } - editor.updateMarkdownConfiguration(MarkdownConfiguration.DEFAULT.changeFontSize(24f)) + editor.editorState.updateRichTextStyles(HammerRichTextStylesDark) assertEquals(before, editor.editorState.textLines.map { it.paragraphStyles }) } - - @Test - fun `body text tracks the new font size`() = runTest { - val editor = newEditor(MarkdownConfiguration.DEFAULT) - editor.importMarkdown("A paragraph.\n\n1. one") - - editor.updateMarkdownConfiguration(MarkdownConfiguration.DEFAULT.changeFontSize(24f)) - - editor.editorState.textLines.filter { it.text.isNotEmpty() }.forEach { line -> - assertEquals(listOf(24f.sp), line.spanStyles.map { it.item.fontSize }) - } - } } diff --git a/composeUi/src/desktopTest/kotlin/MarkdownTableTest.kt b/composeUi/src/desktopTest/kotlin/MarkdownTableTest.kt new file mode 100644 index 000000000..bd5ebc203 --- /dev/null +++ b/composeUi/src/desktopTest/kotlin/MarkdownTableTest.kt @@ -0,0 +1,50 @@ +import com.darkrockstudios.apps.hammer.common.compose.markdown.HammerMarkdownConfiguration +import com.darkrockstudios.apps.hammer.common.compose.markdowneditor.insertHorizontalRule +import com.darkrockstudios.texteditor.CharLineOffset +import com.darkrockstudios.texteditor.markdown.MarkdownExtension +import com.darkrockstudios.texteditor.markdown.withMarkdown +import com.darkrockstudios.texteditor.state.TextEditorState +import com.darkrockstudios.texteditor.state.isTableCell +import io.mockk.mockk +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class MarkdownTableTest { + + private fun TestScope.newEditor(): MarkdownExtension { + val state = TextEditorState(scope = this, measurer = mockk(relaxed = true)) + return state.withMarkdown(HammerMarkdownConfiguration) + } + + private val scene = "Before the table.\n\n| Name | Age |\n| --- | --: |\n| Ada | 36 |\n\nAfter the table." + + @Test + fun `a table in a scene reads back as a table`() = runTest { + val editor = newEditor() + editor.importMarkdown(scene) + + val cells = editor.editorState.textLines.indices.filter { editor.editorState.isTableCell(it) } + assertEquals(listOf("Name", "Age", "Ada", "36"), cells.map { editor.editorState.textLines[it].text }) + + val reopened = newEditor() + reopened.importMarkdown(editor.exportAsMarkdown()) + assertEquals(editor.exportAsMarkdown(), reopened.exportAsMarkdown()) + } + + @Test + fun `a horizontal rule is not put in a table`() = runTest { + val editor = newEditor() + editor.importMarkdown(scene) + val before = editor.exportAsMarkdown() + val cell = editor.editorState.textLines.indices.first { editor.editorState.isTableCell(it) } + + editor.editorState.cursor.updatePosition(CharLineOffset(cell, 2)) + insertHorizontalRule(editor.editorState) + + assertTrue(editor.editorState.isTableCell(cell)) + assertEquals(before, editor.exportAsMarkdown()) + } +} diff --git a/composeUi/src/desktopTest/kotlin/MarkdownViewRestyleTest.kt b/composeUi/src/desktopTest/kotlin/MarkdownViewRestyleTest.kt new file mode 100644 index 000000000..192233980 --- /dev/null +++ b/composeUi/src/desktopTest/kotlin/MarkdownViewRestyleTest.kt @@ -0,0 +1,50 @@ +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.darkrockstudios.apps.hammer.common.compose.LocalEditorTextStyle +import com.darkrockstudios.apps.hammer.common.compose.markdowneditor.MarkdownView +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertTrue + +private const val VIEW_TAG = "markdown-view" + +class MarkdownViewRestyleTest { + + @get:Rule + val compose = createComposeRule() + + private fun viewHeight() = compose.onNodeWithTag(VIEW_TAG).fetchSemanticsNode().size.height + + @Test + fun `a new text size resizes text that is already rendered`() { + var fontSize by mutableStateOf(16f) + compose.setContent { + CompositionLocalProvider(LocalEditorTextStyle provides TextStyle(fontSize = fontSize.sp)) { + Box(modifier = Modifier.width(400.dp)) { + MarkdownView( + markdown = "One line of prose.\n\nAnother line of prose.", + modifier = Modifier.testTag(VIEW_TAG), + ) + } + } + } + compose.waitForIdle() + val before = viewHeight() + + fontSize = 32f + compose.waitForIdle() + + assertTrue(viewHeight() > before, "Text must grow with the font size: $before -> ${viewHeight()}") + } +} diff --git a/composeUi/src/desktopTest/kotlin/PlatformAboutSectionTest.kt b/composeUi/src/desktopTest/kotlin/PlatformAboutSectionTest.kt index e546dba67..c3d596bc5 100644 --- a/composeUi/src/desktopTest/kotlin/PlatformAboutSectionTest.kt +++ b/composeUi/src/desktopTest/kotlin/PlatformAboutSectionTest.kt @@ -27,6 +27,8 @@ class PlatformAboutSectionTest { override fun openGithub() = Unit override fun viewChangelog() = Unit override fun openLatestRelease() = Unit + override fun checkForUpdate() = Unit + override fun updateApp() = Unit } @Test diff --git a/composeUi/src/desktopTest/kotlin/ReaderModeUiTest.kt b/composeUi/src/desktopTest/kotlin/ReaderModeUiTest.kt new file mode 100644 index 000000000..4343346a9 --- /dev/null +++ b/composeUi/src/desktopTest/kotlin/ReaderModeUiTest.kt @@ -0,0 +1,193 @@ +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.unit.dp +import com.arkivanov.decompose.value.MutableValue +import com.arkivanov.decompose.value.update +import com.darkrockstudios.apps.hammer.common.components.storyeditor.readermode.ReaderMode +import com.darkrockstudios.apps.hammer.common.data.ProjectDef +import com.darkrockstudios.apps.hammer.common.data.SceneItem +import com.darkrockstudios.apps.hammer.common.fileio.HPath +import com.darkrockstudios.apps.hammer.common.preview.KoinApplicationPreview +import com.darkrockstudios.apps.hammer.common.storyeditor.readermode.READER_MODE_EDIT_TAG +import com.darkrockstudios.apps.hammer.common.storyeditor.readermode.READER_MODE_HEADINGS_TOGGLE_TAG +import com.darkrockstudios.apps.hammer.common.storyeditor.readermode.READER_MODE_INCREASE_FONT_TAG +import com.darkrockstudios.apps.hammer.common.storyeditor.readermode.READER_MODE_LIST_TAG +import com.darkrockstudios.apps.hammer.common.storyeditor.readermode.READER_MODE_NEXT_CHAPTER_TAG +import com.darkrockstudios.apps.hammer.common.storyeditor.readermode.READER_MODE_SCENE_BREAK_TAG +import com.darkrockstudios.apps.hammer.common.storyeditor.readermode.ReaderModeUi +import com.darkrockstudios.apps.hammer.common.storyeditor.readermode.readerSceneBodyTag +import com.darkrockstudios.apps.hammer.common.storyeditor.readermode.readerSceneHeadingTag +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals + +class ReaderModeUiTest : BaseTest() { + + @get:Rule + val compose = createComposeRule() + + private val projectDef = ProjectDef(name = "Test", path = HPath(name = "Test", path = "/", isAbsolute = true)) + + private fun item(id: Int, name: String, type: SceneItem.Type) = + SceneItem(projectDef = projectDef, type = type, id = id, name = name, order = id) + + private val chapterOne = item(1, "Arrival", SceneItem.Type.Group) + private val chapterTwo = item(4, "The Dark Night", SceneItem.Type.Group) + private val harbour = item(2, "The Harbour", SceneItem.Type.Scene) + private val lampRoom = item(3, "The Lamp Room", SceneItem.Type.Scene) + + private val reader = FakeReader( + ReaderMode.State( + chapters = listOf( + ReaderMode.ReaderChapter(chapterOne, sceneCount = 2), + ReaderMode.ReaderChapter(chapterTwo, sceneCount = 0), + ), + activeChapterId = chapterOne.id, + scenes = listOf( + ReaderMode.ReaderScene(harbour, "The keeper stepped off the ferry."), + ReaderMode.ReaderScene(lampRoom, "One hundred and nine steps."), + ), + isLoading = false, + ) + ) + + private fun showReader() { + compose.setContent { + KoinApplicationPreview { + Box(modifier = Modifier.size(420.dp, 900.dp)) { + ReaderModeUi(component = reader) + } + } + } + } + + @Test + fun `Scenes are stitched with an ornament and no headings by default`() { + showReader() + + compose.onNodeWithTag(readerSceneBodyTag(harbour.id)).assertTextEquals("The keeper stepped off the ferry.") + compose.onNodeWithTag(readerSceneBodyTag(lampRoom.id)).assertTextEquals("One hundred and nine steps.") + compose.onNodeWithTag(READER_MODE_SCENE_BREAK_TAG).assertIsDisplayed() + compose.onNodeWithTag(readerSceneHeadingTag(harbour.id)).assertDoesNotExist() + } + + @Test + fun `Scene headings replace the ornament when enabled`() { + showReader() + + compose.onNodeWithTag(READER_MODE_HEADINGS_TOGGLE_TAG).performClick() + compose.waitForIdle() + + compose.onNodeWithTag(readerSceneHeadingTag(harbour.id)).assertIsDisplayed() + compose.onNodeWithTag(readerSceneHeadingTag(lampRoom.id)).assertIsDisplayed() + compose.onNodeWithTag(READER_MODE_SCENE_BREAK_TAG).assertDoesNotExist() + } + + @Test + fun `A scene heading opens that scene in the editor`() { + reader.update { it.copy(showSceneHeadings = true) } + showReader() + + compose.onNodeWithTag(readerSceneHeadingTag(lampRoom.id)).performClick() + + assertEquals(listOf(lampRoom), reader.edited) + } + + @Test + fun `Edit opens the scene being read`() { + showReader() + + compose.onNodeWithTag(READER_MODE_EDIT_TAG).performClick() + + assertEquals(listOf(null), reader.edited) + } + + @Test + fun `The scene at the top of the column is reported as in view`() { + showReader() + compose.waitForIdle() + + assertEquals(harbour, reader.inView.last()) + } + + @Test + fun `A scroll request is acknowledged once the chapter is on screen`() { + reader.update { it.copy(scrollToSceneId = lampRoom.id) } + showReader() + compose.waitForIdle() + + assertEquals(1, reader.scrollsHandled) + } + + @Test + fun `Font size and chapter controls reach the component`() { + showReader() + + compose.onNodeWithTag(READER_MODE_INCREASE_FONT_TAG).performClick() + compose.onNodeWithTag(READER_MODE_LIST_TAG).performScrollToNode(hasTestTag(READER_MODE_NEXT_CHAPTER_TAG)) + compose.onNodeWithTag(READER_MODE_NEXT_CHAPTER_TAG).performClick() + + assertEquals(1, reader.fontIncreases) + assertEquals(1, reader.nextChapters) + } + + @Test + fun `An empty chapter says so`() { + reader.update { it.copy(activeChapterId = chapterTwo.id, scenes = emptyList()) } + showReader() + + compose.onNodeWithText("This chapter has no scenes yet").assertIsDisplayed() + } +} + +private class FakeReader(initial: ReaderMode.State) : ReaderMode { + override val state = MutableValue(initial) + + val edited = mutableListOf() + val inView = mutableListOf() + var scrollsHandled = 0 + var fontIncreases = 0 + var nextChapters = 0 + + fun update(transform: (ReaderMode.State) -> ReaderMode.State) = state.update(transform) + + override fun showChapter(chapterId: Int) {} + override fun nextChapter() { + nextChapters++ + } + + override fun previousChapter() {} + override fun showScene(sceneItem: SceneItem) {} + override fun scrollHandled() { + scrollsHandled++ + state.update { it.copy(scrollToSceneId = null) } + } + + override fun sceneInView(sceneItem: SceneItem?) { + inView.add(sceneItem) + } + + override fun editScene(sceneItem: SceneItem?) { + edited.add(sceneItem) + } + + override fun toggleSceneHeadings() = state.update { it.copy(showSceneHeadings = !it.showSceneHeadings) } + override fun increaseFontSize() { + fontIncreases++ + } + + override fun decreaseFontSize() {} + override fun resetFontSize() {} + override fun setEditorMaxWidth(width: Float) {} + override fun resetEditorMaxWidth() {} + override fun close() {} +} diff --git a/composeUi/src/desktopTest/kotlin/RichTextStylesRestyleTest.kt b/composeUi/src/desktopTest/kotlin/RichTextStylesRestyleTest.kt new file mode 100644 index 000000000..9a66f2630 --- /dev/null +++ b/composeUi/src/desktopTest/kotlin/RichTextStylesRestyleTest.kt @@ -0,0 +1,51 @@ +import com.darkrockstudios.apps.hammer.common.compose.HammerRichTextStyles +import com.darkrockstudios.apps.hammer.common.compose.HammerRichTextStylesDark +import com.darkrockstudios.apps.hammer.common.compose.markdown.HammerMarkdownConfiguration +import com.darkrockstudios.apps.hammer.common.compose.markdown.updateRichTextStyles +import com.darkrockstudios.texteditor.RichTextStyles +import com.darkrockstudios.texteditor.markdown.MarkdownExtension +import com.darkrockstudios.texteditor.markdown.withMarkdown +import com.darkrockstudios.texteditor.state.TextEditorState +import io.mockk.mockk +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** Switching themes restyles the document in place; the body size never does (see EditorTextSizeTest). */ +class RichTextStylesRestyleTest { + + private fun TestScope.newEditor(styles: RichTextStyles): MarkdownExtension { + val state = TextEditorState(scope = this, measurer = mockk(relaxed = true)) + state.richTextStyles = styles + return state.withMarkdown(HammerMarkdownConfiguration) + } + + @Test + fun `switching themes leaves one baked style per heading`() = runTest { + val editor = newEditor(HammerRichTextStyles) + editor.importMarkdown("# Chapter One\n\nSome body text.") + val before = editor.editorState.textLines[0].spanStyles.size + + repeat(5) { step -> + editor.editorState.updateRichTextStyles( + if (step % 2 == 0) HammerRichTextStylesDark else HammerRichTextStyles + ) + } + + assertEquals(before, editor.editorState.textLines[0].spanStyles.size) + } + + @Test + fun `code spans take the new theme's background`() = runTest { + val editor = newEditor(HammerRichTextStyles) + editor.importMarkdown("Some `code` here.") + + editor.editorState.updateRichTextStyles(HammerRichTextStylesDark) + + val backgrounds = editor.editorState.textLines[0].spanStyles.map { it.item.background } + assertTrue(HammerRichTextStylesDark.codeStyle.background in backgrounds) + assertTrue(HammerRichTextStyles.codeStyle.background !in backgrounds) + } +} diff --git a/composeUi/src/desktopTest/kotlin/SceneContentLoadTest.kt b/composeUi/src/desktopTest/kotlin/SceneContentLoadTest.kt index ab44acea0..557263380 100644 --- a/composeUi/src/desktopTest/kotlin/SceneContentLoadTest.kt +++ b/composeUi/src/desktopTest/kotlin/SceneContentLoadTest.kt @@ -1,12 +1,13 @@ import com.darkrockstudios.apps.hammer.common.compose.ComposeRichText +import com.darkrockstudios.apps.hammer.common.compose.markdown.HammerMarkdownConfiguration import com.darkrockstudios.apps.hammer.common.data.ProjectDef import com.darkrockstudios.apps.hammer.common.data.SceneContent import com.darkrockstudios.apps.hammer.common.data.SceneItem import com.darkrockstudios.apps.hammer.common.fileio.HPath import com.darkrockstudios.apps.hammer.common.storyeditor.sceneeditor.loadSceneContent import com.darkrockstudios.apps.hammer.common.storyeditor.sceneeditor.sceneDiffText -import com.darkrockstudios.texteditor.markdown.MarkdownConfiguration import com.darkrockstudios.texteditor.markdown.MarkdownExtension +import com.darkrockstudios.texteditor.markdown.withMarkdown import com.darkrockstudios.texteditor.richstyle.HorizontalRuleSpanStyle import com.darkrockstudios.texteditor.state.TextEditorState import io.mockk.mockk @@ -33,7 +34,7 @@ class SceneContentLoadTest { private fun TestScope.newEditor(): MarkdownExtension { val state = TextEditorState(scope = this, measurer = mockk(relaxed = true)) - return MarkdownExtension(state, MarkdownConfiguration.DEFAULT) + return state.withMarkdown(HammerMarkdownConfiguration) } private fun MarkdownExtension.horizontalRuleLines(): List = diff --git a/composeUi/src/desktopTest/kotlin/SceneEditorSpellCheckTest.kt b/composeUi/src/desktopTest/kotlin/SceneEditorSpellCheckTest.kt new file mode 100644 index 000000000..247c4dcd4 --- /dev/null +++ b/composeUi/src/desktopTest/kotlin/SceneEditorSpellCheckTest.kt @@ -0,0 +1,72 @@ +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onRoot +import com.darkrockstudios.apps.hammer.common.compose.rememberRootSnackbarHostState +import com.darkrockstudios.apps.hammer.common.preview.KoinApplicationPreview +import com.darkrockstudios.apps.hammer.common.preview.sceneeditor.fakeFocusMode +import com.darkrockstudios.apps.hammer.common.preview.sceneeditor.fakeSceneEditor +import com.darkrockstudios.apps.hammer.common.storyeditor.focusmode.FocusModeUi +import com.darkrockstudios.apps.hammer.common.storyeditor.sceneeditor.SceneEditorUi +import com.darkrockstudios.libs.platformspellchecker.PlatformSpellChecker +import com.darkrockstudios.libs.platformspellchecker.PlatformSpellCheckerFactory +import com.darkrockstudios.libs.platformspellchecker.SpLocale +import kotlinx.coroutines.runBlocking +import org.junit.Assume.assumeTrue +import org.junit.Rule +import org.junit.Test + +class SceneEditorSpellCheckTest { + + @get:Rule + val compose = createComposeRule() + + // The squiggle is the only pure red the editor draws. + private fun squigglePixels(): Int { + val pixels = compose.onRoot().captureToImage().toPixelMap() + var count = 0 + for (x in 0 until pixels.width) { + for (y in 0 until pixels.height) { + val c = pixels[x, y] + if (c.red > 0.8f && c.green < 0.3f && c.blue < 0.3f) count++ + } + } + return count + } + + private fun englishChecker(): PlatformSpellChecker { + val factory = PlatformSpellCheckerFactory() + assumeTrue("en-US dictionary not available", factory.hasLanguage(SpLocale.EN_US)) + return runBlocking { factory.createSpellChecker(SpLocale.EN_US) } + } + + @Test + fun `a misspelling in the loaded scene is underlined`() { + val component = fakeSceneEditor(markdown = TYPO_TEXT, spellChecker = englishChecker()) + + compose.setContent { + KoinApplicationPreview { + SceneEditorUi(component, rememberRootSnackbarHostState()) + } + } + + compose.waitUntil(timeoutMillis = 10_000) { squigglePixels() > 0 } + } + + @Test + fun `a misspelling in the loaded scene is underlined in focus mode`() { + val component = fakeFocusMode(markdown = TYPO_TEXT, spellChecker = englishChecker()) + + compose.setContent { + KoinApplicationPreview { + FocusModeUi(component) + } + } + + compose.waitUntil(timeoutMillis = 10_000) { squigglePixels() > 0 } + } + + private companion object { + const val TYPO_TEXT = "This sentense has a tyypo in it." + } +} diff --git a/composeUi/src/desktopTest/kotlin/SceneListFooterTest.kt b/composeUi/src/desktopTest/kotlin/SceneListFooterTest.kt index 24f95b128..d18d9f665 100644 --- a/composeUi/src/desktopTest/kotlin/SceneListFooterTest.kt +++ b/composeUi/src/desktopTest/kotlin/SceneListFooterTest.kt @@ -134,6 +134,7 @@ private fun fakeComponent() = object : SceneList { override fun onSceneListUpdate(scenes: SceneSummary) {} override fun onSceneBufferUpdate(sceneBuffer: SceneBuffer) {} override fun showOutlineOverview() {} + override fun showReaderMode() {} override suspend fun archiveScene(scene: SceneItem) {} override suspend fun unarchiveScene(scene: SceneItem) {} override fun showArchivedScenes() {} diff --git a/composeUi/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/MainViewController.kt b/composeUi/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/MainViewController.kt index 0717a0e5a..740c05028 100644 --- a/composeUi/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/MainViewController.kt +++ b/composeUi/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/MainViewController.kt @@ -32,7 +32,7 @@ private fun HammerApp(root: IosRoot, shortcutHost: ProjectShortcutHost) { val systemDark = isSystemInDarkTheme() val isDark = when (settingsState.uiTheme) { UiTheme.Light -> false - UiTheme.Dark -> true + UiTheme.Dark, UiTheme.OledBlack -> true UiTheme.FollowSystem -> systemDark } diff --git a/composeUi/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/EditorAction.kt b/composeUi/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/EditorAction.kt index b45fab864..ec93612e5 100644 --- a/composeUi/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/EditorAction.kt +++ b/composeUi/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/markdowneditor/EditorAction.kt @@ -20,19 +20,20 @@ import com.darkrockstudios.apps.hammer.common.compose.Ui internal actual fun EditorAction( icon: ImageVector, active: Boolean, + enabled: Boolean, onClick: () -> Unit ) { val painter = rememberVectorPainter(icon) Box( modifier = Modifier - .clickable { onClick() } + .clickable(enabled = enabled) { onClick() } .padding(Ui.Padding.L) ) { Icon( modifier = Modifier.size(24.dp), painter = painter, - tint = if (active) MaterialTheme.colorScheme.inversePrimary else MaterialTheme.colorScheme.onSurfaceVariant, + tint = editorActionColor(active, enabled), contentDescription = null ) } @@ -42,11 +43,12 @@ internal actual fun EditorAction( internal actual fun EditorTextAction( label: String, active: Boolean, + enabled: Boolean, onClick: () -> Unit, ) { Box( modifier = Modifier - .clickable { onClick() } + .clickable(enabled = enabled) { onClick() } .padding(Ui.Padding.L) .defaultMinSize(minWidth = 24.dp, minHeight = 24.dp), contentAlignment = Alignment.Center, @@ -54,7 +56,7 @@ internal actual fun EditorTextAction( Text( text = label, style = MaterialTheme.typography.labelLarge, - color = if (active) MaterialTheme.colorScheme.inversePrimary else MaterialTheme.colorScheme.onSurfaceVariant, + color = editorActionColor(active, enabled), ) } } diff --git a/composeUi/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/theme/OledBlackAvailability.ios.kt b/composeUi/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/theme/OledBlackAvailability.ios.kt new file mode 100644 index 000000000..dedca9705 --- /dev/null +++ b/composeUi/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/theme/OledBlackAvailability.ios.kt @@ -0,0 +1,3 @@ +package com.darkrockstudios.apps.hammer.common.compose.theme + +actual val oledBlackThemeAvailable: Boolean = true diff --git a/composeUi/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/EditorTopBar.kt b/composeUi/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/EditorTopBar.kt index c2da81675..c12edfce8 100644 --- a/composeUi/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/EditorTopBar.kt +++ b/composeUi/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/storyeditor/sceneeditor/EditorTopBar.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.MenuBook import androidx.compose.material.icons.filled.Fullscreen import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Save @@ -43,6 +44,7 @@ import com.darkrockstudios.apps.hammer.common.compose.resources.get import com.darkrockstudios.apps.hammer.common.storyeditor.sceneTypeMeta import com.darkrockstudios.apps.hammer.scene_editor_cancel_button import com.darkrockstudios.apps.hammer.scene_editor_focus_mode_button +import com.darkrockstudios.apps.hammer.scene_editor_menu_item_read import com.darkrockstudios.apps.hammer.scene_editor_metadata_button import com.darkrockstudios.apps.hammer.scene_editor_name_hint import com.darkrockstudios.apps.hammer.scene_editor_rename_button @@ -111,6 +113,16 @@ actual fun EditorTopBar( } } + if (screen.windowWidthClass != WindowWidthSizeClass.Compact) { + IconButton(onClick = component::readScene) { + Icon( + imageVector = Icons.AutoMirrored.Filled.MenuBook, + contentDescription = Res.string.scene_editor_menu_item_read.get(), + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + if (screen.windowWidthClass == WindowWidthSizeClass.Expanded) { IconButton(onClick = component::enterFocusMode) { Icon( diff --git a/crowdin.yml b/crowdin.yml index 05c253fc5..a6b5af6f8 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -1,6 +1,11 @@ project_id_env: CROWDIN_PROJECT_ID api_token_env: CROWDIN_PERSONAL_TOKEN +# Crowdin's default commit message carries [ci skip], which stops Build CI from +# running on the translation PRs. Supplying a message replaces the default. +commit_message: "New translations from Crowdin" +append_commit_message: false + files: # Not strings*.xml: Crowdin's * requires at least one character, so that # pattern silently skips strings.xml itself. This directory only ever holds @@ -9,6 +14,10 @@ files: translation: /common/src/commonMain/composeResources/values-%android_code%/%original_file_name% translate_attributes: 0 content_segmentation: 0 + # Every file here must be named *_android.xml. The Crowdin post-export + # processor unescapes quotes for the Compose resources and only skips files + # with that suffix, and it sees file names, not paths. AAPT rejects an + # unescaped apostrophe, so a file without the suffix breaks the Android build. - source: /android/src/main/res/values/strings*.xml translation: /android/src/main/res/values-%android_code%/%original_file_name% translate_attributes: 0 diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index 3528411ca..866fb29f1 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -1,3 +1,5 @@ +import com.darkrockstudios.build.DistributionChannel +import com.darkrockstudios.build.distributionChannel import com.darkrockstudios.build.registerLinuxDistributionTasks import org.jetbrains.compose.desktop.application.dsl.TargetFormat import java.util.zip.ZipFile @@ -16,9 +18,8 @@ plugins { group = "com.darkrockstudios.apps.hammer.desktop" version = libs.versions.app.get() -// -PmacOsAppStoreRelease=true -PbuildNumber=N enables App Store packaging. -val isAppStoreRelease: Boolean = - (project.findProperty("macOsAppStoreRelease") as String?)?.toBoolean() ?: false +// -Pchannel=mac-app-store -PbuildNumber=N enables App Store packaging. +val isAppStoreRelease: Boolean = project.distributionChannel() == DistributionChannel.MAC_APP_STORE val macBuildNumber: String = (project.findProperty("buildNumber") as String?) ?: "1" @@ -66,6 +67,7 @@ kotlin { implementation(libs.nucleus.launcher.windows) implementation(libs.nucleus.launcher.linux) implementation(libs.nucleus.launcher.macos) + implementation(libs.nucleus.updater.runtime) } } val jvmTest by getting { @@ -111,6 +113,10 @@ compose.desktop { menuGroup = "Hammer" shortcut = true console = false + // Thar be dragons: this is the MSI UpgradeCode every installed copy carries. jpackage + // derived it from vendor + name; it is pinned so a renamed package or an added vendor + // can never make a new MSI install beside the old one instead of over it. + upgradeUuid = "BA26F056-C16C-38AF-BD48-C993BEAFBE7A" iconFile.set(project.file("icons/windows.ico")) } @@ -154,7 +160,6 @@ compose.desktop { } jvmArgs("-Dcompose.application.configure.swing.globals=false") if (isAppStoreRelease) { - jvmArgs("-Dhammer.app.store=true") // Load libjnidispatch.jnilib from Contents/app/resources/, never extract. jvmArgs("-Djna.nounpack=true", "-Djna.nosys=true") // Lets Nucleus' System.loadLibrary find our pre-bundled signed dylibs diff --git a/desktop/scripts/build-appstore.sh b/desktop/scripts/build-appstore.sh index ffa3124e7..e5e878ad9 100755 --- a/desktop/scripts/build-appstore.sh +++ b/desktop/scripts/build-appstore.sh @@ -62,8 +62,7 @@ fi echo "→ Building Hammer for the Mac App Store (build #$BUILD_NUMBER)" ./gradlew --stop -./gradlew :desktop:packageReleasePkg -Pchannel=mac-app-store \ - -PmacOsAppStoreRelease=true \ +./gradlew :desktop:packageReleasePkg -Pchannel=mac-app-store -Pformat=pkg \ -PbuildNumber="$BUILD_NUMBER" \ --no-daemon diff --git a/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/ConfirmCloseDialogs.kt b/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/ConfirmCloseDialogs.kt index d260dc184..65c03ac11 100644 --- a/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/ConfirmCloseDialogs.kt +++ b/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/ConfirmCloseDialogs.kt @@ -48,7 +48,7 @@ internal fun confirmCloseUnsavedEncyclopediaDialog( SimpleConfirm( title = Res.string.unsaved_encyclopedia_dialog_title.get(), message = Res.string.unsaved_encyclopedia_dialog_message.get(), - positiveButton = Res.string.unsaved_dialog_positive_button.get(), + positiveButton = discardButtonText(closeType), negativeButton = Res.string.unsaved_dialog_negative_button.get(), onDismiss = { /* Noop */ }, onNegative = { @@ -70,7 +70,7 @@ internal fun confirmCloseUnsavedTimelineDialog( SimpleConfirm( title = Res.string.unsaved_timeline_dialog_title.get(), message = Res.string.unsaved_timeline_dialog_message.get(), - positiveButton = Res.string.unsaved_dialog_positive_button.get(), + positiveButton = discardButtonText(closeType), negativeButton = Res.string.unsaved_dialog_negative_button.get(), onDismiss = { /* Noop */ }, onNegative = { @@ -92,7 +92,7 @@ internal fun confirmCloseUnsavedNotesDialog( SimpleConfirm( title = Res.string.unsaved_notes_dialog_title.get(), message = Res.string.unsaved_notes_dialog_message.get(), - positiveButton = Res.string.unsaved_dialog_positive_button.get(), + positiveButton = discardButtonText(closeType), negativeButton = Res.string.unsaved_dialog_negative_button.get(), onDismiss = { /* Noop */ }, onNegative = { @@ -103,3 +103,12 @@ internal fun confirmCloseUnsavedNotesDialog( } ) } + +/** "Discard and close" while a window close is pending, plain "Discard" for a sync gate. */ +@Composable +private fun discardButtonText(closeType: ApplicationState.CloseType): String = + if (closeType == ApplicationState.CloseType.None) { + Res.string.unsaved_entity_dialog_negative_button.get() + } else { + Res.string.unsaved_dialog_positive_button.get() + } diff --git a/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/Main.kt b/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/Main.kt index 9d82017e2..0ec69e553 100644 --- a/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/Main.kt +++ b/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/Main.kt @@ -212,7 +212,7 @@ fun main(args: Array) { val systemDark = isSystemInDarkMode() val darkMode = when (settingsState.uiTheme) { UiTheme.Light -> false - UiTheme.Dark -> true + UiTheme.Dark, UiTheme.OledBlack -> true UiTheme.FollowSystem -> systemDark } NucleusDecoratedWindowTheme(isDark = darkMode) { diff --git a/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/ProjectEditorWindow.kt b/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/ProjectEditorWindow.kt index 98c8df255..7c83a82e6 100644 --- a/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/ProjectEditorWindow.kt +++ b/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/ProjectEditorWindow.kt @@ -153,8 +153,10 @@ internal fun NucleusApplicationScope.ProjectEditorWindow( CloseConfirm.Scenes -> { confirmCloseUnsavedScenesDialog(closeRequest) { result, closeType -> scope.launch { - if (result == ConfirmCloseResult.SaveAll) { - component.storeDirtyBuffers() + when (result) { + ConfirmCloseResult.SaveAll -> component.storeDirtyBuffers() + ConfirmCloseResult.Discard -> component.discardUnsaved(CloseConfirm.Scenes) + ConfirmCloseResult.Cancel -> Unit } withContext(mainDispatcher) { @@ -172,7 +174,12 @@ internal fun NucleusApplicationScope.ProjectEditorWindow( confirmCloseUnsavedNotesDialog(closeRequest) { result, closeType -> when (result) { ConfirmCloseResult.SaveAll -> error("Unhandled close type: $closeType") - ConfirmCloseResult.Discard -> component.closeRequestDealtWith(CloseConfirm.Notes) + ConfirmCloseResult.Discard -> scope.launch { + component.discardUnsaved(CloseConfirm.Notes) + withContext(mainDispatcher) { + component.closeRequestDealtWith(CloseConfirm.Notes) + } + } ConfirmCloseResult.Cancel -> cancelClose() } } @@ -182,7 +189,12 @@ internal fun NucleusApplicationScope.ProjectEditorWindow( confirmCloseUnsavedEncyclopediaDialog(closeRequest) { result, closeType -> when (result) { ConfirmCloseResult.SaveAll -> error("Unhandled close type: $closeType") - ConfirmCloseResult.Discard -> component.closeRequestDealtWith(CloseConfirm.Encyclopedia) + ConfirmCloseResult.Discard -> scope.launch { + component.discardUnsaved(CloseConfirm.Encyclopedia) + withContext(mainDispatcher) { + component.closeRequestDealtWith(CloseConfirm.Encyclopedia) + } + } ConfirmCloseResult.Cancel -> cancelClose() } } @@ -192,9 +204,12 @@ internal fun NucleusApplicationScope.ProjectEditorWindow( confirmCloseUnsavedTimelineDialog(closeRequest) { result, closeType -> when (result) { ConfirmCloseResult.SaveAll -> error("Unhandled close type: $closeType") - ConfirmCloseResult.Discard -> component.closeRequestDealtWith( - CloseConfirm.Timeline - ) + ConfirmCloseResult.Discard -> scope.launch { + component.discardUnsaved(CloseConfirm.Timeline) + withContext(mainDispatcher) { + component.closeRequestDealtWith(CloseConfirm.Timeline) + } + } ConfirmCloseResult.Cancel -> cancelClose() } } diff --git a/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/desktopModule.kt b/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/desktopModule.kt index f5d3009c4..62e236626 100644 --- a/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/desktopModule.kt +++ b/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/desktopModule.kt @@ -1,10 +1,16 @@ package com.darkrockstudios.apps.hammer.desktop +import com.darkrockstudios.apps.hammer.base.DistributionChannel +import com.darkrockstudios.apps.hammer.base.PackageFormat import com.darkrockstudios.apps.hammer.common.HostOs -import com.darkrockstudios.apps.hammer.common.IS_APP_STORE +import com.darkrockstudios.apps.hammer.common.data.appupdate.AppUpdater import com.darkrockstudios.apps.hammer.common.data.projectsrepository.ProjectsRepository +import com.darkrockstudios.apps.hammer.common.dependencyinjection.APP_SCOPE import com.darkrockstudios.apps.hammer.common.dependencyinjection.DISPATCHER_IO +import com.darkrockstudios.apps.hammer.common.getInDevelopmentMode import com.darkrockstudios.apps.hammer.common.hostOs +import com.darkrockstudios.apps.hammer.desktop.update.GlobalSettingsUpdatePreferences +import com.darkrockstudios.apps.hammer.desktop.update.createAppUpdater import com.darkrockstudios.apps.hammer.common.sandbox.NoopSandboxFileAccess import com.darkrockstudios.apps.hammer.common.sandbox.SandboxFileAccess import com.darkrockstudios.apps.hammer.desktop.sandbox.MacOsBookmarks @@ -34,10 +40,20 @@ val desktopModule: Module = module { } } single { - if (IS_APP_STORE && MacOsBookmarks.isAvailable) { + if (DistributionChannel.isMacAppStore && MacOsBookmarks.isAvailable) { MacOsSandboxFileAccess(get()) } else { NoopSandboxFileAccess } } + single { + createAppUpdater( + channel = DistributionChannel.current, + format = PackageFormat.current, + devMode = getInDevelopmentMode(), + preferences = GlobalSettingsUpdatePreferences(get()), + appScope = get(named(APP_SCOPE)), + ioDispatcher = get(named(DISPATCHER_IO)), + ) + } } diff --git a/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/sandbox/SandboxStartup.kt b/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/sandbox/SandboxStartup.kt index 8feb84e25..a55260343 100644 --- a/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/sandbox/SandboxStartup.kt +++ b/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/sandbox/SandboxStartup.kt @@ -1,6 +1,6 @@ package com.darkrockstudios.apps.hammer.desktop.sandbox -import com.darkrockstudios.apps.hammer.common.IS_APP_STORE +import com.darkrockstudios.apps.hammer.base.DistributionChannel import com.darkrockstudios.apps.hammer.common.data.globalsettings.datasource.GlobalSettingsDatasource import io.github.aakira.napier.Napier import org.koin.java.KoinJavaComponent.getKoin @@ -33,7 +33,7 @@ object SandboxStartup { private fun str(key: String): String = SandboxStrings.get(key) fun ensureProjectsDirAccess() { - if (!IS_APP_STORE) return + if (!DistributionChannel.isMacAppStore) return if (!MacOsBookmarks.isAvailable) { Napier.w("App Store flavor but bookmark dylib unavailable — projects-dir access likely to fail") return diff --git a/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/update/HammerReleaseProvider.kt b/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/update/HammerReleaseProvider.kt new file mode 100644 index 000000000..fa29c2477 --- /dev/null +++ b/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/update/HammerReleaseProvider.kt @@ -0,0 +1,46 @@ +package com.darkrockstudios.apps.hammer.desktop.update + +import com.darkrockstudios.apps.hammer.base.GITHUB_URL +import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.updater.provider.UpdateProvider + +/** + * Points the Nucleus updater at the fixed-name assets on the latest GitHub release. + * + * Nucleus's own GitHubProvider downloads from `releases/download/v{version}/`, which does not + * exist for a partial-release tag such as `v1.2.4+google-play`. `releases/latest/download/` is a + * plain redirect to whatever release is marked latest: no API call, no token, no rate limit, and + * the same URLs the README links. + */ +class HammerReleaseProvider( + private val baseUrl: String = defaultBaseUrl(), +) : UpdateProvider { + + override fun getUpdateMetadataUrl(channel: String, platform: Platform): String = + "$baseUrl/${metadataFileName(platform)}" + + override fun getDownloadUrl(fileName: String, version: String): String = "$baseUrl/$fileName" + + companion object { + /** + * Substitutes a local server for the GitHub release, for end-to-end testing of a packaged + * build. The environment variable form exists because an installed app has no command line + * to put a system property on. + */ + const val BASE_URL_PROPERTY = "hammer.update.baseUrl" + const val BASE_URL_ENV = "HAMMER_UPDATE_BASE_URL" + + fun defaultBaseUrl( + property: String? = System.getProperty(BASE_URL_PROPERTY), + environment: String? = System.getenv(BASE_URL_ENV), + ): String = + (property ?: environment)?.trim()?.trimEnd('/')?.takeIf { it.isNotEmpty() } + ?: "${GITHUB_URL}releases/latest/download" + + fun metadataFileName(platform: Platform): String = when (platform) { + Platform.MacOS -> "latest-mac.yml" + Platform.Linux -> "latest-linux.yml" + Platform.Windows, Platform.Unknown -> "latest.yml" + } + } +} diff --git a/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/update/NucleusAppUpdater.kt b/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/update/NucleusAppUpdater.kt new file mode 100644 index 000000000..769f5cf8c --- /dev/null +++ b/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/update/NucleusAppUpdater.kt @@ -0,0 +1,135 @@ +package com.darkrockstudios.apps.hammer.desktop.update + +import com.darkrockstudios.apps.hammer.common.data.appupdate.AppUpdateState +import com.darkrockstudios.apps.hammer.common.data.appupdate.AppUpdater +import dev.nucleusframework.updater.NucleusUpdater +import dev.nucleusframework.updater.UpdateInfo +import dev.nucleusframework.updater.UpdateResult +import io.github.aakira.napier.Napier +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.withContext +import java.io.File +import kotlin.coroutines.CoroutineContext +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.seconds + +private const val PERCENT_MAX = 100.0 + +/** + * [AppUpdater] over the Nucleus updater runtime. [start] checks once shortly after launch and + * then on a schedule while the user allows it; a failed check is logged and never shown. + * [update] downloads, verifies, and hands the file to [install], which by default quits the app + * and relaunches the new version. + */ +class NucleusAppUpdater( + private val updater: NucleusUpdater, + private val installable: Boolean, + private val preferences: UpdatePreferences, + private val scope: CoroutineScope, + private val ioDispatcher: CoroutineContext, + private val initialDelay: Duration = 10.seconds, + private val checkInterval: Duration = 6.hours, + private val install: (File) -> Unit = updater::installAndRestart, +) : AppUpdater { + + private val _state = MutableStateFlow(AppUpdateState.Idle) + override val state: StateFlow = _state.asStateFlow() + + private val checking = Mutex() + + @Volatile + private var available: UpdateInfo? = null + + @Volatile + private var updateJob: Job? = null + + fun start() { + scope.launch { + delay(initialDelay) + while (isActive) { + if (preferences.automaticChecks) check() + delay(checkInterval) + } + } + } + + override fun checkNow() { + scope.launch { check() } + } + + private suspend fun check() { + if (updateJob?.isActive == true) return + if (!checking.tryLock()) return + try { + // An offered release stays on screen while it is re-checked; Checking is only shown + // when there is nothing better to show. + if (available == null) _state.value = AppUpdateState.Checking + val result = withContext(ioDispatcher) { updater.checkForUpdates() } + _state.value = when (result) { + is UpdateResult.Available -> { + available = result.info + availableState(result.info) + } + + UpdateResult.NotAvailable -> { + available = null + AppUpdateState.UpToDate + } + + is UpdateResult.Error -> { + Napier.w("Update check failed", result.exception) + available?.let(::availableState) ?: AppUpdateState.Idle + } + } + } finally { + checking.unlock() + } + } + + private fun availableState(info: UpdateInfo) = AppUpdateState.Available( + version = info.version, + installable = installable, + dismissed = preferences.dismissedVersion == info.version, + ) + + override fun update() { + val info = available ?: return + if (!installable || updateJob?.isActive == true) return + updateJob = scope.launch { + try { + var downloaded: File? = null + updater.downloadUpdate(info).collect { progress -> + downloaded = progress.file ?: downloaded + _state.value = AppUpdateState.Downloading( + version = info.version, + fraction = (progress.percent / PERCENT_MAX).toFloat().coerceIn(0f, 1f), + ) + } + val installer = checkNotNull(downloaded) { "Download finished without a file" } + _state.value = AppUpdateState.Installing(info.version) + withContext(ioDispatcher) { install(installer) } + } catch (e: CancellationException) { + throw e + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + Napier.w("Update to ${info.version} failed", e) + _state.value = AppUpdateState.Failed(info.version, e.message ?: e::class.simpleName.orEmpty()) + } + } + } + + override fun dismiss() { + val current = _state.value as? AppUpdateState.Available ?: return + _state.value = current.copy(dismissed = true) + scope.launch { preferences.dismiss(current.version) } + } +} diff --git a/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/update/UpdatePreferences.kt b/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/update/UpdatePreferences.kt new file mode 100644 index 000000000..f51ea815c --- /dev/null +++ b/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/update/UpdatePreferences.kt @@ -0,0 +1,19 @@ +package com.darkrockstudios.apps.hammer.desktop.update + +import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettingsStore + +/** The two settings the updater reads, narrowed so the updater can be driven by an in-memory fake. */ +interface UpdatePreferences { + val automaticChecks: Boolean + val dismissedVersion: String? + suspend fun dismiss(version: String) +} + +class GlobalSettingsUpdatePreferences(private val store: GlobalSettingsStore) : UpdatePreferences { + override val automaticChecks: Boolean get() = store.globalSettings.automaticUpdateChecks + override val dismissedVersion: String? get() = store.globalSettings.dismissedUpdateVersion + + override suspend fun dismiss(version: String) { + store.updateSettings { it.copy(dismissedUpdateVersion = version) } + } +} diff --git a/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/update/UpdaterSupport.kt b/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/update/UpdaterSupport.kt new file mode 100644 index 000000000..69b66d647 --- /dev/null +++ b/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/update/UpdaterSupport.kt @@ -0,0 +1,69 @@ +package com.darkrockstudios.apps.hammer.desktop.update + +import com.darkrockstudios.apps.hammer.base.BuildMetadata +import com.darkrockstudios.apps.hammer.base.DistributionChannel +import com.darkrockstudios.apps.hammer.base.PackageFormat +import com.darkrockstudios.apps.hammer.common.data.appupdate.AppUpdater +import com.darkrockstudios.apps.hammer.common.data.appupdate.NoOpAppUpdater +import dev.nucleusframework.updater.NucleusUpdater +import kotlinx.coroutines.CoroutineScope +import kotlin.coroutines.CoroutineContext + +/** Only the direct-download vehicles update themselves; every store updates the app itself. */ +fun channelMayUpdate(channel: DistributionChannel): Boolean = + channel == DistributionChannel.GITHUB || channel == DistributionChannel.APPIMAGE + +/** + * The Nucleus executable type whose release asset this install updates from, or null when the + * format has no self-update path at all. The EXE is a wrapped MSI, so the MSI upgrades either. + * The DMG and PKG both leave a bundle that only a signed, notarized zip could swap, and the + * release has no such zip yet, so they are told about releases without downloading them. + */ +fun nucleusExecutableType(format: PackageFormat): String? = when (format) { + PackageFormat.MSI, PackageFormat.EXE -> "msi" + PackageFormat.DEB -> "deb" + PackageFormat.RPM -> "rpm" + PackageFormat.APPIMAGE -> "appimage" + PackageFormat.DMG, PackageFormat.PKG -> "dmg" + PackageFormat.NONE, PackageFormat.MSIX, PackageFormat.SNAP, PackageFormat.FLATPAK -> null +} + +/** Formats Nucleus installs and relaunches on its own; the rest are told and sent to the release page. */ +fun canInstallInPlace(format: PackageFormat): Boolean = when (format) { + PackageFormat.MSI, PackageFormat.EXE, PackageFormat.DEB, PackageFormat.RPM, PackageFormat.APPIMAGE -> true + PackageFormat.NONE, PackageFormat.DMG, PackageFormat.PKG, PackageFormat.MSIX, + PackageFormat.SNAP, PackageFormat.FLATPAK -> false +} + +/** + * Thar be dragons: this is the only place a [NucleusUpdater] may be constructed. Its constructor + * opens a `java.net.http.HttpClient`, whose NIO selector crashes under the MSIX sandbox (the + * same bug that moved Ktor to OkHttp in #938), so it must never be built on a store channel, + * not even to read a version. + */ +fun createAppUpdater( + channel: DistributionChannel, + format: PackageFormat, + devMode: Boolean, + preferences: UpdatePreferences, + appScope: CoroutineScope, + ioDispatcher: CoroutineContext, + currentVersion: String = BuildMetadata.APP_VERSION, +): AppUpdater { + if (!channelMayUpdate(channel) || devMode) return NoOpAppUpdater + val executableType = nucleusExecutableType(format) ?: return NoOpAppUpdater + + val updater = NucleusUpdater { + this.currentVersion = currentVersion + provider = HammerReleaseProvider() + this.executableType = executableType + differentialDownload = false + } + return NucleusAppUpdater( + updater = updater, + installable = canInstallInPlace(format), + preferences = preferences, + scope = appScope, + ioDispatcher = ioDispatcher, + ).also { it.start() } +} diff --git a/desktop/src/jvmMain/resources/aboutlibraries.json b/desktop/src/jvmMain/resources/aboutlibraries.json index c29a20dc8..dc40e814d 100644 --- a/desktop/src/jvmMain/resources/aboutlibraries.json +++ b/desktop/src/jvmMain/resources/aboutlibraries.json @@ -651,7 +651,7 @@ }, { "uniqueId": "com.darkrockstudios:platform-spellcheckerkt-desktop", - "artifactVersion": "1.3.2", + "artifactVersion": "1.4.0", "name": "PlatformSpellChecker", "description": "A Kotlin Multiplatform library providing spell checking functionality for Android, iOS, and Desktop platforms", "website": "https://github.com/Darkrock-Studios/PlatformSpellCheckerKt", diff --git a/desktop/src/jvmTest/kotlin/com/darkrockstudios/apps/hammer/desktop/update/FakeUpdatePreferences.kt b/desktop/src/jvmTest/kotlin/com/darkrockstudios/apps/hammer/desktop/update/FakeUpdatePreferences.kt new file mode 100644 index 000000000..b199fdaab --- /dev/null +++ b/desktop/src/jvmTest/kotlin/com/darkrockstudios/apps/hammer/desktop/update/FakeUpdatePreferences.kt @@ -0,0 +1,10 @@ +package com.darkrockstudios.apps.hammer.desktop.update + +class FakeUpdatePreferences( + override var automaticChecks: Boolean = true, + override var dismissedVersion: String? = null, +) : UpdatePreferences { + override suspend fun dismiss(version: String) { + dismissedVersion = version + } +} diff --git a/desktop/src/jvmTest/kotlin/com/darkrockstudios/apps/hammer/desktop/update/HammerReleaseProviderTest.kt b/desktop/src/jvmTest/kotlin/com/darkrockstudios/apps/hammer/desktop/update/HammerReleaseProviderTest.kt new file mode 100644 index 000000000..048e32f9d --- /dev/null +++ b/desktop/src/jvmTest/kotlin/com/darkrockstudios/apps/hammer/desktop/update/HammerReleaseProviderTest.kt @@ -0,0 +1,66 @@ +package com.darkrockstudios.apps.hammer.desktop.update + +import dev.nucleusframework.core.runtime.Platform +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class HammerReleaseProviderTest { + + @AfterEach + fun clearOverride() { + System.clearProperty(HammerReleaseProvider.BASE_URL_PROPERTY) + } + + @Test + fun `metadata lives on the latest release, one file per platform`() { + val provider = HammerReleaseProvider() + val base = "https://github.com/Darkrock-Studios/hammer-editor/releases/latest/download" + + assertEquals("$base/latest.yml", provider.getUpdateMetadataUrl("latest", Platform.Windows)) + assertEquals("$base/latest-mac.yml", provider.getUpdateMetadataUrl("latest", Platform.MacOS)) + assertEquals("$base/latest-linux.yml", provider.getUpdateMetadataUrl("latest", Platform.Linux)) + assertEquals("$base/latest.yml", provider.getUpdateMetadataUrl("latest", Platform.Unknown)) + } + + @Test + fun `downloads ignore the version and use the fixed asset name`() { + val provider = HammerReleaseProvider() + + assertEquals( + "https://github.com/Darkrock-Studios/hammer-editor/releases/latest/download/hammer.msi", + provider.getDownloadUrl("hammer.msi", "3.12.0"), + ) + } + + @Test + fun `a system property substitutes a local server`() { + System.setProperty(HammerReleaseProvider.BASE_URL_PROPERTY, "http://localhost:8000/") + val provider = HammerReleaseProvider() + + assertEquals("http://localhost:8000/latest.yml", provider.getUpdateMetadataUrl("latest", Platform.Windows)) + assertEquals("http://localhost:8000/hammer.deb", provider.getDownloadUrl("hammer.deb", "1.0.0")) + } + + @Test + fun `a blank override falls back to GitHub`() { + System.setProperty(HammerReleaseProvider.BASE_URL_PROPERTY, " ") + + assertEquals( + "https://github.com/Darkrock-Studios/hammer-editor/releases/latest/download", + HammerReleaseProvider.defaultBaseUrl(), + ) + } + + @Test + fun `the environment variable stands in when no property is set`() { + assertEquals( + "http://localhost:8000", + HammerReleaseProvider.defaultBaseUrl(property = null, environment = "http://localhost:8000/"), + ) + assertEquals( + "http://property:1", + HammerReleaseProvider.defaultBaseUrl(property = "http://property:1", environment = "http://env:2"), + ) + } +} diff --git a/desktop/src/jvmTest/kotlin/com/darkrockstudios/apps/hammer/desktop/update/NucleusAppUpdaterTest.kt b/desktop/src/jvmTest/kotlin/com/darkrockstudios/apps/hammer/desktop/update/NucleusAppUpdaterTest.kt new file mode 100644 index 000000000..d6f1a495a --- /dev/null +++ b/desktop/src/jvmTest/kotlin/com/darkrockstudios/apps/hammer/desktop/update/NucleusAppUpdaterTest.kt @@ -0,0 +1,273 @@ +package com.darkrockstudios.apps.hammer.desktop.update + +import com.darkrockstudios.apps.hammer.common.data.appupdate.AppUpdateState +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import dev.nucleusframework.updater.NucleusUpdater +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.io.File +import java.net.InetSocketAddress +import java.nio.file.Files +import java.security.MessageDigest +import java.util.Base64 +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +/** + * Drives the real Nucleus updater against a local HTTP server standing in for the GitHub + * release, so the check, the download and the digest check are the production code paths. + * Only the final hand-over to the installer is replaced, since the real one exits the process. + */ +class NucleusAppUpdaterTest { + + private val asset = ByteArray(60_000) { (it * 7 % 251).toByte() } + private var servedVersion = "9.9.9" + private var servedSha512 = sha512Base64(asset) + private var metadataStatus = 200 + + private lateinit var server: HttpServer + private lateinit var scope: CoroutineScope + private lateinit var cacheDir: File + + @BeforeEach + fun start() { + server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + server.createContext("/") { exchange -> + val path = exchange.requestURI.path + when { + path.endsWith(".yml") && metadataStatus != 200 -> respond(exchange, metadataStatus, ByteArray(0)) + path.endsWith(".yml") -> respond(exchange, 200, metadata().toByteArray()) + path.endsWith("/hammer.msi") -> respond(exchange, 200, asset) + else -> respond(exchange, 404, ByteArray(0)) + } + } + server.start() + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + cacheDir = Files.createTempDirectory("hammer-update-test").toFile() + } + + @AfterEach + fun stop() { + scope.cancel() + server.stop(0) + cacheDir.deleteRecursively() + } + + @Test + fun `a newer release is reported as available`() = runBlocking { + val updater = newUpdater() + + updater.checkNow() + + val available = updater.awaitState() + assertEquals(AppUpdateState.Available("9.9.9", installable = true, dismissed = false), available) + } + + @Test + fun `the installed version is up to date`() = runBlocking { + servedVersion = "1.0.0" + val updater = newUpdater() + + updater.checkNow() + + assertEquals(AppUpdateState.UpToDate, updater.awaitState()) + } + + @Test + fun `a failed check is swallowed`() = runBlocking { + metadataStatus = 503 + val updater = newUpdater() + val seen = updater.recordStates() + + updater.checkNow() + + await { seen.contains(AppUpdateState.Checking) && seen.last() == AppUpdateState.Idle } + assertFalse(seen.any { it is AppUpdateState.Failed }) + } + + @Test + fun `a release this install cannot apply is announced but not installable`() = runBlocking { + val updater = newUpdater(installable = false) + + updater.checkNow() + val available = updater.awaitState() + assertFalse(available.installable) + + updater.update() + delay(200.milliseconds) + assertEquals(available, updater.state.value) + } + + @Test + fun `a version the user dismissed stays dismissed`() = runBlocking { + val updater = newUpdater(preferences = FakeUpdatePreferences(dismissedVersion = "9.9.9")) + + updater.checkNow() + + assertTrue(updater.awaitState().dismissed) + } + + @Test + fun `dismiss hides the version and records it`() = runBlocking { + val preferences = FakeUpdatePreferences() + val updater = newUpdater(preferences = preferences) + updater.checkNow() + updater.awaitState() + + updater.dismiss() + + assertTrue((updater.state.value as AppUpdateState.Available).dismissed) + await { preferences.dismissedVersion == "9.9.9" } + } + + @Test + fun `a re-check keeps the offered release on screen`() = runBlocking { + val updater = newUpdater() + val seen = updater.recordStates() + updater.checkNow() + val offered = updater.awaitState() + val before = seen.size + + metadataStatus = 503 + updater.checkNow() + delay(500.milliseconds) + + assertEquals(offered, updater.state.value) + assertFalse(seen.drop(before).contains(AppUpdateState.Checking)) + } + + @Test + fun `update downloads, verifies, and hands the installer over`() = runBlocking { + val installed = CompletableDeferred() + val updater = newUpdater(install = { file -> installed.complete(file) }) + val seen = updater.recordStates() + updater.checkNow() + updater.awaitState() + + updater.update() + + updater.awaitState() + val file = withTimeout(TIMEOUT) { installed.await() } + assertTrue(asset.contentEquals(file.readBytes())) + assertEquals("hammer.msi", file.name) + assertTrue(seen.any { it is AppUpdateState.Downloading && it.fraction == 1f }) + // The real installer script removes its staging directory; the stand-in has to. + file.parentFile.deleteRecursively() + assertFalse(file.exists()) + } + + @Test + fun `a corrupt download fails instead of installing`() = runBlocking { + servedSha512 = sha512Base64(ByteArray(10)) + val installed = AtomicBoolean(false) + val updater = newUpdater(install = { installed.set(true) }) + updater.checkNow() + updater.awaitState() + + updater.update() + + val failed = updater.awaitState() + assertEquals("9.9.9", failed.version) + assertFalse(installed.get()) + } + + @Test + fun `automatic checks obey the preference`() = runBlocking { + val preferences = FakeUpdatePreferences(automaticChecks = false) + val updater = newUpdater( + preferences = preferences, + initialDelay = Duration.ZERO, + checkInterval = 50.milliseconds, + ) + val seen = updater.recordStates() + + updater.start() + delay(400.milliseconds) + assertFalse(seen.contains(AppUpdateState.Checking)) + assertNull(updater.state.value as? AppUpdateState.Available) + + preferences.automaticChecks = true + assertEquals("9.9.9", updater.awaitState().version) + } + + private fun newUpdater( + installable: Boolean = true, + preferences: FakeUpdatePreferences = FakeUpdatePreferences(), + initialDelay: Duration = 10.seconds, + checkInterval: Duration = 6.hours, + install: (File) -> Unit = {}, + ): NucleusAppUpdater { + val nucleus = NucleusUpdater { + currentVersion = "1.0.0" + provider = HammerReleaseProvider("http://127.0.0.1:${server.address.port}") + executableType = "msi" + differentialDownload = false + cacheDir = this@NucleusAppUpdaterTest.cacheDir + } + return NucleusAppUpdater( + updater = nucleus, + installable = installable, + preferences = preferences, + scope = scope, + ioDispatcher = Dispatchers.IO, + initialDelay = initialDelay, + checkInterval = checkInterval, + install = install, + ) + } + + private fun metadata(): String = listOf( + "version: $servedVersion", + "releaseDate: 2026-10-20T00:00:00Z", + "files:", + " - url: hammer.msi", + " sha512: $servedSha512", + " size: ${asset.size}", + ).joinToString("\n") + + private fun NucleusAppUpdater.recordStates(): List { + val seen = java.util.concurrent.CopyOnWriteArrayList() + scope.launch { state.collect { seen += it } } + return seen + } + + private suspend inline fun NucleusAppUpdater.awaitState(): T = + withTimeout(TIMEOUT) { state.first { it is T } as T } + + private suspend fun await(condition: () -> Boolean) { + withTimeout(TIMEOUT) { + while (!condition()) delay(20.milliseconds) + } + } + + private fun respond(exchange: HttpExchange, status: Int, body: ByteArray) { + exchange.sendResponseHeaders(status, if (body.isEmpty()) -1L else body.size.toLong()) + exchange.responseBody.use { it.write(body) } + } + + private fun sha512Base64(bytes: ByteArray): String = + Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-512").digest(bytes)) + + private companion object { + val TIMEOUT = 20.seconds + } +} diff --git a/desktop/src/jvmTest/kotlin/com/darkrockstudios/apps/hammer/desktop/update/UpdaterSupportTest.kt b/desktop/src/jvmTest/kotlin/com/darkrockstudios/apps/hammer/desktop/update/UpdaterSupportTest.kt new file mode 100644 index 000000000..e20a94e75 --- /dev/null +++ b/desktop/src/jvmTest/kotlin/com/darkrockstudios/apps/hammer/desktop/update/UpdaterSupportTest.kt @@ -0,0 +1,85 @@ +package com.darkrockstudios.apps.hammer.desktop.update + +import com.darkrockstudios.apps.hammer.base.DistributionChannel +import com.darkrockstudios.apps.hammer.base.PackageFormat +import com.darkrockstudios.apps.hammer.common.data.appupdate.NoOpAppUpdater +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class UpdaterSupportTest { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + @AfterEach + fun tearDown() { + scope.cancel() + } + + @Test + fun `only the direct download channels may update`() { + val allowed = DistributionChannel.entries.filter(::channelMayUpdate) + assertEquals(listOf(DistributionChannel.GITHUB, DistributionChannel.APPIMAGE), allowed) + } + + @Test + fun `every format maps to the asset it updates from`() { + assertEquals("msi", nucleusExecutableType(PackageFormat.MSI)) + assertEquals("msi", nucleusExecutableType(PackageFormat.EXE)) + assertEquals("deb", nucleusExecutableType(PackageFormat.DEB)) + assertEquals("rpm", nucleusExecutableType(PackageFormat.RPM)) + assertEquals("appimage", nucleusExecutableType(PackageFormat.APPIMAGE)) + assertEquals("dmg", nucleusExecutableType(PackageFormat.DMG)) + assertEquals("dmg", nucleusExecutableType(PackageFormat.PKG)) + for (format in listOf(PackageFormat.NONE, PackageFormat.MSIX, PackageFormat.SNAP, PackageFormat.FLATPAK)) { + assertNull(nucleusExecutableType(format), format.token) + } + } + + @Test + fun `macOS installs are announced but not installed in place`() { + assertTrue(canInstallInPlace(PackageFormat.MSI)) + assertTrue(canInstallInPlace(PackageFormat.EXE)) + assertTrue(canInstallInPlace(PackageFormat.DEB)) + assertTrue(canInstallInPlace(PackageFormat.RPM)) + assertTrue(canInstallInPlace(PackageFormat.APPIMAGE)) + assertFalse(canInstallInPlace(PackageFormat.DMG)) + assertFalse(canInstallInPlace(PackageFormat.PKG)) + } + + @Test + fun `store channels, dev runs, and unpackaged builds get the no-op updater`() { + assertSame(NoOpAppUpdater, create(DistributionChannel.MICROSOFT_STORE, PackageFormat.MSIX)) + assertSame(NoOpAppUpdater, create(DistributionChannel.MAC_APP_STORE, PackageFormat.PKG)) + assertSame(NoOpAppUpdater, create(DistributionChannel.SNAP, PackageFormat.SNAP)) + assertSame(NoOpAppUpdater, create(DistributionChannel.DEV, PackageFormat.NONE)) + assertSame(NoOpAppUpdater, create(DistributionChannel.GITHUB, PackageFormat.NONE)) + assertSame(NoOpAppUpdater, create(DistributionChannel.GITHUB, PackageFormat.MSI, devMode = true)) + } + + @Test + fun `the GitHub and AppImage builds get a real updater`() { + assertTrue(create(DistributionChannel.GITHUB, PackageFormat.MSI) is NucleusAppUpdater) + assertTrue(create(DistributionChannel.GITHUB, PackageFormat.DMG) is NucleusAppUpdater) + assertTrue(create(DistributionChannel.APPIMAGE, PackageFormat.APPIMAGE) is NucleusAppUpdater) + } + + private fun create(channel: DistributionChannel, format: PackageFormat, devMode: Boolean = false) = + createAppUpdater( + channel = channel, + format = format, + devMode = devMode, + preferences = FakeUpdatePreferences(), + appScope = scope, + ioDispatcher = Dispatchers.IO, + currentVersion = "1.0.0", + ) +} diff --git a/fastlane/metadata/android/de-DE/full_description.txt b/fastlane/metadata/android/de-DE/full_description.txt index 86866e0b7..54742f5fb 100644 --- a/fastlane/metadata/android/de-DE/full_description.txt +++ b/fastlane/metadata/android/de-DE/full_description.txt @@ -18,4 +18,8 @@ Deine Daten können zwischen Geräten synchronisiert werden, sodass du von über Synchronisierung: optional oder selbstbetrieben -Synchronisierung ist vollständig optional, du kannst den Client installieren und auf einem Gerät verwenden, oder du kannst den Server installieren und deine Daten zwischen den Geräten synchronisieren. \ No newline at end of file +Synchronisierung ist vollständig optional, du kannst den Client installieren und auf einem Gerät verwenden, oder du kannst den Server installieren und deine Daten zwischen den Geräten synchronisieren. + +Wear OS companion + +Capture notes and story ideas from your wrist with the Wear OS app, then pick them up on your other devices. Dictate or type a quick note and file it under a project. Add the Capture tile to start a note or story idea in a single tap, or put the Hammer complication on your watch face to open a new note. The watch app syncs through your Hammer sync server. \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/1312010000.txt b/fastlane/metadata/android/en-US/changelogs/1312010000.txt new file mode 100644 index 000000000..4811653ff --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/1312010000.txt @@ -0,0 +1,15 @@ +New +- Reader Mode: read your story as prose, a chapter at a time +- Export options: chapter numbering, font, blank lines +- OLED black theme +- Turkish translation +- Text editor 3.0 + +Fixes +- Unsaved scene edits could be lost when the app closed in the background +- Restoring a backup could make the project disappear +- Discarded scenes were still saved and uploaded by sync +- PDF export cut off lines at page breaks + +Full changelog: +https://github.com/Darkrock-Studios/hammer-editor/releases/tag/v3.12.0 \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/312010000.txt b/fastlane/metadata/android/en-US/changelogs/312010000.txt new file mode 100644 index 000000000..4811653ff --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/312010000.txt @@ -0,0 +1,15 @@ +New +- Reader Mode: read your story as prose, a chapter at a time +- Export options: chapter numbering, font, blank lines +- OLED black theme +- Turkish translation +- Text editor 3.0 + +Fixes +- Unsaved scene edits could be lost when the app closed in the background +- Restoring a backup could make the project disappear +- Discarded scenes were still saved and uploaded by sync +- PDF export cut off lines at page breaks + +Full changelog: +https://github.com/Darkrock-Studios/hammer-editor/releases/tag/v3.12.0 \ No newline at end of file diff --git a/fastlane/metadata/android/es-ES/full_description.txt b/fastlane/metadata/android/es-ES/full_description.txt index 38256b092..344bcddc9 100644 --- a/fastlane/metadata/android/es-ES/full_description.txt +++ b/fastlane/metadata/android/es-ES/full_description.txt @@ -18,4 +18,8 @@ Your data can be synchronized between devices allowing you to work on your story Optional and Self hosted Syncing -Syncing is entirely optional, you can install the client and use it on one device, or you can install the server and sync your data between devices. \ No newline at end of file +Syncing is entirely optional, you can install the client and use it on one device, or you can install the server and sync your data between devices. + +Wear OS companion + +Capture notes and story ideas from your wrist with the Wear OS app, then pick them up on your other devices. Dictate or type a quick note and file it under a project. Add the Capture tile to start a note or story idea in a single tap, or put the Hammer complication on your watch face to open a new note. The watch app syncs through your Hammer sync server. \ No newline at end of file diff --git a/fastlane/metadata/android/fr-FR/full_description.txt b/fastlane/metadata/android/fr-FR/full_description.txt index fff9b8f79..7594d7d5d 100644 --- a/fastlane/metadata/android/fr-FR/full_description.txt +++ b/fastlane/metadata/android/fr-FR/full_description.txt @@ -1,4 +1,4 @@ -Rejoignez notre Discord pour signaler les bugs ! https://discord.gg/GTmgjZcupk +Rejoignez notre serveur Discord pour signaler les bugs ! https://discord.gg/GTmgjZcupk Multiplateforme @@ -18,4 +18,8 @@ Vos données peuvent être synchronisées entre plusieurs appareils vous permett Synchronisation optionnelle et auto-hébergée -La synchronisation est entièrement optionnelle, vous pouvez installer le client et l'utiliser sur votre appareil sans aucune action supplémentaire, ou vous pouvez installer un serveur pour la synchronisation entre vos appareils. \ No newline at end of file +La synchronisation est entièrement optionnelle, vous pouvez installer le client et l'utiliser sur votre appareil sans aucune action supplémentaire, ou vous pouvez installer un serveur pour la synchronisation entre vos appareils. + +Wear OS compagnon + +Capturer des notes et des idées pour vos histoires depuis votre poignet sur l'application Wear OS, puis récupérez-les sur vos autres appareils. Dictez ou écrivez une note rapide et assignez-la à l'un de vos projets Ajoutez la tuile « Capture » pour commencer une note ou une idée d'histoire d'une simple pression, ou placez la complication « Hammer » sur votre cadran pour ouvrir une nouvelle note. L'application sur votre montre se synchronise à travers votre serveur de synchronisation Hammer. \ No newline at end of file diff --git a/fastlane/metadata/android/it-IT/full_description.txt b/fastlane/metadata/android/it-IT/full_description.txt index 037d3a107..bb0d990f0 100644 --- a/fastlane/metadata/android/it-IT/full_description.txt +++ b/fastlane/metadata/android/it-IT/full_description.txt @@ -1,21 +1,25 @@ -Unisciti al nostro server Discord per segnalare eventuali problemi! https://discord.gg/GTmgjZcupk +Unisciti al nostro Discord per segnalare eventuali problemi! https://discord.gg/GTmgjZcupk Multipiattaforma -Questa app è ovunque tu sia. Sul tuo telefono, tablet, computer fisso o portatile, puoi installare ed eseguire questo programma, non semplicemente un sito web preconfigurato, ma utilizzando tecnologie native lato client per offrire la migliore esperienza possibile. +Questa app ti segue ovunque tu sia. Che tu usi telefono, tablet, desktop o portatile, il programma può essere installato ed eseguito; non si tratta di una semplice "pagina web in un contenitore", bensì di un'applicazione che sfrutta tecnologie native lato client per offrire la migliore esperienza possibile. -Prima di tutto offline +Approccio "Offline-first" -Ero frustrato dalla maggior parte dei software di scrittura che trovavo, perché utilizzavano tecnologie web (ovvero: pagine web preconfigurate) che sembrano sempre dare problemi se utilizzate offline per lunghi periodi. Hammer è stato progettato fin dall'inizio per funzionare interamente in locale, senza mai bisogno di connessione a Internet. +Ero insoddisfatto della maggior parte dei software di scrittura creativa in circolazione, poiché basati su tecnologie web (ovvero "pagine web in un contenitore") che tendevano a dare problemi durante l'uso offline prolungato. Hammer è stato progettato fin dalle fondamenta per funzionare interamente in locale: non richiede mai una connessione a Internet. Dati trasparenti -I tuoi dati sono tuoi. Non sono archiviati nel cloud o in qualche database opaco. Sono memorizzati in semplici file leggibili, utilizzando cartelle e file solo per definire la struttura del progetto. Puoi aprire il file manager del tuo sistema operativo e dare un'occhiata tu stesso. Se questo programma non fosse più disponibile, potresti comunque interagire facilmente con i tuoi dati. +I tuoi dati sono tuoi. Non vengono archiviati nel cloud o in database opachi. Vengono salvati in file semplici e leggibili dall'utente, utilizzando una struttura di file e cartelle per organizzare il progetto. Puoi controllare tu stesso la struttura tramite il file manager del tuo sistema operativo. Anche se il programma dovesse sparire oggi, potresti comunque accedere facilmente ai tuoi dati. Sincronizzazione intelligente tra dispositivi -I tuoi dati possono essere sincronizzati tra i dispositivi, permettendoti di lavorare al tuo progetto ovunque ti trovi, senza il timore che una modifica apportata su un dispositivo sovrascriva una modifica apportata su un altro. +I dati possono essere sincronizzati tra i vari dispositivi, permettendoti di lavorare alla tua storia ovunque ti trovi, senza il timore che una modifica su un dispositivo sovrascriva quella effettuata su un altro. Sincronizzazione opzionale e self-hosted -La sincronizzazione è completamente opzionale: è possibile installare il client e utilizzarlo su un dispositivo, oppure installare il server e sincronizzare i dati tra i dispositivi. +La sincronizzazione è del tutto facoltativa: puoi installare il client e usarlo su un singolo dispositivo, oppure installare il server per sincronizzare i dati tra più dispositivi. + +Companion per Wear OS + +Cattura appunti e idee per la storia direttamente dal polso con l'app per Wear OS, per poi riprenderli sugli altri dispositivi. Dettare o digitare una nota veloce e associarla a un progetto è semplicissimo. Aggiungi il riquadro (tile) "Capture" per avviare una nota o un'idea con un solo tocco, oppure inserisci la complicazione Hammer sul quadrante dell'orologio per aprire subito una nuova nota. L'app per smartwatch si sincronizza tramite il tuo server Hammer dedicato. \ No newline at end of file diff --git a/fastlane/metadata/android/pt-BR/full_description.txt b/fastlane/metadata/android/pt-BR/full_description.txt index f3cef1b81..c29c65067 100644 --- a/fastlane/metadata/android/pt-BR/full_description.txt +++ b/fastlane/metadata/android/pt-BR/full_description.txt @@ -18,4 +18,8 @@ Seus dados podem ser sincronizados entre aparelhos, permitindo que você trabalh Sincronização Opcional e Auto-hospedada -Você pode instalar o cliente e usá-lo em um dispositivo, ou instalar o servidor e sincronizar seus dados com outros dispositivos. \ No newline at end of file +Você pode instalar o cliente e usá-lo em um dispositivo, ou instalar o servidor e sincronizar seus dados com outros dispositivos. + +Wear OS companion + +Capture notes and story ideas from your wrist with the Wear OS app, then pick them up on your other devices. Dictate or type a quick note and file it under a project. Add the Capture tile to start a note or story idea in a single tap, or put the Hammer complication on your watch face to open a new note. The watch app syncs through your Hammer sync server. \ No newline at end of file diff --git a/fastlane/metadata/android/tr-TR/full_description.txt b/fastlane/metadata/android/tr-TR/full_description.txt new file mode 100644 index 000000000..e62b215b4 --- /dev/null +++ b/fastlane/metadata/android/tr-TR/full_description.txt @@ -0,0 +1,25 @@ +Sorunları bildirmek için lütfen Discord sunucumuza katılın! https://discord.gg/GTmgjZcupk + +Çoklu Platform Desteği + +Bu uygulama, siz neredeyseniz orada. Telefonunuz, tabletiniz, masaüstü bilgisayarınız, dizüstü bilgisayarınız fark etmez, bu programı yükleyebilir ve çalıştırabilirsiniz. Üstelik bu uygulama kutuya konmuş bir web sitesinden ibaret değil, size mümkün olan en iyi deneyimi sunmak için yerel, istemci taraflı teknolojiler kullanılarak tasarlandı. + +Önce Çevrimdışı + +Bulabildiğim hikâye yazma yazılımlarının çoğu canımı sıkıyordu çünkü hepsinin web teknolojilerini kullandığını (başka bir deyişle kutuya konmuş birer web sitesinden ibaret olduklarını) gördüm. Bu programlar uzun süreli çevrimdışı kullanımda her zaman sorun çıkarıyorlardı. Hammer, sıfırdan tamamen yerel olarak çalışacak, asla internet bağlantısı gerektirmeyecek şekilde tasarlandı. + +Şeffaf Veriler + +Verileriniz size aittir. Yazdıklarınız bulutta veya göremediğiniz bir veri tabanında saklanmaz. Veriler basit, insanlar tarafından kolayca okunabilecek ve sadece proje yapısını tanımlamak için kullanılan dosya ve klasörler kullanılarak saklanır. İşletim sisteminizin dosya yöneticisini açarak dosyalarınızı bizzat görebilirsiniz. Bu program bugün yok olsa bile verilerinizle kolaylıkla etkileşime girmeye devam edebilirsiniz. + +Cihazlarınız arasında Akıllı Senkronizasyon + +Verileriniz cihazlar arasında senkronize edilebilir, böylece nerede olursanız olun hikâyeleriniz üzerinde çalışabilirsiniz. Cihazların birinde yaptığınız bir değişikliğin başka bir cihazda yaptığınız başka bir değişikilği silmesinden endişe etmenize gerek yoktur. + +İsteğe Bağlı, Kendi Sunucunuzda Barındırabileceğiniz Senkronizasyon + +Senkronizasyon tamamen isteğe bağlıdır. İsterseniz istemciyi tek bir cihaza kurup kullanabilir veya sunucuya yükleyip verilerinizi cihazlar arasında senkronize edebilirsiniz. + +Wear OS Yardımcınız + +Wear OS uygulamasıyla notlarınızı ve hikâye fikirlerinizi kaydedin, ardından bunlara diğer cihazlarınızdan erişin. İster klavyenizi ister sesle yazma özelliğini kullanarak hızlıca bir not alın ve bir projeye ekleyin. Yakala kartını ekleyerek tek dokunuşla bir not veya hikâye fikri başlatabilir, Hammer komplikasyonunu saat yüzünüze yerleştirerek yeni bir not açmak için kullanabilirsiniz. Saat uygulaması, verileri Hammer senkronizasyon sunucunuz üzerinden senkronize eder. \ No newline at end of file diff --git a/fastlane/metadata/android/tr-TR/short_description.txt b/fastlane/metadata/android/tr-TR/short_description.txt new file mode 100644 index 000000000..b943c1e3f --- /dev/null +++ b/fastlane/metadata/android/tr-TR/short_description.txt @@ -0,0 +1 @@ +Yazacağınız hikâyeler için basit bir araç. \ No newline at end of file diff --git a/fastlane/metadata/android/tr-TR/title.txt b/fastlane/metadata/android/tr-TR/title.txt new file mode 100644 index 000000000..4690fe86c --- /dev/null +++ b/fastlane/metadata/android/tr-TR/title.txt @@ -0,0 +1 @@ +Hammer \ No newline at end of file diff --git a/fastlane/metadata/android/uk/full_description.txt b/fastlane/metadata/android/uk/full_description.txt index ba77097fb..99ec483b0 100644 --- a/fastlane/metadata/android/uk/full_description.txt +++ b/fastlane/metadata/android/uk/full_description.txt @@ -18,4 +18,8 @@ Your data can be synchronized between devices allowing you to work on your story Optional and Self hosted Syncing -Syncing is entirely optional, you can install the client and use it on one device, or you can install the server and sync your data between devices. \ No newline at end of file +Syncing is entirely optional, you can install the client and use it on one device, or you can install the server and sync your data between devices. + +Wear OS companion + +Capture notes and story ideas from your wrist with the Wear OS app, then pick them up on your other devices. Dictate or type a quick note and file it under a project. Add the Capture tile to start a note or story idea in a single tap, or put the Hammer complication on your watch face to open a new note. The watch app syncs through your Hammer sync server. \ No newline at end of file diff --git a/fastlane/metadata/ios/en-US/release_notes.txt b/fastlane/metadata/ios/en-US/release_notes.txt index 5f636be27..16b50bd25 100644 --- a/fastlane/metadata/ios/en-US/release_notes.txt +++ b/fastlane/metadata/ios/en-US/release_notes.txt @@ -1,10 +1,13 @@ -Improvements -- Word counts no longer count Markdown markers such as horizontal rules, heading marks, bullets and blockquote markers as words -- General stability and performance improvements +New +- Reader Mode: read your story as prose, a chapter at a time +- Export options: chapter numbering, font, blank lines +- OLED black theme +- Turkish translation +- Text editor 3.0: Huge improvements in input and performance Fixes -- Numpad navigation keys and numpad Enter now work in the editor -- Keyboard input in the editor is more reliable -- Spelling underlines stay on the right words while you type -- The text formatting you choose is kept while you keep writing -- Fixed crashes and misplaced cursors in the editor \ No newline at end of file +- Unsaved scene edits could be lost when the app closed in the background +- Restoring a backup could make the project disappear +- Discarded scenes were still saved and uploaded by sync +- PDF export cut off lines at page breaks +- Crash on launch when the saved login could not be read \ No newline at end of file diff --git a/fastlane/metadata/osx/en-US/release_notes.txt b/fastlane/metadata/osx/en-US/release_notes.txt index 5f636be27..16b50bd25 100644 --- a/fastlane/metadata/osx/en-US/release_notes.txt +++ b/fastlane/metadata/osx/en-US/release_notes.txt @@ -1,10 +1,13 @@ -Improvements -- Word counts no longer count Markdown markers such as horizontal rules, heading marks, bullets and blockquote markers as words -- General stability and performance improvements +New +- Reader Mode: read your story as prose, a chapter at a time +- Export options: chapter numbering, font, blank lines +- OLED black theme +- Turkish translation +- Text editor 3.0: Huge improvements in input and performance Fixes -- Numpad navigation keys and numpad Enter now work in the editor -- Keyboard input in the editor is more reliable -- Spelling underlines stay on the right words while you type -- The text formatting you choose is kept while you keep writing -- Fixed crashes and misplaced cursors in the editor \ No newline at end of file +- Unsaved scene edits could be lost when the app closed in the background +- Restoring a backup could make the project disappear +- Discarded scenes were still saved and uploaded by sync +- PDF export cut off lines at page breaks +- Crash on launch when the saved login could not be read \ No newline at end of file diff --git a/flatpak/studio.darkrock.hammer.metainfo.xml b/flatpak/studio.darkrock.hammer.metainfo.xml index e46a73268..7fd210d07 100644 --- a/flatpak/studio.darkrock.hammer.metainfo.xml +++ b/flatpak/studio.darkrock.hammer.metainfo.xml @@ -32,6 +32,36 @@ + + +

New features

+

- Reader Mode: read your story as prose, one chapter at a time, instead of through the scene editor. Open it from the Read button under the scene list, the book icon in the scene editor, or the editor menu. The scene list becomes a table of contents, and the reader has its own font size

+

- Export: three new options in the Export Story dialog. Number chapter titles, pick a font (Word, RTF and EPUB), and choose whether to keep blank lines between paragraphs

+

- Android and iOS: an OLED theme, the dark theme on a pure black background

+

- Desktop (Windows and Linux installs from GitHub): Hammer tells you when a newer release is out and can update itself from the project list or the About screen. macOS DMG installs are told and sent to the release page. Automatic checks can be turned off in Settings

+

- Text editor 3.0: Huge improvements in input and performance

+

- Text editor: change the text size with Ctrl/Cmd and Plus, Minus or 0, or from the format bar's overflow menu

+

- Text editor: Delete in the right-click menu

+

- Turkish translation

+

- Syncing with unsaved work now asks Save, Discard or Cancel first, where before it silently saved every unsaved scene

+

- Open notes, timeline events, encyclopedia entries and the scene title, outline and notes refresh when a sync or conflict resolution changes them. An edit in progress keeps its draft

+

- The Backup Manager lists backups of projects that are no longer on disk, so a deleted project can be restored

+

- Exports no longer number chapter titles unless you turn the new option on. Word, RTF, PDF and Markdown used to number them always

+

- Text editor: the heading button writes real Markdown headings, and Hammer's shortcuts land on the same keys on non-QWERTY layouts

+

- Android: a launch splash screen on every supported version

+

- Translations and dependencies updated

+

Fixes

+

- Android: unsaved scene edits could be lost when the app was closed in the background. They now come back as unsaved scenes the next time the project opens [#983]

+

- Export: unsaved scene edits were left out of the exported file [#983]

+

- Restoring a backup from the Backup Manager made the project disappear from the project list

+

- Choosing Discard on close with automatic sync on still saved and uploaded the discarded scenes

+

- PDF export: long paragraphs ran off the bottom of the page or left large blank gaps

+

- Android: a crash on launch when the saved login could not be decrypted, for example after restoring the device from a backup. You are asked to sign in again

+

- iOS: a crash when tapping into a text editor

+

- Server URL, number, tag and word-list fields no longer autocorrect or capitalize, which put a space after each "." in a server URL

+

- Encyclopedia references no longer match a name inside a longer word with accented or non-Latin letters ("Ana" in "Anaïs")

+
+

New features

diff --git a/flatpak/studio.darkrock.hammer.yaml b/flatpak/studio.darkrock.hammer.yaml index adc6a5b69..0aadb0eef 100644 --- a/flatpak/studio.darkrock.hammer.yaml +++ b/flatpak/studio.darkrock.hammer.yaml @@ -32,7 +32,7 @@ modules: export PATH=$JAVA_HOME/bin:$PATH java -version chmod +x gradlew - ./gradlew :desktop:createDistributable -Pchannel=flathub -x test --no-daemon + ./gradlew :desktop:createDistributable -Pchannel=flathub -Pformat=flatpak -x test --no-daemon # Install the app (outputBaseDir is set to 'installers' in build.gradle.kts) - mkdir -p /app/lib/hammer - cp -r desktop/build/installers/main/app/hammer/* /app/lib/hammer/ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 042565af2..767a363af 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,17 +1,17 @@ [versions] # App Versions -app = "3.11.0" +app = "3.12.0" data_version = "1.0" # Kotlin flatlaf = "3.7.2" jvm = "21" kache = "2.1.1" -kotlin = "2.4.10" +kotlin = "2.4.20" serialization = "1.11.0" # Android -android_gradle_plugin = "9.2.1" +android_gradle_plugin = "9.4.1" android_sdk_compile = "37" android_sdk_target = "37" android_sdk_min = "28" @@ -35,7 +35,7 @@ asm = "9.10.1" clikt = "5.1.0" kotlinx_atomicfu = "0.33.0" kotlinx_collections_immutable = "0.5.2" -markdown = "0.7.14" +markdown = "0.7.16" rtfParserKmp = "0.2.2" epub4kmp = "0.3.0" pdfkmp = "1.3.0" @@ -52,7 +52,7 @@ datetime = "0.8.0" napier = "2.7.1" jetbrains_compose = "1.12.1" compose_compiler_report_generator = "1.5.0" -jetbrains_kover = "0.9.9" +jetbrains_kover = "0.9.11" moko_permissions = "0.20.1" @@ -65,7 +65,7 @@ coil = "3.6.3" kmpalette = "3.1.0" koin_bom = "4.2.2" -koin_plugin = "1.1.0" +koin_plugin = "1.2.1" ktor = "3.6.0" ktor_plugin = "3.6.0" @@ -98,18 +98,18 @@ hikaricp = "7.1.0" embedded_postgres = "2.2.2" embedded_postgres_binaries = "18.6.0" testcontainers = "2.0.5" -buildconfig = "6.1.1" +buildconfig = "6.1.2" resources = "1.1.0" aboutlibraries = "15.2.0" -logback_classic = "1.6.4" +logback_classic = "1.6.5" multiplatform_settings = "1.3.0" korge-core = "6.0.0" bouncycastle = "1.86" kotlinx-io-okio = "0.9.1" -compose-texteditor = "2.6.0" +compose-texteditor = "3.1.0" kotlin-multiplatform-diff = "1.4.0" -platform-spellcheckerkt = "1.3.2" +platform-spellcheckerkt = "1.4.0" nucleus = "2.5.18" colorpicker-compose = "1.3.0" material-kolor = "5.0.1" @@ -344,6 +344,7 @@ moko-permissions-storage = { group = "dev.icerock.moko", name = "permissions-sto moko-permissions-compose = { group = "dev.icerock.moko", name = "permissions-compose", version.ref = "moko_permissions" } compose-texteditor = { group = "com.darkrockstudios", name = "composetexteditor", version.ref = "compose-texteditor" } +compose-texteditor-markdown = { group = "com.darkrockstudios", name = "composetexteditor-markdown", version.ref = "compose-texteditor" } compose-texteditor-find = { group = "com.darkrockstudios", name = "composetexteditor-find", version.ref = "compose-texteditor" } compose-texteditor-spellcheck = { group = "com.darkrockstudios", name = "composetexteditor-spellcheck", version.ref = "compose-texteditor" } @@ -359,6 +360,7 @@ nucleus_decorated_window_material3 = { module = "dev.nucleusframework:nucleus.de nucleus_launcher_windows = { module = "dev.nucleusframework:nucleus.launcher-windows", version.ref = "nucleus" } nucleus_launcher_linux = { module = "dev.nucleusframework:nucleus.launcher-linux", version.ref = "nucleus" } nucleus_launcher_macos = { module = "dev.nucleusframework:nucleus.launcher-macos", version.ref = "nucleus" } +nucleus_updater_runtime = { module = "dev.nucleusframework:nucleus.updater-runtime", version.ref = "nucleus" } [plugins] kotlin_multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } diff --git a/ios/ios/Info.plist b/ios/ios/Info.plist index 7c528b785..e9cdd6ff1 100644 --- a/ios/ios/Info.plist +++ b/ios/ios/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 3.11.0 + 3.12.0 CFBundleVersion 1 ITSAppUsesNonExemptEncryption diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/StoryKudosRoutes.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/StoryKudosRoutes.kt index f79344f8e..b241b7fa9 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/StoryKudosRoutes.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/StoryKudosRoutes.kt @@ -42,6 +42,7 @@ private val reactionIcons = mapOf( KudosKind.MOVED_ME to "fa-heart", KudosKind.MADE_ME_THINK to "fa-lightbulb", KudosKind.MADE_ME_LAUGH to "fa-face-laugh-beam", + KudosKind.SURPRISED_ME to "fa-face-surprise", KudosKind.PAGE_TURNER to "fa-book-open", ) diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/kudos/KudosKind.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/kudos/KudosKind.kt index 14eb56eb2..b21e31300 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/kudos/KudosKind.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/kudos/KudosKind.kt @@ -23,6 +23,7 @@ enum class KudosKind(val key: String, val group: KudosGroup) { MOVED_ME("moved_me", KudosGroup.REACTION), MADE_ME_THINK("made_me_think", KudosGroup.REACTION), MADE_ME_LAUGH("made_me_laugh", KudosGroup.REACTION), + SURPRISED_ME("surprised_me", KudosGroup.REACTION), PAGE_TURNER("page_turner", KudosGroup.REACTION); val messageKey: String get() = "kudos_kind_$key" diff --git a/server/src/main/resources/assets/css/authors.css b/server/src/main/resources/assets/css/authors.css index 6b6140cb8..228d009c6 100644 --- a/server/src/main/resources/assets/css/authors.css +++ b/server/src/main/resources/assets/css/authors.css @@ -314,15 +314,19 @@ .pagination-button:hover:not(.pagination-button--disabled) { border-color: var(--accent); color: var(--accent); + background: var(--paper-white); transform: translateY(-2px); box-shadow: var(--shadow-md); } -.pagination-button--disabled { +.pagination-button--disabled, +.pagination-button--disabled:hover { color: var(--neutral-400); border-color: var(--neutral-200); background: var(--neutral-50); cursor: not-allowed; + transform: none; + box-shadow: none; } .pagination-info { diff --git a/server/src/main/resources/assets/css/feed.css b/server/src/main/resources/assets/css/feed.css index 4e87834b7..b904b9e19 100644 --- a/server/src/main/resources/assets/css/feed.css +++ b/server/src/main/resources/assets/css/feed.css @@ -417,15 +417,19 @@ .pagination-button:hover:not(.pagination-button--disabled) { border-color: var(--accent); color: var(--accent); + background: var(--paper-white); transform: translateY(-2px); box-shadow: var(--shadow-md); } -.pagination-button--disabled { +.pagination-button--disabled, +.pagination-button--disabled:hover { color: var(--neutral-400); border-color: var(--neutral-200); background: var(--neutral-50); cursor: not-allowed; + transform: none; + box-shadow: none; } .pagination-info { diff --git a/server/src/main/resources/i18n/Messages_de.properties b/server/src/main/resources/i18n/Messages_de.properties index 9e9f46ef7..017ca3818 100644 --- a/server/src/main/resources/i18n/Messages_de.properties +++ b/server/src/main/resources/i18n/Messages_de.properties @@ -278,7 +278,7 @@ admin_pagination_of=von admin_nav_settings=Einstellungen admin_nav_allowedusers=Allowed Users admin_nav_users=Benutzer -admin_users_last_sync_never=Nie +admin_users_last_sync_never=Never # Admin - Email Settings admin_nav_email=E-Mail admin_email_title=E-Mail-Einstellungen @@ -376,7 +376,7 @@ admin_allowedusers_save_reason=Speichern admin_allowedusers_edit_expiry_title=Edit Expiry admin_allowedusers_expiry_label=Expires admin_allowedusers_expiry_help=Access is revoked automatically once the entry expires. -admin_allowedusers_expiry_never=Nie +admin_allowedusers_expiry_never=Never admin_allowedusers_expiry_7days=In 7 days admin_allowedusers_expiry_30days=In 30 days admin_allowedusers_expiry_90days=In 90 days @@ -428,6 +428,14 @@ story_info_title=Infos zur Geschichte story_readers_title=Leser story_readers_total_label=Leser insgesamt, seit Beginn story_readers_graph_caption=Leser pro Tag · letzte 30 Tage +story_kudos_title=Kudos +story_kudos_givers_label={0,choice,0\#readers left kudos|1\#reader left kudos|1