From c50df010915c82f4d75041cc2ea1b261f2d68b96 Mon Sep 17 00:00:00 2001 From: Sergey Drymchenko <2linreal@gmail.com> Date: Sun, 26 Jul 2026 16:45:40 +0300 Subject: [PATCH] T-30 iOS theming support --- ARCHITECTURE.md | 4 + docs/iOsNativeSdk.md | 36 ++- docs/recipes/ThemeCustomization.md | 164 ++++++++++++ editor-ios-sdk/api/editor-ios-sdk.klib.api | 82 ++++++ .../controller/CascadeEditorConfiguration.kt | 9 +- .../ios/controller/CascadeEditorController.kt | 30 +++ .../controller/CascadeEditorViewController.kt | 5 +- .../ios/theme/CascadeEditorColorsBridge.kt | 132 ++++++++++ .../theme/CascadeEditorColorsBridgeTest.kt | 139 ++++++++++ .../iosNativeSample/App/AppTheme.swift | 248 +++++++++++++++++- .../iosNativeSample/App/LandingView.swift | 4 +- .../iosNativeSample/App/NativeSampleApp.swift | 17 +- .../Editor/EditorScreenModel.swift | 12 +- .../Screens/CommentsScreen.swift | 25 +- .../Screens/CustomBlocksScreen.swift | 25 +- .../Screens/EditorDemoScreen.swift | 25 +- .../iosNativeSample/Shell/EditorChrome.swift | 8 +- 17 files changed, 900 insertions(+), 65 deletions(-) create mode 100644 docs/recipes/ThemeCustomization.md create mode 100644 editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/theme/CascadeEditorColorsBridge.kt create mode 100644 editor-ios-sdk/src/iosSimulatorArm64Test/kotlin/io/github/linreal/cascade/ios/theme/CascadeEditorColorsBridgeTest.kt diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7c02385..bf8f593 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -11,6 +11,8 @@ Block-based editor (Craft/Notion-like) for Compose Multiplatform. Unidirectional | Sample comments screen | `sample/src/commonMain/kotlin/io/github/linreal/cascade/screens/comments/CommentsScreen.kt`, `CommentComposer.kt`, `CommentsScreenModel.kt`, `CommentModel.kt` | `CommentsScreen()`, `CommentComposer()` (editor-as-chat-composer: focus-driven fade-in formatting bar via `rememberCascadeEditorToolbarController` + Send), `CommentsScreenModel` (`buildOwnComment()`/`resetComposer()`), `Comment` + `rememberCommentAnnotatedString()` (consumer-side `SpanStyle`→`AnnotatedString`) | | Main composable | `ui/CascadeEditor.kt` | `CascadeEditor(stateHolder, textStates, spanStates, registry, slashRegistry, slashCommand, ...)` | | Native iOS SDK technical context | `docs/iOsNativeSdk.md` | Swift controller facade, UIKit host, native custom blocks/slash commands, public API, integration constraints | +| Theme customization recipes | `docs/recipes/ThemeCustomization.md` | Agent-oriented Compose Multiplatform and native Swift tutorials for complete palettes and family-aware runtime appearance switching | +| Native iOS color bridge | `editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/theme/CascadeEditorColorsBridge.kt` | Swift-visible `CascadeEditorColors` complete ARGB palette; controller snapshot mapping into core colors | | Native iOS publication runbook | `docs/iOsPublication.md` | Version/tag contract, GitHub/SwiftPM setup, immutable release sequence, recovery | | Native iOS release packaging | `scripts/package-ios-sdk.sh`, `distribution/swift-package/` | Serializes memory-heavy native test/release linking, verifies the dynamic Release XCFramework, and emits one-root ZIP, SHA-256, and external Swift package files | | Native iOS clean consumer validation | `scripts/validate-ios-consumer.sh`, `iosConsumerSmokeTest/` | Packaging installs an ignored ZIP-derived framework for direct Xcode use; validation removes it in a temporary copy, then performs generic-device Release and simulator UI/resource tests using only the supplied packaged binary | @@ -361,6 +363,7 @@ All state changes go through `EditorAction.reduce(state) → newState`. | Theming / styling API — data models | Done | `CascadeEditorTheme`, `CascadeEditorColors`, `CascadeEditorTypography`, `LocalCascadeTheme`; light/dark presets | | Theming / styling API — color migration | Done | All UI colors read from `LocalCascadeTheme.current.colors` | | Theming / styling API — typography migration | Done | All UI typography reads from `LocalCascadeTheme.current.typography` | +| Native iOS theming — color bridge | Done | Swift can seed a complete 24-slot `CascadeEditorColors` bag from either preset, apply a snapshotted palette at runtime with `setColors`, and restore preset selection with `clearCustomColors`; the native sample separates user-selected Violet/Forest families from OS-selected light/dark variants and reapplies both values when `colorScheme` changes | | Localization — data models | Done | `CascadeEditorStrings`, `CascadeEditorBlockStrings`, `BlockLocalizedStrings`, `LocalCascadeStrings`, `LocalCascadeBlockStrings` | | Localization — UI string migration | Done | `SlashCommandPopup`, `UnknownBlockRenderer`, `RichTextToolbar` read from `LocalCascadeStrings`; link labels and validation-error labels live in `CascadeEditorStrings` | | Localization — slash command system | Done | `BuiltInSlashCommandFactory.generate()` accepts `CascadeEditorBlockStrings?` for localized titles/descriptions/keywords | @@ -429,6 +432,7 @@ All state changes go through `EditorAction.reduce(state) → newState`. | `SlashPopupUtilsTest.kt` | Popup pure functions: estimatePopupHeightDp (compact/clamped), calculatePopupOffset (below/above/clamp), resolveNextHighlight (null/down/up/first/last/clamped/unknown) | | `CascadeEditorSlashIntegrationTest.kt` | Slash integration: registry coexistence (built-in + custom), custom override, custom execution alongside built-ins, session invalidation pure function (no session, healthy, drag, selection, anchor missing, different block deleted), full scenarios (drag start, anchor deletion) | | `CascadeEditorColorsTest.kt` | Light/dark presets: non-transparent slots including `linkText`, known values, light vs dark differ on key slots, copy/equality semantics | +| `editor-ios-sdk/.../CascadeEditorColorsBridgeTest.kt` | Native iOS bridge: light/dark preset parity, all 24 Swift-facing ARGB slots map exactly, runtime values are snapshotted, custom palettes survive semantic dark-mode changes, and clearing restores preset selection | | `CascadeEditorTypographyTest.kt` | Default preset: positive font sizes, monotonically decreasing headings, monospace code, medium-weight toolbar, copy/equality | | `CascadeEditorStringsTest.kt` | Default preset: non-empty strings, unsupportedBlock interpolation, copy with custom values, known English defaults | | `CascadeEditorBlockStringsTest.kt` | Default preset: all built-in typeIds present, non-empty displayName/description/keywords, forType null for unknown, BlockLocalizedStrings defaults | diff --git a/docs/iOsNativeSdk.md b/docs/iOsNativeSdk.md index 841c3a0..1802fb1 100644 --- a/docs/iOsNativeSdk.md +++ b/docs/iOsNativeSdk.md @@ -2,7 +2,7 @@ ## 1. Feature Overview -The Native iOS SDK exposes CascadeEditor to Swift applications without requiring consumers to use the Kotlin Multiplatform editor API, Compose types, or internal state holders directly. It packages the editor as a dynamic `CascadeEditor.xcframework`, provides a `CascadeEditorController` for document and editing operations, and hosts the Compose editor in a UIKit `UIViewController` that can be embedded in SwiftUI. The bridge also supports native UIKit/SwiftUI custom blocks, native slash commands, localization, runtime configuration, document import/export, and app-owned formatting chrome. A native Swift sample demonstrates persisted editing, a rich-text comments composer, and custom-block integrations while sharing the same serialized document format as the existing KMP sample. +The Native iOS SDK exposes CascadeEditor to Swift applications without requiring consumers to use the Kotlin Multiplatform editor API, Compose types, or internal state holders directly. It packages the editor as a dynamic `CascadeEditor.xcframework`, provides a `CascadeEditorController` for document and editing operations, and hosts the Compose editor in a UIKit `UIViewController` that can be embedded in SwiftUI. The bridge also supports native UIKit/SwiftUI custom blocks, native slash commands, localization, complete runtime color palettes, runtime configuration, document import/export, and app-owned formatting chrome. A native Swift sample demonstrates persisted editing, a rich-text comments composer, custom-block integrations, and live family-aware theme switching: the user selects Violet or Forest while iOS appearance automatically selects that family's light/dark variant. ## 2. Architecture & Design Decisions @@ -28,14 +28,14 @@ included in every release slice. Framework version metadata and - `BlockTextStates` and `BlockSpanStates` - `BlockRegistry` seeded with built-in block types - `SlashCommandRegistry` -- configuration and localization snapshots +- configuration, color-palette, and localization snapshots - mounted toolbar actions and derived toolbar state This is a facade pattern: Swift sees curated value objects and controller methods, while the controller translates them into core editor operations. The owned registries are intentionally stable for the controller lifetime, allowing custom blocks and slash commands to be registered before or after the editor is mounted. ### Compose-to-UIKit host -`CascadeEditorController.makeViewController()` creates a `ComposeUIViewController` and mounts `CascadeEditor` with the controller-owned state, runtime holders, and registries. It maps `CascadeEditorConfiguration` to `CascadeEditorConfig`, theme, toolbar, link popup, and slash-command slots. The host is created non-opaque (`opaque = false`): `CascadeEditor` paints no canvas background, so the native screen background behind the view controller shows through the editor. +`CascadeEditorController.makeViewController()` creates a `ComposeUIViewController` and mounts `CascadeEditor` with the controller-owned state, runtime holders, and registries. It maps `CascadeEditorConfiguration` to `CascadeEditorConfig`, theme, toolbar, link popup, and slash-command slots. A Swift-supplied color snapshot replaces all core `CascadeEditorColors` slots while retaining the preset typography and dimensions; without a custom snapshot, `isDark` selects the built-in light/dark theme. The host is created non-opaque (`opaque = false`): `CascadeEditor` paints no canvas background, so the native screen background behind the view controller shows through the editor. The host uses Compose snapshot state and `snapshotFlow` for two bridge streams: @@ -175,13 +175,38 @@ Editing commands: - `indentForward()`, `indentBackward()` - `applyLink(url, title)`, `removeLink()` -Runtime configuration and localization: +Runtime configuration, colors, and localization: - `updateConfiguration(value)`, `setReadOnly(value)`, `setDarkMode(value)`, `setToolbarMode(value)`, `setSlashCommandsEnabled(value)` +- `setColors(CascadeEditorColors)`, `clearCustomColors()` - `setLocalization(CascadeEditorLocalization)` +- `CascadeEditorColors` is a mutable, complete 24-slot palette seeded from the built-in light (`init()`) or light/dark (`init(isDark:)`) preset. Every property is a Swift `Int64` containing `0xAARRGGBB`. - `CascadeLocalizedStrings` supplies nullable overrides for built-in UI/accessibility strings. - `CascadeLocalizedBlockStrings` supplies slash-menu name, description, and additive keywords by block type ID. +`setColors` snapshots the whole bag and can be called before or after +`makeViewController()`. Later bag mutations do not affect the editor until +`setColors` is called again. A custom palette remains active when `setDarkMode` +changes because `isDark` is also semantic state exposed to native custom blocks; +apps with named themes should update both values together. `clearCustomColors` +returns color selection to the built-in light/dark presets. + +The native sample demonstrates the recommended host integration. It does not +force `preferredColorScheme`; instead it observes SwiftUI's +`@Environment(\.colorScheme)`, resolves the selected theme family's matching +variant, and calls both `setDarkMode` and `setColors` whenever appearance +changes. The SDK remains explicit and host-driven, so apps retain control over +their own theme-family model. + +Example Swift runtime theme application: + +```swift +let colors = CascadeEditorColors(isDark: theme.isDark) +colors.primary = 0xFF147D64 +controller.setDarkMode(value: theme.isDark) +controller.setColors(colors: colors) +``` + Callbacks: - `onDocumentChanged: (() -> Unit)?` @@ -237,7 +262,7 @@ controller.onDocumentChanged = { save(controller.exportJson()) } - **Registry system:** native blocks extend `BlockRegistry`; native commands extend `SlashCommandRegistry`. Observable revisions make runtime registration visible to an already-mounted editor. - **Serialization:** JSON uses `DocumentSchema` plus `NativeCustomBlockCodec`; HTML uses `HtmlProfile.Default`. Both share the same live text/span holders as rendering. - **Rich text and toolbar:** the bridge maps `FormattingState`, `IndentationState`, and `LinkState` from `CascadeEditorToolbarController` into Swift-friendly state and actions. -- **Localization and theme:** Swift overrides are resolved into core `CascadeEditorStrings` and `CascadeEditorBlockStrings`; `isDark` selects the core light/dark theme and is exposed to native custom blocks. +- **Localization and theme:** Swift string overrides resolve into core `CascadeEditorStrings` and `CascadeEditorBlockStrings`. `CascadeEditorColors` snapshots map all 24 ARGB slots into the core palette and recompose live. Without a custom palette, `isDark` selects the built-in colors; it remains exposed to native custom blocks in either mode. - **Local build:** `scripts/build-xcframework.sh` assembles the dynamic debug XCFramework at `editor-ios-sdk/build/XCFrameworks/debug/CascadeEditor.xcframework`. The Xcode sample links and embeds this local artifact. - **External distribution:** `scripts/package-ios-sdk.sh` tests and assembles the release framework, validates resources/privacy/version/dSYMs, and emits a one-root ZIP plus SwiftPM checksum and manifest. `release-ios-sdk.yml` publishes immutable source-tag assets and updates `linreal/cascade-editor-ios`. - **Consumer validation:** `scripts/validate-ios-consumer.sh` stages the publication ZIP in `iosConsumerSmokeTest`, builds a generic-device Release app, executes its UI test on an arm64 simulator, and inspects the embedded framework. @@ -246,6 +271,7 @@ controller.onDocumentChanged = { save(controller.exportJson()) } ## 7. Edge Cases & Known Constraints - All state-mutating controller methods, context operations, and ordinary state getters are main-thread-only. Off-main misuse reports `onInternalError` and returns a stable fallback/no-op. Export methods are the exception: they synchronously hop to the main queue to return authoritative content. Callers must not block the main thread waiting for an off-main export, or they can deadlock. +- Color values use the low 32 bits of each `Int64` as `0xAARRGGBB`. `setColors` is a complete replacement, not a partial merge; seed the bag with `CascadeEditorColors(isDark:)`, change the desired slots, and reapply it for every named-theme switch. - Toolbar actions, `undo()`, and `redo()` are unavailable before the Compose editor is mounted. `canUndo` and `canRedo` return `false` in that state. Document load/export does not require a mounted view. - Structural history requires live text/span runtime holders. `dispatchStructuralAction(...)` still applies the action when no holders are bound or passed, but the change is not undoable and is logged. - JSON parse failure is preflighted and preserves the current document and history. Successful JSON load/reset is a hard replacement and clears history. HTML load follows the core hard-replacement path; fatal warning categories affect `success` but do not provide the same explicit preflight-preservation guarantee. diff --git a/docs/recipes/ThemeCustomization.md b/docs/recipes/ThemeCustomization.md new file mode 100644 index 0000000..d0c1b2b --- /dev/null +++ b/docs/recipes/ThemeCustomization.md @@ -0,0 +1,164 @@ +# Theme Customization + +Use this recipe when an app owns named theme families such as `Forest` and each +family has light and dark variants. + +## Implementation contract + +- Keep the user-selected family and the current light/dark appearance as + separate state. +- Resolve `(family, isDark)` to one complete editor palette. +- Start from the matching built-in preset, then override the app's colors. +- Changing `primary`, `popupBackground`, or `inlineCodeBackground` does not + implicitly change `linkText`, `toolbarBackground`, or + `codeBlockBackground`; override those slots explicitly when required. +- CascadeEditor does not choose a custom family. The host resolves and applies + the variant. + +## Compose Multiplatform + +1. Model the family and resolve both inputs to a `CascadeEditorTheme`: + +```kotlin +enum class ThemeFamily { Violet, Forest } + +fun ThemeFamily.editorTheme(isDark: Boolean): CascadeEditorTheme { + val base = if (isDark) CascadeEditorTheme.dark() else CascadeEditorTheme.light() + val colors = when (this) { + ThemeFamily.Violet -> violetColors(isDark) + ThemeFamily.Forest -> forestColors(isDark) + } + return base.copy(colors = colors) +} + +private fun forestColors(isDark: Boolean): CascadeEditorColors = + (if (isDark) CascadeEditorColors.dark() else CascadeEditorColors.light()).copy( + primary = if (isDark) Color(0xFF67D4AF) else Color(0xFF147D64), + onPrimary = if (isDark) Color(0xFF07231A) else Color.White, + text = if (isDark) Color(0xFFE5F5EF) else Color(0xFF102A23), + popupBackground = if (isDark) Color(0xFF102820) else Color.White, + toolbarBackground = if (isDark) Color(0xFF163229) else Color.White, + linkText = if (isDark) Color(0xFF8FE0C4) else Color(0xFF147D64), + cursor = if (isDark) Color(0xFF67D4AF) else Color(0xFF147D64), + ) +``` + +Define `violetColors` the same way. Add every app-specific slot from the +checklist below. + +2. Resolve and pass the theme at the editor call site: + +```kotlin +@Composable +fun EditorScreen(family: ThemeFamily) { + val isDark = isSystemInDarkTheme() + val editorTheme = remember(family, isDark) { family.editorTheme(isDark) } + + CascadeEditor( + // existing state and registries + theme = editorTheme, + ) +} +``` + +`isSystemInDarkTheme()` invalidates composition when system appearance changes, +so an open editor automatically moves from, for example, Forest Light to Forest +Dark. If the app has its own appearance override, pass that Boolean instead. + +## Native iOS (Swift/SwiftUI) + +The native SDK accepts colors as `0xAARRGGBB` values in a mutable +`CascadeEditorColors` bag. Seed the bag first so it always contains all 24 +slots: + +```swift +enum ThemeFamily { + case violet + case forest + + func colors(isDark: Bool) -> CascadeEditorColors { + switch (self, isDark) { + case (.forest, false): forestLightColors() + case (.forest, true): forestDarkColors() + case (.violet, false): violetLightColors() + case (.violet, true): violetDarkColors() + } + } +} + +private func forestDarkColors() -> CascadeEditorColors { + let colors = CascadeEditorColors(isDark: true) + colors.primary = 0xFF67D4AF + colors.onPrimary = 0xFF07231A + colors.text = 0xFFE5F5EF + colors.popupBackground = 0xFF102820 + colors.toolbarBackground = 0xFF163229 + colors.linkText = 0xFF8FE0C4 + colors.cursor = 0xFF67D4AF + return colors +} +``` + +Create one factory per family variant and set every app-specific slot from the +checklist below. + +Observe iOS appearance in every screen that owns an active editor: + +```swift +struct CascadeEditorHost: UIViewControllerRepresentable { + let controller: CascadeEditorController + + func makeUIViewController(context: Context) -> UIViewController { + controller.makeViewController() + } + + func updateUIViewController( + _ viewController: UIViewController, + context: Context + ) {} +} + +struct EditorScreen: View { + @Environment(\.colorScheme) private var colorScheme + @State private var family: ThemeFamily = .forest + let controller: CascadeEditorController + + private var isDark: Bool { colorScheme == .dark } + + var body: some View { + CascadeEditorHost(controller: controller) + .onAppear { applyTheme() } + .onChange(of: family) { _, _ in applyTheme() } + .onChange(of: colorScheme) { _, _ in applyTheme() } + } + + private func applyTheme() { + controller.setDarkMode(value: isDark) + controller.setColors(colors: family.colors(isDark: isDark)) + } +} +``` + +Always call both methods. `setDarkMode` updates semantic state exposed to native +custom blocks; `setColors` replaces the rendered palette. The SDK does not +observe `UITraitCollection` or SwiftUI environment changes itself. `setColors` +snapshots the bag, so mutate it first and then call `setColors` again for every +variant change. + +## Color-slot checklist + +Both APIs expose the same 24 semantic slots: + +`primary`, `onPrimary`, `text`, `popupBackground`, +`unknownBlockBackground`, `toolbarIcon`, `toolbarIconDisabled`, +`slashItemTitle`, `slashChevron`, `unknownBlockText`, `uiDivider`, +`contentDivider`, `slashSelectedItem`, `inlineCodeBackground`, `highlight`, +`cursor`, `textSelectionBackground`, `quoteBorder`, `quoteBackground`, +`selectionOverlay`, `linkText`, `error`, `codeBlockBackground`, and +`toolbarBackground`. + +Reference implementations: + +- Multiplatform: `sample/src/commonMain/kotlin/io/github/linreal/cascade/theme/SampleEditorTheme.kt` +- Native iOS: `iosNativeSample/iosNativeSample/App/AppTheme.swift` and + `iosNativeSample/iosNativeSample/Screens/EditorDemoScreen.swift` diff --git a/editor-ios-sdk/api/editor-ios-sdk.klib.api b/editor-ios-sdk/api/editor-ios-sdk.klib.api index b5baf5a..029a293 100644 --- a/editor-ios-sdk/api/editor-ios-sdk.klib.api +++ b/editor-ios-sdk/api/editor-ios-sdk.klib.api @@ -220,6 +220,7 @@ final class io.github.linreal.cascade.ios.controller/CascadeEditorController { / final fun (kotlin/Function1?) // io.github.linreal.cascade.ios.controller/CascadeEditorController.onToolbarStateChanged.|(kotlin.Function1?){}[0] final fun applyLink(kotlin/String, kotlin/String?) // io.github.linreal.cascade.ios.controller/CascadeEditorController.applyLink|applyLink(kotlin.String;kotlin.String?){}[0] + final fun clearCustomColors() // io.github.linreal.cascade.ios.controller/CascadeEditorController.clearCustomColors|clearCustomColors(){}[0] final fun clearFocus() // io.github.linreal.cascade.ios.controller/CascadeEditorController.clearFocus|clearFocus(){}[0] final fun clearSelection() // io.github.linreal.cascade.ios.controller/CascadeEditorController.clearSelection|clearSelection(){}[0] final fun deleteSelectedOrFocused() // io.github.linreal.cascade.ios.controller/CascadeEditorController.deleteSelectedOrFocused|deleteSelectedOrFocused(){}[0] @@ -236,6 +237,7 @@ final class io.github.linreal.cascade.ios.controller/CascadeEditorController { / final fun registerSlashCommand(io.github.linreal.cascade.ios.slash/CascadeSlashCommand) // io.github.linreal.cascade.ios.controller/CascadeEditorController.registerSlashCommand|registerSlashCommand(io.github.linreal.cascade.ios.slash.CascadeSlashCommand){}[0] final fun removeLink() // io.github.linreal.cascade.ios.controller/CascadeEditorController.removeLink|removeLink(){}[0] final fun reset(kotlin/String): io.github.linreal.cascade.ios.controller/CascadeDocumentLoadResult // io.github.linreal.cascade.ios.controller/CascadeEditorController.reset|reset(kotlin.String){}[0] + final fun setColors(io.github.linreal.cascade.ios.theme/CascadeEditorColors) // io.github.linreal.cascade.ios.controller/CascadeEditorController.setColors|setColors(io.github.linreal.cascade.ios.theme.CascadeEditorColors){}[0] final fun setDarkMode(kotlin/Boolean) // io.github.linreal.cascade.ios.controller/CascadeEditorController.setDarkMode|setDarkMode(kotlin.Boolean){}[0] final fun setLocalization(io.github.linreal.cascade.ios.localization/CascadeEditorLocalization) // io.github.linreal.cascade.ios.controller/CascadeEditorController.setLocalization|setLocalization(io.github.linreal.cascade.ios.localization.CascadeEditorLocalization){}[0] final fun setReadOnly(kotlin/Boolean) // io.github.linreal.cascade.ios.controller/CascadeEditorController.setReadOnly|setReadOnly(kotlin.Boolean){}[0] @@ -453,6 +455,84 @@ final class io.github.linreal.cascade.ios.slash/CascadeSlashCommandResult { // i } } +final class io.github.linreal.cascade.ios.theme/CascadeEditorColors { // io.github.linreal.cascade.ios.theme/CascadeEditorColors|null[0] + constructor () // io.github.linreal.cascade.ios.theme/CascadeEditorColors.|(){}[0] + constructor (kotlin/Boolean) // io.github.linreal.cascade.ios.theme/CascadeEditorColors.|(kotlin.Boolean){}[0] + + final var codeBlockBackground // io.github.linreal.cascade.ios.theme/CascadeEditorColors.codeBlockBackground|{}codeBlockBackground[0] + final fun (): kotlin/Long // io.github.linreal.cascade.ios.theme/CascadeEditorColors.codeBlockBackground.|(){}[0] + final fun (kotlin/Long) // io.github.linreal.cascade.ios.theme/CascadeEditorColors.codeBlockBackground.|(kotlin.Long){}[0] + final var contentDivider // io.github.linreal.cascade.ios.theme/CascadeEditorColors.contentDivider|{}contentDivider[0] + final fun (): kotlin/Long // io.github.linreal.cascade.ios.theme/CascadeEditorColors.contentDivider.|(){}[0] + final fun (kotlin/Long) // io.github.linreal.cascade.ios.theme/CascadeEditorColors.contentDivider.|(kotlin.Long){}[0] + final var cursor // io.github.linreal.cascade.ios.theme/CascadeEditorColors.cursor|{}cursor[0] + final fun (): kotlin/Long // io.github.linreal.cascade.ios.theme/CascadeEditorColors.cursor.|(){}[0] + final fun (kotlin/Long) // io.github.linreal.cascade.ios.theme/CascadeEditorColors.cursor.|(kotlin.Long){}[0] + final var error // io.github.linreal.cascade.ios.theme/CascadeEditorColors.error|{}error[0] + final fun (): kotlin/Long // io.github.linreal.cascade.ios.theme/CascadeEditorColors.error.|(){}[0] + final fun (kotlin/Long) // io.github.linreal.cascade.ios.theme/CascadeEditorColors.error.|(kotlin.Long){}[0] + final var highlight // io.github.linreal.cascade.ios.theme/CascadeEditorColors.highlight|{}highlight[0] + final fun (): kotlin/Long // io.github.linreal.cascade.ios.theme/CascadeEditorColors.highlight.|(){}[0] + final fun (kotlin/Long) // io.github.linreal.cascade.ios.theme/CascadeEditorColors.highlight.|(kotlin.Long){}[0] + final var inlineCodeBackground // io.github.linreal.cascade.ios.theme/CascadeEditorColors.inlineCodeBackground|{}inlineCodeBackground[0] + final fun (): kotlin/Long // io.github.linreal.cascade.ios.theme/CascadeEditorColors.inlineCodeBackground.|(){}[0] + final fun (kotlin/Long) // io.github.linreal.cascade.ios.theme/CascadeEditorColors.inlineCodeBackground.|(kotlin.Long){}[0] + final var linkText // io.github.linreal.cascade.ios.theme/CascadeEditorColors.linkText|{}linkText[0] + final fun (): kotlin/Long // io.github.linreal.cascade.ios.theme/CascadeEditorColors.linkText.|(){}[0] + final fun (kotlin/Long) // io.github.linreal.cascade.ios.theme/CascadeEditorColors.linkText.|(kotlin.Long){}[0] + final var onPrimary // io.github.linreal.cascade.ios.theme/CascadeEditorColors.onPrimary|{}onPrimary[0] + final fun (): kotlin/Long // io.github.linreal.cascade.ios.theme/CascadeEditorColors.onPrimary.|(){}[0] + final fun (kotlin/Long) // io.github.linreal.cascade.ios.theme/CascadeEditorColors.onPrimary.|(kotlin.Long){}[0] + final var popupBackground // io.github.linreal.cascade.ios.theme/CascadeEditorColors.popupBackground|{}popupBackground[0] + final fun (): kotlin/Long // io.github.linreal.cascade.ios.theme/CascadeEditorColors.popupBackground.|(){}[0] + final fun (kotlin/Long) // io.github.linreal.cascade.ios.theme/CascadeEditorColors.popupBackground.|(kotlin.Long){}[0] + final var primary // io.github.linreal.cascade.ios.theme/CascadeEditorColors.primary|{}primary[0] + final fun (): kotlin/Long // io.github.linreal.cascade.ios.theme/CascadeEditorColors.primary.|(){}[0] + final fun (kotlin/Long) // io.github.linreal.cascade.ios.theme/CascadeEditorColors.primary.|(kotlin.Long){}[0] + final var quoteBackground // io.github.linreal.cascade.ios.theme/CascadeEditorColors.quoteBackground|{}quoteBackground[0] + final fun (): kotlin/Long // io.github.linreal.cascade.ios.theme/CascadeEditorColors.quoteBackground.|(){}[0] + final fun (kotlin/Long) // io.github.linreal.cascade.ios.theme/CascadeEditorColors.quoteBackground.|(kotlin.Long){}[0] + final var quoteBorder // io.github.linreal.cascade.ios.theme/CascadeEditorColors.quoteBorder|{}quoteBorder[0] + final fun (): kotlin/Long // io.github.linreal.cascade.ios.theme/CascadeEditorColors.quoteBorder.|(){}[0] + final fun (kotlin/Long) // io.github.linreal.cascade.ios.theme/CascadeEditorColors.quoteBorder.|(kotlin.Long){}[0] + final var selectionOverlay // io.github.linreal.cascade.ios.theme/CascadeEditorColors.selectionOverlay|{}selectionOverlay[0] + final fun (): kotlin/Long // io.github.linreal.cascade.ios.theme/CascadeEditorColors.selectionOverlay.|(){}[0] + final fun (kotlin/Long) // io.github.linreal.cascade.ios.theme/CascadeEditorColors.selectionOverlay.|(kotlin.Long){}[0] + final var slashChevron // io.github.linreal.cascade.ios.theme/CascadeEditorColors.slashChevron|{}slashChevron[0] + final fun (): kotlin/Long // io.github.linreal.cascade.ios.theme/CascadeEditorColors.slashChevron.|(){}[0] + final fun (kotlin/Long) // io.github.linreal.cascade.ios.theme/CascadeEditorColors.slashChevron.|(kotlin.Long){}[0] + final var slashItemTitle // io.github.linreal.cascade.ios.theme/CascadeEditorColors.slashItemTitle|{}slashItemTitle[0] + final fun (): kotlin/Long // io.github.linreal.cascade.ios.theme/CascadeEditorColors.slashItemTitle.|(){}[0] + final fun (kotlin/Long) // io.github.linreal.cascade.ios.theme/CascadeEditorColors.slashItemTitle.|(kotlin.Long){}[0] + final var slashSelectedItem // io.github.linreal.cascade.ios.theme/CascadeEditorColors.slashSelectedItem|{}slashSelectedItem[0] + final fun (): kotlin/Long // io.github.linreal.cascade.ios.theme/CascadeEditorColors.slashSelectedItem.|(){}[0] + final fun (kotlin/Long) // io.github.linreal.cascade.ios.theme/CascadeEditorColors.slashSelectedItem.|(kotlin.Long){}[0] + final var text // io.github.linreal.cascade.ios.theme/CascadeEditorColors.text|{}text[0] + final fun (): kotlin/Long // io.github.linreal.cascade.ios.theme/CascadeEditorColors.text.|(){}[0] + final fun (kotlin/Long) // io.github.linreal.cascade.ios.theme/CascadeEditorColors.text.|(kotlin.Long){}[0] + final var textSelectionBackground // io.github.linreal.cascade.ios.theme/CascadeEditorColors.textSelectionBackground|{}textSelectionBackground[0] + final fun (): kotlin/Long // io.github.linreal.cascade.ios.theme/CascadeEditorColors.textSelectionBackground.|(){}[0] + final fun (kotlin/Long) // io.github.linreal.cascade.ios.theme/CascadeEditorColors.textSelectionBackground.|(kotlin.Long){}[0] + final var toolbarBackground // io.github.linreal.cascade.ios.theme/CascadeEditorColors.toolbarBackground|{}toolbarBackground[0] + final fun (): kotlin/Long // io.github.linreal.cascade.ios.theme/CascadeEditorColors.toolbarBackground.|(){}[0] + final fun (kotlin/Long) // io.github.linreal.cascade.ios.theme/CascadeEditorColors.toolbarBackground.|(kotlin.Long){}[0] + final var toolbarIcon // io.github.linreal.cascade.ios.theme/CascadeEditorColors.toolbarIcon|{}toolbarIcon[0] + final fun (): kotlin/Long // io.github.linreal.cascade.ios.theme/CascadeEditorColors.toolbarIcon.|(){}[0] + final fun (kotlin/Long) // io.github.linreal.cascade.ios.theme/CascadeEditorColors.toolbarIcon.|(kotlin.Long){}[0] + final var toolbarIconDisabled // io.github.linreal.cascade.ios.theme/CascadeEditorColors.toolbarIconDisabled|{}toolbarIconDisabled[0] + final fun (): kotlin/Long // io.github.linreal.cascade.ios.theme/CascadeEditorColors.toolbarIconDisabled.|(){}[0] + final fun (kotlin/Long) // io.github.linreal.cascade.ios.theme/CascadeEditorColors.toolbarIconDisabled.|(kotlin.Long){}[0] + final var uiDivider // io.github.linreal.cascade.ios.theme/CascadeEditorColors.uiDivider|{}uiDivider[0] + final fun (): kotlin/Long // io.github.linreal.cascade.ios.theme/CascadeEditorColors.uiDivider.|(){}[0] + final fun (kotlin/Long) // io.github.linreal.cascade.ios.theme/CascadeEditorColors.uiDivider.|(kotlin.Long){}[0] + final var unknownBlockBackground // io.github.linreal.cascade.ios.theme/CascadeEditorColors.unknownBlockBackground|{}unknownBlockBackground[0] + final fun (): kotlin/Long // io.github.linreal.cascade.ios.theme/CascadeEditorColors.unknownBlockBackground.|(){}[0] + final fun (kotlin/Long) // io.github.linreal.cascade.ios.theme/CascadeEditorColors.unknownBlockBackground.|(kotlin.Long){}[0] + final var unknownBlockText // io.github.linreal.cascade.ios.theme/CascadeEditorColors.unknownBlockText|{}unknownBlockText[0] + final fun (): kotlin/Long // io.github.linreal.cascade.ios.theme/CascadeEditorColors.unknownBlockText.|(){}[0] + final fun (kotlin/Long) // io.github.linreal.cascade.ios.theme/CascadeEditorColors.unknownBlockText.|(kotlin.Long){}[0] +} + final class io.github.linreal.cascade.ios.toolbar/CascadeToolbarState { // io.github.linreal.cascade.ios.toolbar/CascadeToolbarState|null[0] constructor (kotlin/Boolean, kotlin/Boolean, io.github.linreal.cascade.ios.toolbar/CascadeStyleState, io.github.linreal.cascade.ios.toolbar/CascadeStyleState, io.github.linreal.cascade.ios.toolbar/CascadeStyleState, io.github.linreal.cascade.ios.toolbar/CascadeStyleState, io.github.linreal.cascade.ios.toolbar/CascadeStyleState, io.github.linreal.cascade.ios.toolbar/CascadeStyleState, kotlin/Boolean, kotlin/Boolean, kotlin/Boolean, kotlin/String?) // io.github.linreal.cascade.ios.toolbar/CascadeToolbarState.|(kotlin.Boolean;kotlin.Boolean;io.github.linreal.cascade.ios.toolbar.CascadeStyleState;io.github.linreal.cascade.ios.toolbar.CascadeStyleState;io.github.linreal.cascade.ios.toolbar.CascadeStyleState;io.github.linreal.cascade.ios.toolbar.CascadeStyleState;io.github.linreal.cascade.ios.toolbar.CascadeStyleState;io.github.linreal.cascade.ios.toolbar.CascadeStyleState;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.String?){}[0] @@ -527,6 +607,7 @@ final val io.github.linreal.cascade.ios.model/io_github_linreal_cascade_ios_mode final val io.github.linreal.cascade.ios.slash/io_github_linreal_cascade_ios_slash_CascadeSlashCommand$stableprop // io.github.linreal.cascade.ios.slash/io_github_linreal_cascade_ios_slash_CascadeSlashCommand$stableprop|#static{}io_github_linreal_cascade_ios_slash_CascadeSlashCommand$stableprop[0] final val io.github.linreal.cascade.ios.slash/io_github_linreal_cascade_ios_slash_CascadeSlashCommandContext$stableprop // io.github.linreal.cascade.ios.slash/io_github_linreal_cascade_ios_slash_CascadeSlashCommandContext$stableprop|#static{}io_github_linreal_cascade_ios_slash_CascadeSlashCommandContext$stableprop[0] final val io.github.linreal.cascade.ios.slash/io_github_linreal_cascade_ios_slash_CascadeSlashCommandResult$stableprop // io.github.linreal.cascade.ios.slash/io_github_linreal_cascade_ios_slash_CascadeSlashCommandResult$stableprop|#static{}io_github_linreal_cascade_ios_slash_CascadeSlashCommandResult$stableprop[0] +final val io.github.linreal.cascade.ios.theme/io_github_linreal_cascade_ios_theme_CascadeEditorColors$stableprop // io.github.linreal.cascade.ios.theme/io_github_linreal_cascade_ios_theme_CascadeEditorColors$stableprop|#static{}io_github_linreal_cascade_ios_theme_CascadeEditorColors$stableprop[0] final val io.github.linreal.cascade.ios.toolbar/io_github_linreal_cascade_ios_toolbar_CascadeToolbarState$stableprop // io.github.linreal.cascade.ios.toolbar/io_github_linreal_cascade_ios_toolbar_CascadeToolbarState$stableprop|#static{}io_github_linreal_cascade_ios_toolbar_CascadeToolbarState$stableprop[0] final val io.github.linreal.cascade.ios/io_github_linreal_cascade_ios_CascadeEditorSdk$stableprop // io.github.linreal.cascade.ios/io_github_linreal_cascade_ios_CascadeEditorSdk$stableprop|#static{}io_github_linreal_cascade_ios_CascadeEditorSdk$stableprop[0] @@ -549,5 +630,6 @@ final fun io.github.linreal.cascade.ios.model/io_github_linreal_cascade_ios_mode final fun io.github.linreal.cascade.ios.slash/io_github_linreal_cascade_ios_slash_CascadeSlashCommand$stableprop_getter(): kotlin/Int // io.github.linreal.cascade.ios.slash/io_github_linreal_cascade_ios_slash_CascadeSlashCommand$stableprop_getter|io_github_linreal_cascade_ios_slash_CascadeSlashCommand$stableprop_getter(){}[0] final fun io.github.linreal.cascade.ios.slash/io_github_linreal_cascade_ios_slash_CascadeSlashCommandContext$stableprop_getter(): kotlin/Int // io.github.linreal.cascade.ios.slash/io_github_linreal_cascade_ios_slash_CascadeSlashCommandContext$stableprop_getter|io_github_linreal_cascade_ios_slash_CascadeSlashCommandContext$stableprop_getter(){}[0] final fun io.github.linreal.cascade.ios.slash/io_github_linreal_cascade_ios_slash_CascadeSlashCommandResult$stableprop_getter(): kotlin/Int // io.github.linreal.cascade.ios.slash/io_github_linreal_cascade_ios_slash_CascadeSlashCommandResult$stableprop_getter|io_github_linreal_cascade_ios_slash_CascadeSlashCommandResult$stableprop_getter(){}[0] +final fun io.github.linreal.cascade.ios.theme/io_github_linreal_cascade_ios_theme_CascadeEditorColors$stableprop_getter(): kotlin/Int // io.github.linreal.cascade.ios.theme/io_github_linreal_cascade_ios_theme_CascadeEditorColors$stableprop_getter|io_github_linreal_cascade_ios_theme_CascadeEditorColors$stableprop_getter(){}[0] final fun io.github.linreal.cascade.ios.toolbar/io_github_linreal_cascade_ios_toolbar_CascadeToolbarState$stableprop_getter(): kotlin/Int // io.github.linreal.cascade.ios.toolbar/io_github_linreal_cascade_ios_toolbar_CascadeToolbarState$stableprop_getter|io_github_linreal_cascade_ios_toolbar_CascadeToolbarState$stableprop_getter(){}[0] final fun io.github.linreal.cascade.ios/io_github_linreal_cascade_ios_CascadeEditorSdk$stableprop_getter(): kotlin/Int // io.github.linreal.cascade.ios/io_github_linreal_cascade_ios_CascadeEditorSdk$stableprop_getter|io_github_linreal_cascade_ios_CascadeEditorSdk$stableprop_getter(){}[0] diff --git a/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/controller/CascadeEditorConfiguration.kt b/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/controller/CascadeEditorConfiguration.kt index c3e730d..dfdcfea 100644 --- a/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/controller/CascadeEditorConfiguration.kt +++ b/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/controller/CascadeEditorConfiguration.kt @@ -3,6 +3,7 @@ package io.github.linreal.cascade.ios.controller import io.github.linreal.cascade.editor.CrashPolicy +import io.github.linreal.cascade.editor.theme.CascadeEditorColors import io.github.linreal.cascade.editor.theme.CascadeEditorTheme import kotlin.experimental.ExperimentalObjCName import kotlin.native.ObjCName @@ -72,5 +73,9 @@ internal fun CascadeCrashPolicy.toCoreCrashPolicy(): CrashPolicy = when (this) { * The theme the hosted editor mounts for this configuration. Extracted so tests * can assert the exact mapping the view host composes from when `isDark` flips. */ -internal fun CascadeEditorConfiguration.resolveEditorTheme(): CascadeEditorTheme = - if (isDark) CascadeEditorTheme.dark() else CascadeEditorTheme.light() +internal fun CascadeEditorConfiguration.resolveEditorTheme( + customColors: CascadeEditorColors? = null, +): CascadeEditorTheme { + val preset = if (isDark) CascadeEditorTheme.dark() else CascadeEditorTheme.light() + return if (customColors == null) preset else preset.copy(colors = customColors) +} diff --git a/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/controller/CascadeEditorController.kt b/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/controller/CascadeEditorController.kt index e717565..2e6dc0a 100644 --- a/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/controller/CascadeEditorController.kt +++ b/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/controller/CascadeEditorController.kt @@ -36,6 +36,7 @@ import io.github.linreal.cascade.editor.state.BlockSpanStates import io.github.linreal.cascade.editor.state.BlockTextStates import io.github.linreal.cascade.editor.state.EditorStateHolder import io.github.linreal.cascade.editor.theme.CascadeEditorBlockStrings +import io.github.linreal.cascade.editor.theme.CascadeEditorColors as CoreCascadeEditorColors import io.github.linreal.cascade.editor.theme.CascadeEditorStrings import io.github.linreal.cascade.editor.ui.createEditorRegistry import io.github.linreal.cascade.ios.block.CascadeCustomBlockRegistration @@ -56,6 +57,7 @@ import io.github.linreal.cascade.ios.model.CascadeRichTextSpan import io.github.linreal.cascade.ios.model.CascadeSpanKind import io.github.linreal.cascade.ios.model.parseJsonObjectPayloadSafely import io.github.linreal.cascade.ios.toolbar.CascadeToolbarState +import io.github.linreal.cascade.ios.theme.CascadeEditorColors import kotlin.experimental.ExperimentalObjCName import kotlin.native.ObjCName import platform.Foundation.NSThread @@ -128,6 +130,10 @@ public class CascadeEditorController public constructor( mutableStateOf(CascadeEditorStrings.default()) internal val resolvedBlockStrings: MutableState = mutableStateOf(CascadeEditorBlockStrings.default()) + // A complete snapshot of the optional Swift-supplied palette. Null keeps + // light/dark preset selection driven by configuration.isDark. + internal val customColorsSnapshot: MutableState = + mutableStateOf(null) // Identity of the Compose host that currently owns this controller's bridge // state. makeViewController() can be called more than once, and UIKit/SwiftUI // transitions overlap the old and new hosts; only the owning host may publish @@ -394,6 +400,30 @@ public class CascadeEditorController public constructor( copy(isDark = value) } + /** + * Applies a complete custom color palette to the mounted editor. + * + * Values are snapshotted immediately, so mutating [colors] afterwards has no + * effect until this method is called again. The custom palette remains active + * across [setDarkMode] calls; apps with multiple themes should apply the + * matching palette when their theme changes. + */ + public fun setColors(colors: CascadeEditorColors): Unit = onMainThread( + fallback = {}, + ) { + customColorsSnapshot.value = colors.snapshot() + } + + /** + * Removes the custom palette and resumes the built-in light/dark presets + * selected by [setDarkMode]. + */ + public fun clearCustomColors(): Unit = onMainThread( + fallback = {}, + ) { + customColorsSnapshot.value = null + } + public fun setToolbarMode(value: CascadeToolbarMode): Unit = updateConfiguration { copy(toolbarMode = value) } diff --git a/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/controller/CascadeEditorViewController.kt b/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/controller/CascadeEditorViewController.kt index 4c2a896..10d8c5b 100644 --- a/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/controller/CascadeEditorViewController.kt +++ b/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/controller/CascadeEditorViewController.kt @@ -49,6 +49,7 @@ public fun CascadeEditorController.makeViewController(): UIViewController = onMa // non-opaque for the native screen background to show through it. ComposeUIViewController(configure = { opaque = false }) { val configurationState by configurationSnapshot + val customColors by customColorsSnapshot val editorConfig = remember(configurationState) { CascadeEditorConfig( readOnly = configurationState.readOnly, @@ -64,8 +65,8 @@ public fun CascadeEditorController.makeViewController(): UIViewController = onMa }, ) } - val theme = remember(configurationState.isDark) { - configurationState.resolveEditorTheme() + val theme = remember(configurationState.isDark, customColors) { + configurationState.resolveEditorTheme(customColors) } val strings by resolvedStrings val blockStrings by resolvedBlockStrings diff --git a/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/theme/CascadeEditorColorsBridge.kt b/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/theme/CascadeEditorColorsBridge.kt new file mode 100644 index 0000000..2ef3170 --- /dev/null +++ b/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/theme/CascadeEditorColorsBridge.kt @@ -0,0 +1,132 @@ +@file:OptIn(ExperimentalObjCName::class) + +package io.github.linreal.cascade.ios.theme + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import io.github.linreal.cascade.editor.theme.CascadeEditorColors as CoreCascadeEditorColors +import kotlin.experimental.ExperimentalObjCName +import kotlin.native.ObjCName + +/** + * Complete Swift-facing color palette for CascadeEditor. + * + * Construct from the built-in light or dark preset, customize any fields, then + * apply it through `CascadeEditorController.setColors`. Every value is a + * 32-bit `0xAARRGGBB` color stored in a Swift-friendly `Int64`. + * + * Values are snapshotted when applied: mutating this bag afterwards has no + * effect until `setColors` is called again. + */ +@ObjCName("CascadeEditorColors", exact = true) +public class CascadeEditorColors public constructor( + isDark: Boolean, +) { + public constructor() : this(isDark = false) + + private val preset: CoreCascadeEditorColors = + if (isDark) CoreCascadeEditorColors.dark() else CoreCascadeEditorColors.light() + + /** Accent color: active toolbar background, drop indicator, and popup actions. */ + public var primary: Long = preset.primary.toBridgeArgb() + + /** Content rendered on [primary]-colored surfaces. */ + public var onPrimary: Long = preset.onPrimary.toBridgeArgb() + + /** Default body, heading, and list text. */ + public var text: Long = preset.text.toBridgeArgb() + + /** Slash-command popup background. */ + public var popupBackground: Long = preset.popupBackground.toBridgeArgb() + + /** Unknown/error block background. */ + public var unknownBlockBackground: Long = preset.unknownBlockBackground.toBridgeArgb() + + /** Enabled formatting-toolbar icon. */ + public var toolbarIcon: Long = preset.toolbarIcon.toBridgeArgb() + + /** Disabled formatting-toolbar icon. */ + public var toolbarIconDisabled: Long = preset.toolbarIconDisabled.toBridgeArgb() + + /** Slash-command row title. */ + public var slashItemTitle: Long = preset.slashItemTitle.toBridgeArgb() + + /** Slash-command submenu chevron. */ + public var slashChevron: Long = preset.slashChevron.toBridgeArgb() + + /** Unknown block message text. */ + public var unknownBlockText: Long = preset.unknownBlockText.toBridgeArgb() + + /** Toolbar and slash-popup divider lines. */ + public var uiDivider: Long = preset.uiDivider.toBridgeArgb() + + /** Divider block line. */ + public var contentDivider: Long = preset.contentDivider.toBridgeArgb() + + /** Selected slash-command row background. */ + public var slashSelectedItem: Long = preset.slashSelectedItem.toBridgeArgb() + + /** Inline-code span background. */ + public var inlineCodeBackground: Long = preset.inlineCodeBackground.toBridgeArgb() + + /** Default rich-text highlight background. */ + public var highlight: Long = preset.highlight.toBridgeArgb() + + /** Text cursor/caret. */ + public var cursor: Long = preset.cursor.toBridgeArgb() + + /** Text selection background. */ + public var textSelectionBackground: Long = preset.textSelectionBackground.toBridgeArgb() + + /** Quote block leading border. */ + public var quoteBorder: Long = preset.quoteBorder.toBridgeArgb() + + /** Quote block background. */ + public var quoteBackground: Long = preset.quoteBackground.toBridgeArgb() + + /** Overlay behind selected blocks. */ + public var selectionOverlay: Long = preset.selectionOverlay.toBridgeArgb() + + /** Link text. */ + public var linkText: Long = preset.linkText.toBridgeArgb() + + /** Validation and error messages. */ + public var error: Long = preset.error.toBridgeArgb() + + /** Full code-block background. */ + public var codeBlockBackground: Long = preset.codeBlockBackground.toBridgeArgb() + + /** Floating formatting-toolbar surface. */ + public var toolbarBackground: Long = preset.toolbarBackground.toBridgeArgb() + + internal fun snapshot(): CoreCascadeEditorColors = CoreCascadeEditorColors( + primary = primary.toComposeColor(), + onPrimary = onPrimary.toComposeColor(), + text = text.toComposeColor(), + popupBackground = popupBackground.toComposeColor(), + unknownBlockBackground = unknownBlockBackground.toComposeColor(), + toolbarIcon = toolbarIcon.toComposeColor(), + toolbarIconDisabled = toolbarIconDisabled.toComposeColor(), + slashItemTitle = slashItemTitle.toComposeColor(), + slashChevron = slashChevron.toComposeColor(), + unknownBlockText = unknownBlockText.toComposeColor(), + uiDivider = uiDivider.toComposeColor(), + contentDivider = contentDivider.toComposeColor(), + slashSelectedItem = slashSelectedItem.toComposeColor(), + inlineCodeBackground = inlineCodeBackground.toComposeColor(), + highlight = highlight.toComposeColor(), + cursor = cursor.toComposeColor(), + textSelectionBackground = textSelectionBackground.toComposeColor(), + quoteBorder = quoteBorder.toComposeColor(), + quoteBackground = quoteBackground.toComposeColor(), + selectionOverlay = selectionOverlay.toComposeColor(), + linkText = linkText.toComposeColor(), + error = error.toComposeColor(), + codeBlockBackground = codeBlockBackground.toComposeColor(), + toolbarBackground = toolbarBackground.toComposeColor(), + ) +} + +private fun Color.toBridgeArgb(): Long = toArgb().toLong() and 0xFFFF_FFFFL + +private fun Long.toComposeColor(): Color = Color((this and 0xFFFF_FFFFL).toInt()) diff --git a/editor-ios-sdk/src/iosSimulatorArm64Test/kotlin/io/github/linreal/cascade/ios/theme/CascadeEditorColorsBridgeTest.kt b/editor-ios-sdk/src/iosSimulatorArm64Test/kotlin/io/github/linreal/cascade/ios/theme/CascadeEditorColorsBridgeTest.kt new file mode 100644 index 0000000..a6dd4bc --- /dev/null +++ b/editor-ios-sdk/src/iosSimulatorArm64Test/kotlin/io/github/linreal/cascade/ios/theme/CascadeEditorColorsBridgeTest.kt @@ -0,0 +1,139 @@ +package io.github.linreal.cascade.ios.theme + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import io.github.linreal.cascade.editor.theme.CascadeEditorColors as CoreCascadeEditorColors +import io.github.linreal.cascade.editor.theme.CascadeEditorTheme +import io.github.linreal.cascade.ios.controller.CascadeEditorController +import io.github.linreal.cascade.ios.controller.resolveEditorTheme +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class CascadeEditorColorsBridgeTest { + @Test + fun constructorsExposeCompleteBuiltInPresets() { + assertEquals(CoreCascadeEditorColors.light(), CascadeEditorColors().snapshot()) + assertEquals(CoreCascadeEditorColors.light(), CascadeEditorColors(isDark = false).snapshot()) + assertEquals(CoreCascadeEditorColors.dark(), CascadeEditorColors(isDark = true).snapshot()) + } + + @Test + fun everySwiftFacingSlotMapsToTheCorePalette() { + val colors = CascadeEditorColors() + colors.primary = 0xFF000001L + colors.onPrimary = 0xFF000002L + colors.text = 0xFF000003L + colors.popupBackground = 0xFF000004L + colors.unknownBlockBackground = 0xFF000005L + colors.toolbarIcon = 0xFF000006L + colors.toolbarIconDisabled = 0xFF000007L + colors.slashItemTitle = 0xFF000008L + colors.slashChevron = 0xFF000009L + colors.unknownBlockText = 0xFF00000AL + colors.uiDivider = 0xFF00000BL + colors.contentDivider = 0xFF00000CL + colors.slashSelectedItem = 0xFF00000DL + colors.inlineCodeBackground = 0xFF00000EL + colors.highlight = 0xFF00000FL + colors.cursor = 0xFF000010L + colors.textSelectionBackground = 0xFF000011L + colors.quoteBorder = 0xFF000012L + colors.quoteBackground = 0xFF000013L + colors.selectionOverlay = 0xFF000014L + colors.linkText = 0xFF000015L + colors.error = 0xFF000016L + colors.codeBlockBackground = 0xFF000017L + colors.toolbarBackground = 0xFF000018L + + assertEquals( + listOf( + 0xFF000001L, + 0xFF000002L, + 0xFF000003L, + 0xFF000004L, + 0xFF000005L, + 0xFF000006L, + 0xFF000007L, + 0xFF000008L, + 0xFF000009L, + 0xFF00000AL, + 0xFF00000BL, + 0xFF00000CL, + 0xFF00000DL, + 0xFF00000EL, + 0xFF00000FL, + 0xFF000010L, + 0xFF000011L, + 0xFF000012L, + 0xFF000013L, + 0xFF000014L, + 0xFF000015L, + 0xFF000016L, + 0xFF000017L, + 0xFF000018L, + ), + colors.snapshot().argbSlots(), + ) + } + + @Test + fun controllerSnapshotsRuntimeColorsAndCanRestorePresetSelection() { + val controller = CascadeEditorController() + val colors = CascadeEditorColors() + colors.primary = 0xFF123456L + + controller.setColors(colors) + val applied = controller.customColorsSnapshot.value + assertEquals(0xFF123456L, applied?.primary?.toArgbLong()) + assertEquals( + applied, + controller.configurationSnapshot.value.resolveEditorTheme(applied).colors, + ) + + colors.primary = 0xFFABCDEFL + assertEquals(0xFF123456L, controller.customColorsSnapshot.value?.primary?.toArgbLong()) + + controller.setDarkMode(true) + assertEquals( + applied, + controller.configurationSnapshot.value.resolveEditorTheme(applied).colors, + ) + + controller.clearCustomColors() + assertNull(controller.customColorsSnapshot.value) + assertEquals( + CascadeEditorTheme.dark(), + controller.configurationSnapshot.value.resolveEditorTheme(), + ) + } +} + +private fun CoreCascadeEditorColors.argbSlots(): List = listOf( + primary.toArgbLong(), + onPrimary.toArgbLong(), + text.toArgbLong(), + popupBackground.toArgbLong(), + unknownBlockBackground.toArgbLong(), + toolbarIcon.toArgbLong(), + toolbarIconDisabled.toArgbLong(), + slashItemTitle.toArgbLong(), + slashChevron.toArgbLong(), + unknownBlockText.toArgbLong(), + uiDivider.toArgbLong(), + contentDivider.toArgbLong(), + slashSelectedItem.toArgbLong(), + inlineCodeBackground.toArgbLong(), + highlight.toArgbLong(), + cursor.toArgbLong(), + textSelectionBackground.toArgbLong(), + quoteBorder.toArgbLong(), + quoteBackground.toArgbLong(), + selectionOverlay.toArgbLong(), + linkText.toArgbLong(), + error.toArgbLong(), + codeBlockBackground.toArgbLong(), + toolbarBackground.toArgbLong(), +) + +private fun Color.toArgbLong(): Long = toArgb().toLong() and 0xFFFF_FFFFL diff --git a/iosNativeSample/iosNativeSample/App/AppTheme.swift b/iosNativeSample/iosNativeSample/App/AppTheme.swift index 50eaf64..0d26c20 100644 --- a/iosNativeSample/iosNativeSample/App/AppTheme.swift +++ b/iosNativeSample/iosNativeSample/App/AppTheme.swift @@ -1,11 +1,33 @@ import SwiftUI +import CascadeEditor + +/// The user selects a theme family; iOS appearance selects that family's +/// light/dark variant. Both changes update native chrome and CascadeEditor. +enum SampleThemeFamily: CaseIterable, Hashable { + case violet + case forest + + func appTheme(isDark: Bool) -> AppTheme { + switch (self, isDark) { + case (.violet, false): .violetLight + case (.violet, true): .violetDark + case (.forest, false): .forestLight + case (.forest, true): .forestDark + } + } + + var next: SampleThemeFamily { + let themes = Self.allCases + let index = themes.firstIndex(of: self) ?? themes.startIndex + return themes[themes.index(after: index) % themes.endIndex] + } +} -/// Fixed violet design system mirroring the Compose sample's palette, so the -/// native host looks like the KMP one. Light/dark is driven by the app-level -/// theme toggle (which also flips the editor via `setDarkMode`), not by the -/// system appearance. struct AppTheme { + let name: String let isDark: Bool + /// Complete SDK palette applied through `CascadeEditorController.setColors`. + let editorColors: CascadeEditorColors // Material-scheme equivalents used by the shared chrome. let primary: Color @@ -19,7 +41,7 @@ struct AppTheme { /// Hairline divider inside grouped chrome containers. let divider: Color let destructive: Color - let savedGreen = Color(rgb: 0x34C77B) + let savedGreen: Color // Landing-screen accents. let subtitle: Color @@ -31,15 +53,17 @@ struct AppTheme { let badgeBackground: Color let badgeBorder: Color let badgeText: Color - let badgeDot = Color(rgb: 0xFF6B4A) - let heroGradient = [Color(rgb: 0x6C3DE8), Color(rgb: 0x8B5CF6), Color(rgb: 0xA855F7)] + let badgeDot: Color + let heroGradient: [Color] let tileBlocksBackground: Color let tileBlocksIcon: Color let tileCommentsBackground: Color let tileCommentsIcon: Color - static let light = AppTheme( + static let violetLight = AppTheme( + name: "Violet Light", isDark: false, + editorColors: violetLightEditorColors(), primary: Color(rgb: 0x6C3DE8), onPrimary: .white, background: Color(rgb: 0xF6F2FF), @@ -49,6 +73,7 @@ struct AppTheme { mutedInk: Color(rgb: 0x4A4360), divider: Color(rgb: 0xE4DAFB), destructive: Color(rgb: 0xB3261E), + savedGreen: Color(rgb: 0x34C77B), subtitle: Color(rgb: 0x4A4360), cardBackground: .white, cardBorder: Color(rgb: 0xEDE6FB), @@ -58,14 +83,22 @@ struct AppTheme { badgeBackground: .white, badgeBorder: Color(rgb: 0xE4DAFB), badgeText: Color(rgb: 0x6C3DE8), + badgeDot: Color(rgb: 0xFF6B4A), + heroGradient: [ + Color(rgb: 0x6C3DE8), + Color(rgb: 0x8B5CF6), + Color(rgb: 0xA855F7) + ], tileBlocksBackground: Color(rgb: 0xF0E9FE), tileBlocksIcon: Color(rgb: 0x6C3DE8), tileCommentsBackground: Color(rgb: 0xFFEDE7), tileCommentsIcon: Color(rgb: 0xFF6B4A) ) - static let dark = AppTheme( + static let violetDark = AppTheme( + name: "Violet Dark", isDark: true, + editorColors: violetDarkEditorColors(), primary: Color(rgb: 0xA78BFA), onPrimary: Color(rgb: 0x1B1230), background: Color(rgb: 0x120C24), @@ -75,6 +108,7 @@ struct AppTheme { mutedInk: Color(rgb: 0x9B93B8), divider: Color(rgb: 0x3A3354), destructive: Color(rgb: 0xF2B8B5), + savedGreen: Color(rgb: 0x34C77B), subtitle: Color(rgb: 0x9B93B8), cardBackground: Color(argb: 0x0AFFFFFF), cardBorder: Color(argb: 0x14FFFFFF), @@ -84,15 +118,205 @@ struct AppTheme { badgeBackground: Color(argb: 0x248B5CF6), badgeBorder: Color(argb: 0x4D8B5CF6), badgeText: Color(rgb: 0xC4B5FD), + badgeDot: Color(rgb: 0xFF6B4A), + heroGradient: [ + Color(rgb: 0x6C3DE8), + Color(rgb: 0x8B5CF6), + Color(rgb: 0xA855F7) + ], tileBlocksBackground: Color(argb: 0x2E8B5CF6), tileBlocksIcon: Color(rgb: 0xC4B5FD), tileCommentsBackground: Color(argb: 0x29FF6B4A), tileCommentsIcon: Color(rgb: 0xFF8A6B) ) - static func theme(isDark: Bool) -> AppTheme { - isDark ? .dark : .light - } + static let forestLight = AppTheme( + name: "Forest Light", + isDark: false, + editorColors: forestLightEditorColors(), + primary: Color(rgb: 0x147D64), + onPrimary: .white, + background: Color(rgb: 0xF2F8F4), + surface: .white, + onBackground: Color(rgb: 0x102A23), + onSurface: Color(rgb: 0x102A23), + mutedInk: Color(rgb: 0x526B63), + divider: Color(rgb: 0xD5E5DE), + destructive: Color(rgb: 0xB3261E), + savedGreen: Color(rgb: 0x2F8F68), + subtitle: Color(rgb: 0x526B63), + cardBackground: .white, + cardBorder: Color(rgb: 0xDDEBE5), + cardTitle: Color(rgb: 0x102A23), + cardDescription: Color(rgb: 0x5D756D), + caret: Color(rgb: 0x9BC7B8), + badgeBackground: Color(rgb: 0xE5F1EC), + badgeBorder: Color(rgb: 0xC8E0D6), + badgeText: Color(rgb: 0x147D64), + badgeDot: Color(rgb: 0xC66A16), + heroGradient: [ + Color(rgb: 0x0F684F), + Color(rgb: 0x147D64), + Color(rgb: 0x38A383) + ], + tileBlocksBackground: Color(rgb: 0xDCEFE7), + tileBlocksIcon: Color(rgb: 0x147D64), + tileCommentsBackground: Color(rgb: 0xFFF0D9), + tileCommentsIcon: Color(rgb: 0xC66A16) + ) + + static let forestDark = AppTheme( + name: "Forest Dark", + isDark: true, + editorColors: forestDarkEditorColors(), + primary: Color(rgb: 0x67D4AF), + onPrimary: Color(rgb: 0x07231A), + background: Color(rgb: 0x071A14), + surface: Color(rgb: 0x102820), + onBackground: Color(rgb: 0xE5F5EF), + onSurface: Color(rgb: 0xE5F5EF), + mutedInk: Color(rgb: 0x8EAFA3), + divider: Color(rgb: 0x29483E), + destructive: Color(rgb: 0xF2B8B5), + savedGreen: Color(rgb: 0x67D4AF), + subtitle: Color(rgb: 0x8EAFA3), + cardBackground: Color(rgb: 0x102820), + cardBorder: Color(argb: 0x1FFFFFFF), + cardTitle: Color(rgb: 0xE5F5EF), + cardDescription: Color(rgb: 0x92AFA5), + caret: Color(rgb: 0x486E61), + badgeBackground: Color(argb: 0x2938A383), + badgeBorder: Color(argb: 0x4D38A383), + badgeText: Color(rgb: 0x8FE0C4), + badgeDot: Color(rgb: 0xD99A4E), + heroGradient: [ + Color(rgb: 0x0B4D3A), + Color(rgb: 0x147D64), + Color(rgb: 0x38A383) + ], + tileBlocksBackground: Color(argb: 0x2938A383), + tileBlocksIcon: Color(rgb: 0x8FE0C4), + tileCommentsBackground: Color(argb: 0x29D99A4E), + tileCommentsIcon: Color(rgb: 0xE8B66B) + ) +} + +/// Full 24-slot palettes demonstrate the native bridge without relying on the +/// SDK's built-in light/dark colors. Values are standard 0xAARRGGBB Int64s. +private func violetLightEditorColors() -> CascadeEditorColors { + let colors = CascadeEditorColors(isDark: false) + colors.primary = 0xFF6C3DE8 + colors.onPrimary = 0xFFFFFFFF + colors.text = 0xFF1C1238 + colors.popupBackground = 0xFFFFFFFF + colors.unknownBlockBackground = 0xFFF0E9FE + colors.toolbarIcon = 0xFF5A5470 + colors.toolbarIconDisabled = 0xFFB7ADD0 + colors.slashItemTitle = 0xFF1C1238 + colors.slashChevron = 0xFFCBB8EC + colors.unknownBlockText = 0xFF6B6580 + colors.uiDivider = 0xFFECE4FB + colors.contentDivider = 0xFFE4DAFB + colors.slashSelectedItem = 0xFFF0E9FE + colors.inlineCodeBackground = 0xFFEDE6FB + colors.highlight = 0xCCFFEB3B + colors.cursor = 0xFF6C3DE8 + colors.textSelectionBackground = 0x666C3DE8 + colors.quoteBorder = 0xFFCBB8EC + colors.quoteBackground = 0x0A6C3DE8 + colors.selectionOverlay = 0x226C3DE8 + colors.linkText = 0xFF6C3DE8 + colors.error = 0xFFB3261E + colors.codeBlockBackground = 0x0F6C3DE8 + colors.toolbarBackground = 0xFFFFFFFF + return colors +} + +private func violetDarkEditorColors() -> CascadeEditorColors { + let colors = CascadeEditorColors(isDark: true) + colors.primary = 0xFFA78BFA + colors.onPrimary = 0xFF1B1230 + colors.text = 0xFFF4F1FB + colors.popupBackground = 0xFF1E1832 + colors.unknownBlockBackground = 0xFF251C3D + colors.toolbarIcon = 0xFFA99FC4 + colors.toolbarIconDisabled = 0xFF5A5278 + colors.slashItemTitle = 0xFFFFFFFF + colors.slashChevron = 0xFF5A5278 + colors.unknownBlockText = 0xFF8A82A6 + colors.uiDivider = 0xFF3A3354 + colors.contentDivider = 0xFF3A3354 + colors.slashSelectedItem = 0xFF302451 + colors.inlineCodeBackground = 0x338B5CF6 + colors.highlight = 0x99FFEB3B + colors.cursor = 0xFFA78BFA + colors.textSelectionBackground = 0x668B5CF6 + colors.quoteBorder = 0xFF6F6690 + colors.quoteBackground = 0x1F8B5CF6 + colors.selectionOverlay = 0x3D8B5CF6 + colors.linkText = 0xFFC4B5FD + colors.error = 0xFFF2B8B5 + colors.codeBlockBackground = 0x1F8B5CF6 + colors.toolbarBackground = 0xFF26203A + return colors +} + +private func forestLightEditorColors() -> CascadeEditorColors { + let colors = CascadeEditorColors(isDark: false) + colors.primary = 0xFF147D64 + colors.onPrimary = 0xFFFFFFFF + colors.text = 0xFF102A23 + colors.popupBackground = 0xFFFFFFFF + colors.unknownBlockBackground = 0xFFE5F1EC + colors.toolbarIcon = 0xFF315A4D + colors.toolbarIconDisabled = 0xFF9CB3AA + colors.slashItemTitle = 0xFF102A23 + colors.slashChevron = 0xFF8BA79D + colors.unknownBlockText = 0xFF5D756D + colors.uiDivider = 0xFFD5E5DE + colors.contentDivider = 0xFFD5E5DE + colors.slashSelectedItem = 0xFFDBEFE7 + colors.inlineCodeBackground = 0x24147D64 + colors.highlight = 0xCCE5C45C + colors.cursor = 0xFF147D64 + colors.textSelectionBackground = 0x66147D64 + colors.quoteBorder = 0xFF80B7A4 + colors.quoteBackground = 0x14147D64 + colors.selectionOverlay = 0x29147D64 + colors.linkText = 0xFF147D64 + colors.error = 0xFFB3261E + colors.codeBlockBackground = 0x14147D64 + colors.toolbarBackground = 0xFFFFFFFF + return colors +} + +private func forestDarkEditorColors() -> CascadeEditorColors { + let colors = CascadeEditorColors(isDark: true) + colors.primary = 0xFF67D4AF + colors.onPrimary = 0xFF07231A + colors.text = 0xFFE5F5EF + colors.popupBackground = 0xFF102820 + colors.unknownBlockBackground = 0xFF17372D + colors.toolbarIcon = 0xFFB9D5CB + colors.toolbarIconDisabled = 0xFF486E61 + colors.slashItemTitle = 0xFFE5F5EF + colors.slashChevron = 0xFF597C70 + colors.unknownBlockText = 0xFF8EAFA3 + colors.uiDivider = 0xFF29483E + colors.contentDivider = 0xFF29483E + colors.slashSelectedItem = 0xFF173E32 + colors.inlineCodeBackground = 0x3367D4AF + colors.highlight = 0x99E5C45C + colors.cursor = 0xFF67D4AF + colors.textSelectionBackground = 0x6667D4AF + colors.quoteBorder = 0xFF517F70 + colors.quoteBackground = 0x1F67D4AF + colors.selectionOverlay = 0x3D67D4AF + colors.linkText = 0xFF8FE0C4 + colors.error = 0xFFF2B8B5 + colors.codeBlockBackground = 0x1F67D4AF + colors.toolbarBackground = 0xFF163229 + return colors } extension Color { diff --git a/iosNativeSample/iosNativeSample/App/LandingView.swift b/iosNativeSample/iosNativeSample/App/LandingView.swift index 4daa4ef..31b5186 100644 --- a/iosNativeSample/iosNativeSample/App/LandingView.swift +++ b/iosNativeSample/iosNativeSample/App/LandingView.swift @@ -64,7 +64,7 @@ struct LandingView: View { Circle() .fill(theme.badgeDot) .frame(width: 7, height: 7) - Text("Native Swift · XCFramework") + Text("Native Swift · \(theme.name)") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(theme.badgeText) } @@ -101,7 +101,7 @@ struct LandingView: View { Spacer().frame(height: 22) Text("Editor Demo") .font(.system(size: 24, weight: .bold)) - Text("The full editing experience: formatting, links, selection, autosave.") + Text("Runtime Violet/Forest themes with automatic light/dark variants from iOS appearance.") .font(.system(size: 14)) .opacity(0.9) } diff --git a/iosNativeSample/iosNativeSample/App/NativeSampleApp.swift b/iosNativeSample/iosNativeSample/App/NativeSampleApp.swift index 21bb8dc..67e1936 100644 --- a/iosNativeSample/iosNativeSample/App/NativeSampleApp.swift +++ b/iosNativeSample/iosNativeSample/App/NativeSampleApp.swift @@ -2,12 +2,11 @@ import SwiftUI @main struct NativeSampleApp: App { - @State private var isDark = false + @State private var selectedTheme = SampleThemeFamily.violet var body: some Scene { WindowGroup { - RootView(isDark: $isDark) - .preferredColorScheme(isDark ? .dark : .light) + RootView(selectedTheme: $selectedTheme) } } } @@ -19,10 +18,12 @@ enum SampleDestination: Hashable { } struct RootView: View { - @Binding var isDark: Bool + @Binding var selectedTheme: SampleThemeFamily + @Environment(\.colorScheme) private var colorScheme @State private var path: [SampleDestination] = [] - private var theme: AppTheme { AppTheme.theme(isDark: isDark) } + private var isDark: Bool { colorScheme == .dark } + private var theme: AppTheme { selectedTheme.appTheme(isDark: isDark) } var body: some View { NavigationStack(path: $path) { @@ -41,11 +42,11 @@ struct RootView: View { private func destinationView(for destination: SampleDestination) -> some View { switch destination { case .editorDemo: - EditorDemoScreen(isDark: $isDark) + EditorDemoScreen(selectedTheme: $selectedTheme, initialIsDark: isDark) case .comments: - CommentsScreen(isDark: $isDark) + CommentsScreen(selectedTheme: $selectedTheme, initialIsDark: isDark) case .customBlocks: - CustomBlocksScreen(isDark: $isDark) + CustomBlocksScreen(selectedTheme: $selectedTheme, initialIsDark: isDark) } } } diff --git a/iosNativeSample/iosNativeSample/Editor/EditorScreenModel.swift b/iosNativeSample/iosNativeSample/Editor/EditorScreenModel.swift index 7c11f3c..9137806 100644 --- a/iosNativeSample/iosNativeSample/Editor/EditorScreenModel.swift +++ b/iosNativeSample/iosNativeSample/Editor/EditorScreenModel.swift @@ -31,9 +31,10 @@ final class EditorScreenModel: ObservableObject { /// re-parenting never spawns a second Compose tree over the same state. private(set) lazy var editorViewController: UIViewController = controller.makeViewController() - init(configuration: CascadeEditorConfiguration) { + init(configuration: CascadeEditorConfiguration, colors: CascadeEditorColors) { controller = CascadeEditorController(initialJson: nil, configuration: configuration) isReadOnly = configuration.readOnly + controller.setColors(colors: colors) controller.onStateChanged = { [weak self] in self?.refreshEditorState() } controller.onDocumentChanged = { [weak self] in self?.onDocumentChanged?() } @@ -50,8 +51,9 @@ final class EditorScreenModel: ObservableObject { controller.setReadOnly(value: value) } - func setDarkMode(_ value: Bool) { - controller.setDarkMode(value: value) + func applyTheme(_ theme: AppTheme) { + controller.setDarkMode(value: theme.isDark) + controller.setColors(colors: theme.editorColors) } func undo() { controller.undo() } @@ -69,8 +71,8 @@ final class EditorScreenModel: ObservableObject { } extension CascadeEditorConfiguration { - /// The sample's baseline editor configuration; screens override the pieces - /// they need (`isDark`, chrome toggles) from here. + /// The sample's baseline editor configuration; colors are applied separately + /// through the runtime theme bridge. static func standard( isDark: Bool, readOnly: Bool = false, diff --git a/iosNativeSample/iosNativeSample/Screens/CommentsScreen.swift b/iosNativeSample/iosNativeSample/Screens/CommentsScreen.swift index 7cb8d56..b94c845 100644 --- a/iosNativeSample/iosNativeSample/Screens/CommentsScreen.swift +++ b/iosNativeSample/iosNativeSample/Screens/CommentsScreen.swift @@ -4,27 +4,31 @@ import CascadeEditor /// Editor-as-composer demo: a feed of rich-text bubbles above a pinned /// composer whose formatting bar fades in while the editor is focused. struct CommentsScreen: View { - @Binding var isDark: Bool + @Binding var selectedTheme: SampleThemeFamily + @Environment(\.colorScheme) private var colorScheme @Environment(\.dismiss) private var dismiss @StateObject private var editor: EditorScreenModel @StateObject private var model: CommentsModel - init(isDark: Binding) { - _isDark = isDark + init(selectedTheme: Binding, initialIsDark: Bool) { + _selectedTheme = selectedTheme + let initialTheme = selectedTheme.wrappedValue.appTheme(isDark: initialIsDark) let editorModel = EditorScreenModel( configuration: .standard( - isDark: isDark.wrappedValue, + isDark: initialTheme.isDark, toolbarMode: CascadeToolbarMode.none, slashCommandsEnabled: false, blockSelectionEnabled: false, blockDraggingEnabled: false - ) + ), + colors: initialTheme.editorColors ) _editor = StateObject(wrappedValue: editorModel) _model = StateObject(wrappedValue: CommentsModel(editor: editorModel)) } - private var theme: AppTheme { AppTheme.theme(isDark: isDark) } + private var isDark: Bool { colorScheme == .dark } + private var theme: AppTheme { selectedTheme.appTheme(isDark: isDark) } var body: some View { VStack(spacing: 0) { @@ -32,15 +36,18 @@ struct CommentsScreen: View { theme: theme, title: "Comments", onBack: { dismiss() }, - onToggleTheme: { isDark.toggle() } + onToggleTheme: { selectedTheme = selectedTheme.next } ) feed composer } .padding(.horizontal, 12) .background(theme.background) - .onChange(of: isDark) { _, newValue in - editor.setDarkMode(newValue) + .onChange(of: selectedTheme) { _, newValue in + editor.applyTheme(newValue.appTheme(isDark: isDark)) + } + .onChange(of: colorScheme) { _, newValue in + editor.applyTheme(selectedTheme.appTheme(isDark: newValue == .dark)) } } diff --git a/iosNativeSample/iosNativeSample/Screens/CustomBlocksScreen.swift b/iosNativeSample/iosNativeSample/Screens/CustomBlocksScreen.swift index 5a18df5..7979b8b 100644 --- a/iosNativeSample/iosNativeSample/Screens/CustomBlocksScreen.swift +++ b/iosNativeSample/iosNativeSample/Screens/CustomBlocksScreen.swift @@ -4,19 +4,25 @@ import CascadeEditor /// Native custom blocks and slash commands demo: SwiftUI-rendered table, /// metric, and palette blocks living inside the editor document. struct CustomBlocksScreen: View { - @Binding var isDark: Bool + @Binding var selectedTheme: SampleThemeFamily + @Environment(\.colorScheme) private var colorScheme @Environment(\.dismiss) private var dismiss @StateObject private var editor: EditorScreenModel @StateObject private var model: CustomBlocksModel - init(isDark: Binding) { - _isDark = isDark - let editorModel = EditorScreenModel(configuration: .standard(isDark: isDark.wrappedValue)) + init(selectedTheme: Binding, initialIsDark: Bool) { + _selectedTheme = selectedTheme + let initialTheme = selectedTheme.wrappedValue.appTheme(isDark: initialIsDark) + let editorModel = EditorScreenModel( + configuration: .standard(isDark: initialTheme.isDark), + colors: initialTheme.editorColors + ) _editor = StateObject(wrappedValue: editorModel) _model = StateObject(wrappedValue: CustomBlocksModel(editor: editorModel)) } - private var theme: AppTheme { AppTheme.theme(isDark: isDark) } + private var isDark: Bool { colorScheme == .dark } + private var theme: AppTheme { selectedTheme.appTheme(isDark: isDark) } var body: some View { VStack(spacing: 0) { @@ -24,8 +30,11 @@ struct CustomBlocksScreen: View { CascadeEditorHost(model: editor) } .background(theme.background) - .onChange(of: isDark) { _, newValue in - editor.setDarkMode(newValue) + .onChange(of: selectedTheme) { _, newValue in + editor.applyTheme(newValue.appTheme(isDark: isDark)) + } + .onChange(of: colorScheme) { _, newValue in + editor.applyTheme(selectedTheme.appTheme(isDark: newValue == .dark)) } } @@ -49,7 +58,7 @@ struct CustomBlocksScreen: View { onUndo: { editor.undo() }, onRedo: { editor.redo() }, onToggleReadOnly: { editor.setReadOnly(!editor.isReadOnly) }, - onToggleTheme: { isDark.toggle() }, + onToggleTheme: { selectedTheme = selectedTheme.next }, onReset: { model.reset() } ) } diff --git a/iosNativeSample/iosNativeSample/Screens/EditorDemoScreen.swift b/iosNativeSample/iosNativeSample/Screens/EditorDemoScreen.swift index 1d837de..2a65eea 100644 --- a/iosNativeSample/iosNativeSample/Screens/EditorDemoScreen.swift +++ b/iosNativeSample/iosNativeSample/Screens/EditorDemoScreen.swift @@ -4,19 +4,25 @@ import CascadeEditor /// Full editing experience: shared chrome over a persisted document with /// debounced autosave, link opening, and corrupt-storage recovery. struct EditorDemoScreen: View { - @Binding var isDark: Bool + @Binding var selectedTheme: SampleThemeFamily + @Environment(\.colorScheme) private var colorScheme @Environment(\.dismiss) private var dismiss @StateObject private var editor: EditorScreenModel @StateObject private var demo: EditorDemoModel - init(isDark: Binding) { - _isDark = isDark - let editorModel = EditorScreenModel(configuration: .standard(isDark: isDark.wrappedValue)) + init(selectedTheme: Binding, initialIsDark: Bool) { + _selectedTheme = selectedTheme + let initialTheme = selectedTheme.wrappedValue.appTheme(isDark: initialIsDark) + let editorModel = EditorScreenModel( + configuration: .standard(isDark: initialTheme.isDark), + colors: initialTheme.editorColors + ) _editor = StateObject(wrappedValue: editorModel) _demo = StateObject(wrappedValue: EditorDemoModel(editor: editorModel)) } - private var theme: AppTheme { AppTheme.theme(isDark: isDark) } + private var isDark: Bool { colorScheme == .dark } + private var theme: AppTheme { selectedTheme.appTheme(isDark: isDark) } var body: some View { VStack(spacing: 0) { @@ -33,8 +39,11 @@ struct EditorDemoScreen: View { } } .background(theme.background) - .onChange(of: isDark) { _, newValue in - editor.setDarkMode(newValue) + .onChange(of: selectedTheme) { _, newValue in + editor.applyTheme(newValue.appTheme(isDark: isDark)) + } + .onChange(of: colorScheme) { _, newValue in + editor.applyTheme(selectedTheme.appTheme(isDark: newValue == .dark)) } } @@ -61,7 +70,7 @@ struct EditorDemoScreen: View { onUndo: { editor.undo() }, onRedo: { editor.redo() }, onToggleReadOnly: { editor.setReadOnly(!editor.isReadOnly) }, - onToggleTheme: { isDark.toggle() }, + onToggleTheme: { selectedTheme = selectedTheme.next }, onReset: { demo.reset() } ) } diff --git a/iosNativeSample/iosNativeSample/Shell/EditorChrome.swift b/iosNativeSample/iosNativeSample/Shell/EditorChrome.swift index 79338f0..aef5076 100644 --- a/iosNativeSample/iosNativeSample/Shell/EditorChrome.swift +++ b/iosNativeSample/iosNativeSample/Shell/EditorChrome.swift @@ -49,8 +49,8 @@ struct EditorTopBar: View { ) GroupButton( theme: theme, - systemImage: theme.isDark ? "sun.max" : "moon", - label: theme.isDark ? "Switch to light" : "Switch to dark", + systemImage: "paintpalette", + label: "Switch theme from \(theme.name)", action: onToggleTheme ) } @@ -82,8 +82,8 @@ struct TitledEditorTopBar: View { ChromeActionGroup(theme: theme) { GroupButton( theme: theme, - systemImage: theme.isDark ? "sun.max" : "moon", - label: theme.isDark ? "Switch to light" : "Switch to dark", + systemImage: "paintpalette", + label: "Switch theme from \(theme.name)", action: onToggleTheme ) }